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