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