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