chat.go

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