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			style := t.S().Base.MarginTop(1).PaddingLeft(3)
 617			pillsArea = style.Render(pillsArea)
 618		}
 619
 620		if p.compact {
 621			headerView := p.header.View()
 622			views := []string{headerView, messagesView}
 623			if pillsArea != "" {
 624				views = append(views, pillsArea)
 625			}
 626			views = append(views, editorView)
 627			chatView = lipgloss.JoinVertical(lipgloss.Left, views...)
 628		} else {
 629			sidebarView := p.sidebar.View()
 630			var messagesColumn string
 631			if pillsArea != "" {
 632				messagesColumn = lipgloss.JoinVertical(
 633					lipgloss.Left,
 634					messagesView,
 635					pillsArea,
 636				)
 637			} else {
 638				messagesColumn = messagesView
 639			}
 640			messages := lipgloss.JoinHorizontal(
 641				lipgloss.Left,
 642				messagesColumn,
 643				sidebarView,
 644			)
 645			chatView = lipgloss.JoinVertical(
 646				lipgloss.Left,
 647				messages,
 648				p.editor.View(),
 649			)
 650		}
 651	}
 652
 653	layers := []*lipgloss.Layer{
 654		lipgloss.NewLayer(chatView).X(0).Y(0),
 655	}
 656
 657	if p.showingDetails {
 658		style := t.S().Base.
 659			Width(p.detailsWidth).
 660			Border(lipgloss.RoundedBorder()).
 661			BorderForeground(t.BorderFocus)
 662		version := t.S().Base.Foreground(t.Border).Width(p.detailsWidth - 4).AlignHorizontal(lipgloss.Right).Render(version.Version)
 663		details := style.Render(
 664			lipgloss.JoinVertical(
 665				lipgloss.Left,
 666				p.sidebar.View(),
 667				version,
 668			),
 669		)
 670		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
 671	}
 672	canvas := lipgloss.NewCompositor(layers...)
 673	return canvas.Render()
 674}
 675
 676func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
 677	return func() tea.Msg {
 678		err := config.Get().SetCompactMode(compact)
 679		if err != nil {
 680			return util.InfoMsg{
 681				Type: util.InfoTypeError,
 682				Msg:  "Failed to update compact mode configuration: " + err.Error(),
 683			}
 684		}
 685		return nil
 686	}
 687}
 688
 689func (p *chatPage) toggleThinking() tea.Cmd {
 690	return func() tea.Msg {
 691		cfg := config.Get()
 692		agentCfg := cfg.Agents[config.AgentCoder]
 693		currentModel := cfg.Models[agentCfg.Model]
 694
 695		// Toggle the thinking mode
 696		currentModel.Think = !currentModel.Think
 697		if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
 698			return util.InfoMsg{
 699				Type: util.InfoTypeError,
 700				Msg:  "Failed to update thinking mode: " + err.Error(),
 701			}
 702		}
 703
 704		// Update the agent with the new configuration
 705		go p.app.UpdateAgentModel(context.TODO())
 706
 707		status := "disabled"
 708		if currentModel.Think {
 709			status = "enabled"
 710		}
 711		return util.InfoMsg{
 712			Type: util.InfoTypeInfo,
 713			Msg:  "Thinking mode " + status,
 714		}
 715	}
 716}
 717
 718func (p *chatPage) openReasoningDialog() tea.Cmd {
 719	return func() tea.Msg {
 720		cfg := config.Get()
 721		agentCfg := cfg.Agents[config.AgentCoder]
 722		model := cfg.GetModelByType(agentCfg.Model)
 723		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
 724
 725		if providerCfg != nil && model != nil && len(model.ReasoningLevels) > 0 {
 726			// Return the OpenDialogMsg directly so it bubbles up to the main TUI
 727			return dialogs.OpenDialogMsg{
 728				Model: reasoning.NewReasoningDialog(),
 729			}
 730		}
 731		return nil
 732	}
 733}
 734
 735func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
 736	return func() tea.Msg {
 737		cfg := config.Get()
 738		agentCfg := cfg.Agents[config.AgentCoder]
 739		currentModel := cfg.Models[agentCfg.Model]
 740
 741		// Update the model configuration
 742		currentModel.ReasoningEffort = effort
 743		if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
 744			return util.InfoMsg{
 745				Type: util.InfoTypeError,
 746				Msg:  "Failed to update reasoning effort: " + err.Error(),
 747			}
 748		}
 749
 750		// Update the agent with the new configuration
 751		if err := p.app.UpdateAgentModel(context.TODO()); err != nil {
 752			return util.InfoMsg{
 753				Type: util.InfoTypeError,
 754				Msg:  "Failed to update reasoning effort: " + err.Error(),
 755			}
 756		}
 757
 758		return util.InfoMsg{
 759			Type: util.InfoTypeInfo,
 760			Msg:  "Reasoning effort set to " + effort,
 761		}
 762	}
 763}
 764
 765func (p *chatPage) setCompactMode(compact bool) {
 766	if p.compact == compact {
 767		return
 768	}
 769	p.compact = compact
 770	if compact {
 771		p.sidebar.SetCompactMode(true)
 772	} else {
 773		p.setShowDetails(false)
 774	}
 775}
 776
 777func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
 778	if p.forceCompact {
 779		return
 780	}
 781	if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
 782		p.setCompactMode(true)
 783	}
 784	if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
 785		p.setCompactMode(false)
 786	}
 787}
 788
 789func (p *chatPage) SetSize(width, height int) tea.Cmd {
 790	p.handleCompactMode(width, height)
 791	p.width = width
 792	p.height = height
 793	var cmds []tea.Cmd
 794
 795	if p.session.ID == "" {
 796		if p.splashFullScreen {
 797			cmds = append(cmds, p.splash.SetSize(width, height))
 798		} else {
 799			cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
 800			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 801			cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
 802		}
 803	} else {
 804		hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
 805		hasQueue := p.promptQueue > 0
 806		hasPills := hasIncompleteTodos || hasQueue
 807
 808		pillsAreaHeight := 0
 809		if hasPills {
 810			pillsAreaHeight = pillHeightWithBorder + 1 // +1 for padding top
 811			if p.pillsExpanded {
 812				if p.focusedPillSection == PillSectionTodos && hasIncompleteTodos {
 813					pillsAreaHeight += len(p.session.Todos)
 814				} else if p.focusedPillSection == PillSectionQueue && hasQueue {
 815					pillsAreaHeight += p.promptQueue
 816				}
 817			}
 818		}
 819
 820		if p.compact {
 821			cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight-pillsAreaHeight))
 822			p.detailsWidth = width - DetailsPositioning
 823			cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
 824			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 825			cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
 826		} else {
 827			cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight-pillsAreaHeight))
 828			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
 829			cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
 830		}
 831		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
 832	}
 833	return tea.Batch(cmds...)
 834}
 835
 836func (p *chatPage) newSession() tea.Cmd {
 837	if p.session.ID == "" {
 838		return nil
 839	}
 840
 841	p.session = session.Session{}
 842	p.focusedPane = PanelTypeEditor
 843	p.editor.Focus()
 844	p.chat.Blur()
 845	p.isCanceling = false
 846	return tea.Batch(
 847		util.CmdHandler(chat.SessionClearedMsg{}),
 848		p.SetSize(p.width, p.height),
 849	)
 850}
 851
 852func (p *chatPage) setSession(sess session.Session) tea.Cmd {
 853	if p.session.ID == sess.ID {
 854		return nil
 855	}
 856
 857	var cmds []tea.Cmd
 858	p.session = sess
 859
 860	if p.hasInProgressTodo() {
 861		cmds = append(cmds, p.todoSpinner.Tick)
 862	}
 863
 864	cmds = append(cmds, p.SetSize(p.width, p.height))
 865	cmds = append(cmds, p.chat.SetSession(sess))
 866	cmds = append(cmds, p.sidebar.SetSession(sess))
 867	cmds = append(cmds, p.header.SetSession(sess))
 868	cmds = append(cmds, p.editor.SetSession(sess))
 869
 870	return tea.Sequence(cmds...)
 871}
 872
 873func (p *chatPage) changeFocus() tea.Cmd {
 874	if p.session.ID == "" {
 875		return nil
 876	}
 877
 878	switch p.focusedPane {
 879	case PanelTypeEditor:
 880		p.focusedPane = PanelTypeChat
 881		p.chat.Focus()
 882		p.editor.Blur()
 883	case PanelTypeChat:
 884		p.focusedPane = PanelTypeEditor
 885		p.editor.Focus()
 886		p.chat.Blur()
 887	}
 888	return nil
 889}
 890
 891func (p *chatPage) togglePillsExpanded() tea.Cmd {
 892	hasPills := hasIncompleteTodos(p.session.Todos) || p.promptQueue > 0
 893	if !hasPills {
 894		return nil
 895	}
 896	p.pillsExpanded = !p.pillsExpanded
 897	if p.pillsExpanded {
 898		if hasIncompleteTodos(p.session.Todos) {
 899			p.focusedPillSection = PillSectionTodos
 900		} else {
 901			p.focusedPillSection = PillSectionQueue
 902		}
 903	}
 904	return p.SetSize(p.width, p.height)
 905}
 906
 907func (p *chatPage) switchPillSection(dir int) tea.Cmd {
 908	if !p.pillsExpanded {
 909		return nil
 910	}
 911	hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
 912	hasQueue := p.promptQueue > 0
 913
 914	if dir < 0 && p.focusedPillSection == PillSectionQueue && hasIncompleteTodos {
 915		p.focusedPillSection = PillSectionTodos
 916		return p.SetSize(p.width, p.height)
 917	}
 918	if dir > 0 && p.focusedPillSection == PillSectionTodos && hasQueue {
 919		p.focusedPillSection = PillSectionQueue
 920		return p.SetSize(p.width, p.height)
 921	}
 922	return nil
 923}
 924
 925func (p *chatPage) cancel() tea.Cmd {
 926	if p.isCanceling {
 927		p.isCanceling = false
 928		if p.app.AgentCoordinator != nil {
 929			p.app.AgentCoordinator.Cancel(p.session.ID)
 930		}
 931		return nil
 932	}
 933
 934	if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
 935		p.app.AgentCoordinator.ClearQueue(p.session.ID)
 936		return nil
 937	}
 938	p.isCanceling = true
 939	return cancelTimerCmd()
 940}
 941
 942func (p *chatPage) setShowDetails(show bool) {
 943	p.showingDetails = show
 944	p.header.SetDetailsOpen(p.showingDetails)
 945	if !p.compact {
 946		p.sidebar.SetCompactMode(false)
 947	}
 948}
 949
 950func (p *chatPage) toggleDetails() {
 951	if p.session.ID == "" || !p.compact {
 952		return
 953	}
 954	p.setShowDetails(!p.showingDetails)
 955}
 956
 957func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
 958	session := p.session
 959	var cmds []tea.Cmd
 960	if p.session.ID == "" {
 961		newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
 962		if err != nil {
 963			return util.ReportError(err)
 964		}
 965		session = newSession
 966		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
 967	}
 968	if p.app.AgentCoordinator == nil {
 969		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
 970	}
 971	cmds = append(cmds, p.chat.GoToBottom())
 972	cmds = append(cmds, func() tea.Msg {
 973		_, err := p.app.AgentCoordinator.Run(context.Background(), session.ID, text, attachments...)
 974		if err != nil {
 975			isCancelErr := errors.Is(err, context.Canceled)
 976			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
 977			if isCancelErr || isPermissionErr {
 978				return nil
 979			}
 980			return util.InfoMsg{
 981				Type: util.InfoTypeError,
 982				Msg:  err.Error(),
 983			}
 984		}
 985		return nil
 986	})
 987	return tea.Batch(cmds...)
 988}
 989
 990func (p *chatPage) Bindings() []key.Binding {
 991	bindings := []key.Binding{
 992		p.keyMap.NewSession,
 993		p.keyMap.AddAttachment,
 994	}
 995	if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
 996		cancelBinding := p.keyMap.Cancel
 997		if p.isCanceling {
 998			cancelBinding = key.NewBinding(
 999				key.WithKeys("esc", "alt+esc"),
1000				key.WithHelp("esc", "press again to cancel"),
1001			)
1002		}
1003		bindings = append([]key.Binding{cancelBinding}, bindings...)
1004	}
1005
1006	switch p.focusedPane {
1007	case PanelTypeChat:
1008		bindings = append([]key.Binding{
1009			key.NewBinding(
1010				key.WithKeys("tab"),
1011				key.WithHelp("tab", "focus editor"),
1012			),
1013		}, bindings...)
1014		bindings = append(bindings, p.chat.Bindings()...)
1015	case PanelTypeEditor:
1016		bindings = append([]key.Binding{
1017			key.NewBinding(
1018				key.WithKeys("tab"),
1019				key.WithHelp("tab", "focus chat"),
1020			),
1021		}, bindings...)
1022		bindings = append(bindings, p.editor.Bindings()...)
1023	case PanelTypeSplash:
1024		bindings = append(bindings, p.splash.Bindings()...)
1025	}
1026
1027	return bindings
1028}
1029
1030func (p *chatPage) Help() help.KeyMap {
1031	var shortList []key.Binding
1032	var fullList [][]key.Binding
1033	switch {
1034	case p.isOnboarding && p.splash.IsShowingClaudeAuthMethodChooser():
1035		shortList = append(shortList,
1036			// Choose auth method
1037			key.NewBinding(
1038				key.WithKeys("left", "right", "tab"),
1039				key.WithHelp("←→/tab", "choose"),
1040			),
1041			// Accept selection
1042			key.NewBinding(
1043				key.WithKeys("enter"),
1044				key.WithHelp("enter", "accept"),
1045			),
1046			// Go back
1047			key.NewBinding(
1048				key.WithKeys("esc", "alt+esc"),
1049				key.WithHelp("esc", "back"),
1050			),
1051			// Quit
1052			key.NewBinding(
1053				key.WithKeys("ctrl+c"),
1054				key.WithHelp("ctrl+c", "quit"),
1055			),
1056		)
1057		// keep them the same
1058		for _, v := range shortList {
1059			fullList = append(fullList, []key.Binding{v})
1060		}
1061	case p.isOnboarding && p.splash.IsShowingClaudeOAuth2():
1062		switch {
1063		case p.splash.IsClaudeOAuthURLState():
1064			shortList = append(shortList,
1065				key.NewBinding(
1066					key.WithKeys("enter"),
1067					key.WithHelp("enter", "open"),
1068				),
1069				key.NewBinding(
1070					key.WithKeys("c"),
1071					key.WithHelp("c", "copy url"),
1072				),
1073			)
1074		case p.splash.IsClaudeOAuthComplete():
1075			shortList = append(shortList,
1076				key.NewBinding(
1077					key.WithKeys("enter"),
1078					key.WithHelp("enter", "continue"),
1079				),
1080			)
1081		case p.splash.IsShowingHyperOAuth2() || p.splash.IsShowingCopilotOAuth2():
1082			shortList = append(shortList,
1083				key.NewBinding(
1084					key.WithKeys("enter"),
1085					key.WithHelp("enter", "copy url & open signup"),
1086				),
1087				key.NewBinding(
1088					key.WithKeys("c"),
1089					key.WithHelp("c", "copy url"),
1090				),
1091			)
1092		default:
1093			shortList = append(shortList,
1094				key.NewBinding(
1095					key.WithKeys("enter"),
1096					key.WithHelp("enter", "submit"),
1097				),
1098			)
1099		}
1100		shortList = append(shortList,
1101			// Quit
1102			key.NewBinding(
1103				key.WithKeys("ctrl+c"),
1104				key.WithHelp("ctrl+c", "quit"),
1105			),
1106		)
1107		// keep them the same
1108		for _, v := range shortList {
1109			fullList = append(fullList, []key.Binding{v})
1110		}
1111	case p.isOnboarding && !p.splash.IsShowingAPIKey():
1112		shortList = append(shortList,
1113			// Choose model
1114			key.NewBinding(
1115				key.WithKeys("up", "down"),
1116				key.WithHelp("↑/↓", "choose"),
1117			),
1118			// Accept selection
1119			key.NewBinding(
1120				key.WithKeys("enter", "ctrl+y"),
1121				key.WithHelp("enter", "accept"),
1122			),
1123			// Quit
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	case p.isOnboarding && p.splash.IsShowingAPIKey():
1134		if p.splash.IsAPIKeyValid() {
1135			shortList = append(shortList,
1136				key.NewBinding(
1137					key.WithKeys("enter"),
1138					key.WithHelp("enter", "continue"),
1139				),
1140			)
1141		} else {
1142			shortList = append(shortList,
1143				// Go back
1144				key.NewBinding(
1145					key.WithKeys("esc", "alt+esc"),
1146					key.WithHelp("esc", "back"),
1147				),
1148			)
1149		}
1150		shortList = append(shortList,
1151			// Quit
1152			key.NewBinding(
1153				key.WithKeys("ctrl+c"),
1154				key.WithHelp("ctrl+c", "quit"),
1155			),
1156		)
1157		// keep them the same
1158		for _, v := range shortList {
1159			fullList = append(fullList, []key.Binding{v})
1160		}
1161	case p.isProjectInit:
1162		shortList = append(shortList,
1163			key.NewBinding(
1164				key.WithKeys("ctrl+c"),
1165				key.WithHelp("ctrl+c", "quit"),
1166			),
1167		)
1168		// keep them the same
1169		for _, v := range shortList {
1170			fullList = append(fullList, []key.Binding{v})
1171		}
1172	default:
1173		if p.editor.IsCompletionsOpen() {
1174			shortList = append(shortList,
1175				key.NewBinding(
1176					key.WithKeys("tab", "enter"),
1177					key.WithHelp("tab/enter", "complete"),
1178				),
1179				key.NewBinding(
1180					key.WithKeys("esc", "alt+esc"),
1181					key.WithHelp("esc", "cancel"),
1182				),
1183				key.NewBinding(
1184					key.WithKeys("up", "down"),
1185					key.WithHelp("↑/↓", "choose"),
1186				),
1187			)
1188			for _, v := range shortList {
1189				fullList = append(fullList, []key.Binding{v})
1190			}
1191			return core.NewSimpleHelp(shortList, fullList)
1192		}
1193		if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
1194			cancelBinding := key.NewBinding(
1195				key.WithKeys("esc", "alt+esc"),
1196				key.WithHelp("esc", "cancel"),
1197			)
1198			if p.isCanceling {
1199				cancelBinding = key.NewBinding(
1200					key.WithKeys("esc", "alt+esc"),
1201					key.WithHelp("esc", "press again to cancel"),
1202				)
1203			}
1204			if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
1205				cancelBinding = key.NewBinding(
1206					key.WithKeys("esc", "alt+esc"),
1207					key.WithHelp("esc", "clear queue"),
1208				)
1209			}
1210			shortList = append(shortList, cancelBinding)
1211			fullList = append(fullList,
1212				[]key.Binding{
1213					cancelBinding,
1214				},
1215			)
1216		}
1217		globalBindings := []key.Binding{}
1218		// we are in a session
1219		if p.session.ID != "" {
1220			var tabKey key.Binding
1221			switch p.focusedPane {
1222			case PanelTypeEditor:
1223				tabKey = key.NewBinding(
1224					key.WithKeys("tab"),
1225					key.WithHelp("tab", "focus chat"),
1226				)
1227			case PanelTypeChat:
1228				tabKey = key.NewBinding(
1229					key.WithKeys("tab"),
1230					key.WithHelp("tab", "focus editor"),
1231				)
1232			default:
1233				tabKey = key.NewBinding(
1234					key.WithKeys("tab"),
1235					key.WithHelp("tab", "focus chat"),
1236				)
1237			}
1238			shortList = append(shortList, tabKey)
1239			globalBindings = append(globalBindings, tabKey)
1240
1241			// Show left/right to switch sections when expanded and both exist
1242			hasTodos := hasIncompleteTodos(p.session.Todos)
1243			hasQueue := p.promptQueue > 0
1244			if p.pillsExpanded && hasTodos && hasQueue {
1245				shortList = append(shortList, p.keyMap.PillLeft)
1246				globalBindings = append(globalBindings, p.keyMap.PillLeft)
1247			}
1248		}
1249		commandsBinding := key.NewBinding(
1250			key.WithKeys("ctrl+p"),
1251			key.WithHelp("ctrl+p", "commands"),
1252		)
1253		if p.focusedPane == PanelTypeEditor && p.editor.IsEmpty() {
1254			commandsBinding.SetHelp("/ or ctrl+p", "commands")
1255		}
1256		modelsBinding := key.NewBinding(
1257			key.WithKeys("ctrl+m", "ctrl+l"),
1258			key.WithHelp("ctrl+l", "models"),
1259		)
1260		if p.keyboardEnhancements.Flags > 0 {
1261			// non-zero flags mean we have at least key disambiguation
1262			modelsBinding.SetHelp("ctrl+m", "models")
1263		}
1264		helpBinding := key.NewBinding(
1265			key.WithKeys("ctrl+g"),
1266			key.WithHelp("ctrl+g", "more"),
1267		)
1268		globalBindings = append(globalBindings, commandsBinding, modelsBinding)
1269		globalBindings = append(globalBindings,
1270			key.NewBinding(
1271				key.WithKeys("ctrl+s"),
1272				key.WithHelp("ctrl+s", "sessions"),
1273			),
1274		)
1275		if p.session.ID != "" {
1276			globalBindings = append(globalBindings,
1277				key.NewBinding(
1278					key.WithKeys("ctrl+n"),
1279					key.WithHelp("ctrl+n", "new sessions"),
1280				))
1281		}
1282		shortList = append(shortList,
1283			// Commands
1284			commandsBinding,
1285			modelsBinding,
1286		)
1287		fullList = append(fullList, globalBindings)
1288
1289		switch p.focusedPane {
1290		case PanelTypeChat:
1291			shortList = append(shortList,
1292				key.NewBinding(
1293					key.WithKeys("up", "down"),
1294					key.WithHelp("↑↓", "scroll"),
1295				),
1296				messages.CopyKey,
1297			)
1298			fullList = append(fullList,
1299				[]key.Binding{
1300					key.NewBinding(
1301						key.WithKeys("up", "down"),
1302						key.WithHelp("↑↓", "scroll"),
1303					),
1304					key.NewBinding(
1305						key.WithKeys("shift+up", "shift+down"),
1306						key.WithHelp("shift+↑↓", "next/prev item"),
1307					),
1308					key.NewBinding(
1309						key.WithKeys("pgup", "b"),
1310						key.WithHelp("b/pgup", "page up"),
1311					),
1312					key.NewBinding(
1313						key.WithKeys("pgdown", " ", "f"),
1314						key.WithHelp("f/pgdn", "page down"),
1315					),
1316				},
1317				[]key.Binding{
1318					key.NewBinding(
1319						key.WithKeys("u"),
1320						key.WithHelp("u", "half page up"),
1321					),
1322					key.NewBinding(
1323						key.WithKeys("d"),
1324						key.WithHelp("d", "half page down"),
1325					),
1326					key.NewBinding(
1327						key.WithKeys("g", "home"),
1328						key.WithHelp("g", "home"),
1329					),
1330					key.NewBinding(
1331						key.WithKeys("G", "end"),
1332						key.WithHelp("G", "end"),
1333					),
1334				},
1335				[]key.Binding{
1336					messages.CopyKey,
1337					messages.ClearSelectionKey,
1338				},
1339			)
1340		case PanelTypeEditor:
1341			newLineBinding := key.NewBinding(
1342				key.WithKeys("shift+enter", "ctrl+j"),
1343				// "ctrl+j" is a common keybinding for newline in many editors. If
1344				// the terminal supports "shift+enter", we substitute the help text
1345				// to reflect that.
1346				key.WithHelp("ctrl+j", "newline"),
1347			)
1348			if p.keyboardEnhancements.Flags > 0 {
1349				// Non-zero flags mean we have at least key disambiguation.
1350				newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
1351			}
1352			shortList = append(shortList, newLineBinding)
1353			fullList = append(fullList,
1354				[]key.Binding{
1355					newLineBinding,
1356					key.NewBinding(
1357						key.WithKeys("ctrl+f"),
1358						key.WithHelp("ctrl+f", "add image"),
1359					),
1360					key.NewBinding(
1361						key.WithKeys("@"),
1362						key.WithHelp("@", "mention file"),
1363					),
1364					key.NewBinding(
1365						key.WithKeys("ctrl+o"),
1366						key.WithHelp("ctrl+o", "open editor"),
1367					),
1368				})
1369
1370			if p.editor.HasAttachments() {
1371				fullList = append(fullList, []key.Binding{
1372					key.NewBinding(
1373						key.WithKeys("ctrl+r"),
1374						key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
1375					),
1376					key.NewBinding(
1377						key.WithKeys("ctrl+r", "r"),
1378						key.WithHelp("ctrl+r+r", "delete all attachments"),
1379					),
1380					key.NewBinding(
1381						key.WithKeys("esc", "alt+esc"),
1382						key.WithHelp("esc", "cancel delete mode"),
1383					),
1384				})
1385			}
1386		}
1387		shortList = append(shortList,
1388			// Quit
1389			key.NewBinding(
1390				key.WithKeys("ctrl+c"),
1391				key.WithHelp("ctrl+c", "quit"),
1392			),
1393			// Help
1394			helpBinding,
1395		)
1396		fullList = append(fullList, []key.Binding{
1397			key.NewBinding(
1398				key.WithKeys("ctrl+g"),
1399				key.WithHelp("ctrl+g", "less"),
1400			),
1401		})
1402	}
1403
1404	return core.NewSimpleHelp(shortList, fullList)
1405}
1406
1407func (p *chatPage) IsChatFocused() bool {
1408	return p.focusedPane == PanelTypeChat
1409}
1410
1411// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
1412// Returns true if the mouse is over the chat area, false otherwise.
1413func (p *chatPage) isMouseOverChat(x, y int) bool {
1414	// No session means no chat area
1415	if p.session.ID == "" {
1416		return false
1417	}
1418
1419	var chatX, chatY, chatWidth, chatHeight int
1420
1421	if p.compact {
1422		// In compact mode: chat area starts after header and spans full width
1423		chatX = 0
1424		chatY = HeaderHeight
1425		chatWidth = p.width
1426		chatHeight = p.height - EditorHeight - HeaderHeight
1427	} else {
1428		// In non-compact mode: chat area spans from left edge to sidebar
1429		chatX = 0
1430		chatY = 0
1431		chatWidth = p.width - SideBarWidth
1432		chatHeight = p.height - EditorHeight
1433	}
1434
1435	// Check if mouse coordinates are within chat bounds
1436	return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1437}
1438
1439func (p *chatPage) hasInProgressTodo() bool {
1440	for _, todo := range p.session.Todos {
1441		if todo.Status == session.TodoStatusInProgress {
1442			return true
1443		}
1444	}
1445	return false
1446}