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	if p.app.CoderAgent != nil && p.app.CoderAgent.QueuedPrompts(p.session.ID) > 0 {
 657		p.app.CoderAgent.ClearQueue(p.session.ID)
 658		return nil
 659	}
 660	p.isCanceling = true
 661	return cancelTimerCmd()
 662}
 663
 664func (p *chatPage) setShowDetails(show bool) {
 665	p.showingDetails = show
 666	p.header.SetDetailsOpen(p.showingDetails)
 667	if !p.compact {
 668		p.sidebar.SetCompactMode(false)
 669	}
 670}
 671
 672func (p *chatPage) toggleDetails() {
 673	if p.session.ID == "" || !p.compact {
 674		return
 675	}
 676	p.setShowDetails(!p.showingDetails)
 677}
 678
 679func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
 680	session := p.session
 681	var cmds []tea.Cmd
 682	if p.session.ID == "" {
 683		newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
 684		if err != nil {
 685			return util.ReportError(err)
 686		}
 687		session = newSession
 688		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
 689	}
 690	_, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
 691	if err != nil {
 692		return util.ReportError(err)
 693	}
 694	cmds = append(cmds, p.chat.GoToBottom())
 695	return tea.Batch(cmds...)
 696}
 697
 698func (p *chatPage) Bindings() []key.Binding {
 699	bindings := []key.Binding{
 700		p.keyMap.NewSession,
 701		p.keyMap.AddAttachment,
 702	}
 703	if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
 704		cancelBinding := p.keyMap.Cancel
 705		if p.isCanceling {
 706			cancelBinding = key.NewBinding(
 707				key.WithKeys("esc"),
 708				key.WithHelp("esc", "press again to cancel"),
 709			)
 710		}
 711		bindings = append([]key.Binding{cancelBinding}, bindings...)
 712	}
 713
 714	switch p.focusedPane {
 715	case PanelTypeChat:
 716		bindings = append([]key.Binding{
 717			key.NewBinding(
 718				key.WithKeys("tab"),
 719				key.WithHelp("tab", "focus editor"),
 720			),
 721		}, bindings...)
 722		bindings = append(bindings, p.chat.Bindings()...)
 723	case PanelTypeEditor:
 724		bindings = append([]key.Binding{
 725			key.NewBinding(
 726				key.WithKeys("tab"),
 727				key.WithHelp("tab", "focus chat"),
 728			),
 729		}, bindings...)
 730		bindings = append(bindings, p.editor.Bindings()...)
 731	case PanelTypeSplash:
 732		bindings = append(bindings, p.splash.Bindings()...)
 733	}
 734
 735	return bindings
 736}
 737
 738func (p *chatPage) Help() help.KeyMap {
 739	var shortList []key.Binding
 740	var fullList [][]key.Binding
 741	switch {
 742	case p.isOnboarding && !p.splash.IsShowingAPIKey():
 743		shortList = append(shortList,
 744			// Choose model
 745			key.NewBinding(
 746				key.WithKeys("up", "down"),
 747				key.WithHelp("↑/↓", "choose"),
 748			),
 749			// Accept selection
 750			key.NewBinding(
 751				key.WithKeys("enter", "ctrl+y"),
 752				key.WithHelp("enter", "accept"),
 753			),
 754			// Quit
 755			key.NewBinding(
 756				key.WithKeys("ctrl+c"),
 757				key.WithHelp("ctrl+c", "quit"),
 758			),
 759		)
 760		// keep them the same
 761		for _, v := range shortList {
 762			fullList = append(fullList, []key.Binding{v})
 763		}
 764	case p.isOnboarding && p.splash.IsShowingAPIKey():
 765		if p.splash.IsAPIKeyValid() {
 766			shortList = append(shortList,
 767				key.NewBinding(
 768					key.WithKeys("enter"),
 769					key.WithHelp("enter", "continue"),
 770				),
 771			)
 772		} else {
 773			shortList = append(shortList,
 774				// Go back
 775				key.NewBinding(
 776					key.WithKeys("esc"),
 777					key.WithHelp("esc", "back"),
 778				),
 779			)
 780		}
 781		shortList = append(shortList,
 782			// Quit
 783			key.NewBinding(
 784				key.WithKeys("ctrl+c"),
 785				key.WithHelp("ctrl+c", "quit"),
 786			),
 787		)
 788		// keep them the same
 789		for _, v := range shortList {
 790			fullList = append(fullList, []key.Binding{v})
 791		}
 792	case p.isProjectInit:
 793		shortList = append(shortList,
 794			key.NewBinding(
 795				key.WithKeys("ctrl+c"),
 796				key.WithHelp("ctrl+c", "quit"),
 797			),
 798		)
 799		// keep them the same
 800		for _, v := range shortList {
 801			fullList = append(fullList, []key.Binding{v})
 802		}
 803	default:
 804		if p.editor.IsCompletionsOpen() {
 805			shortList = append(shortList,
 806				key.NewBinding(
 807					key.WithKeys("tab", "enter"),
 808					key.WithHelp("tab/enter", "complete"),
 809				),
 810				key.NewBinding(
 811					key.WithKeys("esc"),
 812					key.WithHelp("esc", "cancel"),
 813				),
 814				key.NewBinding(
 815					key.WithKeys("up", "down"),
 816					key.WithHelp("↑/↓", "choose"),
 817				),
 818			)
 819			for _, v := range shortList {
 820				fullList = append(fullList, []key.Binding{v})
 821			}
 822			return core.NewSimpleHelp(shortList, fullList)
 823		}
 824		if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
 825			cancelBinding := key.NewBinding(
 826				key.WithKeys("esc"),
 827				key.WithHelp("esc", "cancel"),
 828			)
 829			if p.isCanceling {
 830				cancelBinding = key.NewBinding(
 831					key.WithKeys("esc"),
 832					key.WithHelp("esc", "press again to cancel"),
 833				)
 834			}
 835			if p.app.CoderAgent.QueuedPrompts(p.session.ID) > 0 {
 836				cancelBinding = key.NewBinding(
 837					key.WithKeys("esc"),
 838					key.WithHelp("esc", "clear queue"),
 839				)
 840			}
 841			shortList = append(shortList, cancelBinding)
 842			fullList = append(fullList,
 843				[]key.Binding{
 844					cancelBinding,
 845				},
 846			)
 847		}
 848		globalBindings := []key.Binding{}
 849		// we are in a session
 850		if p.session.ID != "" {
 851			tabKey := key.NewBinding(
 852				key.WithKeys("tab"),
 853				key.WithHelp("tab", "focus chat"),
 854			)
 855			if p.focusedPane == PanelTypeChat {
 856				tabKey = key.NewBinding(
 857					key.WithKeys("tab"),
 858					key.WithHelp("tab", "focus editor"),
 859				)
 860			}
 861			shortList = append(shortList, tabKey)
 862			globalBindings = append(globalBindings, tabKey)
 863		}
 864		commandsBinding := key.NewBinding(
 865			key.WithKeys("ctrl+p"),
 866			key.WithHelp("ctrl+p", "commands"),
 867		)
 868		helpBinding := key.NewBinding(
 869			key.WithKeys("ctrl+g"),
 870			key.WithHelp("ctrl+g", "more"),
 871		)
 872		globalBindings = append(globalBindings, commandsBinding)
 873		globalBindings = append(globalBindings,
 874			key.NewBinding(
 875				key.WithKeys("ctrl+s"),
 876				key.WithHelp("ctrl+s", "sessions"),
 877			),
 878		)
 879		if p.session.ID != "" {
 880			globalBindings = append(globalBindings,
 881				key.NewBinding(
 882					key.WithKeys("ctrl+n"),
 883					key.WithHelp("ctrl+n", "new sessions"),
 884				))
 885		}
 886		shortList = append(shortList,
 887			// Commands
 888			commandsBinding,
 889		)
 890		fullList = append(fullList, globalBindings)
 891
 892		switch p.focusedPane {
 893		case PanelTypeChat:
 894			shortList = append(shortList,
 895				key.NewBinding(
 896					key.WithKeys("up", "down"),
 897					key.WithHelp("↑↓", "scroll"),
 898				),
 899				messages.CopyKey,
 900			)
 901			fullList = append(fullList,
 902				[]key.Binding{
 903					key.NewBinding(
 904						key.WithKeys("up", "down"),
 905						key.WithHelp("↑↓", "scroll"),
 906					),
 907					key.NewBinding(
 908						key.WithKeys("shift+up", "shift+down"),
 909						key.WithHelp("shift+↑↓", "next/prev item"),
 910					),
 911					key.NewBinding(
 912						key.WithKeys("pgup", "b"),
 913						key.WithHelp("b/pgup", "page up"),
 914					),
 915					key.NewBinding(
 916						key.WithKeys("pgdown", " ", "f"),
 917						key.WithHelp("f/pgdn", "page down"),
 918					),
 919				},
 920				[]key.Binding{
 921					key.NewBinding(
 922						key.WithKeys("u"),
 923						key.WithHelp("u", "half page up"),
 924					),
 925					key.NewBinding(
 926						key.WithKeys("d"),
 927						key.WithHelp("d", "half page down"),
 928					),
 929					key.NewBinding(
 930						key.WithKeys("g", "home"),
 931						key.WithHelp("g", "home"),
 932					),
 933					key.NewBinding(
 934						key.WithKeys("G", "end"),
 935						key.WithHelp("G", "end"),
 936					),
 937				},
 938				[]key.Binding{
 939					messages.CopyKey,
 940					messages.ClearSelectionKey,
 941				},
 942			)
 943		case PanelTypeEditor:
 944			newLineBinding := key.NewBinding(
 945				key.WithKeys("shift+enter", "ctrl+j"),
 946				// "ctrl+j" is a common keybinding for newline in many editors. If
 947				// the terminal supports "shift+enter", we substitute the help text
 948				// to reflect that.
 949				key.WithHelp("ctrl+j", "newline"),
 950			)
 951			if p.keyboardEnhancements.SupportsKeyDisambiguation() {
 952				newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
 953			}
 954			shortList = append(shortList, newLineBinding)
 955			fullList = append(fullList,
 956				[]key.Binding{
 957					newLineBinding,
 958					key.NewBinding(
 959						key.WithKeys("ctrl+f"),
 960						key.WithHelp("ctrl+f", "add image"),
 961					),
 962					key.NewBinding(
 963						key.WithKeys("/"),
 964						key.WithHelp("/", "add file"),
 965					),
 966					key.NewBinding(
 967						key.WithKeys("ctrl+o"),
 968						key.WithHelp("ctrl+o", "open editor"),
 969					),
 970				})
 971
 972			if p.editor.HasAttachments() {
 973				fullList = append(fullList, []key.Binding{
 974					key.NewBinding(
 975						key.WithKeys("ctrl+r"),
 976						key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
 977					),
 978					key.NewBinding(
 979						key.WithKeys("ctrl+r", "r"),
 980						key.WithHelp("ctrl+r+r", "delete all attachments"),
 981					),
 982					key.NewBinding(
 983						key.WithKeys("esc"),
 984						key.WithHelp("esc", "cancel delete mode"),
 985					),
 986				})
 987			}
 988		}
 989		shortList = append(shortList,
 990			// Quit
 991			key.NewBinding(
 992				key.WithKeys("ctrl+c"),
 993				key.WithHelp("ctrl+c", "quit"),
 994			),
 995			// Help
 996			helpBinding,
 997		)
 998		fullList = append(fullList, []key.Binding{
 999			key.NewBinding(
1000				key.WithKeys("ctrl+g"),
1001				key.WithHelp("ctrl+g", "less"),
1002			),
1003		})
1004	}
1005
1006	return core.NewSimpleHelp(shortList, fullList)
1007}
1008
1009func (p *chatPage) IsChatFocused() bool {
1010	return p.focusedPane == PanelTypeChat
1011}
1012
1013// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
1014// Returns true if the mouse is over the chat area, false otherwise.
1015func (p *chatPage) isMouseOverChat(x, y int) bool {
1016	// No session means no chat area
1017	if p.session.ID == "" {
1018		return false
1019	}
1020
1021	var chatX, chatY, chatWidth, chatHeight int
1022
1023	if p.compact {
1024		// In compact mode: chat area starts after header and spans full width
1025		chatX = 0
1026		chatY = HeaderHeight
1027		chatWidth = p.width
1028		chatHeight = p.height - EditorHeight - HeaderHeight
1029	} else {
1030		// In non-compact mode: chat area spans from left edge to sidebar
1031		chatX = 0
1032		chatY = 0
1033		chatWidth = p.width - SideBarWidth
1034		chatHeight = p.height - EditorHeight
1035	}
1036
1037	// Check if mouse coordinates are within chat bounds
1038	return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1039}