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