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