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