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