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}