chat.go

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