chat.go

   1package chat
   2
   3import (
   4	"context"
   5	"errors"
   6	"fmt"
   7	"time"
   8
   9	"github.com/charmbracelet/bubbles/v2/help"
  10	"github.com/charmbracelet/bubbles/v2/key"
  11	"github.com/charmbracelet/bubbles/v2/spinner"
  12	tea "github.com/charmbracelet/bubbletea/v2"
  13	"github.com/charmbracelet/crush/internal/app"
  14	"github.com/charmbracelet/crush/internal/config"
  15	"github.com/charmbracelet/crush/internal/history"
  16	"github.com/charmbracelet/crush/internal/message"
  17	"github.com/charmbracelet/crush/internal/permission"
  18	"github.com/charmbracelet/crush/internal/pubsub"
  19	"github.com/charmbracelet/crush/internal/session"
  20	"github.com/charmbracelet/crush/internal/tui/components/anim"
  21	"github.com/charmbracelet/crush/internal/tui/components/chat"
  22	"github.com/charmbracelet/crush/internal/tui/components/chat/editor"
  23	"github.com/charmbracelet/crush/internal/tui/components/chat/header"
  24	"github.com/charmbracelet/crush/internal/tui/components/chat/messages"
  25	"github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
  26	"github.com/charmbracelet/crush/internal/tui/components/chat/splash"
  27	"github.com/charmbracelet/crush/internal/tui/components/completions"
  28	"github.com/charmbracelet/crush/internal/tui/components/core"
  29	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
  30	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
  31	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
  32	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
  33	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
  34	"github.com/charmbracelet/crush/internal/tui/components/dialogs/reasoning"
  35	"github.com/charmbracelet/crush/internal/tui/page"
  36	"github.com/charmbracelet/crush/internal/tui/styles"
  37	"github.com/charmbracelet/crush/internal/tui/util"
  38	"github.com/charmbracelet/crush/internal/version"
  39	"github.com/charmbracelet/lipgloss/v2"
  40)
  41
  42var ChatPageID page.PageID = "chat"
  43
  44type (
  45	ChatFocusedMsg struct {
  46		Focused bool
  47	}
  48	CancelTimerExpiredMsg struct{}
  49)
  50
  51type PanelType string
  52
  53const (
  54	PanelTypeChat   PanelType = "chat"
  55	PanelTypeEditor PanelType = "editor"
  56	PanelTypeSplash PanelType = "splash"
  57)
  58
  59const (
  60	CompactModeWidthBreakpoint  = 120 // Width at which the chat page switches to compact mode
  61	CompactModeHeightBreakpoint = 30  // Height at which the chat page switches to compact mode
  62	EditorHeight                = 5   // Height of the editor input area including padding
  63	SideBarWidth                = 31  // Width of the sidebar
  64	SideBarDetailsPadding       = 1   // Padding for the sidebar details section
  65	HeaderHeight                = 1   // Height of the header
  66
  67	// Layout constants for borders and padding
  68	BorderWidth        = 1 // Width of component borders
  69	LeftRightBorders   = 2 // Left + right border width (1 + 1)
  70	TopBottomBorders   = 2 // Top + bottom border width (1 + 1)
  71	DetailsPositioning = 2 // Positioning adjustment for details panel
  72
  73	// Timing constants
  74	CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires
  75)
  76
  77type ChatPage interface {
  78	util.Model
  79	layout.Help
  80	IsChatFocused() bool
  81}
  82
  83// cancelTimerCmd creates a command that expires the cancel timer
  84func cancelTimerCmd() tea.Cmd {
  85	return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg {
  86		return CancelTimerExpiredMsg{}
  87	})
  88}
  89
  90type chatPage struct {
  91	width, height               int
  92	detailsWidth, detailsHeight int
  93	app                         *app.App
  94	keyboardEnhancements        tea.KeyboardEnhancementsMsg
  95
  96	// Layout state
  97	compact      bool
  98	forceCompact bool
  99	focusedPane  PanelType
 100
 101	// Session
 102	session session.Session
 103	keyMap  KeyMap
 104
 105	// Components
 106	header  header.Header
 107	sidebar sidebar.Sidebar
 108	chat    chat.MessageListCmp
 109	editor  editor.Editor
 110	splash  splash.Splash
 111
 112	// Simple state flags
 113	showingDetails   bool
 114	isCanceling      bool
 115	splashFullScreen bool
 116	isOnboarding     bool
 117	isProjectInit    bool
 118}
 119
 120func New(app *app.App) ChatPage {
 121	return &chatPage{
 122		app:         app,
 123		keyMap:      DefaultKeyMap(),
 124		header:      header.New(app.LSPClients),
 125		sidebar:     sidebar.New(app.History, app.LSPClients, false),
 126		chat:        chat.New(app),
 127		editor:      editor.New(app),
 128		splash:      splash.New(),
 129		focusedPane: PanelTypeSplash,
 130	}
 131}
 132
 133func (p *chatPage) Init() tea.Cmd {
 134	cfg := config.Get()
 135	compact := cfg.Options.TUI.CompactMode
 136	p.compact = compact
 137	p.forceCompact = compact
 138	p.sidebar.SetCompactMode(p.compact)
 139
 140	// Set splash state based on config
 141	if !config.HasInitialDataConfig() {
 142		// First-time setup: show model selection
 143		p.splash.SetOnboarding(true)
 144		p.isOnboarding = true
 145		p.splashFullScreen = true
 146	} else if b, _ := config.ProjectNeedsInitialization(); b {
 147		// Project needs CRUSH.md initialization
 148		p.splash.SetProjectInit(true)
 149		p.isProjectInit = true
 150		p.splashFullScreen = true
 151	} else {
 152		// Ready to chat: focus editor, splash in background
 153		p.focusedPane = PanelTypeEditor
 154		p.splashFullScreen = false
 155	}
 156
 157	return tea.Batch(
 158		p.header.Init(),
 159		p.sidebar.Init(),
 160		p.chat.Init(),
 161		p.editor.Init(),
 162		p.splash.Init(),
 163	)
 164}
 165
 166func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 167	var cmds []tea.Cmd
 168	switch msg := msg.(type) {
 169	case tea.KeyboardEnhancementsMsg:
 170		p.keyboardEnhancements = msg
 171		return p, nil
 172	case tea.MouseWheelMsg:
 173		if p.compact {
 174			msg.Y -= 1
 175		}
 176		if p.isMouseOverChat(msg.X, msg.Y) {
 177			u, cmd := p.chat.Update(msg)
 178			p.chat = u.(chat.MessageListCmp)
 179			return p, cmd
 180		}
 181		return p, nil
 182	case tea.MouseClickMsg:
 183		if p.isOnboarding {
 184			return p, nil
 185		}
 186		if p.compact {
 187			msg.Y -= 1
 188		}
 189		if p.isMouseOverChat(msg.X, msg.Y) {
 190			p.focusedPane = PanelTypeChat
 191			p.chat.Focus()
 192			p.editor.Blur()
 193		} else {
 194			p.focusedPane = PanelTypeEditor
 195			p.editor.Focus()
 196			p.chat.Blur()
 197		}
 198		u, cmd := p.chat.Update(msg)
 199		p.chat = u.(chat.MessageListCmp)
 200		return p, cmd
 201	case tea.MouseMotionMsg:
 202		if p.compact {
 203			msg.Y -= 1
 204		}
 205		if msg.Button == tea.MouseLeft {
 206			u, cmd := p.chat.Update(msg)
 207			p.chat = u.(chat.MessageListCmp)
 208			return p, cmd
 209		}
 210		return p, nil
 211	case tea.MouseReleaseMsg:
 212		if p.isOnboarding {
 213			return p, nil
 214		}
 215		if p.compact {
 216			msg.Y -= 1
 217		}
 218		if msg.Button == tea.MouseLeft {
 219			u, cmd := p.chat.Update(msg)
 220			p.chat = u.(chat.MessageListCmp)
 221			return p, cmd
 222		}
 223		return p, nil
 224	case chat.SelectionCopyMsg:
 225		u, cmd := p.chat.Update(msg)
 226		p.chat = u.(chat.MessageListCmp)
 227		return p, cmd
 228	case tea.WindowSizeMsg:
 229		u, cmd := p.editor.Update(msg)
 230		p.editor = u.(editor.Editor)
 231		return p, tea.Batch(p.SetSize(msg.Width, msg.Height), cmd)
 232	case CancelTimerExpiredMsg:
 233		p.isCanceling = false
 234		return p, nil
 235	case editor.OpenEditorMsg:
 236		u, cmd := p.editor.Update(msg)
 237		p.editor = u.(editor.Editor)
 238		return p, cmd
 239	case chat.SendMsg:
 240		return p, p.sendMessage(msg.Text, msg.Attachments)
 241	case chat.SessionSelectedMsg:
 242		return p, p.setSession(msg)
 243	case splash.SubmitAPIKeyMsg:
 244		u, cmd := p.splash.Update(msg)
 245		p.splash = u.(splash.Splash)
 246		cmds = append(cmds, cmd)
 247		return p, tea.Batch(cmds...)
 248	case commands.ToggleCompactModeMsg:
 249		p.forceCompact = !p.forceCompact
 250		var cmd tea.Cmd
 251		if p.forceCompact {
 252			p.setCompactMode(true)
 253			cmd = p.updateCompactConfig(true)
 254		} else if p.width >= CompactModeWidthBreakpoint && p.height >= CompactModeHeightBreakpoint {
 255			p.setCompactMode(false)
 256			cmd = p.updateCompactConfig(false)
 257		}
 258		return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
 259	case commands.ToggleThinkingMsg:
 260		return p, p.toggleThinking()
 261	case commands.OpenReasoningDialogMsg:
 262		return p, p.openReasoningDialog()
 263	case reasoning.ReasoningEffortSelectedMsg:
 264		return p, p.handleReasoningEffortSelected(msg.Effort)
 265	case commands.OpenExternalEditorMsg:
 266		u, cmd := p.editor.Update(msg)
 267		p.editor = u.(editor.Editor)
 268		return p, cmd
 269	case pubsub.Event[session.Session]:
 270		u, cmd := p.header.Update(msg)
 271		p.header = u.(header.Header)
 272		cmds = append(cmds, cmd)
 273		u, cmd = p.sidebar.Update(msg)
 274		p.sidebar = u.(sidebar.Sidebar)
 275		cmds = append(cmds, cmd)
 276		return p, tea.Batch(cmds...)
 277	case chat.SessionClearedMsg:
 278		u, cmd := p.header.Update(msg)
 279		p.header = u.(header.Header)
 280		cmds = append(cmds, cmd)
 281		u, cmd = p.sidebar.Update(msg)
 282		p.sidebar = u.(sidebar.Sidebar)
 283		cmds = append(cmds, cmd)
 284		u, cmd = p.chat.Update(msg)
 285		p.chat = u.(chat.MessageListCmp)
 286		cmds = append(cmds, cmd)
 287		return p, tea.Batch(cmds...)
 288	case filepicker.FilePickedMsg,
 289		completions.CompletionsClosedMsg,
 290		completions.SelectCompletionMsg:
 291		u, cmd := p.editor.Update(msg)
 292		p.editor = u.(editor.Editor)
 293		cmds = append(cmds, cmd)
 294		return p, tea.Batch(cmds...)
 295
 296	case models.APIKeyStateChangeMsg:
 297		if p.focusedPane == PanelTypeSplash {
 298			u, cmd := p.splash.Update(msg)
 299			p.splash = u.(splash.Splash)
 300			cmds = append(cmds, cmd)
 301		}
 302		return p, tea.Batch(cmds...)
 303	case pubsub.Event[message.Message],
 304		anim.StepMsg,
 305		spinner.TickMsg:
 306		if p.focusedPane == PanelTypeSplash {
 307			u, cmd := p.splash.Update(msg)
 308			p.splash = u.(splash.Splash)
 309			cmds = append(cmds, cmd)
 310		} else {
 311			u, cmd := p.chat.Update(msg)
 312			p.chat = u.(chat.MessageListCmp)
 313			cmds = append(cmds, cmd)
 314		}
 315
 316		return p, tea.Batch(cmds...)
 317	case commands.ToggleYoloModeMsg:
 318		// update the editor style
 319		u, cmd := p.editor.Update(msg)
 320		p.editor = u.(editor.Editor)
 321		return p, cmd
 322	case pubsub.Event[history.File], sidebar.SessionFilesMsg:
 323		u, cmd := p.sidebar.Update(msg)
 324		p.sidebar = u.(sidebar.Sidebar)
 325		cmds = append(cmds, cmd)
 326		return p, tea.Batch(cmds...)
 327	case pubsub.Event[permission.PermissionNotification]:
 328		u, cmd := p.chat.Update(msg)
 329		p.chat = u.(chat.MessageListCmp)
 330		cmds = append(cmds, cmd)
 331		return p, tea.Batch(cmds...)
 332
 333	case commands.CommandRunCustomMsg:
 334		if p.app.AgentCoordinator.IsBusy() {
 335			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
 336		}
 337
 338		cmd := p.sendMessage(msg.Content, nil)
 339		if cmd != nil {
 340			return p, cmd
 341		}
 342	case splash.OnboardingCompleteMsg:
 343		p.splashFullScreen = false
 344		if b, _ := config.ProjectNeedsInitialization(); b {
 345			p.splash.SetProjectInit(true)
 346			p.splashFullScreen = true
 347			return p, p.SetSize(p.width, p.height)
 348		}
 349		err := p.app.InitCoderAgent()
 350		if err != nil {
 351			return p, util.ReportError(err)
 352		}
 353		p.isOnboarding = false
 354		p.isProjectInit = false
 355		p.focusedPane = PanelTypeEditor
 356		return p, p.SetSize(p.width, p.height)
 357	case commands.NewSessionsMsg:
 358		if p.app.AgentCoordinator.IsBusy() {
 359			return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
 360		}
 361		return p, p.newSession()
 362	case tea.KeyPressMsg:
 363		switch {
 364		case key.Matches(msg, p.keyMap.NewSession):
 365			// if we have no agent do nothing
 366			if p.app.AgentCoordinator == nil {
 367				return p, nil
 368			}
 369			if p.app.AgentCoordinator.IsBusy() {
 370				return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
 371			}
 372			return p, p.newSession()
 373		case key.Matches(msg, p.keyMap.AddAttachment):
 374			agentCfg := config.Get().Agents[config.AgentCoder]
 375			model := config.Get().GetModelByType(agentCfg.Model)
 376			if model.SupportsImages {
 377				return p, util.CmdHandler(commands.OpenFilePickerMsg{})
 378			} else {
 379				return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
 380			}
 381		case key.Matches(msg, p.keyMap.Tab):
 382			if p.session.ID == "" {
 383				u, cmd := p.splash.Update(msg)
 384				p.splash = u.(splash.Splash)
 385				return p, cmd
 386			}
 387			p.changeFocus()
 388			return p, nil
 389		case key.Matches(msg, p.keyMap.Cancel):
 390			if p.session.ID != "" && p.app.AgentCoordinator.IsBusy() {
 391				return p, p.cancel()
 392			}
 393		case key.Matches(msg, p.keyMap.Details):
 394			p.toggleDetails()
 395			return p, nil
 396		}
 397
 398		switch p.focusedPane {
 399		case PanelTypeChat:
 400			u, cmd := p.chat.Update(msg)
 401			p.chat = u.(chat.MessageListCmp)
 402			cmds = append(cmds, cmd)
 403		case PanelTypeEditor:
 404			u, cmd := p.editor.Update(msg)
 405			p.editor = u.(editor.Editor)
 406			cmds = append(cmds, cmd)
 407		case PanelTypeSplash:
 408			u, cmd := p.splash.Update(msg)
 409			p.splash = u.(splash.Splash)
 410			cmds = append(cmds, cmd)
 411		}
 412	case tea.PasteMsg:
 413		switch p.focusedPane {
 414		case PanelTypeEditor:
 415			u, cmd := p.editor.Update(msg)
 416			p.editor = u.(editor.Editor)
 417			cmds = append(cmds, cmd)
 418			return p, tea.Batch(cmds...)
 419		case PanelTypeChat:
 420			u, cmd := p.chat.Update(msg)
 421			p.chat = u.(chat.MessageListCmp)
 422			cmds = append(cmds, cmd)
 423			return p, tea.Batch(cmds...)
 424		case PanelTypeSplash:
 425			u, cmd := p.splash.Update(msg)
 426			p.splash = u.(splash.Splash)
 427			cmds = append(cmds, cmd)
 428			return p, tea.Batch(cmds...)
 429		}
 430	}
 431	return p, tea.Batch(cmds...)
 432}
 433
 434func (p *chatPage) Cursor() *tea.Cursor {
 435	if p.header.ShowingDetails() {
 436		return nil
 437	}
 438	switch p.focusedPane {
 439	case PanelTypeEditor:
 440		return p.editor.Cursor()
 441	case PanelTypeSplash:
 442		return p.splash.Cursor()
 443	default:
 444		return nil
 445	}
 446}
 447
 448func (p *chatPage) View() string {
 449	var chatView string
 450	t := styles.CurrentTheme()
 451
 452	if p.session.ID == "" {
 453		splashView := p.splash.View()
 454		// Full screen during onboarding or project initialization
 455		if p.splashFullScreen {
 456			chatView = splashView
 457		} else {
 458			// Show splash + editor for new message state
 459			editorView := p.editor.View()
 460			chatView = lipgloss.JoinVertical(
 461				lipgloss.Left,
 462				t.S().Base.Render(splashView),
 463				editorView,
 464			)
 465		}
 466	} else {
 467		messagesView := p.chat.View()
 468		editorView := p.editor.View()
 469		if p.compact {
 470			headerView := p.header.View()
 471			chatView = lipgloss.JoinVertical(
 472				lipgloss.Left,
 473				headerView,
 474				messagesView,
 475				editorView,
 476			)
 477		} else {
 478			sidebarView := p.sidebar.View()
 479			messages := lipgloss.JoinHorizontal(
 480				lipgloss.Left,
 481				messagesView,
 482				sidebarView,
 483			)
 484			chatView = lipgloss.JoinVertical(
 485				lipgloss.Left,
 486				messages,
 487				p.editor.View(),
 488			)
 489		}
 490	}
 491
 492	layers := []*lipgloss.Layer{
 493		lipgloss.NewLayer(chatView).X(0).Y(0),
 494	}
 495
 496	if p.showingDetails {
 497		style := t.S().Base.
 498			Width(p.detailsWidth).
 499			Border(lipgloss.RoundedBorder()).
 500			BorderForeground(t.BorderFocus)
 501		version := t.S().Base.Foreground(t.Border).Width(p.detailsWidth - 4).AlignHorizontal(lipgloss.Right).Render(version.Version)
 502		details := style.Render(
 503			lipgloss.JoinVertical(
 504				lipgloss.Left,
 505				p.sidebar.View(),
 506				version,
 507			),
 508		)
 509		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
 510	}
 511	canvas := lipgloss.NewCanvas(
 512		layers...,
 513	)
 514	return canvas.Render()
 515}
 516
 517func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
 518	return func() tea.Msg {
 519		err := config.Get().SetCompactMode(compact)
 520		if err != nil {
 521			return util.InfoMsg{
 522				Type: util.InfoTypeError,
 523				Msg:  "Failed to update compact mode configuration: " + err.Error(),
 524			}
 525		}
 526		return nil
 527	}
 528}
 529
 530func (p *chatPage) toggleThinking() tea.Cmd {
 531	return func() tea.Msg {
 532		cfg := config.Get()
 533		agentCfg := cfg.Agents[config.AgentCoder]
 534		currentModel := cfg.Models[agentCfg.Model]
 535
 536		// Toggle the thinking mode
 537		currentModel.Think = !currentModel.Think
 538		cfg.Models[agentCfg.Model] = currentModel
 539
 540		// Update the agent with the new configuration
 541		if err := p.app.UpdateAgentModel(); err != nil {
 542			return util.InfoMsg{
 543				Type: util.InfoTypeError,
 544				Msg:  "Failed to update thinking mode: " + err.Error(),
 545			}
 546		}
 547
 548		status := "disabled"
 549		if currentModel.Think {
 550			status = "enabled"
 551		}
 552		return util.InfoMsg{
 553			Type: util.InfoTypeInfo,
 554			Msg:  "Thinking mode " + status,
 555		}
 556	}
 557}
 558
 559func (p *chatPage) openReasoningDialog() tea.Cmd {
 560	return func() tea.Msg {
 561		cfg := config.Get()
 562		agentCfg := cfg.Agents[config.AgentCoder]
 563		model := cfg.GetModelByType(agentCfg.Model)
 564		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
 565
 566		if providerCfg != nil && model != nil && model.HasReasoningEffort {
 567			// Return the OpenDialogMsg directly so it bubbles up to the main TUI
 568			return dialogs.OpenDialogMsg{
 569				Model: reasoning.NewReasoningDialog(),
 570			}
 571		}
 572		return nil
 573	}
 574}
 575
 576func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
 577	return func() tea.Msg {
 578		cfg := config.Get()
 579		agentCfg := cfg.Agents[config.AgentCoder]
 580		currentModel := cfg.Models[agentCfg.Model]
 581
 582		// Update the model configuration
 583		currentModel.ReasoningEffort = effort
 584		cfg.Models[agentCfg.Model] = currentModel
 585
 586		// Update the agent with the new configuration
 587		if err := p.app.UpdateAgentModel(); err != nil {
 588			return util.InfoMsg{
 589				Type: util.InfoTypeError,
 590				Msg:  "Failed to update reasoning effort: " + err.Error(),
 591			}
 592		}
 593
 594		return util.InfoMsg{
 595			Type: util.InfoTypeInfo,
 596			Msg:  "Reasoning effort set to " + effort,
 597		}
 598	}
 599}
 600
 601func (p *chatPage) setCompactMode(compact bool) {
 602	if p.compact == compact {
 603		return
 604	}
 605	p.compact = compact
 606	if compact {
 607		p.sidebar.SetCompactMode(true)
 608	} else {
 609		p.setShowDetails(false)
 610	}
 611}
 612
 613func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
 614	if p.forceCompact {
 615		return
 616	}
 617	if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
 618		p.setCompactMode(true)
 619	}
 620	if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
 621		p.setCompactMode(false)
 622	}
 623}
 624
 625func (p *chatPage) SetSize(width, height int) tea.Cmd {
 626	p.handleCompactMode(width, height)
 627	p.width = width
 628	p.height = height
 629	var cmds []tea.Cmd
 630
 631	if p.session.ID == "" {
 632		if p.splashFullScreen {
 633			cmds = append(cmds, p.splash.SetSize(width, height))
 634		} else {
 635			cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
 636			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 637			cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
 638		}
 639	} else {
 640		if p.compact {
 641			cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
 642			p.detailsWidth = width - DetailsPositioning
 643			cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
 644			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 645			cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
 646		} else {
 647			cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
 648			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 649			cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
 650		}
 651		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
 652	}
 653	return tea.Batch(cmds...)
 654}
 655
 656func (p *chatPage) newSession() tea.Cmd {
 657	if p.session.ID == "" {
 658		return nil
 659	}
 660
 661	p.session = session.Session{}
 662	p.focusedPane = PanelTypeEditor
 663	p.editor.Focus()
 664	p.chat.Blur()
 665	p.isCanceling = false
 666	return tea.Batch(
 667		util.CmdHandler(chat.SessionClearedMsg{}),
 668		p.SetSize(p.width, p.height),
 669	)
 670}
 671
 672func (p *chatPage) setSession(session session.Session) tea.Cmd {
 673	if p.session.ID == session.ID {
 674		return nil
 675	}
 676
 677	var cmds []tea.Cmd
 678	p.session = session
 679
 680	cmds = append(cmds, p.SetSize(p.width, p.height))
 681	cmds = append(cmds, p.chat.SetSession(session))
 682	cmds = append(cmds, p.sidebar.SetSession(session))
 683	cmds = append(cmds, p.header.SetSession(session))
 684	cmds = append(cmds, p.editor.SetSession(session))
 685
 686	return tea.Sequence(cmds...)
 687}
 688
 689func (p *chatPage) changeFocus() {
 690	if p.session.ID == "" {
 691		return
 692	}
 693	switch p.focusedPane {
 694	case PanelTypeChat:
 695		p.focusedPane = PanelTypeEditor
 696		p.editor.Focus()
 697		p.chat.Blur()
 698	case PanelTypeEditor:
 699		p.focusedPane = PanelTypeChat
 700		p.chat.Focus()
 701		p.editor.Blur()
 702	}
 703}
 704
 705func (p *chatPage) cancel() tea.Cmd {
 706	if p.isCanceling {
 707		p.isCanceling = false
 708		if p.app.AgentCoordinator != nil {
 709			p.app.AgentCoordinator.Cancel(p.session.ID)
 710		}
 711		return nil
 712	}
 713
 714	if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
 715		p.app.AgentCoordinator.ClearQueue(p.session.ID)
 716		return nil
 717	}
 718	p.isCanceling = true
 719	return cancelTimerCmd()
 720}
 721
 722func (p *chatPage) setShowDetails(show bool) {
 723	p.showingDetails = show
 724	p.header.SetDetailsOpen(p.showingDetails)
 725	if !p.compact {
 726		p.sidebar.SetCompactMode(false)
 727	}
 728}
 729
 730func (p *chatPage) toggleDetails() {
 731	if p.session.ID == "" || !p.compact {
 732		return
 733	}
 734	p.setShowDetails(!p.showingDetails)
 735}
 736
 737func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
 738	session := p.session
 739	var cmds []tea.Cmd
 740	if p.session.ID == "" {
 741		newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
 742		if err != nil {
 743			return util.ReportError(err)
 744		}
 745		session = newSession
 746		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
 747	}
 748	if p.app.AgentCoordinator == nil {
 749		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
 750	}
 751	cmds = append(cmds, p.chat.GoToBottom())
 752	cmds = append(cmds, func() tea.Msg {
 753		_, err := p.app.AgentCoordinator.Run(context.Background(), session.ID, text, attachments...)
 754		if err != nil {
 755			isCancelErr := errors.Is(err, context.Canceled)
 756			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
 757			if isCancelErr || isPermissionErr {
 758				return nil
 759			}
 760			return util.InfoMsg{
 761				Type: util.InfoTypeError,
 762				Msg:  err.Error(),
 763			}
 764		}
 765		return nil
 766	})
 767	return tea.Batch(cmds...)
 768}
 769
 770func (p *chatPage) Bindings() []key.Binding {
 771	bindings := []key.Binding{
 772		p.keyMap.NewSession,
 773		p.keyMap.AddAttachment,
 774	}
 775	if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
 776		cancelBinding := p.keyMap.Cancel
 777		if p.isCanceling {
 778			cancelBinding = key.NewBinding(
 779				key.WithKeys("esc", "alt+esc"),
 780				key.WithHelp("esc", "press again to cancel"),
 781			)
 782		}
 783		bindings = append([]key.Binding{cancelBinding}, bindings...)
 784	}
 785
 786	switch p.focusedPane {
 787	case PanelTypeChat:
 788		bindings = append([]key.Binding{
 789			key.NewBinding(
 790				key.WithKeys("tab"),
 791				key.WithHelp("tab", "focus editor"),
 792			),
 793		}, bindings...)
 794		bindings = append(bindings, p.chat.Bindings()...)
 795	case PanelTypeEditor:
 796		bindings = append([]key.Binding{
 797			key.NewBinding(
 798				key.WithKeys("tab"),
 799				key.WithHelp("tab", "focus chat"),
 800			),
 801		}, bindings...)
 802		bindings = append(bindings, p.editor.Bindings()...)
 803	case PanelTypeSplash:
 804		bindings = append(bindings, p.splash.Bindings()...)
 805	}
 806
 807	return bindings
 808}
 809
 810func (p *chatPage) Help() help.KeyMap {
 811	var shortList []key.Binding
 812	var fullList [][]key.Binding
 813	switch {
 814	case p.isOnboarding && !p.splash.IsShowingAPIKey():
 815		shortList = append(shortList,
 816			// Choose model
 817			key.NewBinding(
 818				key.WithKeys("up", "down"),
 819				key.WithHelp("↑/↓", "choose"),
 820			),
 821			// Accept selection
 822			key.NewBinding(
 823				key.WithKeys("enter", "ctrl+y"),
 824				key.WithHelp("enter", "accept"),
 825			),
 826			// Quit
 827			key.NewBinding(
 828				key.WithKeys("ctrl+c"),
 829				key.WithHelp("ctrl+c", "quit"),
 830			),
 831		)
 832		// keep them the same
 833		for _, v := range shortList {
 834			fullList = append(fullList, []key.Binding{v})
 835		}
 836	case p.isOnboarding && p.splash.IsShowingAPIKey():
 837		if p.splash.IsAPIKeyValid() {
 838			shortList = append(shortList,
 839				key.NewBinding(
 840					key.WithKeys("enter"),
 841					key.WithHelp("enter", "continue"),
 842				),
 843			)
 844		} else {
 845			shortList = append(shortList,
 846				// Go back
 847				key.NewBinding(
 848					key.WithKeys("esc", "alt+esc"),
 849					key.WithHelp("esc", "back"),
 850				),
 851			)
 852		}
 853		shortList = append(shortList,
 854			// Quit
 855			key.NewBinding(
 856				key.WithKeys("ctrl+c"),
 857				key.WithHelp("ctrl+c", "quit"),
 858			),
 859		)
 860		// keep them the same
 861		for _, v := range shortList {
 862			fullList = append(fullList, []key.Binding{v})
 863		}
 864	case p.isProjectInit:
 865		shortList = append(shortList,
 866			key.NewBinding(
 867				key.WithKeys("ctrl+c"),
 868				key.WithHelp("ctrl+c", "quit"),
 869			),
 870		)
 871		// keep them the same
 872		for _, v := range shortList {
 873			fullList = append(fullList, []key.Binding{v})
 874		}
 875	default:
 876		if p.editor.IsCompletionsOpen() {
 877			shortList = append(shortList,
 878				key.NewBinding(
 879					key.WithKeys("tab", "enter"),
 880					key.WithHelp("tab/enter", "complete"),
 881				),
 882				key.NewBinding(
 883					key.WithKeys("esc", "alt+esc"),
 884					key.WithHelp("esc", "cancel"),
 885				),
 886				key.NewBinding(
 887					key.WithKeys("up", "down"),
 888					key.WithHelp("↑/↓", "choose"),
 889				),
 890			)
 891			for _, v := range shortList {
 892				fullList = append(fullList, []key.Binding{v})
 893			}
 894			return core.NewSimpleHelp(shortList, fullList)
 895		}
 896		if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
 897			cancelBinding := key.NewBinding(
 898				key.WithKeys("esc", "alt+esc"),
 899				key.WithHelp("esc", "cancel"),
 900			)
 901			if p.isCanceling {
 902				cancelBinding = key.NewBinding(
 903					key.WithKeys("esc", "alt+esc"),
 904					key.WithHelp("esc", "press again to cancel"),
 905				)
 906			}
 907			if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
 908				cancelBinding = key.NewBinding(
 909					key.WithKeys("esc", "alt+esc"),
 910					key.WithHelp("esc", "clear queue"),
 911				)
 912			}
 913			shortList = append(shortList, cancelBinding)
 914			fullList = append(fullList,
 915				[]key.Binding{
 916					cancelBinding,
 917				},
 918			)
 919		}
 920		globalBindings := []key.Binding{}
 921		// we are in a session
 922		if p.session.ID != "" {
 923			tabKey := key.NewBinding(
 924				key.WithKeys("tab"),
 925				key.WithHelp("tab", "focus chat"),
 926			)
 927			if p.focusedPane == PanelTypeChat {
 928				tabKey = key.NewBinding(
 929					key.WithKeys("tab"),
 930					key.WithHelp("tab", "focus editor"),
 931				)
 932			}
 933			shortList = append(shortList, tabKey)
 934			globalBindings = append(globalBindings, tabKey)
 935		}
 936		commandsBinding := key.NewBinding(
 937			key.WithKeys("ctrl+p"),
 938			key.WithHelp("ctrl+p", "commands"),
 939		)
 940		helpBinding := key.NewBinding(
 941			key.WithKeys("ctrl+g"),
 942			key.WithHelp("ctrl+g", "more"),
 943		)
 944		globalBindings = append(globalBindings, commandsBinding)
 945		globalBindings = append(globalBindings,
 946			key.NewBinding(
 947				key.WithKeys("ctrl+s"),
 948				key.WithHelp("ctrl+s", "sessions"),
 949			),
 950		)
 951		if p.session.ID != "" {
 952			globalBindings = append(globalBindings,
 953				key.NewBinding(
 954					key.WithKeys("ctrl+n"),
 955					key.WithHelp("ctrl+n", "new sessions"),
 956				))
 957		}
 958		shortList = append(shortList,
 959			// Commands
 960			commandsBinding,
 961		)
 962		fullList = append(fullList, globalBindings)
 963
 964		switch p.focusedPane {
 965		case PanelTypeChat:
 966			shortList = append(shortList,
 967				key.NewBinding(
 968					key.WithKeys("up", "down"),
 969					key.WithHelp("↑↓", "scroll"),
 970				),
 971				messages.CopyKey,
 972			)
 973			fullList = append(fullList,
 974				[]key.Binding{
 975					key.NewBinding(
 976						key.WithKeys("up", "down"),
 977						key.WithHelp("↑↓", "scroll"),
 978					),
 979					key.NewBinding(
 980						key.WithKeys("shift+up", "shift+down"),
 981						key.WithHelp("shift+↑↓", "next/prev item"),
 982					),
 983					key.NewBinding(
 984						key.WithKeys("pgup", "b"),
 985						key.WithHelp("b/pgup", "page up"),
 986					),
 987					key.NewBinding(
 988						key.WithKeys("pgdown", " ", "f"),
 989						key.WithHelp("f/pgdn", "page down"),
 990					),
 991				},
 992				[]key.Binding{
 993					key.NewBinding(
 994						key.WithKeys("u"),
 995						key.WithHelp("u", "half page up"),
 996					),
 997					key.NewBinding(
 998						key.WithKeys("d"),
 999						key.WithHelp("d", "half page down"),
1000					),
1001					key.NewBinding(
1002						key.WithKeys("g", "home"),
1003						key.WithHelp("g", "home"),
1004					),
1005					key.NewBinding(
1006						key.WithKeys("G", "end"),
1007						key.WithHelp("G", "end"),
1008					),
1009				},
1010				[]key.Binding{
1011					messages.CopyKey,
1012					messages.ClearSelectionKey,
1013				},
1014			)
1015		case PanelTypeEditor:
1016			newLineBinding := key.NewBinding(
1017				key.WithKeys("shift+enter", "ctrl+j"),
1018				// "ctrl+j" is a common keybinding for newline in many editors. If
1019				// the terminal supports "shift+enter", we substitute the help text
1020				// to reflect that.
1021				key.WithHelp("ctrl+j", "newline"),
1022			)
1023			if p.keyboardEnhancements.SupportsKeyDisambiguation() {
1024				newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
1025			}
1026			shortList = append(shortList, newLineBinding)
1027			fullList = append(fullList,
1028				[]key.Binding{
1029					newLineBinding,
1030					key.NewBinding(
1031						key.WithKeys("ctrl+f"),
1032						key.WithHelp("ctrl+f", "add image"),
1033					),
1034					key.NewBinding(
1035						key.WithKeys("/"),
1036						key.WithHelp("/", "add file"),
1037					),
1038					key.NewBinding(
1039						key.WithKeys("ctrl+o"),
1040						key.WithHelp("ctrl+o", "open editor"),
1041					),
1042				})
1043
1044			if p.editor.HasAttachments() {
1045				fullList = append(fullList, []key.Binding{
1046					key.NewBinding(
1047						key.WithKeys("ctrl+r"),
1048						key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
1049					),
1050					key.NewBinding(
1051						key.WithKeys("ctrl+r", "r"),
1052						key.WithHelp("ctrl+r+r", "delete all attachments"),
1053					),
1054					key.NewBinding(
1055						key.WithKeys("esc", "alt+esc"),
1056						key.WithHelp("esc", "cancel delete mode"),
1057					),
1058				})
1059			}
1060		}
1061		shortList = append(shortList,
1062			// Quit
1063			key.NewBinding(
1064				key.WithKeys("ctrl+c"),
1065				key.WithHelp("ctrl+c", "quit"),
1066			),
1067			// Help
1068			helpBinding,
1069		)
1070		fullList = append(fullList, []key.Binding{
1071			key.NewBinding(
1072				key.WithKeys("ctrl+g"),
1073				key.WithHelp("ctrl+g", "less"),
1074			),
1075		})
1076	}
1077
1078	return core.NewSimpleHelp(shortList, fullList)
1079}
1080
1081func (p *chatPage) IsChatFocused() bool {
1082	return p.focusedPane == PanelTypeChat
1083}
1084
1085// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
1086// Returns true if the mouse is over the chat area, false otherwise.
1087func (p *chatPage) isMouseOverChat(x, y int) bool {
1088	// No session means no chat area
1089	if p.session.ID == "" {
1090		return false
1091	}
1092
1093	var chatX, chatY, chatWidth, chatHeight int
1094
1095	if p.compact {
1096		// In compact mode: chat area starts after header and spans full width
1097		chatX = 0
1098		chatY = HeaderHeight
1099		chatWidth = p.width
1100		chatHeight = p.height - EditorHeight - HeaderHeight
1101	} else {
1102		// In non-compact mode: chat area spans from left edge to sidebar
1103		chatX = 0
1104		chatY = 0
1105		chatWidth = p.width - SideBarWidth
1106		chatHeight = p.height - EditorHeight
1107	}
1108
1109	// Check if mouse coordinates are within chat bounds
1110	return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1111}