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