chat.go

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