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.NewCompositor(layers...)
 529	return canvas.Render()
 530}
 531
 532func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
 533	return func() tea.Msg {
 534		err := config.Get().SetCompactMode(compact)
 535		if err != nil {
 536			return util.InfoMsg{
 537				Type: util.InfoTypeError,
 538				Msg:  "Failed to update compact mode configuration: " + err.Error(),
 539			}
 540		}
 541		return nil
 542	}
 543}
 544
 545func (p *chatPage) toggleThinking() tea.Cmd {
 546	return func() tea.Msg {
 547		cfg := config.Get()
 548		agentCfg := cfg.Agents[config.AgentCoder]
 549		currentModel := cfg.Models[agentCfg.Model]
 550
 551		// Toggle the thinking mode
 552		currentModel.Think = !currentModel.Think
 553		if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
 554			return util.InfoMsg{
 555				Type: util.InfoTypeError,
 556				Msg:  "Failed to update thinking mode: " + err.Error(),
 557			}
 558		}
 559
 560		// Update the agent with the new configuration
 561		go p.app.UpdateAgentModel(context.TODO())
 562
 563		status := "disabled"
 564		if currentModel.Think {
 565			status = "enabled"
 566		}
 567		return util.InfoMsg{
 568			Type: util.InfoTypeInfo,
 569			Msg:  "Thinking mode " + status,
 570		}
 571	}
 572}
 573
 574func (p *chatPage) openReasoningDialog() tea.Cmd {
 575	return func() tea.Msg {
 576		cfg := config.Get()
 577		agentCfg := cfg.Agents[config.AgentCoder]
 578		model := cfg.GetModelByType(agentCfg.Model)
 579		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
 580
 581		if providerCfg != nil && model != nil && len(model.ReasoningLevels) > 0 {
 582			// Return the OpenDialogMsg directly so it bubbles up to the main TUI
 583			return dialogs.OpenDialogMsg{
 584				Model: reasoning.NewReasoningDialog(),
 585			}
 586		}
 587		return nil
 588	}
 589}
 590
 591func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
 592	return func() tea.Msg {
 593		cfg := config.Get()
 594		agentCfg := cfg.Agents[config.AgentCoder]
 595		currentModel := cfg.Models[agentCfg.Model]
 596
 597		// Update the model configuration
 598		currentModel.ReasoningEffort = effort
 599		if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
 600			return util.InfoMsg{
 601				Type: util.InfoTypeError,
 602				Msg:  "Failed to update reasoning effort: " + err.Error(),
 603			}
 604		}
 605
 606		// Update the agent with the new configuration
 607		if err := p.app.UpdateAgentModel(context.TODO()); err != nil {
 608			return util.InfoMsg{
 609				Type: util.InfoTypeError,
 610				Msg:  "Failed to update reasoning effort: " + err.Error(),
 611			}
 612		}
 613
 614		return util.InfoMsg{
 615			Type: util.InfoTypeInfo,
 616			Msg:  "Reasoning effort set to " + effort,
 617		}
 618	}
 619}
 620
 621func (p *chatPage) setCompactMode(compact bool) {
 622	if p.compact == compact {
 623		return
 624	}
 625	p.compact = compact
 626	if compact {
 627		p.sidebar.SetCompactMode(true)
 628	} else {
 629		p.setShowDetails(false)
 630	}
 631}
 632
 633func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
 634	if p.forceCompact {
 635		return
 636	}
 637	if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
 638		p.setCompactMode(true)
 639	}
 640	if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
 641		p.setCompactMode(false)
 642	}
 643}
 644
 645func (p *chatPage) SetSize(width, height int) tea.Cmd {
 646	p.handleCompactMode(width, height)
 647	p.width = width
 648	p.height = height
 649	var cmds []tea.Cmd
 650
 651	if p.session.ID == "" {
 652		if p.splashFullScreen {
 653			cmds = append(cmds, p.splash.SetSize(width, height))
 654		} else {
 655			cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
 656			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 657			cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
 658		}
 659	} else {
 660		if p.compact {
 661			cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
 662			p.detailsWidth = width - DetailsPositioning
 663			cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
 664			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 665			cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
 666		} else {
 667			cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
 668			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 669			cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
 670		}
 671		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
 672	}
 673	return tea.Batch(cmds...)
 674}
 675
 676func (p *chatPage) newSession() tea.Cmd {
 677	if p.session.ID == "" {
 678		return nil
 679	}
 680
 681	p.session = session.Session{}
 682	p.focusedPane = PanelTypeEditor
 683	p.editor.Focus()
 684	p.chat.Blur()
 685	p.isCanceling = false
 686	return tea.Batch(
 687		util.CmdHandler(chat.SessionClearedMsg{}),
 688		p.SetSize(p.width, p.height),
 689	)
 690}
 691
 692func (p *chatPage) setSession(session session.Session) tea.Cmd {
 693	if p.session.ID == session.ID {
 694		return nil
 695	}
 696
 697	var cmds []tea.Cmd
 698	p.session = session
 699
 700	cmds = append(cmds, p.SetSize(p.width, p.height))
 701	cmds = append(cmds, p.chat.SetSession(session))
 702	cmds = append(cmds, p.sidebar.SetSession(session))
 703	cmds = append(cmds, p.header.SetSession(session))
 704	cmds = append(cmds, p.editor.SetSession(session))
 705
 706	return tea.Sequence(cmds...)
 707}
 708
 709func (p *chatPage) changeFocus() {
 710	if p.session.ID == "" {
 711		return
 712	}
 713	switch p.focusedPane {
 714	case PanelTypeChat:
 715		p.focusedPane = PanelTypeEditor
 716		p.editor.Focus()
 717		p.chat.Blur()
 718	case PanelTypeEditor:
 719		p.focusedPane = PanelTypeChat
 720		p.chat.Focus()
 721		p.editor.Blur()
 722	}
 723}
 724
 725func (p *chatPage) cancel() tea.Cmd {
 726	if p.isCanceling {
 727		p.isCanceling = false
 728		if p.app.AgentCoordinator != nil {
 729			p.app.AgentCoordinator.Cancel(p.session.ID)
 730		}
 731		return nil
 732	}
 733
 734	if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
 735		p.app.AgentCoordinator.ClearQueue(p.session.ID)
 736		return nil
 737	}
 738	p.isCanceling = true
 739	return cancelTimerCmd()
 740}
 741
 742func (p *chatPage) setShowDetails(show bool) {
 743	p.showingDetails = show
 744	p.header.SetDetailsOpen(p.showingDetails)
 745	if !p.compact {
 746		p.sidebar.SetCompactMode(false)
 747	}
 748}
 749
 750func (p *chatPage) toggleDetails() {
 751	if p.session.ID == "" || !p.compact {
 752		return
 753	}
 754	p.setShowDetails(!p.showingDetails)
 755}
 756
 757func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
 758	session := p.session
 759	var cmds []tea.Cmd
 760	if p.session.ID == "" {
 761		newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
 762		if err != nil {
 763			return util.ReportError(err)
 764		}
 765		session = newSession
 766		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
 767	}
 768	if p.app.AgentCoordinator == nil {
 769		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
 770	}
 771	cmds = append(cmds, p.chat.GoToBottom())
 772	cmds = append(cmds, func() tea.Msg {
 773		_, err := p.app.AgentCoordinator.Run(context.Background(), session.ID, text, attachments...)
 774		if err != nil {
 775			isCancelErr := errors.Is(err, context.Canceled)
 776			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
 777			if isCancelErr || isPermissionErr {
 778				return nil
 779			}
 780			return util.InfoMsg{
 781				Type: util.InfoTypeError,
 782				Msg:  err.Error(),
 783			}
 784		}
 785		return nil
 786	})
 787	return tea.Batch(cmds...)
 788}
 789
 790func (p *chatPage) Bindings() []key.Binding {
 791	bindings := []key.Binding{
 792		p.keyMap.NewSession,
 793		p.keyMap.AddAttachment,
 794	}
 795	if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
 796		cancelBinding := p.keyMap.Cancel
 797		if p.isCanceling {
 798			cancelBinding = key.NewBinding(
 799				key.WithKeys("esc", "alt+esc"),
 800				key.WithHelp("esc", "press again to cancel"),
 801			)
 802		}
 803		bindings = append([]key.Binding{cancelBinding}, bindings...)
 804	}
 805
 806	switch p.focusedPane {
 807	case PanelTypeChat:
 808		bindings = append([]key.Binding{
 809			key.NewBinding(
 810				key.WithKeys("tab"),
 811				key.WithHelp("tab", "focus editor"),
 812			),
 813		}, bindings...)
 814		bindings = append(bindings, p.chat.Bindings()...)
 815	case PanelTypeEditor:
 816		bindings = append([]key.Binding{
 817			key.NewBinding(
 818				key.WithKeys("tab"),
 819				key.WithHelp("tab", "focus chat"),
 820			),
 821		}, bindings...)
 822		bindings = append(bindings, p.editor.Bindings()...)
 823	case PanelTypeSplash:
 824		bindings = append(bindings, p.splash.Bindings()...)
 825	}
 826
 827	return bindings
 828}
 829
 830func (p *chatPage) Help() help.KeyMap {
 831	var shortList []key.Binding
 832	var fullList [][]key.Binding
 833	switch {
 834	case p.isOnboarding && p.splash.IsShowingClaudeAuthMethodChooser():
 835		shortList = append(shortList,
 836			// Choose auth method
 837			key.NewBinding(
 838				key.WithKeys("left", "right", "tab"),
 839				key.WithHelp("←→/tab", "choose"),
 840			),
 841			// Accept selection
 842			key.NewBinding(
 843				key.WithKeys("enter"),
 844				key.WithHelp("enter", "accept"),
 845			),
 846			// Go back
 847			key.NewBinding(
 848				key.WithKeys("esc", "alt+esc"),
 849				key.WithHelp("esc", "back"),
 850			),
 851			// Quit
 852			key.NewBinding(
 853				key.WithKeys("ctrl+c"),
 854				key.WithHelp("ctrl+c", "quit"),
 855			),
 856		)
 857		// keep them the same
 858		for _, v := range shortList {
 859			fullList = append(fullList, []key.Binding{v})
 860		}
 861	case p.isOnboarding && p.splash.IsShowingClaudeOAuth2():
 862		if p.splash.IsClaudeOAuthURLState() {
 863			shortList = append(shortList,
 864				key.NewBinding(
 865					key.WithKeys("enter"),
 866					key.WithHelp("enter", "open"),
 867				),
 868				key.NewBinding(
 869					key.WithKeys("c"),
 870					key.WithHelp("c", "copy url"),
 871				),
 872			)
 873		} else if p.splash.IsClaudeOAuthComplete() {
 874			shortList = append(shortList,
 875				key.NewBinding(
 876					key.WithKeys("enter"),
 877					key.WithHelp("enter", "continue"),
 878				),
 879			)
 880		} else {
 881			shortList = append(shortList,
 882				key.NewBinding(
 883					key.WithKeys("enter"),
 884					key.WithHelp("enter", "submit"),
 885				),
 886			)
 887		}
 888		shortList = append(shortList,
 889			// Quit
 890			key.NewBinding(
 891				key.WithKeys("ctrl+c"),
 892				key.WithHelp("ctrl+c", "quit"),
 893			),
 894		)
 895		// keep them the same
 896		for _, v := range shortList {
 897			fullList = append(fullList, []key.Binding{v})
 898		}
 899	case p.isOnboarding && !p.splash.IsShowingAPIKey():
 900		shortList = append(shortList,
 901			// Choose model
 902			key.NewBinding(
 903				key.WithKeys("up", "down"),
 904				key.WithHelp("↑/↓", "choose"),
 905			),
 906			// Accept selection
 907			key.NewBinding(
 908				key.WithKeys("enter", "ctrl+y"),
 909				key.WithHelp("enter", "accept"),
 910			),
 911			// Quit
 912			key.NewBinding(
 913				key.WithKeys("ctrl+c"),
 914				key.WithHelp("ctrl+c", "quit"),
 915			),
 916		)
 917		// keep them the same
 918		for _, v := range shortList {
 919			fullList = append(fullList, []key.Binding{v})
 920		}
 921	case p.isOnboarding && p.splash.IsShowingAPIKey():
 922		if p.splash.IsAPIKeyValid() {
 923			shortList = append(shortList,
 924				key.NewBinding(
 925					key.WithKeys("enter"),
 926					key.WithHelp("enter", "continue"),
 927				),
 928			)
 929		} else {
 930			shortList = append(shortList,
 931				// Go back
 932				key.NewBinding(
 933					key.WithKeys("esc", "alt+esc"),
 934					key.WithHelp("esc", "back"),
 935				),
 936			)
 937		}
 938		shortList = append(shortList,
 939			// Quit
 940			key.NewBinding(
 941				key.WithKeys("ctrl+c"),
 942				key.WithHelp("ctrl+c", "quit"),
 943			),
 944		)
 945		// keep them the same
 946		for _, v := range shortList {
 947			fullList = append(fullList, []key.Binding{v})
 948		}
 949	case p.isProjectInit:
 950		shortList = append(shortList,
 951			key.NewBinding(
 952				key.WithKeys("ctrl+c"),
 953				key.WithHelp("ctrl+c", "quit"),
 954			),
 955		)
 956		// keep them the same
 957		for _, v := range shortList {
 958			fullList = append(fullList, []key.Binding{v})
 959		}
 960	default:
 961		if p.editor.IsCompletionsOpen() {
 962			shortList = append(shortList,
 963				key.NewBinding(
 964					key.WithKeys("tab", "enter"),
 965					key.WithHelp("tab/enter", "complete"),
 966				),
 967				key.NewBinding(
 968					key.WithKeys("esc", "alt+esc"),
 969					key.WithHelp("esc", "cancel"),
 970				),
 971				key.NewBinding(
 972					key.WithKeys("up", "down"),
 973					key.WithHelp("↑/↓", "choose"),
 974				),
 975			)
 976			for _, v := range shortList {
 977				fullList = append(fullList, []key.Binding{v})
 978			}
 979			return core.NewSimpleHelp(shortList, fullList)
 980		}
 981		if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
 982			cancelBinding := key.NewBinding(
 983				key.WithKeys("esc", "alt+esc"),
 984				key.WithHelp("esc", "cancel"),
 985			)
 986			if p.isCanceling {
 987				cancelBinding = key.NewBinding(
 988					key.WithKeys("esc", "alt+esc"),
 989					key.WithHelp("esc", "press again to cancel"),
 990				)
 991			}
 992			if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
 993				cancelBinding = key.NewBinding(
 994					key.WithKeys("esc", "alt+esc"),
 995					key.WithHelp("esc", "clear queue"),
 996				)
 997			}
 998			shortList = append(shortList, cancelBinding)
 999			fullList = append(fullList,
1000				[]key.Binding{
1001					cancelBinding,
1002				},
1003			)
1004		}
1005		globalBindings := []key.Binding{}
1006		// we are in a session
1007		if p.session.ID != "" {
1008			tabKey := key.NewBinding(
1009				key.WithKeys("tab"),
1010				key.WithHelp("tab", "focus chat"),
1011			)
1012			if p.focusedPane == PanelTypeChat {
1013				tabKey = key.NewBinding(
1014					key.WithKeys("tab"),
1015					key.WithHelp("tab", "focus editor"),
1016				)
1017			}
1018			shortList = append(shortList, tabKey)
1019			globalBindings = append(globalBindings, tabKey)
1020		}
1021		commandsBinding := key.NewBinding(
1022			key.WithKeys("ctrl+p"),
1023			key.WithHelp("ctrl+p", "commands"),
1024		)
1025		if p.focusedPane == PanelTypeEditor && p.editor.IsEmpty() {
1026			commandsBinding.SetHelp("/ or ctrl+p", "commands")
1027		}
1028		modelsBinding := key.NewBinding(
1029			key.WithKeys("ctrl+m", "ctrl+l"),
1030			key.WithHelp("ctrl+l", "models"),
1031		)
1032		if p.keyboardEnhancements.Flags > 0 {
1033			// non-zero flags mean we have at least key disambiguation
1034			modelsBinding.SetHelp("ctrl+m", "models")
1035		}
1036		helpBinding := key.NewBinding(
1037			key.WithKeys("ctrl+g"),
1038			key.WithHelp("ctrl+g", "more"),
1039		)
1040		globalBindings = append(globalBindings, commandsBinding, modelsBinding)
1041		globalBindings = append(globalBindings,
1042			key.NewBinding(
1043				key.WithKeys("ctrl+s"),
1044				key.WithHelp("ctrl+s", "sessions"),
1045			),
1046		)
1047		if p.session.ID != "" {
1048			globalBindings = append(globalBindings,
1049				key.NewBinding(
1050					key.WithKeys("ctrl+n"),
1051					key.WithHelp("ctrl+n", "new sessions"),
1052				))
1053		}
1054		shortList = append(shortList,
1055			// Commands
1056			commandsBinding,
1057			modelsBinding,
1058		)
1059		fullList = append(fullList, globalBindings)
1060
1061		switch p.focusedPane {
1062		case PanelTypeChat:
1063			shortList = append(shortList,
1064				key.NewBinding(
1065					key.WithKeys("up", "down"),
1066					key.WithHelp("↑↓", "scroll"),
1067				),
1068				messages.CopyKey,
1069			)
1070			fullList = append(fullList,
1071				[]key.Binding{
1072					key.NewBinding(
1073						key.WithKeys("up", "down"),
1074						key.WithHelp("↑↓", "scroll"),
1075					),
1076					key.NewBinding(
1077						key.WithKeys("shift+up", "shift+down"),
1078						key.WithHelp("shift+↑↓", "next/prev item"),
1079					),
1080					key.NewBinding(
1081						key.WithKeys("pgup", "b"),
1082						key.WithHelp("b/pgup", "page up"),
1083					),
1084					key.NewBinding(
1085						key.WithKeys("pgdown", " ", "f"),
1086						key.WithHelp("f/pgdn", "page down"),
1087					),
1088				},
1089				[]key.Binding{
1090					key.NewBinding(
1091						key.WithKeys("u"),
1092						key.WithHelp("u", "half page up"),
1093					),
1094					key.NewBinding(
1095						key.WithKeys("d"),
1096						key.WithHelp("d", "half page down"),
1097					),
1098					key.NewBinding(
1099						key.WithKeys("g", "home"),
1100						key.WithHelp("g", "home"),
1101					),
1102					key.NewBinding(
1103						key.WithKeys("G", "end"),
1104						key.WithHelp("G", "end"),
1105					),
1106				},
1107				[]key.Binding{
1108					messages.CopyKey,
1109					messages.ClearSelectionKey,
1110				},
1111			)
1112		case PanelTypeEditor:
1113			newLineBinding := key.NewBinding(
1114				key.WithKeys("shift+enter", "ctrl+j"),
1115				// "ctrl+j" is a common keybinding for newline in many editors. If
1116				// the terminal supports "shift+enter", we substitute the help text
1117				// to reflect that.
1118				key.WithHelp("ctrl+j", "newline"),
1119			)
1120			if p.keyboardEnhancements.Flags > 0 {
1121				// Non-zero flags mean we have at least key disambiguation.
1122				newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
1123			}
1124			shortList = append(shortList, newLineBinding)
1125			fullList = append(fullList,
1126				[]key.Binding{
1127					newLineBinding,
1128					key.NewBinding(
1129						key.WithKeys("ctrl+f"),
1130						key.WithHelp("ctrl+f", "add image"),
1131					),
1132					key.NewBinding(
1133						key.WithKeys("@"),
1134						key.WithHelp("@", "mention file"),
1135					),
1136					key.NewBinding(
1137						key.WithKeys("ctrl+o"),
1138						key.WithHelp("ctrl+o", "open editor"),
1139					),
1140				})
1141
1142			if p.editor.HasAttachments() {
1143				fullList = append(fullList, []key.Binding{
1144					key.NewBinding(
1145						key.WithKeys("ctrl+r"),
1146						key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
1147					),
1148					key.NewBinding(
1149						key.WithKeys("ctrl+r", "r"),
1150						key.WithHelp("ctrl+r+r", "delete all attachments"),
1151					),
1152					key.NewBinding(
1153						key.WithKeys("esc", "alt+esc"),
1154						key.WithHelp("esc", "cancel delete mode"),
1155					),
1156				})
1157			}
1158		}
1159		shortList = append(shortList,
1160			// Quit
1161			key.NewBinding(
1162				key.WithKeys("ctrl+c"),
1163				key.WithHelp("ctrl+c", "quit"),
1164			),
1165			// Help
1166			helpBinding,
1167		)
1168		fullList = append(fullList, []key.Binding{
1169			key.NewBinding(
1170				key.WithKeys("ctrl+g"),
1171				key.WithHelp("ctrl+g", "less"),
1172			),
1173		})
1174	}
1175
1176	return core.NewSimpleHelp(shortList, fullList)
1177}
1178
1179func (p *chatPage) IsChatFocused() bool {
1180	return p.focusedPane == PanelTypeChat
1181}
1182
1183// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
1184// Returns true if the mouse is over the chat area, false otherwise.
1185func (p *chatPage) isMouseOverChat(x, y int) bool {
1186	// No session means no chat area
1187	if p.session.ID == "" {
1188		return false
1189	}
1190
1191	var chatX, chatY, chatWidth, chatHeight int
1192
1193	if p.compact {
1194		// In compact mode: chat area starts after header and spans full width
1195		chatX = 0
1196		chatY = HeaderHeight
1197		chatWidth = p.width
1198		chatHeight = p.height - EditorHeight - HeaderHeight
1199	} else {
1200		// In non-compact mode: chat area spans from left edge to sidebar
1201		chatX = 0
1202		chatY = 0
1203		chatWidth = p.width - SideBarWidth
1204		chatHeight = p.height - EditorHeight
1205	}
1206
1207	// Check if mouse coordinates are within chat bounds
1208	return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1209}