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