chat.go

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