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/claude"
  33	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
  34	"github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
  35	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
  36	"github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
  37	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
  38	"github.com/charmbracelet/crush/internal/tui/components/dialogs/reasoning"
  39	"github.com/charmbracelet/crush/internal/tui/page"
  40	"github.com/charmbracelet/crush/internal/tui/styles"
  41	"github.com/charmbracelet/crush/internal/tui/util"
  42	"github.com/charmbracelet/crush/internal/version"
  43)
  44
  45var ChatPageID page.PageID = "chat"
  46
  47type (
  48	ChatFocusedMsg struct {
  49		Focused bool
  50	}
  51	CancelTimerExpiredMsg struct{}
  52)
  53
  54type PanelType string
  55
  56const (
  57	PanelTypeChat   PanelType = "chat"
  58	PanelTypeEditor PanelType = "editor"
  59	PanelTypeSplash PanelType = "splash"
  60)
  61
  62// PillSection represents which pill section is focused when in pills panel.
  63type PillSection int
  64
  65const (
  66	PillSectionTodos PillSection = iota
  67	PillSectionQueue
  68)
  69
  70const (
  71	CompactModeWidthBreakpoint  = 120 // Width at which the chat page switches to compact mode
  72	CompactModeHeightBreakpoint = 30  // Height at which the chat page switches to compact mode
  73	EditorHeight                = 5   // Height of the editor input area including padding
  74	SideBarWidth                = 31  // Width of the sidebar
  75	SideBarDetailsPadding       = 1   // Padding for the sidebar details section
  76	HeaderHeight                = 1   // Height of the header
  77
  78	// Layout constants for borders and padding
  79	BorderWidth        = 1 // Width of component borders
  80	LeftRightBorders   = 2 // Left + right border width (1 + 1)
  81	TopBottomBorders   = 2 // Top + bottom border width (1 + 1)
  82	DetailsPositioning = 2 // Positioning adjustment for details panel
  83
  84	// Timing constants
  85	CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires
  86)
  87
  88type ChatPage interface {
  89	util.Model
  90	layout.Help
  91	IsChatFocused() bool
  92}
  93
  94// cancelTimerCmd creates a command that expires the cancel timer
  95func cancelTimerCmd() tea.Cmd {
  96	return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg {
  97		return CancelTimerExpiredMsg{}
  98	})
  99}
 100
 101type chatPage struct {
 102	width, height               int
 103	detailsWidth, detailsHeight int
 104	app                         *app.App
 105	keyboardEnhancements        tea.KeyboardEnhancementsMsg
 106
 107	// Layout state
 108	compact      bool
 109	forceCompact bool
 110	focusedPane  PanelType
 111
 112	// Session
 113	session session.Session
 114	keyMap  KeyMap
 115
 116	// Components
 117	header  header.Header
 118	sidebar sidebar.Sidebar
 119	chat    chat.MessageListCmp
 120	editor  editor.Editor
 121	splash  splash.Splash
 122
 123	// Simple state flags
 124	showingDetails   bool
 125	isCanceling      bool
 126	splashFullScreen bool
 127	isOnboarding     bool
 128	isProjectInit    bool
 129	promptQueue      int
 130
 131	// Pills state
 132	pillsExpanded      bool
 133	focusedPillSection PillSection
 134
 135	// Todo spinner
 136	todoSpinner spinner.Model
 137}
 138
 139func New(app *app.App) ChatPage {
 140	t := styles.CurrentTheme()
 141	return &chatPage{
 142		app:         app,
 143		keyMap:      DefaultKeyMap(),
 144		header:      header.New(app.LSPClients),
 145		sidebar:     sidebar.New(app.History, app.LSPClients, false),
 146		chat:        chat.New(app),
 147		editor:      editor.New(app),
 148		splash:      splash.New(),
 149		focusedPane: PanelTypeSplash,
 150		todoSpinner: spinner.New(
 151			spinner.WithSpinner(spinner.MiniDot),
 152			spinner.WithStyle(t.S().Base.Foreground(t.GreenDark)),
 153		),
 154	}
 155}
 156
 157func (p *chatPage) Init() tea.Cmd {
 158	cfg := config.Get()
 159	compact := cfg.Options.TUI.CompactMode
 160	p.compact = compact
 161	p.forceCompact = compact
 162	p.sidebar.SetCompactMode(p.compact)
 163
 164	// Set splash state based on config
 165	if !config.HasInitialDataConfig() {
 166		// First-time setup: show model selection
 167		p.splash.SetOnboarding(true)
 168		p.isOnboarding = true
 169		p.splashFullScreen = true
 170	} else if b, _ := config.ProjectNeedsInitialization(); b {
 171		// Project needs context initialization
 172		p.splash.SetProjectInit(true)
 173		p.isProjectInit = true
 174		p.splashFullScreen = true
 175	} else {
 176		// Ready to chat: focus editor, splash in background
 177		p.focusedPane = PanelTypeEditor
 178		p.splashFullScreen = false
 179	}
 180
 181	return tea.Batch(
 182		p.header.Init(),
 183		p.sidebar.Init(),
 184		p.chat.Init(),
 185		p.editor.Init(),
 186		p.splash.Init(),
 187	)
 188}
 189
 190func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 191	var cmds []tea.Cmd
 192	if p.session.ID != "" && p.app.AgentCoordinator != nil {
 193		queueSize := p.app.AgentCoordinator.QueuedPrompts(p.session.ID)
 194		if queueSize != p.promptQueue {
 195			p.promptQueue = queueSize
 196			cmds = append(cmds, p.SetSize(p.width, p.height))
 197		}
 198	}
 199	switch msg := msg.(type) {
 200	case tea.KeyboardEnhancementsMsg:
 201		p.keyboardEnhancements = msg
 202		return p, nil
 203	case tea.MouseWheelMsg:
 204		if p.compact {
 205			msg.Y -= 1
 206		}
 207		if p.isMouseOverChat(msg.X, msg.Y) {
 208			u, cmd := p.chat.Update(msg)
 209			p.chat = u.(chat.MessageListCmp)
 210			return p, cmd
 211		}
 212		return p, nil
 213	case tea.MouseClickMsg:
 214		if p.isOnboarding || p.isProjectInit {
 215			return p, nil
 216		}
 217		if p.compact {
 218			msg.Y -= 1
 219		}
 220		if p.isMouseOverChat(msg.X, msg.Y) {
 221			p.focusedPane = PanelTypeChat
 222			p.chat.Focus()
 223			p.editor.Blur()
 224		} else {
 225			p.focusedPane = PanelTypeEditor
 226			p.editor.Focus()
 227			p.chat.Blur()
 228		}
 229		u, cmd := p.chat.Update(msg)
 230		p.chat = u.(chat.MessageListCmp)
 231		return p, cmd
 232	case tea.MouseMotionMsg:
 233		if p.compact {
 234			msg.Y -= 1
 235		}
 236		if msg.Button == tea.MouseLeft {
 237			u, cmd := p.chat.Update(msg)
 238			p.chat = u.(chat.MessageListCmp)
 239			return p, cmd
 240		}
 241		return p, nil
 242	case tea.MouseReleaseMsg:
 243		if p.isOnboarding || p.isProjectInit {
 244			return p, nil
 245		}
 246		if p.compact {
 247			msg.Y -= 1
 248		}
 249		if msg.Button == tea.MouseLeft {
 250			u, cmd := p.chat.Update(msg)
 251			p.chat = u.(chat.MessageListCmp)
 252			return p, cmd
 253		}
 254		return p, nil
 255	case chat.SelectionCopyMsg:
 256		u, cmd := p.chat.Update(msg)
 257		p.chat = u.(chat.MessageListCmp)
 258		return p, cmd
 259	case tea.WindowSizeMsg:
 260		u, cmd := p.editor.Update(msg)
 261		p.editor = u.(editor.Editor)
 262		return p, tea.Batch(p.SetSize(msg.Width, msg.Height), cmd)
 263	case CancelTimerExpiredMsg:
 264		p.isCanceling = false
 265		return p, nil
 266	case editor.OpenEditorMsg:
 267		u, cmd := p.editor.Update(msg)
 268		p.editor = u.(editor.Editor)
 269		return p, cmd
 270	case chat.SendMsg:
 271		return p, p.sendMessage(msg.Text, msg.Attachments)
 272	case chat.SessionSelectedMsg:
 273		return p, p.setSession(msg)
 274	case splash.SubmitAPIKeyMsg:
 275		u, cmd := p.splash.Update(msg)
 276		p.splash = u.(splash.Splash)
 277		cmds = append(cmds, cmd)
 278		return p, tea.Batch(cmds...)
 279	case commands.ToggleCompactModeMsg:
 280		p.forceCompact = !p.forceCompact
 281		var cmd tea.Cmd
 282		if p.forceCompact {
 283			p.setCompactMode(true)
 284			cmd = p.updateCompactConfig(true)
 285		} else if p.width >= CompactModeWidthBreakpoint && p.height >= CompactModeHeightBreakpoint {
 286			p.setCompactMode(false)
 287			cmd = p.updateCompactConfig(false)
 288		}
 289		return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
 290	case commands.ToggleThinkingMsg:
 291		return p, p.toggleThinking()
 292	case commands.OpenReasoningDialogMsg:
 293		return p, p.openReasoningDialog()
 294	case reasoning.ReasoningEffortSelectedMsg:
 295		return p, p.handleReasoningEffortSelected(msg.Effort)
 296	case commands.OpenExternalEditorMsg:
 297		u, cmd := p.editor.Update(msg)
 298		p.editor = u.(editor.Editor)
 299		return p, cmd
 300	case pubsub.Event[session.Session]:
 301		if msg.Payload.ID == p.session.ID {
 302			prevHasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
 303			prevHasInProgress := p.hasInProgressTodo()
 304			p.session = msg.Payload
 305			newHasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
 306			newHasInProgress := p.hasInProgressTodo()
 307			if prevHasIncompleteTodos != newHasIncompleteTodos {
 308				cmds = append(cmds, p.SetSize(p.width, p.height))
 309			}
 310			if !prevHasInProgress && newHasInProgress {
 311				cmds = append(cmds, p.todoSpinner.Tick)
 312			}
 313		}
 314		u, cmd := p.header.Update(msg)
 315		p.header = u.(header.Header)
 316		cmds = append(cmds, cmd)
 317		u, cmd = p.sidebar.Update(msg)
 318		p.sidebar = u.(sidebar.Sidebar)
 319		cmds = append(cmds, cmd)
 320		return p, tea.Batch(cmds...)
 321	case chat.SessionClearedMsg:
 322		u, cmd := p.header.Update(msg)
 323		p.header = u.(header.Header)
 324		cmds = append(cmds, cmd)
 325		u, cmd = p.sidebar.Update(msg)
 326		p.sidebar = u.(sidebar.Sidebar)
 327		cmds = append(cmds, cmd)
 328		u, cmd = p.chat.Update(msg)
 329		p.chat = u.(chat.MessageListCmp)
 330		cmds = append(cmds, cmd)
 331		return p, tea.Batch(cmds...)
 332	case filepicker.FilePickedMsg,
 333		completions.CompletionsClosedMsg,
 334		completions.SelectCompletionMsg:
 335		u, cmd := p.editor.Update(msg)
 336		p.editor = u.(editor.Editor)
 337		cmds = append(cmds, cmd)
 338		return p, tea.Batch(cmds...)
 339
 340	case claude.ValidationCompletedMsg,
 341		claude.AuthenticationCompleteMsg,
 342		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 && p.splash.IsShowingClaudeAuthMethodChooser():
1041		shortList = append(shortList,
1042			// Choose auth method
1043			key.NewBinding(
1044				key.WithKeys("left", "right", "tab"),
1045				key.WithHelp("←→/tab", "choose"),
1046			),
1047			// Accept selection
1048			key.NewBinding(
1049				key.WithKeys("enter"),
1050				key.WithHelp("enter", "accept"),
1051			),
1052			// Go back
1053			key.NewBinding(
1054				key.WithKeys("esc", "alt+esc"),
1055				key.WithHelp("esc", "back"),
1056			),
1057			// Quit
1058			key.NewBinding(
1059				key.WithKeys("ctrl+c"),
1060				key.WithHelp("ctrl+c", "quit"),
1061			),
1062		)
1063		// keep them the same
1064		for _, v := range shortList {
1065			fullList = append(fullList, []key.Binding{v})
1066		}
1067	case p.isOnboarding && p.splash.IsShowingClaudeOAuth2():
1068		switch {
1069		case p.splash.IsClaudeOAuthURLState():
1070			shortList = append(shortList,
1071				key.NewBinding(
1072					key.WithKeys("enter"),
1073					key.WithHelp("enter", "open"),
1074				),
1075				key.NewBinding(
1076					key.WithKeys("c"),
1077					key.WithHelp("c", "copy url"),
1078				),
1079			)
1080		case p.splash.IsClaudeOAuthComplete():
1081			shortList = append(shortList,
1082				key.NewBinding(
1083					key.WithKeys("enter"),
1084					key.WithHelp("enter", "continue"),
1085				),
1086			)
1087		case p.splash.IsShowingHyperOAuth2() || p.splash.IsShowingCopilotOAuth2():
1088			shortList = append(shortList,
1089				key.NewBinding(
1090					key.WithKeys("enter"),
1091					key.WithHelp("enter", "copy url & open signup"),
1092				),
1093				key.NewBinding(
1094					key.WithKeys("c"),
1095					key.WithHelp("c", "copy url"),
1096				),
1097			)
1098		default:
1099			shortList = append(shortList,
1100				key.NewBinding(
1101					key.WithKeys("enter"),
1102					key.WithHelp("enter", "submit"),
1103				),
1104			)
1105		}
1106		shortList = append(shortList,
1107			// Quit
1108			key.NewBinding(
1109				key.WithKeys("ctrl+c"),
1110				key.WithHelp("ctrl+c", "quit"),
1111			),
1112		)
1113		// keep them the same
1114		for _, v := range shortList {
1115			fullList = append(fullList, []key.Binding{v})
1116		}
1117	case p.isOnboarding && !p.splash.IsShowingAPIKey():
1118		shortList = append(shortList,
1119			// Choose model
1120			key.NewBinding(
1121				key.WithKeys("up", "down"),
1122				key.WithHelp("↑/↓", "choose"),
1123			),
1124			// Accept selection
1125			key.NewBinding(
1126				key.WithKeys("enter", "ctrl+y"),
1127				key.WithHelp("enter", "accept"),
1128			),
1129			// Quit
1130			key.NewBinding(
1131				key.WithKeys("ctrl+c"),
1132				key.WithHelp("ctrl+c", "quit"),
1133			),
1134		)
1135		// keep them the same
1136		for _, v := range shortList {
1137			fullList = append(fullList, []key.Binding{v})
1138		}
1139	case p.isOnboarding && p.splash.IsShowingAPIKey():
1140		if p.splash.IsAPIKeyValid() {
1141			shortList = append(shortList,
1142				key.NewBinding(
1143					key.WithKeys("enter"),
1144					key.WithHelp("enter", "continue"),
1145				),
1146			)
1147		} else {
1148			shortList = append(shortList,
1149				// Go back
1150				key.NewBinding(
1151					key.WithKeys("esc", "alt+esc"),
1152					key.WithHelp("esc", "back"),
1153				),
1154			)
1155		}
1156		shortList = append(shortList,
1157			// Quit
1158			key.NewBinding(
1159				key.WithKeys("ctrl+c"),
1160				key.WithHelp("ctrl+c", "quit"),
1161			),
1162		)
1163		// keep them the same
1164		for _, v := range shortList {
1165			fullList = append(fullList, []key.Binding{v})
1166		}
1167	case p.isProjectInit:
1168		shortList = append(shortList,
1169			key.NewBinding(
1170				key.WithKeys("ctrl+c"),
1171				key.WithHelp("ctrl+c", "quit"),
1172			),
1173		)
1174		// keep them the same
1175		for _, v := range shortList {
1176			fullList = append(fullList, []key.Binding{v})
1177		}
1178	default:
1179		if p.editor.IsCompletionsOpen() {
1180			shortList = append(shortList,
1181				key.NewBinding(
1182					key.WithKeys("tab", "enter"),
1183					key.WithHelp("tab/enter", "complete"),
1184				),
1185				key.NewBinding(
1186					key.WithKeys("esc", "alt+esc"),
1187					key.WithHelp("esc", "cancel"),
1188				),
1189				key.NewBinding(
1190					key.WithKeys("up", "down"),
1191					key.WithHelp("↑/↓", "choose"),
1192				),
1193			)
1194			for _, v := range shortList {
1195				fullList = append(fullList, []key.Binding{v})
1196			}
1197			return core.NewSimpleHelp(shortList, fullList)
1198		}
1199		if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
1200			cancelBinding := key.NewBinding(
1201				key.WithKeys("esc", "alt+esc"),
1202				key.WithHelp("esc", "cancel"),
1203			)
1204			if p.isCanceling {
1205				cancelBinding = key.NewBinding(
1206					key.WithKeys("esc", "alt+esc"),
1207					key.WithHelp("esc", "press again to cancel"),
1208				)
1209			}
1210			if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
1211				cancelBinding = key.NewBinding(
1212					key.WithKeys("esc", "alt+esc"),
1213					key.WithHelp("esc", "clear queue"),
1214				)
1215			}
1216			shortList = append(shortList, cancelBinding)
1217			fullList = append(fullList,
1218				[]key.Binding{
1219					cancelBinding,
1220				},
1221			)
1222		}
1223		globalBindings := []key.Binding{}
1224		// we are in a session
1225		if p.session.ID != "" {
1226			var tabKey key.Binding
1227			switch p.focusedPane {
1228			case PanelTypeEditor:
1229				tabKey = key.NewBinding(
1230					key.WithKeys("tab"),
1231					key.WithHelp("tab", "focus chat"),
1232				)
1233			case PanelTypeChat:
1234				tabKey = key.NewBinding(
1235					key.WithKeys("tab"),
1236					key.WithHelp("tab", "focus editor"),
1237				)
1238			default:
1239				tabKey = key.NewBinding(
1240					key.WithKeys("tab"),
1241					key.WithHelp("tab", "focus chat"),
1242				)
1243			}
1244			shortList = append(shortList, tabKey)
1245			globalBindings = append(globalBindings, tabKey)
1246
1247			// Show left/right to switch sections when expanded and both exist
1248			hasTodos := hasIncompleteTodos(p.session.Todos)
1249			hasQueue := p.promptQueue > 0
1250			if p.pillsExpanded && hasTodos && hasQueue {
1251				shortList = append(shortList, p.keyMap.PillLeft)
1252				globalBindings = append(globalBindings, p.keyMap.PillLeft)
1253			}
1254		}
1255		commandsBinding := key.NewBinding(
1256			key.WithKeys("ctrl+p"),
1257			key.WithHelp("ctrl+p", "commands"),
1258		)
1259		if p.focusedPane == PanelTypeEditor && p.editor.IsEmpty() {
1260			commandsBinding.SetHelp("/ or ctrl+p", "commands")
1261		}
1262		modelsBinding := key.NewBinding(
1263			key.WithKeys("ctrl+m", "ctrl+l"),
1264			key.WithHelp("ctrl+l", "models"),
1265		)
1266		if p.keyboardEnhancements.Flags > 0 {
1267			// non-zero flags mean we have at least key disambiguation
1268			modelsBinding.SetHelp("ctrl+m", "models")
1269		}
1270		helpBinding := key.NewBinding(
1271			key.WithKeys("ctrl+g"),
1272			key.WithHelp("ctrl+g", "more"),
1273		)
1274		globalBindings = append(globalBindings, commandsBinding, modelsBinding)
1275		globalBindings = append(globalBindings,
1276			key.NewBinding(
1277				key.WithKeys("ctrl+s"),
1278				key.WithHelp("ctrl+s", "sessions"),
1279			),
1280		)
1281		if p.session.ID != "" {
1282			globalBindings = append(globalBindings,
1283				key.NewBinding(
1284					key.WithKeys("ctrl+n"),
1285					key.WithHelp("ctrl+n", "new sessions"),
1286				))
1287		}
1288		shortList = append(shortList,
1289			// Commands
1290			commandsBinding,
1291			modelsBinding,
1292		)
1293		fullList = append(fullList, globalBindings)
1294
1295		switch p.focusedPane {
1296		case PanelTypeChat:
1297			shortList = append(shortList,
1298				key.NewBinding(
1299					key.WithKeys("up", "down"),
1300					key.WithHelp("↑↓", "scroll"),
1301				),
1302				messages.CopyKey,
1303			)
1304			fullList = append(fullList,
1305				[]key.Binding{
1306					key.NewBinding(
1307						key.WithKeys("up", "down"),
1308						key.WithHelp("↑↓", "scroll"),
1309					),
1310					key.NewBinding(
1311						key.WithKeys("shift+up", "shift+down"),
1312						key.WithHelp("shift+↑↓", "next/prev item"),
1313					),
1314					key.NewBinding(
1315						key.WithKeys("pgup", "b"),
1316						key.WithHelp("b/pgup", "page up"),
1317					),
1318					key.NewBinding(
1319						key.WithKeys("pgdown", " ", "f"),
1320						key.WithHelp("f/pgdn", "page down"),
1321					),
1322				},
1323				[]key.Binding{
1324					key.NewBinding(
1325						key.WithKeys("u"),
1326						key.WithHelp("u", "half page up"),
1327					),
1328					key.NewBinding(
1329						key.WithKeys("d"),
1330						key.WithHelp("d", "half page down"),
1331					),
1332					key.NewBinding(
1333						key.WithKeys("g", "home"),
1334						key.WithHelp("g", "home"),
1335					),
1336					key.NewBinding(
1337						key.WithKeys("G", "end"),
1338						key.WithHelp("G", "end"),
1339					),
1340				},
1341				[]key.Binding{
1342					messages.CopyKey,
1343					messages.ClearSelectionKey,
1344				},
1345			)
1346		case PanelTypeEditor:
1347			newLineBinding := key.NewBinding(
1348				key.WithKeys("shift+enter", "ctrl+j"),
1349				// "ctrl+j" is a common keybinding for newline in many editors. If
1350				// the terminal supports "shift+enter", we substitute the help text
1351				// to reflect that.
1352				key.WithHelp("ctrl+j", "newline"),
1353			)
1354			if p.keyboardEnhancements.Flags > 0 {
1355				// Non-zero flags mean we have at least key disambiguation.
1356				newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
1357			}
1358			shortList = append(shortList, newLineBinding)
1359			fullList = append(fullList,
1360				[]key.Binding{
1361					newLineBinding,
1362					key.NewBinding(
1363						key.WithKeys("ctrl+f"),
1364						key.WithHelp("ctrl+f", "add image"),
1365					),
1366					key.NewBinding(
1367						key.WithKeys("@"),
1368						key.WithHelp("@", "mention file"),
1369					),
1370					key.NewBinding(
1371						key.WithKeys("ctrl+o"),
1372						key.WithHelp("ctrl+o", "open editor"),
1373					),
1374				})
1375
1376			if p.editor.HasAttachments() {
1377				fullList = append(fullList, []key.Binding{
1378					key.NewBinding(
1379						key.WithKeys("ctrl+r"),
1380						key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
1381					),
1382					key.NewBinding(
1383						key.WithKeys("ctrl+r", "r"),
1384						key.WithHelp("ctrl+r+r", "delete all attachments"),
1385					),
1386					key.NewBinding(
1387						key.WithKeys("esc", "alt+esc"),
1388						key.WithHelp("esc", "cancel delete mode"),
1389					),
1390				})
1391			}
1392		}
1393		shortList = append(shortList,
1394			// Quit
1395			key.NewBinding(
1396				key.WithKeys("ctrl+c"),
1397				key.WithHelp("ctrl+c", "quit"),
1398			),
1399			// Help
1400			helpBinding,
1401		)
1402		fullList = append(fullList, []key.Binding{
1403			key.NewBinding(
1404				key.WithKeys("ctrl+g"),
1405				key.WithHelp("ctrl+g", "less"),
1406			),
1407		})
1408	}
1409
1410	return core.NewSimpleHelp(shortList, fullList)
1411}
1412
1413func (p *chatPage) IsChatFocused() bool {
1414	return p.focusedPane == PanelTypeChat
1415}
1416
1417// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
1418// Returns true if the mouse is over the chat area, false otherwise.
1419func (p *chatPage) isMouseOverChat(x, y int) bool {
1420	// No session means no chat area
1421	if p.session.ID == "" {
1422		return false
1423	}
1424
1425	var chatX, chatY, chatWidth, chatHeight int
1426
1427	if p.compact {
1428		// In compact mode: chat area starts after header and spans full width
1429		chatX = 0
1430		chatY = HeaderHeight
1431		chatWidth = p.width
1432		chatHeight = p.height - EditorHeight - HeaderHeight
1433	} else {
1434		// In non-compact mode: chat area spans from left edge to sidebar
1435		chatX = 0
1436		chatY = 0
1437		chatWidth = p.width - SideBarWidth
1438		chatHeight = p.height - EditorHeight
1439	}
1440
1441	// Check if mouse coordinates are within chat bounds
1442	return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1443}
1444
1445func (p *chatPage) hasInProgressTodo() bool {
1446	for _, todo := range p.session.Todos {
1447		if todo.Status == session.TodoStatusInProgress {
1448			return true
1449		}
1450	}
1451	return false
1452}