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/copilot"
35 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
36 "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
37 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
38 "github.com/charmbracelet/crush/internal/tui/components/dialogs/reasoning"
39 "github.com/charmbracelet/crush/internal/tui/page"
40 "github.com/charmbracelet/crush/internal/tui/styles"
41 "github.com/charmbracelet/crush/internal/tui/util"
42 "github.com/charmbracelet/crush/internal/version"
43)
44
45var ChatPageID page.PageID = "chat"
46
47type (
48 ChatFocusedMsg struct {
49 Focused bool
50 }
51 CancelTimerExpiredMsg struct{}
52)
53
54type PanelType string
55
56const (
57 PanelTypeChat PanelType = "chat"
58 PanelTypeEditor PanelType = "editor"
59 PanelTypeSplash PanelType = "splash"
60)
61
62// PillSection represents which pill section is focused when in pills panel.
63type PillSection int
64
65const (
66 PillSectionTodos PillSection = iota
67 PillSectionQueue
68)
69
70const (
71 CompactModeWidthBreakpoint = 120 // Width at which the chat page switches to compact mode
72 CompactModeHeightBreakpoint = 30 // Height at which the chat page switches to compact mode
73 EditorHeight = 5 // Height of the editor input area including padding
74 SideBarWidth = 31 // Width of the sidebar
75 SideBarDetailsPadding = 1 // Padding for the sidebar details section
76 HeaderHeight = 1 // Height of the header
77
78 // Layout constants for borders and padding
79 BorderWidth = 1 // Width of component borders
80 LeftRightBorders = 2 // Left + right border width (1 + 1)
81 TopBottomBorders = 2 // Top + bottom border width (1 + 1)
82 DetailsPositioning = 2 // Positioning adjustment for details panel
83
84 // Timing constants
85 CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires
86)
87
88type ChatPage interface {
89 util.Model
90 layout.Help
91 IsChatFocused() bool
92}
93
94// cancelTimerCmd creates a command that expires the cancel timer
95func cancelTimerCmd() tea.Cmd {
96 return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg {
97 return CancelTimerExpiredMsg{}
98 })
99}
100
101type chatPage struct {
102 width, height int
103 detailsWidth, detailsHeight int
104 app *app.App
105 keyboardEnhancements tea.KeyboardEnhancementsMsg
106
107 // Layout state
108 compact bool
109 forceCompact bool
110 focusedPane PanelType
111
112 // Session
113 session session.Session
114 keyMap KeyMap
115
116 // Components
117 header header.Header
118 sidebar sidebar.Sidebar
119 chat chat.MessageListCmp
120 editor editor.Editor
121 splash splash.Splash
122
123 // Simple state flags
124 showingDetails bool
125 isCanceling bool
126 splashFullScreen bool
127 isOnboarding bool
128 isProjectInit bool
129 promptQueue int
130
131 // Pills state
132 pillsExpanded bool
133 focusedPillSection PillSection
134
135 // Todo spinner
136 todoSpinner spinner.Model
137}
138
139func New(app *app.App) ChatPage {
140 t := styles.CurrentTheme()
141 return &chatPage{
142 app: app,
143 keyMap: DefaultKeyMap(),
144 header: header.New(app.LSPClients),
145 sidebar: sidebar.New(app.History, app.LSPClients, false),
146 chat: chat.New(app),
147 editor: editor.New(app),
148 splash: splash.New(),
149 focusedPane: PanelTypeSplash,
150 todoSpinner: spinner.New(
151 spinner.WithSpinner(spinner.MiniDot),
152 spinner.WithStyle(t.S().Base.Foreground(t.GreenDark)),
153 ),
154 }
155}
156
157func (p *chatPage) Init() tea.Cmd {
158 cfg := config.Get()
159 compact := cfg.Options.TUI.CompactMode
160 p.compact = compact
161 p.forceCompact = compact
162 p.sidebar.SetCompactMode(p.compact)
163
164 // Set splash state based on config
165 if !config.HasInitialDataConfig() {
166 // First-time setup: show model selection
167 p.splash.SetOnboarding(true)
168 p.isOnboarding = true
169 p.splashFullScreen = true
170 } else if b, _ := config.ProjectNeedsInitialization(); b {
171 // Project needs context initialization
172 p.splash.SetProjectInit(true)
173 p.isProjectInit = true
174 p.splashFullScreen = true
175 } else {
176 // Ready to chat: focus editor, splash in background
177 p.focusedPane = PanelTypeEditor
178 p.splashFullScreen = false
179 }
180
181 return tea.Batch(
182 p.header.Init(),
183 p.sidebar.Init(),
184 p.chat.Init(),
185 p.editor.Init(),
186 p.splash.Init(),
187 )
188}
189
190func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
191 var cmds []tea.Cmd
192 if p.session.ID != "" && p.app.AgentCoordinator != nil {
193 queueSize := p.app.AgentCoordinator.QueuedPrompts(p.session.ID)
194 if queueSize != p.promptQueue {
195 p.promptQueue = queueSize
196 cmds = append(cmds, p.SetSize(p.width, p.height))
197 }
198 }
199 switch msg := msg.(type) {
200 case tea.KeyboardEnhancementsMsg:
201 p.keyboardEnhancements = msg
202 return p, nil
203 case tea.MouseWheelMsg:
204 if p.compact {
205 msg.Y -= 1
206 }
207 if p.isMouseOverChat(msg.X, msg.Y) {
208 u, cmd := p.chat.Update(msg)
209 p.chat = u.(chat.MessageListCmp)
210 return p, cmd
211 }
212 return p, nil
213 case tea.MouseClickMsg:
214 if p.isOnboarding || p.isProjectInit {
215 return p, nil
216 }
217 if p.compact {
218 msg.Y -= 1
219 }
220 if p.isMouseOverChat(msg.X, msg.Y) {
221 p.focusedPane = PanelTypeChat
222 p.chat.Focus()
223 p.editor.Blur()
224 } else {
225 p.focusedPane = PanelTypeEditor
226 p.editor.Focus()
227 p.chat.Blur()
228 }
229 u, cmd := p.chat.Update(msg)
230 p.chat = u.(chat.MessageListCmp)
231 return p, cmd
232 case tea.MouseMotionMsg:
233 if p.compact {
234 msg.Y -= 1
235 }
236 if msg.Button == tea.MouseLeft {
237 u, cmd := p.chat.Update(msg)
238 p.chat = u.(chat.MessageListCmp)
239 return p, cmd
240 }
241 return p, nil
242 case tea.MouseReleaseMsg:
243 if p.isOnboarding || p.isProjectInit {
244 return p, nil
245 }
246 if p.compact {
247 msg.Y -= 1
248 }
249 if msg.Button == tea.MouseLeft {
250 u, cmd := p.chat.Update(msg)
251 p.chat = u.(chat.MessageListCmp)
252 return p, cmd
253 }
254 return p, nil
255 case chat.SelectionCopyMsg:
256 u, cmd := p.chat.Update(msg)
257 p.chat = u.(chat.MessageListCmp)
258 return p, cmd
259 case tea.WindowSizeMsg:
260 u, cmd := p.editor.Update(msg)
261 p.editor = u.(editor.Editor)
262 return p, tea.Batch(p.SetSize(msg.Width, msg.Height), cmd)
263 case CancelTimerExpiredMsg:
264 p.isCanceling = false
265 return p, nil
266 case editor.OpenEditorMsg:
267 u, cmd := p.editor.Update(msg)
268 p.editor = u.(editor.Editor)
269 return p, cmd
270 case chat.SendMsg:
271 return p, p.sendMessage(msg.Text, msg.Attachments)
272 case chat.SessionSelectedMsg:
273 return p, p.setSession(msg)
274 case splash.SubmitAPIKeyMsg:
275 u, cmd := p.splash.Update(msg)
276 p.splash = u.(splash.Splash)
277 cmds = append(cmds, cmd)
278 return p, tea.Batch(cmds...)
279 case commands.ToggleCompactModeMsg:
280 p.forceCompact = !p.forceCompact
281 var cmd tea.Cmd
282 if p.forceCompact {
283 p.setCompactMode(true)
284 cmd = p.updateCompactConfig(true)
285 } else if p.width >= CompactModeWidthBreakpoint && p.height >= CompactModeHeightBreakpoint {
286 p.setCompactMode(false)
287 cmd = p.updateCompactConfig(false)
288 }
289 return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
290 case commands.ToggleThinkingMsg:
291 return p, p.toggleThinking()
292 case commands.OpenReasoningDialogMsg:
293 return p, p.openReasoningDialog()
294 case reasoning.ReasoningEffortSelectedMsg:
295 return p, p.handleReasoningEffortSelected(msg.Effort)
296 case commands.OpenExternalEditorMsg:
297 u, cmd := p.editor.Update(msg)
298 p.editor = u.(editor.Editor)
299 return p, cmd
300 case pubsub.Event[session.Session]:
301 if msg.Payload.ID == p.session.ID {
302 prevHasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
303 prevHasInProgress := p.hasInProgressTodo()
304 p.session = msg.Payload
305 newHasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
306 newHasInProgress := p.hasInProgressTodo()
307 if prevHasIncompleteTodos != newHasIncompleteTodos {
308 cmds = append(cmds, p.SetSize(p.width, p.height))
309 }
310 if !prevHasInProgress && newHasInProgress {
311 cmds = append(cmds, p.todoSpinner.Tick)
312 }
313 }
314 u, cmd := p.header.Update(msg)
315 p.header = u.(header.Header)
316 cmds = append(cmds, cmd)
317 u, cmd = p.sidebar.Update(msg)
318 p.sidebar = u.(sidebar.Sidebar)
319 cmds = append(cmds, cmd)
320 return p, tea.Batch(cmds...)
321 case chat.SessionClearedMsg:
322 u, cmd := p.header.Update(msg)
323 p.header = u.(header.Header)
324 cmds = append(cmds, cmd)
325 u, cmd = p.sidebar.Update(msg)
326 p.sidebar = u.(sidebar.Sidebar)
327 cmds = append(cmds, cmd)
328 u, cmd = p.chat.Update(msg)
329 p.chat = u.(chat.MessageListCmp)
330 cmds = append(cmds, cmd)
331 return p, tea.Batch(cmds...)
332 case filepicker.FilePickedMsg,
333 completions.CompletionsClosedMsg,
334 completions.SelectCompletionMsg:
335 u, cmd := p.editor.Update(msg)
336 p.editor = u.(editor.Editor)
337 cmds = append(cmds, cmd)
338 return p, tea.Batch(cmds...)
339
340 case claude.ValidationCompletedMsg,
341 claude.AuthenticationCompleteMsg,
342 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 style := t.S().Base.MarginTop(1).PaddingLeft(3)
617 pillsArea = style.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 newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
962 if err != nil {
963 return util.ReportError(err)
964 }
965 session = newSession
966 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
967 }
968 if p.app.AgentCoordinator == nil {
969 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
970 }
971 cmds = append(cmds, p.chat.GoToBottom())
972 cmds = append(cmds, func() tea.Msg {
973 _, err := p.app.AgentCoordinator.Run(context.Background(), session.ID, text, attachments...)
974 if err != nil {
975 isCancelErr := errors.Is(err, context.Canceled)
976 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
977 if isCancelErr || isPermissionErr {
978 return nil
979 }
980 return util.InfoMsg{
981 Type: util.InfoTypeError,
982 Msg: err.Error(),
983 }
984 }
985 return nil
986 })
987 return tea.Batch(cmds...)
988}
989
990func (p *chatPage) Bindings() []key.Binding {
991 bindings := []key.Binding{
992 p.keyMap.NewSession,
993 p.keyMap.AddAttachment,
994 }
995 if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
996 cancelBinding := p.keyMap.Cancel
997 if p.isCanceling {
998 cancelBinding = key.NewBinding(
999 key.WithKeys("esc", "alt+esc"),
1000 key.WithHelp("esc", "press again to cancel"),
1001 )
1002 }
1003 bindings = append([]key.Binding{cancelBinding}, bindings...)
1004 }
1005
1006 switch p.focusedPane {
1007 case PanelTypeChat:
1008 bindings = append([]key.Binding{
1009 key.NewBinding(
1010 key.WithKeys("tab"),
1011 key.WithHelp("tab", "focus editor"),
1012 ),
1013 }, bindings...)
1014 bindings = append(bindings, p.chat.Bindings()...)
1015 case PanelTypeEditor:
1016 bindings = append([]key.Binding{
1017 key.NewBinding(
1018 key.WithKeys("tab"),
1019 key.WithHelp("tab", "focus chat"),
1020 ),
1021 }, bindings...)
1022 bindings = append(bindings, p.editor.Bindings()...)
1023 case PanelTypeSplash:
1024 bindings = append(bindings, p.splash.Bindings()...)
1025 }
1026
1027 return bindings
1028}
1029
1030func (p *chatPage) Help() help.KeyMap {
1031 var shortList []key.Binding
1032 var fullList [][]key.Binding
1033 switch {
1034 case p.isOnboarding && p.splash.IsShowingClaudeAuthMethodChooser():
1035 shortList = append(shortList,
1036 // Choose auth method
1037 key.NewBinding(
1038 key.WithKeys("left", "right", "tab"),
1039 key.WithHelp("←→/tab", "choose"),
1040 ),
1041 // Accept selection
1042 key.NewBinding(
1043 key.WithKeys("enter"),
1044 key.WithHelp("enter", "accept"),
1045 ),
1046 // Go back
1047 key.NewBinding(
1048 key.WithKeys("esc", "alt+esc"),
1049 key.WithHelp("esc", "back"),
1050 ),
1051 // Quit
1052 key.NewBinding(
1053 key.WithKeys("ctrl+c"),
1054 key.WithHelp("ctrl+c", "quit"),
1055 ),
1056 )
1057 // keep them the same
1058 for _, v := range shortList {
1059 fullList = append(fullList, []key.Binding{v})
1060 }
1061 case p.isOnboarding && p.splash.IsShowingClaudeOAuth2():
1062 switch {
1063 case p.splash.IsClaudeOAuthURLState():
1064 shortList = append(shortList,
1065 key.NewBinding(
1066 key.WithKeys("enter"),
1067 key.WithHelp("enter", "open"),
1068 ),
1069 key.NewBinding(
1070 key.WithKeys("c"),
1071 key.WithHelp("c", "copy url"),
1072 ),
1073 )
1074 case p.splash.IsClaudeOAuthComplete():
1075 shortList = append(shortList,
1076 key.NewBinding(
1077 key.WithKeys("enter"),
1078 key.WithHelp("enter", "continue"),
1079 ),
1080 )
1081 case p.splash.IsShowingHyperOAuth2() || p.splash.IsShowingCopilotOAuth2():
1082 shortList = append(shortList,
1083 key.NewBinding(
1084 key.WithKeys("enter"),
1085 key.WithHelp("enter", "copy url & open signup"),
1086 ),
1087 key.NewBinding(
1088 key.WithKeys("c"),
1089 key.WithHelp("c", "copy url"),
1090 ),
1091 )
1092 default:
1093 shortList = append(shortList,
1094 key.NewBinding(
1095 key.WithKeys("enter"),
1096 key.WithHelp("enter", "submit"),
1097 ),
1098 )
1099 }
1100 shortList = append(shortList,
1101 // Quit
1102 key.NewBinding(
1103 key.WithKeys("ctrl+c"),
1104 key.WithHelp("ctrl+c", "quit"),
1105 ),
1106 )
1107 // keep them the same
1108 for _, v := range shortList {
1109 fullList = append(fullList, []key.Binding{v})
1110 }
1111 case p.isOnboarding && !p.splash.IsShowingAPIKey():
1112 shortList = append(shortList,
1113 // Choose model
1114 key.NewBinding(
1115 key.WithKeys("up", "down"),
1116 key.WithHelp("↑/↓", "choose"),
1117 ),
1118 // Accept selection
1119 key.NewBinding(
1120 key.WithKeys("enter", "ctrl+y"),
1121 key.WithHelp("enter", "accept"),
1122 ),
1123 // Quit
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 case p.isOnboarding && p.splash.IsShowingAPIKey():
1134 if p.splash.IsAPIKeyValid() {
1135 shortList = append(shortList,
1136 key.NewBinding(
1137 key.WithKeys("enter"),
1138 key.WithHelp("enter", "continue"),
1139 ),
1140 )
1141 } else {
1142 shortList = append(shortList,
1143 // Go back
1144 key.NewBinding(
1145 key.WithKeys("esc", "alt+esc"),
1146 key.WithHelp("esc", "back"),
1147 ),
1148 )
1149 }
1150 shortList = append(shortList,
1151 // Quit
1152 key.NewBinding(
1153 key.WithKeys("ctrl+c"),
1154 key.WithHelp("ctrl+c", "quit"),
1155 ),
1156 )
1157 // keep them the same
1158 for _, v := range shortList {
1159 fullList = append(fullList, []key.Binding{v})
1160 }
1161 case p.isProjectInit:
1162 shortList = append(shortList,
1163 key.NewBinding(
1164 key.WithKeys("ctrl+c"),
1165 key.WithHelp("ctrl+c", "quit"),
1166 ),
1167 )
1168 // keep them the same
1169 for _, v := range shortList {
1170 fullList = append(fullList, []key.Binding{v})
1171 }
1172 default:
1173 if p.editor.IsCompletionsOpen() {
1174 shortList = append(shortList,
1175 key.NewBinding(
1176 key.WithKeys("tab", "enter"),
1177 key.WithHelp("tab/enter", "complete"),
1178 ),
1179 key.NewBinding(
1180 key.WithKeys("esc", "alt+esc"),
1181 key.WithHelp("esc", "cancel"),
1182 ),
1183 key.NewBinding(
1184 key.WithKeys("up", "down"),
1185 key.WithHelp("↑/↓", "choose"),
1186 ),
1187 )
1188 for _, v := range shortList {
1189 fullList = append(fullList, []key.Binding{v})
1190 }
1191 return core.NewSimpleHelp(shortList, fullList)
1192 }
1193 if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
1194 cancelBinding := key.NewBinding(
1195 key.WithKeys("esc", "alt+esc"),
1196 key.WithHelp("esc", "cancel"),
1197 )
1198 if p.isCanceling {
1199 cancelBinding = key.NewBinding(
1200 key.WithKeys("esc", "alt+esc"),
1201 key.WithHelp("esc", "press again to cancel"),
1202 )
1203 }
1204 if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
1205 cancelBinding = key.NewBinding(
1206 key.WithKeys("esc", "alt+esc"),
1207 key.WithHelp("esc", "clear queue"),
1208 )
1209 }
1210 shortList = append(shortList, cancelBinding)
1211 fullList = append(fullList,
1212 []key.Binding{
1213 cancelBinding,
1214 },
1215 )
1216 }
1217 globalBindings := []key.Binding{}
1218 // we are in a session
1219 if p.session.ID != "" {
1220 var tabKey key.Binding
1221 switch p.focusedPane {
1222 case PanelTypeEditor:
1223 tabKey = key.NewBinding(
1224 key.WithKeys("tab"),
1225 key.WithHelp("tab", "focus chat"),
1226 )
1227 case PanelTypeChat:
1228 tabKey = key.NewBinding(
1229 key.WithKeys("tab"),
1230 key.WithHelp("tab", "focus editor"),
1231 )
1232 default:
1233 tabKey = key.NewBinding(
1234 key.WithKeys("tab"),
1235 key.WithHelp("tab", "focus chat"),
1236 )
1237 }
1238 shortList = append(shortList, tabKey)
1239 globalBindings = append(globalBindings, tabKey)
1240
1241 // Show left/right to switch sections when expanded and both exist
1242 hasTodos := hasIncompleteTodos(p.session.Todos)
1243 hasQueue := p.promptQueue > 0
1244 if p.pillsExpanded && hasTodos && hasQueue {
1245 shortList = append(shortList, p.keyMap.PillLeft)
1246 globalBindings = append(globalBindings, p.keyMap.PillLeft)
1247 }
1248 }
1249 commandsBinding := key.NewBinding(
1250 key.WithKeys("ctrl+p"),
1251 key.WithHelp("ctrl+p", "commands"),
1252 )
1253 if p.focusedPane == PanelTypeEditor && p.editor.IsEmpty() {
1254 commandsBinding.SetHelp("/ or ctrl+p", "commands")
1255 }
1256 modelsBinding := key.NewBinding(
1257 key.WithKeys("ctrl+m", "ctrl+l"),
1258 key.WithHelp("ctrl+l", "models"),
1259 )
1260 if p.keyboardEnhancements.Flags > 0 {
1261 // non-zero flags mean we have at least key disambiguation
1262 modelsBinding.SetHelp("ctrl+m", "models")
1263 }
1264 helpBinding := key.NewBinding(
1265 key.WithKeys("ctrl+g"),
1266 key.WithHelp("ctrl+g", "more"),
1267 )
1268 globalBindings = append(globalBindings, commandsBinding, modelsBinding)
1269 globalBindings = append(globalBindings,
1270 key.NewBinding(
1271 key.WithKeys("ctrl+s"),
1272 key.WithHelp("ctrl+s", "sessions"),
1273 ),
1274 )
1275 if p.session.ID != "" {
1276 globalBindings = append(globalBindings,
1277 key.NewBinding(
1278 key.WithKeys("ctrl+n"),
1279 key.WithHelp("ctrl+n", "new sessions"),
1280 ))
1281 }
1282 shortList = append(shortList,
1283 // Commands
1284 commandsBinding,
1285 modelsBinding,
1286 )
1287 fullList = append(fullList, globalBindings)
1288
1289 switch p.focusedPane {
1290 case PanelTypeChat:
1291 shortList = append(shortList,
1292 key.NewBinding(
1293 key.WithKeys("up", "down"),
1294 key.WithHelp("↑↓", "scroll"),
1295 ),
1296 messages.CopyKey,
1297 )
1298 fullList = append(fullList,
1299 []key.Binding{
1300 key.NewBinding(
1301 key.WithKeys("up", "down"),
1302 key.WithHelp("↑↓", "scroll"),
1303 ),
1304 key.NewBinding(
1305 key.WithKeys("shift+up", "shift+down"),
1306 key.WithHelp("shift+↑↓", "next/prev item"),
1307 ),
1308 key.NewBinding(
1309 key.WithKeys("pgup", "b"),
1310 key.WithHelp("b/pgup", "page up"),
1311 ),
1312 key.NewBinding(
1313 key.WithKeys("pgdown", " ", "f"),
1314 key.WithHelp("f/pgdn", "page down"),
1315 ),
1316 },
1317 []key.Binding{
1318 key.NewBinding(
1319 key.WithKeys("u"),
1320 key.WithHelp("u", "half page up"),
1321 ),
1322 key.NewBinding(
1323 key.WithKeys("d"),
1324 key.WithHelp("d", "half page down"),
1325 ),
1326 key.NewBinding(
1327 key.WithKeys("g", "home"),
1328 key.WithHelp("g", "home"),
1329 ),
1330 key.NewBinding(
1331 key.WithKeys("G", "end"),
1332 key.WithHelp("G", "end"),
1333 ),
1334 },
1335 []key.Binding{
1336 messages.CopyKey,
1337 messages.ClearSelectionKey,
1338 },
1339 )
1340 case PanelTypeEditor:
1341 newLineBinding := key.NewBinding(
1342 key.WithKeys("shift+enter", "ctrl+j"),
1343 // "ctrl+j" is a common keybinding for newline in many editors. If
1344 // the terminal supports "shift+enter", we substitute the help text
1345 // to reflect that.
1346 key.WithHelp("ctrl+j", "newline"),
1347 )
1348 if p.keyboardEnhancements.Flags > 0 {
1349 // Non-zero flags mean we have at least key disambiguation.
1350 newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
1351 }
1352 shortList = append(shortList, newLineBinding)
1353 fullList = append(fullList,
1354 []key.Binding{
1355 newLineBinding,
1356 key.NewBinding(
1357 key.WithKeys("ctrl+f"),
1358 key.WithHelp("ctrl+f", "add image"),
1359 ),
1360 key.NewBinding(
1361 key.WithKeys("@"),
1362 key.WithHelp("@", "mention file"),
1363 ),
1364 key.NewBinding(
1365 key.WithKeys("ctrl+o"),
1366 key.WithHelp("ctrl+o", "open editor"),
1367 ),
1368 })
1369
1370 if p.editor.HasAttachments() {
1371 fullList = append(fullList, []key.Binding{
1372 key.NewBinding(
1373 key.WithKeys("ctrl+r"),
1374 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
1375 ),
1376 key.NewBinding(
1377 key.WithKeys("ctrl+r", "r"),
1378 key.WithHelp("ctrl+r+r", "delete all attachments"),
1379 ),
1380 key.NewBinding(
1381 key.WithKeys("esc", "alt+esc"),
1382 key.WithHelp("esc", "cancel delete mode"),
1383 ),
1384 })
1385 }
1386 }
1387 shortList = append(shortList,
1388 // Quit
1389 key.NewBinding(
1390 key.WithKeys("ctrl+c"),
1391 key.WithHelp("ctrl+c", "quit"),
1392 ),
1393 // Help
1394 helpBinding,
1395 )
1396 fullList = append(fullList, []key.Binding{
1397 key.NewBinding(
1398 key.WithKeys("ctrl+g"),
1399 key.WithHelp("ctrl+g", "less"),
1400 ),
1401 })
1402 }
1403
1404 return core.NewSimpleHelp(shortList, fullList)
1405}
1406
1407func (p *chatPage) IsChatFocused() bool {
1408 return p.focusedPane == PanelTypeChat
1409}
1410
1411// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
1412// Returns true if the mouse is over the chat area, false otherwise.
1413func (p *chatPage) isMouseOverChat(x, y int) bool {
1414 // No session means no chat area
1415 if p.session.ID == "" {
1416 return false
1417 }
1418
1419 var chatX, chatY, chatWidth, chatHeight int
1420
1421 if p.compact {
1422 // In compact mode: chat area starts after header and spans full width
1423 chatX = 0
1424 chatY = HeaderHeight
1425 chatWidth = p.width
1426 chatHeight = p.height - EditorHeight - HeaderHeight
1427 } else {
1428 // In non-compact mode: chat area spans from left edge to sidebar
1429 chatX = 0
1430 chatY = 0
1431 chatWidth = p.width - SideBarWidth
1432 chatHeight = p.height - EditorHeight
1433 }
1434
1435 // Check if mouse coordinates are within chat bounds
1436 return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1437}
1438
1439func (p *chatPage) hasInProgressTodo() bool {
1440 for _, todo := range p.session.Todos {
1441 if todo.Status == session.TodoStatusInProgress {
1442 return true
1443 }
1444 }
1445 return false
1446}