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