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