chat.go

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