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