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 newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
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 && p.splash.IsShowingClaudeAuthMethodChooser():
1038 shortList = append(shortList,
1039 // Choose auth method
1040 key.NewBinding(
1041 key.WithKeys("left", "right", "tab"),
1042 key.WithHelp("←→/tab", "choose"),
1043 ),
1044 // Accept selection
1045 key.NewBinding(
1046 key.WithKeys("enter"),
1047 key.WithHelp("enter", "accept"),
1048 ),
1049 // Go back
1050 key.NewBinding(
1051 key.WithKeys("esc", "alt+esc"),
1052 key.WithHelp("esc", "back"),
1053 ),
1054 // Quit
1055 key.NewBinding(
1056 key.WithKeys("ctrl+c"),
1057 key.WithHelp("ctrl+c", "quit"),
1058 ),
1059 )
1060 // keep them the same
1061 for _, v := range shortList {
1062 fullList = append(fullList, []key.Binding{v})
1063 }
1064 case p.isOnboarding && p.splash.IsShowingClaudeOAuth2():
1065 switch {
1066 case p.splash.IsClaudeOAuthURLState():
1067 shortList = append(shortList,
1068 key.NewBinding(
1069 key.WithKeys("enter"),
1070 key.WithHelp("enter", "open"),
1071 ),
1072 key.NewBinding(
1073 key.WithKeys("c"),
1074 key.WithHelp("c", "copy url"),
1075 ),
1076 )
1077 case p.splash.IsClaudeOAuthComplete():
1078 shortList = append(shortList,
1079 key.NewBinding(
1080 key.WithKeys("enter"),
1081 key.WithHelp("enter", "continue"),
1082 ),
1083 )
1084 case p.splash.IsShowingHyperOAuth2() || p.splash.IsShowingCopilotOAuth2():
1085 shortList = append(shortList,
1086 key.NewBinding(
1087 key.WithKeys("enter"),
1088 key.WithHelp("enter", "copy url & open signup"),
1089 ),
1090 key.NewBinding(
1091 key.WithKeys("c"),
1092 key.WithHelp("c", "copy url"),
1093 ),
1094 )
1095 default:
1096 shortList = append(shortList,
1097 key.NewBinding(
1098 key.WithKeys("enter"),
1099 key.WithHelp("enter", "submit"),
1100 ),
1101 )
1102 }
1103 shortList = append(shortList,
1104 // Quit
1105 key.NewBinding(
1106 key.WithKeys("ctrl+c"),
1107 key.WithHelp("ctrl+c", "quit"),
1108 ),
1109 )
1110 // keep them the same
1111 for _, v := range shortList {
1112 fullList = append(fullList, []key.Binding{v})
1113 }
1114 case p.isOnboarding && !p.splash.IsShowingAPIKey():
1115 shortList = append(shortList,
1116 // Choose model
1117 key.NewBinding(
1118 key.WithKeys("up", "down"),
1119 key.WithHelp("↑/↓", "choose"),
1120 ),
1121 // Accept selection
1122 key.NewBinding(
1123 key.WithKeys("enter", "ctrl+y"),
1124 key.WithHelp("enter", "accept"),
1125 ),
1126 // Quit
1127 key.NewBinding(
1128 key.WithKeys("ctrl+c"),
1129 key.WithHelp("ctrl+c", "quit"),
1130 ),
1131 )
1132 // keep them the same
1133 for _, v := range shortList {
1134 fullList = append(fullList, []key.Binding{v})
1135 }
1136 case p.isOnboarding && p.splash.IsShowingAPIKey():
1137 if p.splash.IsAPIKeyValid() {
1138 shortList = append(shortList,
1139 key.NewBinding(
1140 key.WithKeys("enter"),
1141 key.WithHelp("enter", "continue"),
1142 ),
1143 )
1144 } else {
1145 shortList = append(shortList,
1146 // Go back
1147 key.NewBinding(
1148 key.WithKeys("esc", "alt+esc"),
1149 key.WithHelp("esc", "back"),
1150 ),
1151 )
1152 }
1153 shortList = append(shortList,
1154 // Quit
1155 key.NewBinding(
1156 key.WithKeys("ctrl+c"),
1157 key.WithHelp("ctrl+c", "quit"),
1158 ),
1159 )
1160 // keep them the same
1161 for _, v := range shortList {
1162 fullList = append(fullList, []key.Binding{v})
1163 }
1164 case p.isProjectInit:
1165 shortList = append(shortList,
1166 key.NewBinding(
1167 key.WithKeys("ctrl+c"),
1168 key.WithHelp("ctrl+c", "quit"),
1169 ),
1170 )
1171 // keep them the same
1172 for _, v := range shortList {
1173 fullList = append(fullList, []key.Binding{v})
1174 }
1175 default:
1176 if p.editor.IsCompletionsOpen() {
1177 shortList = append(shortList,
1178 key.NewBinding(
1179 key.WithKeys("tab", "enter"),
1180 key.WithHelp("tab/enter", "complete"),
1181 ),
1182 key.NewBinding(
1183 key.WithKeys("esc", "alt+esc"),
1184 key.WithHelp("esc", "cancel"),
1185 ),
1186 key.NewBinding(
1187 key.WithKeys("up", "down"),
1188 key.WithHelp("↑/↓", "choose"),
1189 ),
1190 )
1191 for _, v := range shortList {
1192 fullList = append(fullList, []key.Binding{v})
1193 }
1194 return core.NewSimpleHelp(shortList, fullList)
1195 }
1196 if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
1197 cancelBinding := key.NewBinding(
1198 key.WithKeys("esc", "alt+esc"),
1199 key.WithHelp("esc", "cancel"),
1200 )
1201 if p.isCanceling {
1202 cancelBinding = key.NewBinding(
1203 key.WithKeys("esc", "alt+esc"),
1204 key.WithHelp("esc", "press again to cancel"),
1205 )
1206 }
1207 if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
1208 cancelBinding = key.NewBinding(
1209 key.WithKeys("esc", "alt+esc"),
1210 key.WithHelp("esc", "clear queue"),
1211 )
1212 }
1213 shortList = append(shortList, cancelBinding)
1214 fullList = append(fullList,
1215 []key.Binding{
1216 cancelBinding,
1217 },
1218 )
1219 }
1220 globalBindings := []key.Binding{}
1221 // we are in a session
1222 if p.session.ID != "" {
1223 var tabKey key.Binding
1224 switch p.focusedPane {
1225 case PanelTypeEditor:
1226 tabKey = key.NewBinding(
1227 key.WithKeys("tab"),
1228 key.WithHelp("tab", "focus chat"),
1229 )
1230 case PanelTypeChat:
1231 tabKey = key.NewBinding(
1232 key.WithKeys("tab"),
1233 key.WithHelp("tab", "focus editor"),
1234 )
1235 default:
1236 tabKey = key.NewBinding(
1237 key.WithKeys("tab"),
1238 key.WithHelp("tab", "focus chat"),
1239 )
1240 }
1241 shortList = append(shortList, tabKey)
1242 globalBindings = append(globalBindings, tabKey)
1243
1244 // Show left/right to switch sections when expanded and both exist
1245 hasTodos := hasIncompleteTodos(p.session.Todos)
1246 hasQueue := p.promptQueue > 0
1247 if p.pillsExpanded && hasTodos && hasQueue {
1248 shortList = append(shortList, p.keyMap.PillLeft)
1249 globalBindings = append(globalBindings, p.keyMap.PillLeft)
1250 }
1251 }
1252 commandsBinding := key.NewBinding(
1253 key.WithKeys("ctrl+p"),
1254 key.WithHelp("ctrl+p", "commands"),
1255 )
1256 if p.focusedPane == PanelTypeEditor && p.editor.IsEmpty() {
1257 commandsBinding.SetHelp("/ or ctrl+p", "commands")
1258 }
1259 modelsBinding := key.NewBinding(
1260 key.WithKeys("ctrl+m", "ctrl+l"),
1261 key.WithHelp("ctrl+l", "models"),
1262 )
1263 if p.keyboardEnhancements.Flags > 0 {
1264 // non-zero flags mean we have at least key disambiguation
1265 modelsBinding.SetHelp("ctrl+m", "models")
1266 }
1267 helpBinding := key.NewBinding(
1268 key.WithKeys("ctrl+g"),
1269 key.WithHelp("ctrl+g", "more"),
1270 )
1271 globalBindings = append(globalBindings, commandsBinding, modelsBinding)
1272 globalBindings = append(globalBindings,
1273 key.NewBinding(
1274 key.WithKeys("ctrl+s"),
1275 key.WithHelp("ctrl+s", "sessions"),
1276 ),
1277 )
1278 if p.session.ID != "" {
1279 globalBindings = append(globalBindings,
1280 key.NewBinding(
1281 key.WithKeys("ctrl+n"),
1282 key.WithHelp("ctrl+n", "new sessions"),
1283 ))
1284 }
1285 shortList = append(shortList,
1286 // Commands
1287 commandsBinding,
1288 modelsBinding,
1289 )
1290 fullList = append(fullList, globalBindings)
1291
1292 switch p.focusedPane {
1293 case PanelTypeChat:
1294 shortList = append(shortList,
1295 key.NewBinding(
1296 key.WithKeys("up", "down"),
1297 key.WithHelp("↑↓", "scroll"),
1298 ),
1299 messages.CopyKey,
1300 )
1301 fullList = append(fullList,
1302 []key.Binding{
1303 key.NewBinding(
1304 key.WithKeys("up", "down"),
1305 key.WithHelp("↑↓", "scroll"),
1306 ),
1307 key.NewBinding(
1308 key.WithKeys("shift+up", "shift+down"),
1309 key.WithHelp("shift+↑↓", "next/prev item"),
1310 ),
1311 key.NewBinding(
1312 key.WithKeys("pgup", "b"),
1313 key.WithHelp("b/pgup", "page up"),
1314 ),
1315 key.NewBinding(
1316 key.WithKeys("pgdown", " ", "f"),
1317 key.WithHelp("f/pgdn", "page down"),
1318 ),
1319 },
1320 []key.Binding{
1321 key.NewBinding(
1322 key.WithKeys("u"),
1323 key.WithHelp("u", "half page up"),
1324 ),
1325 key.NewBinding(
1326 key.WithKeys("d"),
1327 key.WithHelp("d", "half page down"),
1328 ),
1329 key.NewBinding(
1330 key.WithKeys("g", "home"),
1331 key.WithHelp("g", "home"),
1332 ),
1333 key.NewBinding(
1334 key.WithKeys("G", "end"),
1335 key.WithHelp("G", "end"),
1336 ),
1337 },
1338 []key.Binding{
1339 messages.CopyKey,
1340 messages.ClearSelectionKey,
1341 },
1342 )
1343 case PanelTypeEditor:
1344 newLineBinding := key.NewBinding(
1345 key.WithKeys("shift+enter", "ctrl+j"),
1346 // "ctrl+j" is a common keybinding for newline in many editors. If
1347 // the terminal supports "shift+enter", we substitute the help text
1348 // to reflect that.
1349 key.WithHelp("ctrl+j", "newline"),
1350 )
1351 if p.keyboardEnhancements.Flags > 0 {
1352 // Non-zero flags mean we have at least key disambiguation.
1353 newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
1354 }
1355 shortList = append(shortList, newLineBinding)
1356 fullList = append(fullList,
1357 []key.Binding{
1358 newLineBinding,
1359 key.NewBinding(
1360 key.WithKeys("ctrl+f"),
1361 key.WithHelp("ctrl+f", "add image"),
1362 ),
1363 key.NewBinding(
1364 key.WithKeys("@"),
1365 key.WithHelp("@", "mention file"),
1366 ),
1367 key.NewBinding(
1368 key.WithKeys("ctrl+o"),
1369 key.WithHelp("ctrl+o", "open editor"),
1370 ),
1371 })
1372
1373 if p.editor.HasAttachments() {
1374 fullList = append(fullList, []key.Binding{
1375 key.NewBinding(
1376 key.WithKeys("ctrl+r"),
1377 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
1378 ),
1379 key.NewBinding(
1380 key.WithKeys("ctrl+r", "r"),
1381 key.WithHelp("ctrl+r+r", "delete all attachments"),
1382 ),
1383 key.NewBinding(
1384 key.WithKeys("esc", "alt+esc"),
1385 key.WithHelp("esc", "cancel delete mode"),
1386 ),
1387 })
1388 }
1389 }
1390 shortList = append(shortList,
1391 // Quit
1392 key.NewBinding(
1393 key.WithKeys("ctrl+c"),
1394 key.WithHelp("ctrl+c", "quit"),
1395 ),
1396 // Help
1397 helpBinding,
1398 )
1399 fullList = append(fullList, []key.Binding{
1400 key.NewBinding(
1401 key.WithKeys("ctrl+g"),
1402 key.WithHelp("ctrl+g", "less"),
1403 ),
1404 })
1405 }
1406
1407 return core.NewSimpleHelp(shortList, fullList)
1408}
1409
1410func (p *chatPage) IsChatFocused() bool {
1411 return p.focusedPane == PanelTypeChat
1412}
1413
1414// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
1415// Returns true if the mouse is over the chat area, false otherwise.
1416func (p *chatPage) isMouseOverChat(x, y int) bool {
1417 // No session means no chat area
1418 if p.session.ID == "" {
1419 return false
1420 }
1421
1422 var chatX, chatY, chatWidth, chatHeight int
1423
1424 if p.compact {
1425 // In compact mode: chat area starts after header and spans full width
1426 chatX = 0
1427 chatY = HeaderHeight
1428 chatWidth = p.width
1429 chatHeight = p.height - EditorHeight - HeaderHeight
1430 } else {
1431 // In non-compact mode: chat area spans from left edge to sidebar
1432 chatX = 0
1433 chatY = 0
1434 chatWidth = p.width - SideBarWidth
1435 chatHeight = p.height - EditorHeight
1436 }
1437
1438 // Check if mouse coordinates are within chat bounds
1439 return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1440}
1441
1442func (p *chatPage) hasInProgressTodo() bool {
1443 for _, todo := range p.session.Todos {
1444 if todo.Status == session.TodoStatusInProgress {
1445 return true
1446 }
1447 }
1448 return false
1449}