chat.go

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