1package chat
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "time"
8
9 "charm.land/bubbles/v2/help"
10 "charm.land/bubbles/v2/key"
11 "charm.land/bubbles/v2/spinner"
12 tea "charm.land/bubbletea/v2"
13 "charm.land/lipgloss/v2"
14 "github.com/charmbracelet/crush/internal/app"
15 "github.com/charmbracelet/crush/internal/config"
16 "github.com/charmbracelet/crush/internal/history"
17 "github.com/charmbracelet/crush/internal/message"
18 "github.com/charmbracelet/crush/internal/permission"
19 "github.com/charmbracelet/crush/internal/pubsub"
20 "github.com/charmbracelet/crush/internal/session"
21 "github.com/charmbracelet/crush/internal/tui/components/anim"
22 "github.com/charmbracelet/crush/internal/tui/components/chat"
23 "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
24 "github.com/charmbracelet/crush/internal/tui/components/chat/header"
25 "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
26 "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
27 "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
28 "github.com/charmbracelet/crush/internal/tui/components/completions"
29 "github.com/charmbracelet/crush/internal/tui/components/core"
30 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
31 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
32 "github.com/charmbracelet/crush/internal/tui/components/dialogs/claude"
33 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
34 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
35 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
36 "github.com/charmbracelet/crush/internal/tui/components/dialogs/reasoning"
37 "github.com/charmbracelet/crush/internal/tui/page"
38 "github.com/charmbracelet/crush/internal/tui/styles"
39 "github.com/charmbracelet/crush/internal/tui/util"
40 "github.com/charmbracelet/crush/internal/version"
41)
42
43var ChatPageID page.PageID = "chat"
44
45type (
46 ChatFocusedMsg struct {
47 Focused bool
48 }
49 CancelTimerExpiredMsg struct{}
50)
51
52type PanelType string
53
54const (
55 PanelTypeChat PanelType = "chat"
56 PanelTypeEditor PanelType = "editor"
57 PanelTypeSplash PanelType = "splash"
58 PanelTypeStatus PanelType = "status"
59)
60
61// PillSection represents which pill section is focused when in pills panel.
62type PillSection int
63
64const (
65 PillSectionTodos PillSection = iota
66 PillSectionQueue
67)
68
69const (
70 CompactModeWidthBreakpoint = 120 // Width at which the chat page switches to compact mode
71 CompactModeHeightBreakpoint = 30 // Height at which the chat page switches to compact mode
72 EditorHeight = 5 // Height of the editor input area including padding
73 SideBarWidth = 31 // Width of the sidebar
74 SideBarDetailsPadding = 1 // Padding for the sidebar details section
75 HeaderHeight = 1 // Height of the header
76
77 // Layout constants for borders and padding
78 BorderWidth = 1 // Width of component borders
79 LeftRightBorders = 2 // Left + right border width (1 + 1)
80 TopBottomBorders = 2 // Top + bottom border width (1 + 1)
81 DetailsPositioning = 2 // Positioning adjustment for details panel
82
83 // Timing constants
84 CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires
85)
86
87type ChatPage interface {
88 util.Model
89 layout.Help
90 IsChatFocused() bool
91}
92
93// cancelTimerCmd creates a command that expires the cancel timer
94func cancelTimerCmd() tea.Cmd {
95 return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg {
96 return CancelTimerExpiredMsg{}
97 })
98}
99
100type chatPage struct {
101 width, height int
102 detailsWidth, detailsHeight int
103 app *app.App
104 keyboardEnhancements tea.KeyboardEnhancementsMsg
105
106 // Layout state
107 compact bool
108 forceCompact bool
109 focusedPane PanelType
110
111 // Session
112 session session.Session
113 keyMap KeyMap
114 pillsKeyMap PillsKeyMap
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 focusedPillSection PillSection
133 previousFocusedPane PanelType
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 pillsKeyMap: DefaultPillsKeyMap(),
145 header: header.New(app.LSPClients),
146 sidebar: sidebar.New(app.History, app.LSPClients, false),
147 chat: chat.New(app),
148 editor: editor.New(app),
149 splash: splash.New(),
150 focusedPane: PanelTypeSplash,
151 todoSpinner: spinner.New(
152 spinner.WithSpinner(spinner.MiniDot),
153 spinner.WithStyle(t.S().Base.Foreground(t.GreenDark)),
154 ),
155 }
156}
157
158func (p *chatPage) Init() tea.Cmd {
159 cfg := config.Get()
160 compact := cfg.Options.TUI.CompactMode
161 p.compact = compact
162 p.forceCompact = compact
163 p.sidebar.SetCompactMode(p.compact)
164
165 // Set splash state based on config
166 if !config.HasInitialDataConfig() {
167 // First-time setup: show model selection
168 p.splash.SetOnboarding(true)
169 p.isOnboarding = true
170 p.splashFullScreen = true
171 } else if b, _ := config.ProjectNeedsInitialization(); b {
172 // Project needs context initialization
173 p.splash.SetProjectInit(true)
174 p.isProjectInit = true
175 p.splashFullScreen = true
176 } else {
177 // Ready to chat: focus editor, splash in background
178 p.focusedPane = PanelTypeEditor
179 p.splashFullScreen = false
180 }
181
182 return tea.Batch(
183 p.header.Init(),
184 p.sidebar.Init(),
185 p.chat.Init(),
186 p.editor.Init(),
187 p.splash.Init(),
188 )
189}
190
191func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
192 var cmds []tea.Cmd
193 if p.session.ID != "" && p.app.AgentCoordinator != nil {
194 queueSize := p.app.AgentCoordinator.QueuedPrompts(p.session.ID)
195 if queueSize != p.promptQueue {
196 p.promptQueue = queueSize
197 cmds = append(cmds, p.SetSize(p.width, p.height))
198 }
199 }
200 switch msg := msg.(type) {
201 case tea.KeyboardEnhancementsMsg:
202 p.keyboardEnhancements = msg
203 return p, nil
204 case tea.MouseWheelMsg:
205 if p.compact {
206 msg.Y -= 1
207 }
208 if p.isMouseOverChat(msg.X, msg.Y) {
209 u, cmd := p.chat.Update(msg)
210 p.chat = u.(chat.MessageListCmp)
211 return p, cmd
212 }
213 return p, nil
214 case tea.MouseClickMsg:
215 if p.isOnboarding || p.isProjectInit {
216 return p, nil
217 }
218 if p.compact {
219 msg.Y -= 1
220 }
221 if p.isMouseOverChat(msg.X, msg.Y) {
222 p.focusedPane = PanelTypeChat
223 p.chat.Focus()
224 p.editor.Blur()
225 } else {
226 p.focusedPane = PanelTypeEditor
227 p.editor.Focus()
228 p.chat.Blur()
229 }
230 u, cmd := p.chat.Update(msg)
231 p.chat = u.(chat.MessageListCmp)
232 return p, cmd
233 case tea.MouseMotionMsg:
234 if p.compact {
235 msg.Y -= 1
236 }
237 if msg.Button == tea.MouseLeft {
238 u, cmd := p.chat.Update(msg)
239 p.chat = u.(chat.MessageListCmp)
240 return p, cmd
241 }
242 return p, nil
243 case tea.MouseReleaseMsg:
244 if p.isOnboarding || p.isProjectInit {
245 return p, nil
246 }
247 if p.compact {
248 msg.Y -= 1
249 }
250 if msg.Button == tea.MouseLeft {
251 u, cmd := p.chat.Update(msg)
252 p.chat = u.(chat.MessageListCmp)
253 return p, cmd
254 }
255 return p, nil
256 case chat.SelectionCopyMsg:
257 u, cmd := p.chat.Update(msg)
258 p.chat = u.(chat.MessageListCmp)
259 return p, cmd
260 case tea.WindowSizeMsg:
261 u, cmd := p.editor.Update(msg)
262 p.editor = u.(editor.Editor)
263 return p, tea.Batch(p.SetSize(msg.Width, msg.Height), cmd)
264 case CancelTimerExpiredMsg:
265 p.isCanceling = false
266 return p, nil
267 case editor.OpenEditorMsg:
268 u, cmd := p.editor.Update(msg)
269 p.editor = u.(editor.Editor)
270 return p, cmd
271 case chat.SendMsg:
272 return p, p.sendMessage(msg.Text, msg.Attachments)
273 case chat.SessionSelectedMsg:
274 return p, p.setSession(msg)
275 case splash.SubmitAPIKeyMsg:
276 u, cmd := p.splash.Update(msg)
277 p.splash = u.(splash.Splash)
278 cmds = append(cmds, cmd)
279 return p, tea.Batch(cmds...)
280 case commands.ToggleCompactModeMsg:
281 p.forceCompact = !p.forceCompact
282 var cmd tea.Cmd
283 if p.forceCompact {
284 p.setCompactMode(true)
285 cmd = p.updateCompactConfig(true)
286 } else if p.width >= CompactModeWidthBreakpoint && p.height >= CompactModeHeightBreakpoint {
287 p.setCompactMode(false)
288 cmd = p.updateCompactConfig(false)
289 }
290 return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
291 case commands.ToggleThinkingMsg:
292 return p, p.toggleThinking()
293 case commands.OpenReasoningDialogMsg:
294 return p, p.openReasoningDialog()
295 case reasoning.ReasoningEffortSelectedMsg:
296 return p, p.handleReasoningEffortSelected(msg.Effort)
297 case commands.OpenExternalEditorMsg:
298 u, cmd := p.editor.Update(msg)
299 p.editor = u.(editor.Editor)
300 return p, cmd
301 case pubsub.Event[session.Session]:
302 if msg.Payload.ID == p.session.ID {
303 prevHasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
304 prevHasInProgress := p.hasInProgressTodo()
305 p.session = msg.Payload
306 newHasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
307 newHasInProgress := p.hasInProgressTodo()
308 if prevHasIncompleteTodos != newHasIncompleteTodos {
309 cmds = append(cmds, p.SetSize(p.width, p.height))
310 }
311 if !prevHasInProgress && newHasInProgress {
312 cmds = append(cmds, p.todoSpinner.Tick)
313 }
314 }
315 u, cmd := p.header.Update(msg)
316 p.header = u.(header.Header)
317 cmds = append(cmds, cmd)
318 u, cmd = p.sidebar.Update(msg)
319 p.sidebar = u.(sidebar.Sidebar)
320 cmds = append(cmds, cmd)
321 return p, tea.Batch(cmds...)
322 case chat.SessionClearedMsg:
323 u, cmd := p.header.Update(msg)
324 p.header = u.(header.Header)
325 cmds = append(cmds, cmd)
326 u, cmd = p.sidebar.Update(msg)
327 p.sidebar = u.(sidebar.Sidebar)
328 cmds = append(cmds, cmd)
329 u, cmd = p.chat.Update(msg)
330 p.chat = u.(chat.MessageListCmp)
331 cmds = append(cmds, cmd)
332 return p, tea.Batch(cmds...)
333 case filepicker.FilePickedMsg,
334 completions.CompletionsClosedMsg,
335 completions.SelectCompletionMsg:
336 u, cmd := p.editor.Update(msg)
337 p.editor = u.(editor.Editor)
338 cmds = append(cmds, cmd)
339 return p, tea.Batch(cmds...)
340
341 case claude.ValidationCompletedMsg, claude.AuthenticationCompleteMsg:
342 if p.focusedPane == PanelTypeSplash {
343 u, cmd := p.splash.Update(msg)
344 p.splash = u.(splash.Splash)
345 cmds = append(cmds, cmd)
346 }
347 return p, tea.Batch(cmds...)
348 case models.APIKeyStateChangeMsg:
349 if p.focusedPane == PanelTypeSplash {
350 u, cmd := p.splash.Update(msg)
351 p.splash = u.(splash.Splash)
352 cmds = append(cmds, cmd)
353 }
354 return p, tea.Batch(cmds...)
355 case pubsub.Event[message.Message],
356 anim.StepMsg,
357 spinner.TickMsg:
358 // Update todo spinner if agent is busy and we have in-progress todos
359 agentBusy := p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy()
360 if _, ok := msg.(spinner.TickMsg); ok && p.hasInProgressTodo() && agentBusy {
361 var cmd tea.Cmd
362 p.todoSpinner, cmd = p.todoSpinner.Update(msg)
363 cmds = append(cmds, cmd)
364 }
365 // Start spinner when agent becomes busy and we have in-progress todos
366 if _, ok := msg.(pubsub.Event[message.Message]); ok && p.hasInProgressTodo() && agentBusy {
367 cmds = append(cmds, p.todoSpinner.Tick)
368 }
369 if p.focusedPane == PanelTypeSplash {
370 u, cmd := p.splash.Update(msg)
371 p.splash = u.(splash.Splash)
372 cmds = append(cmds, cmd)
373 } else {
374 u, cmd := p.chat.Update(msg)
375 p.chat = u.(chat.MessageListCmp)
376 cmds = append(cmds, cmd)
377 }
378
379 return p, tea.Batch(cmds...)
380 case commands.ToggleYoloModeMsg:
381 // update the editor style
382 u, cmd := p.editor.Update(msg)
383 p.editor = u.(editor.Editor)
384 return p, cmd
385 case pubsub.Event[history.File], sidebar.SessionFilesMsg:
386 u, cmd := p.sidebar.Update(msg)
387 p.sidebar = u.(sidebar.Sidebar)
388 cmds = append(cmds, cmd)
389 return p, tea.Batch(cmds...)
390 case pubsub.Event[permission.PermissionNotification]:
391 u, cmd := p.chat.Update(msg)
392 p.chat = u.(chat.MessageListCmp)
393 cmds = append(cmds, cmd)
394 return p, tea.Batch(cmds...)
395
396 case commands.CommandRunCustomMsg:
397 if p.app.AgentCoordinator.IsBusy() {
398 return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
399 }
400
401 cmd := p.sendMessage(msg.Content, nil)
402 if cmd != nil {
403 return p, cmd
404 }
405 case splash.OnboardingCompleteMsg:
406 p.splashFullScreen = false
407 if b, _ := config.ProjectNeedsInitialization(); b {
408 p.splash.SetProjectInit(true)
409 p.splashFullScreen = true
410 return p, p.SetSize(p.width, p.height)
411 }
412 err := p.app.InitCoderAgent(context.TODO())
413 if err != nil {
414 return p, util.ReportError(err)
415 }
416 p.isOnboarding = false
417 p.isProjectInit = false
418 p.focusedPane = PanelTypeEditor
419 return p, p.SetSize(p.width, p.height)
420 case commands.NewSessionsMsg:
421 if p.app.AgentCoordinator.IsBusy() {
422 return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
423 }
424 return p, p.newSession()
425 case tea.KeyPressMsg:
426 switch {
427 case key.Matches(msg, p.keyMap.NewSession):
428 // if we have no agent do nothing
429 if p.app.AgentCoordinator == nil {
430 return p, nil
431 }
432 if p.app.AgentCoordinator.IsBusy() {
433 return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
434 }
435 return p, p.newSession()
436 case key.Matches(msg, p.keyMap.AddAttachment):
437 // Skip attachment handling during onboarding/splash screen
438 if p.focusedPane == PanelTypeSplash || p.isOnboarding {
439 u, cmd := p.splash.Update(msg)
440 p.splash = u.(splash.Splash)
441 return p, cmd
442 }
443 agentCfg := config.Get().Agents[config.AgentCoder]
444 model := config.Get().GetModelByType(agentCfg.Model)
445 if model == nil {
446 return p, util.ReportWarn("No model configured yet")
447 }
448 if model.SupportsImages {
449 return p, util.CmdHandler(commands.OpenFilePickerMsg{})
450 } else {
451 return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
452 }
453 case key.Matches(msg, p.keyMap.Tab):
454 if p.session.ID == "" {
455 u, cmd := p.splash.Update(msg)
456 p.splash = u.(splash.Splash)
457 return p, cmd
458 }
459 return p, p.changeFocus()
460 case key.Matches(msg, p.keyMap.Cancel):
461 if p.session.ID != "" && p.app.AgentCoordinator.IsBusy() {
462 return p, p.cancel()
463 }
464 case key.Matches(msg, p.keyMap.Details):
465 p.toggleDetails()
466 return p, nil
467 }
468
469 switch p.focusedPane {
470 case PanelTypeChat:
471 u, cmd := p.chat.Update(msg)
472 p.chat = u.(chat.MessageListCmp)
473 cmds = append(cmds, cmd)
474 case PanelTypeEditor:
475 u, cmd := p.editor.Update(msg)
476 p.editor = u.(editor.Editor)
477 cmds = append(cmds, cmd)
478 case PanelTypeSplash:
479 u, cmd := p.splash.Update(msg)
480 p.splash = u.(splash.Splash)
481 cmds = append(cmds, cmd)
482 case PanelTypeStatus:
483 cmds = append(cmds, p.handlePillsKeyPress(msg))
484 }
485 case tea.PasteMsg:
486 switch p.focusedPane {
487 case PanelTypeEditor:
488 u, cmd := p.editor.Update(msg)
489 p.editor = u.(editor.Editor)
490 cmds = append(cmds, cmd)
491 return p, tea.Batch(cmds...)
492 case PanelTypeChat:
493 u, cmd := p.chat.Update(msg)
494 p.chat = u.(chat.MessageListCmp)
495 cmds = append(cmds, cmd)
496 return p, tea.Batch(cmds...)
497 case PanelTypeSplash:
498 u, cmd := p.splash.Update(msg)
499 p.splash = u.(splash.Splash)
500 cmds = append(cmds, cmd)
501 return p, tea.Batch(cmds...)
502 }
503 }
504 return p, tea.Batch(cmds...)
505}
506
507func (p *chatPage) Cursor() *tea.Cursor {
508 if p.header.ShowingDetails() {
509 return nil
510 }
511 switch p.focusedPane {
512 case PanelTypeEditor:
513 return p.editor.Cursor()
514 case PanelTypeSplash:
515 return p.splash.Cursor()
516 default:
517 return nil
518 }
519}
520
521func (p *chatPage) View() string {
522 var chatView string
523 t := styles.CurrentTheme()
524
525 if p.session.ID == "" {
526 splashView := p.splash.View()
527 // Full screen during onboarding or project initialization
528 if p.splashFullScreen {
529 chatView = splashView
530 } else {
531 // Show splash + editor for new message state
532 editorView := p.editor.View()
533 chatView = lipgloss.JoinVertical(
534 lipgloss.Left,
535 t.S().Base.Render(splashView),
536 editorView,
537 )
538 }
539 } else {
540 messagesView := p.chat.View()
541 editorView := p.editor.View()
542
543 hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
544 hasQueue := p.promptQueue > 0
545 pillsFocused := p.focusedPane == PanelTypeStatus
546 todosFocused := pillsFocused && p.focusedPillSection == PillSectionTodos
547 queueFocused := pillsFocused && p.focusedPillSection == PillSectionQueue
548
549 // Use spinner when agent is busy, otherwise show static icon
550 agentBusy := p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy()
551 inProgressIcon := t.S().Base.Foreground(t.GreenDark).Render(styles.CenterSpinnerIcon)
552 if agentBusy {
553 inProgressIcon = p.todoSpinner.View()
554 }
555
556 var pills []string
557 if hasIncompleteTodos {
558 pills = append(pills, todoPill(p.session.Todos, inProgressIcon, todosFocused, pillsFocused, t))
559 }
560 if hasQueue {
561 pills = append(pills, queuePill(p.promptQueue, queueFocused, pillsFocused, t))
562 }
563
564 var expandedList string
565 if pillsFocused {
566 if todosFocused && hasIncompleteTodos {
567 expandedList = todoList(p.session.Todos, inProgressIcon, t, p.width-SideBarWidth)
568 } else if queueFocused && hasQueue {
569 queueItems := p.app.AgentCoordinator.QueuedPromptsList(p.session.ID)
570 expandedList = queueList(queueItems, t)
571 }
572 }
573
574 var pillsArea string
575 if len(pills) > 0 {
576 pillsRow := lipgloss.JoinHorizontal(lipgloss.Top, pills...)
577
578 // Add section line only when focused
579 if pillsFocused {
580 // Calculate available width for section line after pills
581 totalWidth := p.width - SideBarWidth - 2 // -2 for left padding
582 totalWidth -= 2 // Additional padding when focused
583 pillsRowWidth := lipgloss.Width(pillsRow)
584 availableWidth := totalWidth - pillsRowWidth
585
586 if availableWidth > 0 {
587 section := sectionLine(availableWidth, t)
588 // Center the section line vertically with the pills by adding padding
589 centeredSection := t.S().Base.PaddingTop(1).PaddingBottom(1).Render(section)
590 pillsRow = lipgloss.JoinHorizontal(lipgloss.Center, pillsRow, " ", centeredSection)
591 }
592 }
593
594 if expandedList != "" {
595 pillsArea = lipgloss.JoinVertical(
596 lipgloss.Left,
597 pillsRow,
598 expandedList,
599 )
600 } else {
601 pillsArea = pillsRow
602 }
603
604 style := t.S().Base.MarginTop(1)
605 if pillsFocused {
606 style = style.PaddingLeft(1).MarginLeft(1).BorderLeft(true).BorderStyle(lipgloss.ThickBorder()).BorderForeground(t.GreenDark)
607 } else {
608 style = style.PaddingLeft(4)
609 }
610 pillsArea = style.Render(pillsArea)
611 }
612
613 if p.compact {
614 headerView := p.header.View()
615 views := []string{headerView, messagesView}
616 if pillsArea != "" {
617 views = append(views, pillsArea)
618 }
619 views = append(views, editorView)
620 chatView = lipgloss.JoinVertical(lipgloss.Left, views...)
621 } else {
622 sidebarView := p.sidebar.View()
623 var messagesColumn string
624 if pillsArea != "" {
625 messagesColumn = lipgloss.JoinVertical(
626 lipgloss.Left,
627 messagesView,
628 pillsArea,
629 )
630 } else {
631 messagesColumn = messagesView
632 }
633 messages := lipgloss.JoinHorizontal(
634 lipgloss.Left,
635 messagesColumn,
636 sidebarView,
637 )
638 chatView = lipgloss.JoinVertical(
639 lipgloss.Left,
640 messages,
641 p.editor.View(),
642 )
643 }
644 }
645
646 layers := []*lipgloss.Layer{
647 lipgloss.NewLayer(chatView).X(0).Y(0),
648 }
649
650 if p.showingDetails {
651 style := t.S().Base.
652 Width(p.detailsWidth).
653 Border(lipgloss.RoundedBorder()).
654 BorderForeground(t.BorderFocus)
655 version := t.S().Base.Foreground(t.Border).Width(p.detailsWidth - 4).AlignHorizontal(lipgloss.Right).Render(version.Version)
656 details := style.Render(
657 lipgloss.JoinVertical(
658 lipgloss.Left,
659 p.sidebar.View(),
660 version,
661 ),
662 )
663 layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
664 }
665 canvas := lipgloss.NewCompositor(layers...)
666 return canvas.Render()
667}
668
669func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
670 return func() tea.Msg {
671 err := config.Get().SetCompactMode(compact)
672 if err != nil {
673 return util.InfoMsg{
674 Type: util.InfoTypeError,
675 Msg: "Failed to update compact mode configuration: " + err.Error(),
676 }
677 }
678 return nil
679 }
680}
681
682func (p *chatPage) toggleThinking() tea.Cmd {
683 return func() tea.Msg {
684 cfg := config.Get()
685 agentCfg := cfg.Agents[config.AgentCoder]
686 currentModel := cfg.Models[agentCfg.Model]
687
688 // Toggle the thinking mode
689 currentModel.Think = !currentModel.Think
690 if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
691 return util.InfoMsg{
692 Type: util.InfoTypeError,
693 Msg: "Failed to update thinking mode: " + err.Error(),
694 }
695 }
696
697 // Update the agent with the new configuration
698 go p.app.UpdateAgentModel(context.TODO())
699
700 status := "disabled"
701 if currentModel.Think {
702 status = "enabled"
703 }
704 return util.InfoMsg{
705 Type: util.InfoTypeInfo,
706 Msg: "Thinking mode " + status,
707 }
708 }
709}
710
711func (p *chatPage) openReasoningDialog() tea.Cmd {
712 return func() tea.Msg {
713 cfg := config.Get()
714 agentCfg := cfg.Agents[config.AgentCoder]
715 model := cfg.GetModelByType(agentCfg.Model)
716 providerCfg := cfg.GetProviderForModel(agentCfg.Model)
717
718 if providerCfg != nil && model != nil && len(model.ReasoningLevels) > 0 {
719 // Return the OpenDialogMsg directly so it bubbles up to the main TUI
720 return dialogs.OpenDialogMsg{
721 Model: reasoning.NewReasoningDialog(),
722 }
723 }
724 return nil
725 }
726}
727
728func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
729 return func() tea.Msg {
730 cfg := config.Get()
731 agentCfg := cfg.Agents[config.AgentCoder]
732 currentModel := cfg.Models[agentCfg.Model]
733
734 // Update the model configuration
735 currentModel.ReasoningEffort = effort
736 if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
737 return util.InfoMsg{
738 Type: util.InfoTypeError,
739 Msg: "Failed to update reasoning effort: " + err.Error(),
740 }
741 }
742
743 // Update the agent with the new configuration
744 if err := p.app.UpdateAgentModel(context.TODO()); err != nil {
745 return util.InfoMsg{
746 Type: util.InfoTypeError,
747 Msg: "Failed to update reasoning effort: " + err.Error(),
748 }
749 }
750
751 return util.InfoMsg{
752 Type: util.InfoTypeInfo,
753 Msg: "Reasoning effort set to " + effort,
754 }
755 }
756}
757
758func (p *chatPage) setCompactMode(compact bool) {
759 if p.compact == compact {
760 return
761 }
762 p.compact = compact
763 if compact {
764 p.sidebar.SetCompactMode(true)
765 } else {
766 p.setShowDetails(false)
767 }
768}
769
770func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
771 if p.forceCompact {
772 return
773 }
774 if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
775 p.setCompactMode(true)
776 }
777 if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
778 p.setCompactMode(false)
779 }
780}
781
782func (p *chatPage) SetSize(width, height int) tea.Cmd {
783 p.handleCompactMode(width, height)
784 p.width = width
785 p.height = height
786 var cmds []tea.Cmd
787
788 if p.session.ID == "" {
789 if p.splashFullScreen {
790 cmds = append(cmds, p.splash.SetSize(width, height))
791 } else {
792 cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
793 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
794 cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
795 }
796 } else {
797 hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
798 hasQueue := p.promptQueue > 0
799 hasPills := hasIncompleteTodos || hasQueue
800 pillsFocused := p.focusedPane == PanelTypeStatus
801
802 pillsAreaHeight := 0
803 if hasPills {
804 pillsAreaHeight = pillHeightWithBorder + 1 // +1 for padding top
805 if pillsFocused {
806 if p.focusedPillSection == PillSectionTodos && hasIncompleteTodos {
807 pillsAreaHeight += len(p.session.Todos)
808 } else if p.focusedPillSection == PillSectionQueue && hasQueue {
809 pillsAreaHeight += p.promptQueue
810 }
811 }
812 }
813
814 if p.compact {
815 cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight-pillsAreaHeight))
816 p.detailsWidth = width - DetailsPositioning
817 cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
818 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
819 cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
820 } else {
821 cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight-pillsAreaHeight))
822 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
823 cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
824 }
825 cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
826 }
827 return tea.Batch(cmds...)
828}
829
830func (p *chatPage) newSession() tea.Cmd {
831 if p.session.ID == "" {
832 return nil
833 }
834
835 p.session = session.Session{}
836 p.focusedPane = PanelTypeEditor
837 p.editor.Focus()
838 p.chat.Blur()
839 p.isCanceling = false
840 return tea.Batch(
841 util.CmdHandler(chat.SessionClearedMsg{}),
842 p.SetSize(p.width, p.height),
843 )
844}
845
846func (p *chatPage) setSession(sess session.Session) tea.Cmd {
847 if p.session.ID == sess.ID {
848 return nil
849 }
850
851 var cmds []tea.Cmd
852 p.session = sess
853
854 if p.hasInProgressTodo() {
855 cmds = append(cmds, p.todoSpinner.Tick)
856 }
857
858 cmds = append(cmds, p.SetSize(p.width, p.height))
859 cmds = append(cmds, p.chat.SetSession(sess))
860 cmds = append(cmds, p.sidebar.SetSession(sess))
861 cmds = append(cmds, p.header.SetSession(sess))
862 cmds = append(cmds, p.editor.SetSession(sess))
863
864 return tea.Sequence(cmds...)
865}
866
867func (p *chatPage) changeFocus() tea.Cmd {
868 if p.session.ID == "" {
869 return nil
870 }
871
872 hasPills := hasIncompleteTodos(p.session.Todos) || p.promptQueue > 0
873 wasPillsFocused := p.focusedPane == PanelTypeStatus
874
875 // Focus flow: editor -> status -> chat -> status -> editor (when status exists)
876 // Or: editor -> chat -> editor (when no status)
877 switch p.focusedPane {
878 case PanelTypeEditor:
879 if hasPills {
880 p.previousFocusedPane = PanelTypeEditor
881 p.focusedPane = PanelTypeStatus
882 p.editor.Blur()
883 if hasIncompleteTodos(p.session.Todos) {
884 p.focusedPillSection = PillSectionTodos
885 } else {
886 p.focusedPillSection = PillSectionQueue
887 }
888 } else {
889 p.focusedPane = PanelTypeChat
890 p.chat.Focus()
891 p.editor.Blur()
892 }
893 case PanelTypeStatus:
894 if p.previousFocusedPane == PanelTypeEditor {
895 p.focusedPane = PanelTypeChat
896 p.chat.Focus()
897 } else {
898 p.focusedPane = PanelTypeEditor
899 p.editor.Focus()
900 }
901 case PanelTypeChat:
902 if hasPills {
903 p.previousFocusedPane = PanelTypeChat
904 p.focusedPane = PanelTypeStatus
905 p.chat.Blur()
906 if hasIncompleteTodos(p.session.Todos) {
907 p.focusedPillSection = PillSectionTodos
908 } else {
909 p.focusedPillSection = PillSectionQueue
910 }
911 } else {
912 p.focusedPane = PanelTypeEditor
913 p.editor.Focus()
914 p.chat.Blur()
915 }
916 }
917
918 isPillsFocused := p.focusedPane == PanelTypeStatus
919 if hasPills && (wasPillsFocused != isPillsFocused) {
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) handlePillsKeyPress(msg tea.KeyPressMsg) tea.Cmd {
991 hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
992 hasQueue := p.promptQueue > 0
993
994 switch {
995 case key.Matches(msg, p.pillsKeyMap.Left):
996 if p.focusedPillSection == PillSectionQueue && hasIncompleteTodos {
997 p.focusedPillSection = PillSectionTodos
998 return p.SetSize(p.width, p.height)
999 }
1000 case key.Matches(msg, p.pillsKeyMap.Right):
1001 if p.focusedPillSection == PillSectionTodos && hasQueue {
1002 p.focusedPillSection = PillSectionQueue
1003 return p.SetSize(p.width, p.height)
1004 }
1005 case key.Matches(msg, p.pillsKeyMap.Up):
1006 p.focusedPane = PanelTypeChat
1007 p.chat.Focus()
1008 return p.SetSize(p.width, p.height)
1009 case key.Matches(msg, p.pillsKeyMap.Down):
1010 p.focusedPane = PanelTypeEditor
1011 p.editor.Focus()
1012 return p.SetSize(p.width, p.height)
1013 }
1014 return nil
1015}
1016
1017func (p *chatPage) Bindings() []key.Binding {
1018 bindings := []key.Binding{
1019 p.keyMap.NewSession,
1020 p.keyMap.AddAttachment,
1021 }
1022 if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
1023 cancelBinding := p.keyMap.Cancel
1024 if p.isCanceling {
1025 cancelBinding = key.NewBinding(
1026 key.WithKeys("esc", "alt+esc"),
1027 key.WithHelp("esc", "press again to cancel"),
1028 )
1029 }
1030 bindings = append([]key.Binding{cancelBinding}, bindings...)
1031 }
1032
1033 switch p.focusedPane {
1034 case PanelTypeChat:
1035 bindings = append([]key.Binding{
1036 key.NewBinding(
1037 key.WithKeys("tab"),
1038 key.WithHelp("tab", "focus editor"),
1039 ),
1040 }, bindings...)
1041 bindings = append(bindings, p.chat.Bindings()...)
1042 case PanelTypeEditor:
1043 bindings = append([]key.Binding{
1044 key.NewBinding(
1045 key.WithKeys("tab"),
1046 key.WithHelp("tab", "focus chat"),
1047 ),
1048 }, bindings...)
1049 bindings = append(bindings, p.editor.Bindings()...)
1050 case PanelTypeSplash:
1051 bindings = append(bindings, p.splash.Bindings()...)
1052 }
1053
1054 return bindings
1055}
1056
1057func (p *chatPage) Help() help.KeyMap {
1058 var shortList []key.Binding
1059 var fullList [][]key.Binding
1060 switch {
1061 case p.isOnboarding && p.splash.IsShowingClaudeAuthMethodChooser():
1062 shortList = append(shortList,
1063 // Choose auth method
1064 key.NewBinding(
1065 key.WithKeys("left", "right", "tab"),
1066 key.WithHelp("←→/tab", "choose"),
1067 ),
1068 // Accept selection
1069 key.NewBinding(
1070 key.WithKeys("enter"),
1071 key.WithHelp("enter", "accept"),
1072 ),
1073 // Go back
1074 key.NewBinding(
1075 key.WithKeys("esc", "alt+esc"),
1076 key.WithHelp("esc", "back"),
1077 ),
1078 // Quit
1079 key.NewBinding(
1080 key.WithKeys("ctrl+c"),
1081 key.WithHelp("ctrl+c", "quit"),
1082 ),
1083 )
1084 // keep them the same
1085 for _, v := range shortList {
1086 fullList = append(fullList, []key.Binding{v})
1087 }
1088 case p.isOnboarding && p.splash.IsShowingClaudeOAuth2():
1089 if p.splash.IsClaudeOAuthURLState() {
1090 shortList = append(shortList,
1091 key.NewBinding(
1092 key.WithKeys("enter"),
1093 key.WithHelp("enter", "open"),
1094 ),
1095 key.NewBinding(
1096 key.WithKeys("c"),
1097 key.WithHelp("c", "copy url"),
1098 ),
1099 )
1100 } else if p.splash.IsClaudeOAuthComplete() {
1101 shortList = append(shortList,
1102 key.NewBinding(
1103 key.WithKeys("enter"),
1104 key.WithHelp("enter", "continue"),
1105 ),
1106 )
1107 } else {
1108 shortList = append(shortList,
1109 key.NewBinding(
1110 key.WithKeys("enter"),
1111 key.WithHelp("enter", "submit"),
1112 ),
1113 )
1114 }
1115 shortList = append(shortList,
1116 // Quit
1117 key.NewBinding(
1118 key.WithKeys("ctrl+c"),
1119 key.WithHelp("ctrl+c", "quit"),
1120 ),
1121 )
1122 // keep them the same
1123 for _, v := range shortList {
1124 fullList = append(fullList, []key.Binding{v})
1125 }
1126 case p.isOnboarding && !p.splash.IsShowingAPIKey():
1127 shortList = append(shortList,
1128 // Choose model
1129 key.NewBinding(
1130 key.WithKeys("up", "down"),
1131 key.WithHelp("↑/↓", "choose"),
1132 ),
1133 // Accept selection
1134 key.NewBinding(
1135 key.WithKeys("enter", "ctrl+y"),
1136 key.WithHelp("enter", "accept"),
1137 ),
1138 // Quit
1139 key.NewBinding(
1140 key.WithKeys("ctrl+c"),
1141 key.WithHelp("ctrl+c", "quit"),
1142 ),
1143 )
1144 // keep them the same
1145 for _, v := range shortList {
1146 fullList = append(fullList, []key.Binding{v})
1147 }
1148 case p.isOnboarding && p.splash.IsShowingAPIKey():
1149 if p.splash.IsAPIKeyValid() {
1150 shortList = append(shortList,
1151 key.NewBinding(
1152 key.WithKeys("enter"),
1153 key.WithHelp("enter", "continue"),
1154 ),
1155 )
1156 } else {
1157 shortList = append(shortList,
1158 // Go back
1159 key.NewBinding(
1160 key.WithKeys("esc", "alt+esc"),
1161 key.WithHelp("esc", "back"),
1162 ),
1163 )
1164 }
1165 shortList = append(shortList,
1166 // Quit
1167 key.NewBinding(
1168 key.WithKeys("ctrl+c"),
1169 key.WithHelp("ctrl+c", "quit"),
1170 ),
1171 )
1172 // keep them the same
1173 for _, v := range shortList {
1174 fullList = append(fullList, []key.Binding{v})
1175 }
1176 case p.isProjectInit:
1177 shortList = append(shortList,
1178 key.NewBinding(
1179 key.WithKeys("ctrl+c"),
1180 key.WithHelp("ctrl+c", "quit"),
1181 ),
1182 )
1183 // keep them the same
1184 for _, v := range shortList {
1185 fullList = append(fullList, []key.Binding{v})
1186 }
1187 default:
1188 if p.editor.IsCompletionsOpen() {
1189 shortList = append(shortList,
1190 key.NewBinding(
1191 key.WithKeys("tab", "enter"),
1192 key.WithHelp("tab/enter", "complete"),
1193 ),
1194 key.NewBinding(
1195 key.WithKeys("esc", "alt+esc"),
1196 key.WithHelp("esc", "cancel"),
1197 ),
1198 key.NewBinding(
1199 key.WithKeys("up", "down"),
1200 key.WithHelp("↑/↓", "choose"),
1201 ),
1202 )
1203 for _, v := range shortList {
1204 fullList = append(fullList, []key.Binding{v})
1205 }
1206 return core.NewSimpleHelp(shortList, fullList)
1207 }
1208 if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
1209 cancelBinding := key.NewBinding(
1210 key.WithKeys("esc", "alt+esc"),
1211 key.WithHelp("esc", "cancel"),
1212 )
1213 if p.isCanceling {
1214 cancelBinding = key.NewBinding(
1215 key.WithKeys("esc", "alt+esc"),
1216 key.WithHelp("esc", "press again to cancel"),
1217 )
1218 }
1219 if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
1220 cancelBinding = key.NewBinding(
1221 key.WithKeys("esc", "alt+esc"),
1222 key.WithHelp("esc", "clear queue"),
1223 )
1224 }
1225 shortList = append(shortList, cancelBinding)
1226 fullList = append(fullList,
1227 []key.Binding{
1228 cancelBinding,
1229 },
1230 )
1231 }
1232 globalBindings := []key.Binding{}
1233 // we are in a session
1234 if p.session.ID != "" {
1235 var tabKey key.Binding
1236 switch p.focusedPane {
1237 case PanelTypeEditor:
1238 hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
1239 hasQueue := p.promptQueue > 0
1240 if hasIncompleteTodos {
1241 tabKey = key.NewBinding(
1242 key.WithKeys("tab"),
1243 key.WithHelp("tab", "focus todos"),
1244 )
1245 } else if hasQueue {
1246 tabKey = key.NewBinding(
1247 key.WithKeys("tab"),
1248 key.WithHelp("tab", "focus queued"),
1249 )
1250 } else {
1251 tabKey = key.NewBinding(
1252 key.WithKeys("tab"),
1253 key.WithHelp("tab", "focus chat"),
1254 )
1255 }
1256 case PanelTypeStatus:
1257 if p.previousFocusedPane == PanelTypeEditor {
1258 tabKey = key.NewBinding(
1259 key.WithKeys("tab"),
1260 key.WithHelp("tab", "focus chat"),
1261 )
1262 } else {
1263 tabKey = key.NewBinding(
1264 key.WithKeys("tab"),
1265 key.WithHelp("tab", "focus editor"),
1266 )
1267 }
1268 case PanelTypeChat:
1269 hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
1270 hasQueue := p.promptQueue > 0
1271 if hasIncompleteTodos {
1272 tabKey = key.NewBinding(
1273 key.WithKeys("tab"),
1274 key.WithHelp("tab", "focus todos"),
1275 )
1276 } else if hasQueue {
1277 tabKey = key.NewBinding(
1278 key.WithKeys("tab"),
1279 key.WithHelp("tab", "focus queued"),
1280 )
1281 } else {
1282 tabKey = key.NewBinding(
1283 key.WithKeys("tab"),
1284 key.WithHelp("tab", "focus editor"),
1285 )
1286 }
1287 default:
1288 tabKey = key.NewBinding(
1289 key.WithKeys("tab"),
1290 key.WithHelp("tab", "focus chat"),
1291 )
1292 }
1293 shortList = append(shortList, tabKey)
1294 globalBindings = append(globalBindings, tabKey)
1295 }
1296 commandsBinding := key.NewBinding(
1297 key.WithKeys("ctrl+p"),
1298 key.WithHelp("ctrl+p", "commands"),
1299 )
1300 if p.focusedPane == PanelTypeEditor && p.editor.IsEmpty() {
1301 commandsBinding.SetHelp("/ or ctrl+p", "commands")
1302 }
1303 modelsBinding := key.NewBinding(
1304 key.WithKeys("ctrl+m", "ctrl+l"),
1305 key.WithHelp("ctrl+l", "models"),
1306 )
1307 if p.keyboardEnhancements.Flags > 0 {
1308 // non-zero flags mean we have at least key disambiguation
1309 modelsBinding.SetHelp("ctrl+m", "models")
1310 }
1311 helpBinding := key.NewBinding(
1312 key.WithKeys("ctrl+g"),
1313 key.WithHelp("ctrl+g", "more"),
1314 )
1315 globalBindings = append(globalBindings, commandsBinding, modelsBinding)
1316 globalBindings = append(globalBindings,
1317 key.NewBinding(
1318 key.WithKeys("ctrl+s"),
1319 key.WithHelp("ctrl+s", "sessions"),
1320 ),
1321 )
1322 if p.session.ID != "" {
1323 globalBindings = append(globalBindings,
1324 key.NewBinding(
1325 key.WithKeys("ctrl+n"),
1326 key.WithHelp("ctrl+n", "new sessions"),
1327 ))
1328 }
1329 shortList = append(shortList,
1330 // Commands
1331 commandsBinding,
1332 modelsBinding,
1333 )
1334 fullList = append(fullList, globalBindings)
1335
1336 switch p.focusedPane {
1337 case PanelTypeChat:
1338 shortList = append(shortList,
1339 key.NewBinding(
1340 key.WithKeys("up", "down"),
1341 key.WithHelp("↑↓", "scroll"),
1342 ),
1343 messages.CopyKey,
1344 )
1345 fullList = append(fullList,
1346 []key.Binding{
1347 key.NewBinding(
1348 key.WithKeys("up", "down"),
1349 key.WithHelp("↑↓", "scroll"),
1350 ),
1351 key.NewBinding(
1352 key.WithKeys("shift+up", "shift+down"),
1353 key.WithHelp("shift+↑↓", "next/prev item"),
1354 ),
1355 key.NewBinding(
1356 key.WithKeys("pgup", "b"),
1357 key.WithHelp("b/pgup", "page up"),
1358 ),
1359 key.NewBinding(
1360 key.WithKeys("pgdown", " ", "f"),
1361 key.WithHelp("f/pgdn", "page down"),
1362 ),
1363 },
1364 []key.Binding{
1365 key.NewBinding(
1366 key.WithKeys("u"),
1367 key.WithHelp("u", "half page up"),
1368 ),
1369 key.NewBinding(
1370 key.WithKeys("d"),
1371 key.WithHelp("d", "half page down"),
1372 ),
1373 key.NewBinding(
1374 key.WithKeys("g", "home"),
1375 key.WithHelp("g", "home"),
1376 ),
1377 key.NewBinding(
1378 key.WithKeys("G", "end"),
1379 key.WithHelp("G", "end"),
1380 ),
1381 },
1382 []key.Binding{
1383 messages.CopyKey,
1384 messages.ClearSelectionKey,
1385 },
1386 )
1387 case PanelTypeEditor:
1388 newLineBinding := key.NewBinding(
1389 key.WithKeys("shift+enter", "ctrl+j"),
1390 // "ctrl+j" is a common keybinding for newline in many editors. If
1391 // the terminal supports "shift+enter", we substitute the help text
1392 // to reflect that.
1393 key.WithHelp("ctrl+j", "newline"),
1394 )
1395 if p.keyboardEnhancements.Flags > 0 {
1396 // Non-zero flags mean we have at least key disambiguation.
1397 newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
1398 }
1399 shortList = append(shortList, newLineBinding)
1400 fullList = append(fullList,
1401 []key.Binding{
1402 newLineBinding,
1403 key.NewBinding(
1404 key.WithKeys("ctrl+f"),
1405 key.WithHelp("ctrl+f", "add image"),
1406 ),
1407 key.NewBinding(
1408 key.WithKeys("@"),
1409 key.WithHelp("@", "mention file"),
1410 ),
1411 key.NewBinding(
1412 key.WithKeys("ctrl+o"),
1413 key.WithHelp("ctrl+o", "open editor"),
1414 ),
1415 })
1416
1417 if p.editor.HasAttachments() {
1418 fullList = append(fullList, []key.Binding{
1419 key.NewBinding(
1420 key.WithKeys("ctrl+r"),
1421 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
1422 ),
1423 key.NewBinding(
1424 key.WithKeys("ctrl+r", "r"),
1425 key.WithHelp("ctrl+r+r", "delete all attachments"),
1426 ),
1427 key.NewBinding(
1428 key.WithKeys("esc", "alt+esc"),
1429 key.WithHelp("esc", "cancel delete mode"),
1430 ),
1431 })
1432 }
1433 case PanelTypeStatus:
1434 shortList = append(shortList,
1435 p.pillsKeyMap.Left,
1436 )
1437 fullList = append(fullList,
1438 []key.Binding{
1439 p.pillsKeyMap.Left,
1440 },
1441 )
1442 }
1443 shortList = append(shortList,
1444 // Quit
1445 key.NewBinding(
1446 key.WithKeys("ctrl+c"),
1447 key.WithHelp("ctrl+c", "quit"),
1448 ),
1449 // Help
1450 helpBinding,
1451 )
1452 fullList = append(fullList, []key.Binding{
1453 key.NewBinding(
1454 key.WithKeys("ctrl+g"),
1455 key.WithHelp("ctrl+g", "less"),
1456 ),
1457 })
1458 }
1459
1460 return core.NewSimpleHelp(shortList, fullList)
1461}
1462
1463func (p *chatPage) IsChatFocused() bool {
1464 return p.focusedPane == PanelTypeChat
1465}
1466
1467// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
1468// Returns true if the mouse is over the chat area, false otherwise.
1469func (p *chatPage) isMouseOverChat(x, y int) bool {
1470 // No session means no chat area
1471 if p.session.ID == "" {
1472 return false
1473 }
1474
1475 var chatX, chatY, chatWidth, chatHeight int
1476
1477 if p.compact {
1478 // In compact mode: chat area starts after header and spans full width
1479 chatX = 0
1480 chatY = HeaderHeight
1481 chatWidth = p.width
1482 chatHeight = p.height - EditorHeight - HeaderHeight
1483 } else {
1484 // In non-compact mode: chat area spans from left edge to sidebar
1485 chatX = 0
1486 chatY = 0
1487 chatWidth = p.width - SideBarWidth
1488 chatHeight = p.height - EditorHeight
1489 }
1490
1491 // Check if mouse coordinates are within chat bounds
1492 return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1493}
1494
1495func (p *chatPage) hasInProgressTodo() bool {
1496 for _, todo := range p.session.Todos {
1497 if todo.Status == session.TodoStatusInProgress {
1498 return true
1499 }
1500 }
1501 return false
1502}