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