chat.go

   1package chat
   2
   3import (
   4	"context"
   5	"errors"
   6	"fmt"
   7	"time"
   8
   9	"charm.land/bubbles/v2/help"
  10	"charm.land/bubbles/v2/key"
  11	"charm.land/bubbles/v2/spinner"
  12	tea "charm.land/bubbletea/v2"
  13	"charm.land/lipgloss/v2"
  14	"github.com/charmbracelet/crush/internal/app"
  15	"github.com/charmbracelet/crush/internal/config"
  16	"github.com/charmbracelet/crush/internal/history"
  17	"github.com/charmbracelet/crush/internal/message"
  18	"github.com/charmbracelet/crush/internal/permission"
  19	"github.com/charmbracelet/crush/internal/pubsub"
  20	"github.com/charmbracelet/crush/internal/session"
  21	"github.com/charmbracelet/crush/internal/tui/components/anim"
  22	"github.com/charmbracelet/crush/internal/tui/components/chat"
  23	"github.com/charmbracelet/crush/internal/tui/components/chat/editor"
  24	"github.com/charmbracelet/crush/internal/tui/components/chat/header"
  25	"github.com/charmbracelet/crush/internal/tui/components/chat/messages"
  26	"github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
  27	"github.com/charmbracelet/crush/internal/tui/components/chat/splash"
  28	"github.com/charmbracelet/crush/internal/tui/components/completions"
  29	"github.com/charmbracelet/crush/internal/tui/components/core"
  30	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
  31	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
  32	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
  33	"github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
  34	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
  35	"github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
  36	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
  37	"github.com/charmbracelet/crush/internal/tui/components/dialogs/reasoning"
  38	"github.com/charmbracelet/crush/internal/tui/page"
  39	"github.com/charmbracelet/crush/internal/tui/styles"
  40	"github.com/charmbracelet/crush/internal/tui/util"
  41	"github.com/charmbracelet/crush/internal/version"
  42)
  43
  44var ChatPageID page.PageID = "chat"
  45
  46type (
  47	ChatFocusedMsg struct {
  48		Focused bool
  49	}
  50	CancelTimerExpiredMsg struct{}
  51)
  52
  53type PanelType string
  54
  55const (
  56	PanelTypeChat   PanelType = "chat"
  57	PanelTypeEditor PanelType = "editor"
  58	PanelTypeSplash PanelType = "splash"
  59)
  60
  61// PillSection represents which pill section is focused when in pills panel.
  62type PillSection int
  63
  64const (
  65	PillSectionTodos PillSection = iota
  66	PillSectionQueue
  67)
  68
  69const (
  70	CompactModeWidthBreakpoint  = 120 // Width at which the chat page switches to compact mode
  71	CompactModeHeightBreakpoint = 30  // Height at which the chat page switches to compact mode
  72	EditorHeight                = 5   // Height of the editor input area including padding
  73	SideBarWidth                = 31  // Width of the sidebar
  74	SideBarDetailsPadding       = 1   // Padding for the sidebar details section
  75	HeaderHeight                = 1   // Height of the header
  76
  77	// Layout constants for borders and padding
  78	BorderWidth        = 1 // Width of component borders
  79	LeftRightBorders   = 2 // Left + right border width (1 + 1)
  80	TopBottomBorders   = 2 // Top + bottom border width (1 + 1)
  81	DetailsPositioning = 2 // Positioning adjustment for details panel
  82
  83	// Timing constants
  84	CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires
  85)
  86
  87type ChatPage interface {
  88	util.Model
  89	layout.Help
  90	IsChatFocused() bool
  91}
  92
  93// cancelTimerCmd creates a command that expires the cancel timer
  94func cancelTimerCmd() tea.Cmd {
  95	return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg {
  96		return CancelTimerExpiredMsg{}
  97	})
  98}
  99
 100type chatPage struct {
 101	width, height               int
 102	detailsWidth, detailsHeight int
 103	app                         *app.App
 104	keyboardEnhancements        tea.KeyboardEnhancementsMsg
 105
 106	// Layout state
 107	compact      bool
 108	forceCompact bool
 109	focusedPane  PanelType
 110
 111	// Session
 112	session session.Session
 113	keyMap  KeyMap
 114
 115	// Components
 116	header  header.Header
 117	sidebar sidebar.Sidebar
 118	chat    chat.MessageListCmp
 119	editor  editor.Editor
 120	splash  splash.Splash
 121
 122	// Simple state flags
 123	showingDetails   bool
 124	isCanceling      bool
 125	splashFullScreen bool
 126	isOnboarding     bool
 127	isProjectInit    bool
 128	promptQueue      int
 129
 130	// Pills state
 131	pillsExpanded      bool
 132	focusedPillSection PillSection
 133
 134	// Todo spinner
 135	todoSpinner spinner.Model
 136}
 137
 138func New(app *app.App) ChatPage {
 139	t := styles.CurrentTheme()
 140	return &chatPage{
 141		app:         app,
 142		keyMap:      DefaultKeyMap(),
 143		header:      header.New(app.LSPClients),
 144		sidebar:     sidebar.New(app.History, app.LSPClients, false),
 145		chat:        chat.New(app),
 146		editor:      editor.New(app),
 147		splash:      splash.New(),
 148		focusedPane: PanelTypeSplash,
 149		todoSpinner: spinner.New(
 150			spinner.WithSpinner(spinner.MiniDot),
 151			spinner.WithStyle(t.S().Base.Foreground(t.GreenDark)),
 152		),
 153	}
 154}
 155
 156func (p *chatPage) Init() tea.Cmd {
 157	cfg := config.Get()
 158	compact := cfg.Options.TUI.CompactMode
 159	p.compact = compact
 160	p.forceCompact = compact
 161	p.sidebar.SetCompactMode(p.compact)
 162
 163	// Set splash state based on config
 164	if !config.HasInitialDataConfig() {
 165		// First-time setup: show model selection
 166		p.splash.SetOnboarding(true)
 167		p.isOnboarding = true
 168		p.splashFullScreen = true
 169	} else if b, _ := config.ProjectNeedsInitialization(); b {
 170		// Project needs context initialization
 171		p.splash.SetProjectInit(true)
 172		p.isProjectInit = true
 173		p.splashFullScreen = true
 174	} else {
 175		// Ready to chat: focus editor, splash in background
 176		p.focusedPane = PanelTypeEditor
 177		p.splashFullScreen = false
 178	}
 179
 180	return tea.Batch(
 181		p.header.Init(),
 182		p.sidebar.Init(),
 183		p.chat.Init(),
 184		p.editor.Init(),
 185		p.splash.Init(),
 186	)
 187}
 188
 189func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 190	var cmds []tea.Cmd
 191	if p.session.ID != "" && p.app.AgentCoordinator != nil {
 192		queueSize := p.app.AgentCoordinator.QueuedPrompts(p.session.ID)
 193		if queueSize != p.promptQueue {
 194			p.promptQueue = queueSize
 195			cmds = append(cmds, p.SetSize(p.width, p.height))
 196		}
 197	}
 198	switch msg := msg.(type) {
 199	case tea.KeyboardEnhancementsMsg:
 200		p.keyboardEnhancements = msg
 201		return p, nil
 202	case tea.MouseWheelMsg:
 203		if p.compact {
 204			msg.Y -= 1
 205		}
 206		if p.isMouseOverChat(msg.X, msg.Y) {
 207			u, cmd := p.chat.Update(msg)
 208			p.chat = u.(chat.MessageListCmp)
 209			return p, cmd
 210		}
 211		return p, nil
 212	case tea.MouseClickMsg:
 213		if p.isOnboarding || p.isProjectInit {
 214			return p, nil
 215		}
 216		if p.compact {
 217			msg.Y -= 1
 218		}
 219		if p.isMouseOverChat(msg.X, msg.Y) {
 220			p.focusedPane = PanelTypeChat
 221			p.chat.Focus()
 222			p.editor.Blur()
 223		} else {
 224			p.focusedPane = PanelTypeEditor
 225			p.editor.Focus()
 226			p.chat.Blur()
 227		}
 228		u, cmd := p.chat.Update(msg)
 229		p.chat = u.(chat.MessageListCmp)
 230		return p, cmd
 231	case tea.MouseMotionMsg:
 232		if p.compact {
 233			msg.Y -= 1
 234		}
 235		if msg.Button == tea.MouseLeft {
 236			u, cmd := p.chat.Update(msg)
 237			p.chat = u.(chat.MessageListCmp)
 238			return p, cmd
 239		}
 240		return p, nil
 241	case tea.MouseReleaseMsg:
 242		if p.isOnboarding || p.isProjectInit {
 243			return p, nil
 244		}
 245		if p.compact {
 246			msg.Y -= 1
 247		}
 248		if msg.Button == tea.MouseLeft {
 249			u, cmd := p.chat.Update(msg)
 250			p.chat = u.(chat.MessageListCmp)
 251			return p, cmd
 252		}
 253		return p, nil
 254	case chat.SelectionCopyMsg:
 255		u, cmd := p.chat.Update(msg)
 256		p.chat = u.(chat.MessageListCmp)
 257		return p, cmd
 258	case tea.WindowSizeMsg:
 259		u, cmd := p.editor.Update(msg)
 260		p.editor = u.(editor.Editor)
 261		return p, tea.Batch(p.SetSize(msg.Width, msg.Height), cmd)
 262	case CancelTimerExpiredMsg:
 263		p.isCanceling = false
 264		return p, nil
 265	case editor.OpenEditorMsg:
 266		u, cmd := p.editor.Update(msg)
 267		p.editor = u.(editor.Editor)
 268		return p, cmd
 269	case chat.SendMsg:
 270		return p, p.sendMessage(msg.Text, msg.Attachments)
 271	case chat.SessionSelectedMsg:
 272		return p, p.setSession(msg)
 273	case splash.SubmitAPIKeyMsg:
 274		u, cmd := p.splash.Update(msg)
 275		p.splash = u.(splash.Splash)
 276		cmds = append(cmds, cmd)
 277		return p, tea.Batch(cmds...)
 278	case commands.ToggleCompactModeMsg:
 279		p.forceCompact = !p.forceCompact
 280		var cmd tea.Cmd
 281		if p.forceCompact {
 282			p.setCompactMode(true)
 283			cmd = p.updateCompactConfig(true)
 284		} else if p.width >= CompactModeWidthBreakpoint && p.height >= CompactModeHeightBreakpoint {
 285			p.setCompactMode(false)
 286			cmd = p.updateCompactConfig(false)
 287		}
 288		return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
 289	case commands.ToggleThinkingMsg:
 290		return p, p.toggleThinking()
 291	case commands.OpenReasoningDialogMsg:
 292		return p, p.openReasoningDialog()
 293	case reasoning.ReasoningEffortSelectedMsg:
 294		return p, p.handleReasoningEffortSelected(msg.Effort)
 295	case commands.OpenExternalEditorMsg:
 296		u, cmd := p.editor.Update(msg)
 297		p.editor = u.(editor.Editor)
 298		return p, cmd
 299	case pubsub.Event[session.Session]:
 300		if msg.Payload.ID == p.session.ID {
 301			prevHasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
 302			prevHasInProgress := p.hasInProgressTodo()
 303			p.session = msg.Payload
 304			newHasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
 305			newHasInProgress := p.hasInProgressTodo()
 306			if prevHasIncompleteTodos != newHasIncompleteTodos {
 307				cmds = append(cmds, p.SetSize(p.width, p.height))
 308			}
 309			if !prevHasInProgress && newHasInProgress {
 310				cmds = append(cmds, p.todoSpinner.Tick)
 311			}
 312		}
 313		u, cmd := p.header.Update(msg)
 314		p.header = u.(header.Header)
 315		cmds = append(cmds, cmd)
 316		u, cmd = p.sidebar.Update(msg)
 317		p.sidebar = u.(sidebar.Sidebar)
 318		cmds = append(cmds, cmd)
 319		return p, tea.Batch(cmds...)
 320	case chat.SessionClearedMsg:
 321		u, cmd := p.header.Update(msg)
 322		p.header = u.(header.Header)
 323		cmds = append(cmds, cmd)
 324		u, cmd = p.sidebar.Update(msg)
 325		p.sidebar = u.(sidebar.Sidebar)
 326		cmds = append(cmds, cmd)
 327		u, cmd = p.chat.Update(msg)
 328		p.chat = u.(chat.MessageListCmp)
 329		cmds = append(cmds, cmd)
 330		u, cmd = p.editor.Update(msg)
 331		p.editor = u.(editor.Editor)
 332		cmds = append(cmds, cmd)
 333		return p, tea.Batch(cmds...)
 334	case filepicker.FilePickedMsg,
 335		completions.CompletionsClosedMsg,
 336		completions.SelectCompletionMsg:
 337		u, cmd := p.editor.Update(msg)
 338		p.editor = u.(editor.Editor)
 339		cmds = append(cmds, cmd)
 340		return p, tea.Batch(cmds...)
 341
 342	case hyper.DeviceFlowCompletedMsg,
 343		hyper.DeviceAuthInitiatedMsg,
 344		hyper.DeviceFlowErrorMsg,
 345		copilot.DeviceAuthInitiatedMsg,
 346		copilot.DeviceFlowErrorMsg,
 347		copilot.DeviceFlowCompletedMsg:
 348		if p.focusedPane == PanelTypeSplash {
 349			u, cmd := p.splash.Update(msg)
 350			p.splash = u.(splash.Splash)
 351			cmds = append(cmds, cmd)
 352		}
 353		return p, tea.Batch(cmds...)
 354	case models.APIKeyStateChangeMsg:
 355		if p.focusedPane == PanelTypeSplash {
 356			u, cmd := p.splash.Update(msg)
 357			p.splash = u.(splash.Splash)
 358			cmds = append(cmds, cmd)
 359		}
 360		return p, tea.Batch(cmds...)
 361	case pubsub.Event[message.Message],
 362		anim.StepMsg,
 363		spinner.TickMsg:
 364		// Update todo spinner if agent is busy and we have in-progress todos
 365		agentBusy := p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy()
 366		if _, ok := msg.(spinner.TickMsg); ok && p.hasInProgressTodo() && agentBusy {
 367			var cmd tea.Cmd
 368			p.todoSpinner, cmd = p.todoSpinner.Update(msg)
 369			cmds = append(cmds, cmd)
 370		}
 371		// Start spinner when agent becomes busy and we have in-progress todos
 372		if _, ok := msg.(pubsub.Event[message.Message]); ok && p.hasInProgressTodo() && agentBusy {
 373			cmds = append(cmds, p.todoSpinner.Tick)
 374		}
 375		if p.focusedPane == PanelTypeSplash {
 376			u, cmd := p.splash.Update(msg)
 377			p.splash = u.(splash.Splash)
 378			cmds = append(cmds, cmd)
 379		} else {
 380			u, cmd := p.chat.Update(msg)
 381			p.chat = u.(chat.MessageListCmp)
 382			cmds = append(cmds, cmd)
 383		}
 384
 385		return p, tea.Batch(cmds...)
 386	case commands.ToggleYoloModeMsg:
 387		// update the editor style
 388		u, cmd := p.editor.Update(msg)
 389		p.editor = u.(editor.Editor)
 390		return p, cmd
 391	case pubsub.Event[history.File], sidebar.SessionFilesMsg:
 392		u, cmd := p.sidebar.Update(msg)
 393		p.sidebar = u.(sidebar.Sidebar)
 394		cmds = append(cmds, cmd)
 395		return p, tea.Batch(cmds...)
 396	case pubsub.Event[permission.PermissionNotification]:
 397		u, cmd := p.chat.Update(msg)
 398		p.chat = u.(chat.MessageListCmp)
 399		cmds = append(cmds, cmd)
 400		return p, tea.Batch(cmds...)
 401
 402	case commands.CommandRunCustomMsg:
 403		if p.app.AgentCoordinator.IsBusy() {
 404			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
 405		}
 406
 407		cmd := p.sendMessage(msg.Content, nil)
 408		if cmd != nil {
 409			return p, cmd
 410		}
 411	case splash.OnboardingCompleteMsg:
 412		p.splashFullScreen = false
 413		if b, _ := config.ProjectNeedsInitialization(); b {
 414			p.splash.SetProjectInit(true)
 415			p.splashFullScreen = true
 416			return p, p.SetSize(p.width, p.height)
 417		}
 418		err := p.app.InitCoderAgent(context.TODO())
 419		if err != nil {
 420			return p, util.ReportError(err)
 421		}
 422		p.isOnboarding = false
 423		p.isProjectInit = false
 424		p.focusedPane = PanelTypeEditor
 425		return p, p.SetSize(p.width, p.height)
 426	case commands.NewSessionsMsg:
 427		if p.app.AgentCoordinator.IsBusy() {
 428			return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
 429		}
 430		return p, p.newSession()
 431	case tea.KeyPressMsg:
 432		switch {
 433		case key.Matches(msg, p.keyMap.NewSession):
 434			// if we have no agent do nothing
 435			if p.app.AgentCoordinator == nil {
 436				return p, nil
 437			}
 438			if p.app.AgentCoordinator.IsBusy() {
 439				return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
 440			}
 441			return p, p.newSession()
 442		case key.Matches(msg, p.keyMap.AddAttachment):
 443			// Skip attachment handling during onboarding/splash screen
 444			if p.focusedPane == PanelTypeSplash || p.isOnboarding {
 445				u, cmd := p.splash.Update(msg)
 446				p.splash = u.(splash.Splash)
 447				return p, cmd
 448			}
 449			agentCfg := config.Get().Agents[config.AgentCoder]
 450			model := config.Get().GetModelByType(agentCfg.Model)
 451			if model == nil {
 452				return p, util.ReportWarn("No model configured yet")
 453			}
 454			if model.SupportsImages {
 455				return p, util.CmdHandler(commands.OpenFilePickerMsg{})
 456			} else {
 457				return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
 458			}
 459		case key.Matches(msg, p.keyMap.Tab):
 460			if p.session.ID == "" {
 461				u, cmd := p.splash.Update(msg)
 462				p.splash = u.(splash.Splash)
 463				return p, cmd
 464			}
 465			return p, p.changeFocus()
 466		case key.Matches(msg, p.keyMap.Cancel):
 467			if p.session.ID != "" && p.app.AgentCoordinator.IsBusy() {
 468				return p, p.cancel()
 469			}
 470		case key.Matches(msg, p.keyMap.Details):
 471			p.toggleDetails()
 472			return p, nil
 473		case key.Matches(msg, p.keyMap.TogglePills):
 474			if p.session.ID != "" {
 475				return p, p.togglePillsExpanded()
 476			}
 477		case key.Matches(msg, p.keyMap.PillLeft):
 478			if p.session.ID != "" && p.pillsExpanded {
 479				return p, p.switchPillSection(-1)
 480			}
 481		case key.Matches(msg, p.keyMap.PillRight):
 482			if p.session.ID != "" && p.pillsExpanded {
 483				return p, p.switchPillSection(1)
 484			}
 485		}
 486
 487		switch p.focusedPane {
 488		case PanelTypeChat:
 489			u, cmd := p.chat.Update(msg)
 490			p.chat = u.(chat.MessageListCmp)
 491			cmds = append(cmds, cmd)
 492		case PanelTypeEditor:
 493			u, cmd := p.editor.Update(msg)
 494			p.editor = u.(editor.Editor)
 495			cmds = append(cmds, cmd)
 496		case PanelTypeSplash:
 497			u, cmd := p.splash.Update(msg)
 498			p.splash = u.(splash.Splash)
 499			cmds = append(cmds, cmd)
 500		}
 501	case tea.PasteMsg:
 502		switch p.focusedPane {
 503		case PanelTypeEditor:
 504			u, cmd := p.editor.Update(msg)
 505			p.editor = u.(editor.Editor)
 506			cmds = append(cmds, cmd)
 507			return p, tea.Batch(cmds...)
 508		case PanelTypeChat:
 509			u, cmd := p.chat.Update(msg)
 510			p.chat = u.(chat.MessageListCmp)
 511			cmds = append(cmds, cmd)
 512			return p, tea.Batch(cmds...)
 513		case PanelTypeSplash:
 514			u, cmd := p.splash.Update(msg)
 515			p.splash = u.(splash.Splash)
 516			cmds = append(cmds, cmd)
 517			return p, tea.Batch(cmds...)
 518		}
 519	}
 520	return p, tea.Batch(cmds...)
 521}
 522
 523func (p *chatPage) Cursor() *tea.Cursor {
 524	if p.header.ShowingDetails() {
 525		return nil
 526	}
 527	switch p.focusedPane {
 528	case PanelTypeEditor:
 529		return p.editor.Cursor()
 530	case PanelTypeSplash:
 531		return p.splash.Cursor()
 532	default:
 533		return nil
 534	}
 535}
 536
 537func (p *chatPage) View() string {
 538	var chatView string
 539	t := styles.CurrentTheme()
 540
 541	if p.session.ID == "" {
 542		splashView := p.splash.View()
 543		// Full screen during onboarding or project initialization
 544		if p.splashFullScreen {
 545			chatView = splashView
 546		} else {
 547			// Show splash + editor for new message state
 548			editorView := p.editor.View()
 549			chatView = lipgloss.JoinVertical(
 550				lipgloss.Left,
 551				t.S().Base.Render(splashView),
 552				editorView,
 553			)
 554		}
 555	} else {
 556		messagesView := p.chat.View()
 557		editorView := p.editor.View()
 558
 559		hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
 560		hasQueue := p.promptQueue > 0
 561		todosFocused := p.pillsExpanded && p.focusedPillSection == PillSectionTodos
 562		queueFocused := p.pillsExpanded && p.focusedPillSection == PillSectionQueue
 563
 564		// Use spinner when agent is busy, otherwise show static icon
 565		agentBusy := p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy()
 566		inProgressIcon := t.S().Base.Foreground(t.GreenDark).Render(styles.CenterSpinnerIcon)
 567		if agentBusy {
 568			inProgressIcon = p.todoSpinner.View()
 569		}
 570
 571		var pills []string
 572		if hasIncompleteTodos {
 573			pills = append(pills, todoPill(p.session.Todos, inProgressIcon, todosFocused, p.pillsExpanded, t))
 574		}
 575		if hasQueue {
 576			pills = append(pills, queuePill(p.promptQueue, queueFocused, p.pillsExpanded, t))
 577		}
 578
 579		var expandedList string
 580		if p.pillsExpanded {
 581			if todosFocused && hasIncompleteTodos {
 582				expandedList = todoList(p.session.Todos, inProgressIcon, t, p.width-SideBarWidth)
 583			} else if queueFocused && hasQueue {
 584				queueItems := p.app.AgentCoordinator.QueuedPromptsList(p.session.ID)
 585				expandedList = queueList(queueItems, t)
 586			}
 587		}
 588
 589		var pillsArea string
 590		if len(pills) > 0 {
 591			pillsRow := lipgloss.JoinHorizontal(lipgloss.Top, pills...)
 592
 593			// Add help hint for expanding/collapsing pills based on state.
 594			var helpDesc string
 595			if p.pillsExpanded {
 596				helpDesc = "close"
 597			} else {
 598				helpDesc = "open"
 599			}
 600			// Style to match help section: keys in FgMuted, description in FgSubtle
 601			helpKey := t.S().Base.Foreground(t.FgMuted).Render("ctrl+space")
 602			helpText := t.S().Base.Foreground(t.FgSubtle).Render(helpDesc)
 603			helpHint := lipgloss.JoinHorizontal(lipgloss.Center, helpKey, " ", helpText)
 604			pillsRow = lipgloss.JoinHorizontal(lipgloss.Center, pillsRow, " ", helpHint)
 605
 606			if expandedList != "" {
 607				pillsArea = lipgloss.JoinVertical(
 608					lipgloss.Left,
 609					pillsRow,
 610					expandedList,
 611				)
 612			} else {
 613				pillsArea = pillsRow
 614			}
 615
 616			pillsArea = t.S().Base.
 617				MaxWidth(p.width).
 618				MarginTop(1).
 619				PaddingLeft(3).
 620				Render(pillsArea)
 621		}
 622
 623		if p.compact {
 624			headerView := p.header.View()
 625			views := []string{headerView, messagesView}
 626			if pillsArea != "" {
 627				views = append(views, pillsArea)
 628			}
 629			views = append(views, editorView)
 630			chatView = lipgloss.JoinVertical(lipgloss.Left, views...)
 631		} else {
 632			sidebarView := p.sidebar.View()
 633			var messagesColumn string
 634			if pillsArea != "" {
 635				messagesColumn = lipgloss.JoinVertical(
 636					lipgloss.Left,
 637					messagesView,
 638					pillsArea,
 639				)
 640			} else {
 641				messagesColumn = messagesView
 642			}
 643			messages := lipgloss.JoinHorizontal(
 644				lipgloss.Left,
 645				messagesColumn,
 646				sidebarView,
 647			)
 648			chatView = lipgloss.JoinVertical(
 649				lipgloss.Left,
 650				messages,
 651				p.editor.View(),
 652			)
 653		}
 654	}
 655
 656	layers := []*lipgloss.Layer{
 657		lipgloss.NewLayer(chatView).X(0).Y(0),
 658	}
 659
 660	if p.showingDetails {
 661		style := t.S().Base.
 662			Width(p.detailsWidth).
 663			Border(lipgloss.RoundedBorder()).
 664			BorderForeground(t.BorderFocus)
 665		version := t.S().Base.Foreground(t.Border).Width(p.detailsWidth - 4).AlignHorizontal(lipgloss.Right).Render(version.Version)
 666		details := style.Render(
 667			lipgloss.JoinVertical(
 668				lipgloss.Left,
 669				p.sidebar.View(),
 670				version,
 671			),
 672		)
 673		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
 674	}
 675	canvas := lipgloss.NewCompositor(layers...)
 676	return canvas.Render()
 677}
 678
 679func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
 680	return func() tea.Msg {
 681		err := config.Get().SetCompactMode(compact)
 682		if err != nil {
 683			return util.InfoMsg{
 684				Type: util.InfoTypeError,
 685				Msg:  "Failed to update compact mode configuration: " + err.Error(),
 686			}
 687		}
 688		return nil
 689	}
 690}
 691
 692func (p *chatPage) toggleThinking() tea.Cmd {
 693	return func() tea.Msg {
 694		cfg := config.Get()
 695		agentCfg := cfg.Agents[config.AgentCoder]
 696		currentModel := cfg.Models[agentCfg.Model]
 697
 698		// Toggle the thinking mode
 699		currentModel.Think = !currentModel.Think
 700		if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
 701			return util.InfoMsg{
 702				Type: util.InfoTypeError,
 703				Msg:  "Failed to update thinking mode: " + err.Error(),
 704			}
 705		}
 706
 707		// Update the agent with the new configuration
 708		go p.app.UpdateAgentModel(context.TODO())
 709
 710		status := "disabled"
 711		if currentModel.Think {
 712			status = "enabled"
 713		}
 714		return util.InfoMsg{
 715			Type: util.InfoTypeInfo,
 716			Msg:  "Thinking mode " + status,
 717		}
 718	}
 719}
 720
 721func (p *chatPage) openReasoningDialog() tea.Cmd {
 722	return func() tea.Msg {
 723		cfg := config.Get()
 724		agentCfg := cfg.Agents[config.AgentCoder]
 725		model := cfg.GetModelByType(agentCfg.Model)
 726		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
 727
 728		if providerCfg != nil && model != nil && len(model.ReasoningLevels) > 0 {
 729			// Return the OpenDialogMsg directly so it bubbles up to the main TUI
 730			return dialogs.OpenDialogMsg{
 731				Model: reasoning.NewReasoningDialog(),
 732			}
 733		}
 734		return nil
 735	}
 736}
 737
 738func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
 739	return func() tea.Msg {
 740		cfg := config.Get()
 741		agentCfg := cfg.Agents[config.AgentCoder]
 742		currentModel := cfg.Models[agentCfg.Model]
 743
 744		// Update the model configuration
 745		currentModel.ReasoningEffort = effort
 746		if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
 747			return util.InfoMsg{
 748				Type: util.InfoTypeError,
 749				Msg:  "Failed to update reasoning effort: " + err.Error(),
 750			}
 751		}
 752
 753		// Update the agent with the new configuration
 754		if err := p.app.UpdateAgentModel(context.TODO()); err != nil {
 755			return util.InfoMsg{
 756				Type: util.InfoTypeError,
 757				Msg:  "Failed to update reasoning effort: " + err.Error(),
 758			}
 759		}
 760
 761		return util.InfoMsg{
 762			Type: util.InfoTypeInfo,
 763			Msg:  "Reasoning effort set to " + effort,
 764		}
 765	}
 766}
 767
 768func (p *chatPage) setCompactMode(compact bool) {
 769	if p.compact == compact {
 770		return
 771	}
 772	p.compact = compact
 773	if compact {
 774		p.sidebar.SetCompactMode(true)
 775	} else {
 776		p.setShowDetails(false)
 777	}
 778}
 779
 780func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
 781	if p.forceCompact {
 782		return
 783	}
 784	if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
 785		p.setCompactMode(true)
 786	}
 787	if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
 788		p.setCompactMode(false)
 789	}
 790}
 791
 792func (p *chatPage) SetSize(width, height int) tea.Cmd {
 793	p.handleCompactMode(width, height)
 794	p.width = width
 795	p.height = height
 796	var cmds []tea.Cmd
 797
 798	if p.session.ID == "" {
 799		if p.splashFullScreen {
 800			cmds = append(cmds, p.splash.SetSize(width, height))
 801		} else {
 802			cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
 803			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 804			cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
 805		}
 806	} else {
 807		hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
 808		hasQueue := p.promptQueue > 0
 809		hasPills := hasIncompleteTodos || hasQueue
 810
 811		pillsAreaHeight := 0
 812		if hasPills {
 813			pillsAreaHeight = pillHeightWithBorder + 1 // +1 for padding top
 814			if p.pillsExpanded {
 815				if p.focusedPillSection == PillSectionTodos && hasIncompleteTodos {
 816					pillsAreaHeight += len(p.session.Todos)
 817				} else if p.focusedPillSection == PillSectionQueue && hasQueue {
 818					pillsAreaHeight += p.promptQueue
 819				}
 820			}
 821		}
 822
 823		if p.compact {
 824			cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight-pillsAreaHeight))
 825			p.detailsWidth = width - DetailsPositioning
 826			cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
 827			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 828			cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
 829		} else {
 830			cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight-pillsAreaHeight))
 831			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 832			cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
 833		}
 834		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
 835	}
 836	return tea.Batch(cmds...)
 837}
 838
 839func (p *chatPage) newSession() tea.Cmd {
 840	if p.session.ID == "" {
 841		return nil
 842	}
 843
 844	p.session = session.Session{}
 845	p.focusedPane = PanelTypeEditor
 846	p.editor.Focus()
 847	p.chat.Blur()
 848	p.isCanceling = false
 849	return tea.Batch(
 850		util.CmdHandler(chat.SessionClearedMsg{}),
 851		p.SetSize(p.width, p.height),
 852	)
 853}
 854
 855func (p *chatPage) setSession(sess session.Session) tea.Cmd {
 856	if p.session.ID == sess.ID {
 857		return nil
 858	}
 859
 860	var cmds []tea.Cmd
 861	p.session = sess
 862
 863	if p.hasInProgressTodo() {
 864		cmds = append(cmds, p.todoSpinner.Tick)
 865	}
 866
 867	cmds = append(cmds, p.SetSize(p.width, p.height))
 868	cmds = append(cmds, p.chat.SetSession(sess))
 869	cmds = append(cmds, p.sidebar.SetSession(sess))
 870	cmds = append(cmds, p.header.SetSession(sess))
 871	cmds = append(cmds, p.editor.SetSession(sess))
 872
 873	return tea.Sequence(cmds...)
 874}
 875
 876func (p *chatPage) changeFocus() tea.Cmd {
 877	if p.session.ID == "" {
 878		return nil
 879	}
 880
 881	switch p.focusedPane {
 882	case PanelTypeEditor:
 883		p.focusedPane = PanelTypeChat
 884		p.chat.Focus()
 885		p.editor.Blur()
 886	case PanelTypeChat:
 887		p.focusedPane = PanelTypeEditor
 888		p.editor.Focus()
 889		p.chat.Blur()
 890	}
 891	return nil
 892}
 893
 894func (p *chatPage) togglePillsExpanded() tea.Cmd {
 895	hasPills := hasIncompleteTodos(p.session.Todos) || p.promptQueue > 0
 896	if !hasPills {
 897		return nil
 898	}
 899	p.pillsExpanded = !p.pillsExpanded
 900	if p.pillsExpanded {
 901		if hasIncompleteTodos(p.session.Todos) {
 902			p.focusedPillSection = PillSectionTodos
 903		} else {
 904			p.focusedPillSection = PillSectionQueue
 905		}
 906	}
 907	return p.SetSize(p.width, p.height)
 908}
 909
 910func (p *chatPage) switchPillSection(dir int) tea.Cmd {
 911	if !p.pillsExpanded {
 912		return nil
 913	}
 914	hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
 915	hasQueue := p.promptQueue > 0
 916
 917	if dir < 0 && p.focusedPillSection == PillSectionQueue && hasIncompleteTodos {
 918		p.focusedPillSection = PillSectionTodos
 919		return p.SetSize(p.width, p.height)
 920	}
 921	if dir > 0 && p.focusedPillSection == PillSectionTodos && hasQueue {
 922		p.focusedPillSection = PillSectionQueue
 923		return p.SetSize(p.width, p.height)
 924	}
 925	return nil
 926}
 927
 928func (p *chatPage) cancel() tea.Cmd {
 929	if p.isCanceling {
 930		p.isCanceling = false
 931		if p.app.AgentCoordinator != nil {
 932			p.app.AgentCoordinator.Cancel(p.session.ID)
 933		}
 934		return nil
 935	}
 936
 937	if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
 938		p.app.AgentCoordinator.ClearQueue(p.session.ID)
 939		return nil
 940	}
 941	p.isCanceling = true
 942	return cancelTimerCmd()
 943}
 944
 945func (p *chatPage) setShowDetails(show bool) {
 946	p.showingDetails = show
 947	p.header.SetDetailsOpen(p.showingDetails)
 948	if !p.compact {
 949		p.sidebar.SetCompactMode(false)
 950	}
 951}
 952
 953func (p *chatPage) toggleDetails() {
 954	if p.session.ID == "" || !p.compact {
 955		return
 956	}
 957	p.setShowDetails(!p.showingDetails)
 958}
 959
 960func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
 961	session := p.session
 962	var cmds []tea.Cmd
 963	if p.session.ID == "" {
 964		// XXX: The second argument here is the session name, which we leave
 965		// blank as it will be auto-generated. Ideally, we remove the need for
 966		// that argument entirely.
 967		newSession, err := p.app.Sessions.Create(context.Background(), "")
 968		if err != nil {
 969			return util.ReportError(err)
 970		}
 971		session = newSession
 972		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
 973	}
 974	if p.app.AgentCoordinator == nil {
 975		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
 976	}
 977	cmds = append(cmds, p.chat.GoToBottom())
 978	cmds = append(cmds, func() tea.Msg {
 979		_, err := p.app.AgentCoordinator.Run(context.Background(), session.ID, text, attachments...)
 980		if err != nil {
 981			isCancelErr := errors.Is(err, context.Canceled)
 982			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
 983			if isCancelErr || isPermissionErr {
 984				return nil
 985			}
 986			return util.InfoMsg{
 987				Type: util.InfoTypeError,
 988				Msg:  err.Error(),
 989			}
 990		}
 991		return nil
 992	})
 993	return tea.Batch(cmds...)
 994}
 995
 996func (p *chatPage) Bindings() []key.Binding {
 997	bindings := []key.Binding{
 998		p.keyMap.NewSession,
 999		p.keyMap.AddAttachment,
1000	}
1001	if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
1002		cancelBinding := p.keyMap.Cancel
1003		if p.isCanceling {
1004			cancelBinding = key.NewBinding(
1005				key.WithKeys("esc", "alt+esc"),
1006				key.WithHelp("esc", "press again to cancel"),
1007			)
1008		}
1009		bindings = append([]key.Binding{cancelBinding}, bindings...)
1010	}
1011
1012	switch p.focusedPane {
1013	case PanelTypeChat:
1014		bindings = append([]key.Binding{
1015			key.NewBinding(
1016				key.WithKeys("tab"),
1017				key.WithHelp("tab", "focus editor"),
1018			),
1019		}, bindings...)
1020		bindings = append(bindings, p.chat.Bindings()...)
1021	case PanelTypeEditor:
1022		bindings = append([]key.Binding{
1023			key.NewBinding(
1024				key.WithKeys("tab"),
1025				key.WithHelp("tab", "focus chat"),
1026			),
1027		}, bindings...)
1028		bindings = append(bindings, p.editor.Bindings()...)
1029	case PanelTypeSplash:
1030		bindings = append(bindings, p.splash.Bindings()...)
1031	}
1032
1033	return bindings
1034}
1035
1036func (p *chatPage) Help() help.KeyMap {
1037	var shortList []key.Binding
1038	var fullList [][]key.Binding
1039	switch {
1040	case p.isOnboarding:
1041		switch {
1042		case p.splash.IsShowingHyperOAuth2() || p.splash.IsShowingCopilotOAuth2():
1043			shortList = append(shortList,
1044				key.NewBinding(
1045					key.WithKeys("enter"),
1046					key.WithHelp("enter", "copy url & open signup"),
1047				),
1048				key.NewBinding(
1049					key.WithKeys("c"),
1050					key.WithHelp("c", "copy url"),
1051				),
1052			)
1053		default:
1054			shortList = append(shortList,
1055				key.NewBinding(
1056					key.WithKeys("enter"),
1057					key.WithHelp("enter", "submit"),
1058				),
1059			)
1060		}
1061		shortList = append(shortList,
1062			// Quit
1063			key.NewBinding(
1064				key.WithKeys("ctrl+c"),
1065				key.WithHelp("ctrl+c", "quit"),
1066			),
1067		)
1068		// keep them the same
1069		for _, v := range shortList {
1070			fullList = append(fullList, []key.Binding{v})
1071		}
1072	case p.isOnboarding && !p.splash.IsShowingAPIKey():
1073		shortList = append(shortList,
1074			// Choose model
1075			key.NewBinding(
1076				key.WithKeys("up", "down"),
1077				key.WithHelp("↑/↓", "choose"),
1078			),
1079			// Accept selection
1080			key.NewBinding(
1081				key.WithKeys("enter", "ctrl+y"),
1082				key.WithHelp("enter", "accept"),
1083			),
1084			// Quit
1085			key.NewBinding(
1086				key.WithKeys("ctrl+c"),
1087				key.WithHelp("ctrl+c", "quit"),
1088			),
1089		)
1090		// keep them the same
1091		for _, v := range shortList {
1092			fullList = append(fullList, []key.Binding{v})
1093		}
1094	case p.isOnboarding && p.splash.IsShowingAPIKey():
1095		if p.splash.IsAPIKeyValid() {
1096			shortList = append(shortList,
1097				key.NewBinding(
1098					key.WithKeys("enter"),
1099					key.WithHelp("enter", "continue"),
1100				),
1101			)
1102		} else {
1103			shortList = append(shortList,
1104				// Go back
1105				key.NewBinding(
1106					key.WithKeys("esc", "alt+esc"),
1107					key.WithHelp("esc", "back"),
1108				),
1109			)
1110		}
1111		shortList = append(shortList,
1112			// Quit
1113			key.NewBinding(
1114				key.WithKeys("ctrl+c"),
1115				key.WithHelp("ctrl+c", "quit"),
1116			),
1117		)
1118		// keep them the same
1119		for _, v := range shortList {
1120			fullList = append(fullList, []key.Binding{v})
1121		}
1122	case p.isProjectInit:
1123		shortList = append(shortList,
1124			key.NewBinding(
1125				key.WithKeys("ctrl+c"),
1126				key.WithHelp("ctrl+c", "quit"),
1127			),
1128		)
1129		// keep them the same
1130		for _, v := range shortList {
1131			fullList = append(fullList, []key.Binding{v})
1132		}
1133	default:
1134		if p.editor.IsCompletionsOpen() {
1135			shortList = append(shortList,
1136				key.NewBinding(
1137					key.WithKeys("tab", "enter"),
1138					key.WithHelp("tab/enter", "complete"),
1139				),
1140				key.NewBinding(
1141					key.WithKeys("esc", "alt+esc"),
1142					key.WithHelp("esc", "cancel"),
1143				),
1144				key.NewBinding(
1145					key.WithKeys("up", "down"),
1146					key.WithHelp("↑/↓", "choose"),
1147				),
1148			)
1149			for _, v := range shortList {
1150				fullList = append(fullList, []key.Binding{v})
1151			}
1152			return core.NewSimpleHelp(shortList, fullList)
1153		}
1154		if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
1155			cancelBinding := key.NewBinding(
1156				key.WithKeys("esc", "alt+esc"),
1157				key.WithHelp("esc", "cancel"),
1158			)
1159			if p.isCanceling {
1160				cancelBinding = key.NewBinding(
1161					key.WithKeys("esc", "alt+esc"),
1162					key.WithHelp("esc", "press again to cancel"),
1163				)
1164			}
1165			if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
1166				cancelBinding = key.NewBinding(
1167					key.WithKeys("esc", "alt+esc"),
1168					key.WithHelp("esc", "clear queue"),
1169				)
1170			}
1171			shortList = append(shortList, cancelBinding)
1172			fullList = append(fullList,
1173				[]key.Binding{
1174					cancelBinding,
1175				},
1176			)
1177		}
1178		globalBindings := []key.Binding{}
1179		// we are in a session
1180		if p.session.ID != "" {
1181			var tabKey key.Binding
1182			switch p.focusedPane {
1183			case PanelTypeEditor:
1184				tabKey = key.NewBinding(
1185					key.WithKeys("tab"),
1186					key.WithHelp("tab", "focus chat"),
1187				)
1188			case PanelTypeChat:
1189				tabKey = key.NewBinding(
1190					key.WithKeys("tab"),
1191					key.WithHelp("tab", "focus editor"),
1192				)
1193			default:
1194				tabKey = key.NewBinding(
1195					key.WithKeys("tab"),
1196					key.WithHelp("tab", "focus chat"),
1197				)
1198			}
1199			shortList = append(shortList, tabKey)
1200			globalBindings = append(globalBindings, tabKey)
1201
1202			// Show left/right to switch sections when expanded and both exist
1203			hasTodos := hasIncompleteTodos(p.session.Todos)
1204			hasQueue := p.promptQueue > 0
1205			if p.pillsExpanded && hasTodos && hasQueue {
1206				shortList = append(shortList, p.keyMap.PillLeft)
1207				globalBindings = append(globalBindings, p.keyMap.PillLeft)
1208			}
1209		}
1210		commandsBinding := key.NewBinding(
1211			key.WithKeys("ctrl+p"),
1212			key.WithHelp("ctrl+p", "commands"),
1213		)
1214		if p.focusedPane == PanelTypeEditor && p.editor.IsEmpty() {
1215			commandsBinding.SetHelp("/ or ctrl+p", "commands")
1216		}
1217		modelsBinding := key.NewBinding(
1218			key.WithKeys("ctrl+m", "ctrl+l"),
1219			key.WithHelp("ctrl+l", "models"),
1220		)
1221		if p.keyboardEnhancements.Flags > 0 {
1222			// non-zero flags mean we have at least key disambiguation
1223			modelsBinding.SetHelp("ctrl+m", "models")
1224		}
1225		helpBinding := key.NewBinding(
1226			key.WithKeys("ctrl+g"),
1227			key.WithHelp("ctrl+g", "more"),
1228		)
1229		globalBindings = append(globalBindings, commandsBinding, modelsBinding)
1230		globalBindings = append(globalBindings,
1231			key.NewBinding(
1232				key.WithKeys("ctrl+s"),
1233				key.WithHelp("ctrl+s", "sessions"),
1234			),
1235		)
1236		if p.session.ID != "" {
1237			globalBindings = append(globalBindings,
1238				key.NewBinding(
1239					key.WithKeys("ctrl+n"),
1240					key.WithHelp("ctrl+n", "new sessions"),
1241				))
1242		}
1243		shortList = append(shortList,
1244			// Commands
1245			commandsBinding,
1246			modelsBinding,
1247		)
1248		fullList = append(fullList, globalBindings)
1249
1250		switch p.focusedPane {
1251		case PanelTypeChat:
1252			shortList = append(shortList,
1253				key.NewBinding(
1254					key.WithKeys("up", "down"),
1255					key.WithHelp("↑↓", "scroll"),
1256				),
1257				messages.CopyKey,
1258			)
1259			fullList = append(fullList,
1260				[]key.Binding{
1261					key.NewBinding(
1262						key.WithKeys("up", "down"),
1263						key.WithHelp("↑↓", "scroll"),
1264					),
1265					key.NewBinding(
1266						key.WithKeys("shift+up", "shift+down"),
1267						key.WithHelp("shift+↑↓", "next/prev item"),
1268					),
1269					key.NewBinding(
1270						key.WithKeys("pgup", "b"),
1271						key.WithHelp("b/pgup", "page up"),
1272					),
1273					key.NewBinding(
1274						key.WithKeys("pgdown", " ", "f"),
1275						key.WithHelp("f/pgdn", "page down"),
1276					),
1277				},
1278				[]key.Binding{
1279					key.NewBinding(
1280						key.WithKeys("u"),
1281						key.WithHelp("u", "half page up"),
1282					),
1283					key.NewBinding(
1284						key.WithKeys("d"),
1285						key.WithHelp("d", "half page down"),
1286					),
1287					key.NewBinding(
1288						key.WithKeys("g", "home"),
1289						key.WithHelp("g", "home"),
1290					),
1291					key.NewBinding(
1292						key.WithKeys("G", "end"),
1293						key.WithHelp("G", "end"),
1294					),
1295				},
1296				[]key.Binding{
1297					messages.CopyKey,
1298					messages.ClearSelectionKey,
1299				},
1300			)
1301		case PanelTypeEditor:
1302			newLineBinding := key.NewBinding(
1303				key.WithKeys("shift+enter", "ctrl+j"),
1304				// "ctrl+j" is a common keybinding for newline in many editors. If
1305				// the terminal supports "shift+enter", we substitute the help text
1306				// to reflect that.
1307				key.WithHelp("ctrl+j", "newline"),
1308			)
1309			if p.keyboardEnhancements.Flags > 0 {
1310				// Non-zero flags mean we have at least key disambiguation.
1311				newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
1312			}
1313			shortList = append(shortList, newLineBinding)
1314			fullList = append(fullList,
1315				[]key.Binding{
1316					newLineBinding,
1317					key.NewBinding(
1318						key.WithKeys("ctrl+f"),
1319						key.WithHelp("ctrl+f", "add image"),
1320					),
1321					key.NewBinding(
1322						key.WithKeys("@"),
1323						key.WithHelp("@", "mention file"),
1324					),
1325					key.NewBinding(
1326						key.WithKeys("ctrl+o"),
1327						key.WithHelp("ctrl+o", "open editor"),
1328					),
1329				})
1330
1331			if p.editor.HasAttachments() {
1332				fullList = append(fullList, []key.Binding{
1333					key.NewBinding(
1334						key.WithKeys("ctrl+r"),
1335						key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
1336					),
1337					key.NewBinding(
1338						key.WithKeys("ctrl+r", "r"),
1339						key.WithHelp("ctrl+r+r", "delete all attachments"),
1340					),
1341					key.NewBinding(
1342						key.WithKeys("esc", "alt+esc"),
1343						key.WithHelp("esc", "cancel delete mode"),
1344					),
1345				})
1346			}
1347		}
1348		shortList = append(shortList,
1349			// Quit
1350			key.NewBinding(
1351				key.WithKeys("ctrl+c"),
1352				key.WithHelp("ctrl+c", "quit"),
1353			),
1354			// Help
1355			helpBinding,
1356		)
1357		fullList = append(fullList, []key.Binding{
1358			key.NewBinding(
1359				key.WithKeys("ctrl+g"),
1360				key.WithHelp("ctrl+g", "less"),
1361			),
1362		})
1363	}
1364
1365	return core.NewSimpleHelp(shortList, fullList)
1366}
1367
1368func (p *chatPage) IsChatFocused() bool {
1369	return p.focusedPane == PanelTypeChat
1370}
1371
1372// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
1373// Returns true if the mouse is over the chat area, false otherwise.
1374func (p *chatPage) isMouseOverChat(x, y int) bool {
1375	// No session means no chat area
1376	if p.session.ID == "" {
1377		return false
1378	}
1379
1380	var chatX, chatY, chatWidth, chatHeight int
1381
1382	if p.compact {
1383		// In compact mode: chat area starts after header and spans full width
1384		chatX = 0
1385		chatY = HeaderHeight
1386		chatWidth = p.width
1387		chatHeight = p.height - EditorHeight - HeaderHeight
1388	} else {
1389		// In non-compact mode: chat area spans from left edge to sidebar
1390		chatX = 0
1391		chatY = 0
1392		chatWidth = p.width - SideBarWidth
1393		chatHeight = p.height - EditorHeight
1394	}
1395
1396	// Check if mouse coordinates are within chat bounds
1397	return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1398}
1399
1400func (p *chatPage) hasInProgressTodo() bool {
1401	for _, todo := range p.session.Todos {
1402		if todo.Status == session.TodoStatusInProgress {
1403			return true
1404		}
1405	}
1406	return false
1407}