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