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 "git.secluded.site/crush/internal/app"
15 "git.secluded.site/crush/internal/config"
16 "git.secluded.site/crush/internal/history"
17 "git.secluded.site/crush/internal/message"
18 "git.secluded.site/crush/internal/permission"
19 "git.secluded.site/crush/internal/pubsub"
20 "git.secluded.site/crush/internal/session"
21 "git.secluded.site/crush/internal/tui/components/anim"
22 "git.secluded.site/crush/internal/tui/components/chat"
23 "git.secluded.site/crush/internal/tui/components/chat/editor"
24 "git.secluded.site/crush/internal/tui/components/chat/header"
25 "git.secluded.site/crush/internal/tui/components/chat/messages"
26 "git.secluded.site/crush/internal/tui/components/chat/sidebar"
27 "git.secluded.site/crush/internal/tui/components/chat/splash"
28 "git.secluded.site/crush/internal/tui/components/completions"
29 "git.secluded.site/crush/internal/tui/components/core"
30 "git.secluded.site/crush/internal/tui/components/core/layout"
31 "git.secluded.site/crush/internal/tui/components/dialogs"
32 "git.secluded.site/crush/internal/tui/components/dialogs/claude"
33 "git.secluded.site/crush/internal/tui/components/dialogs/commands"
34 "git.secluded.site/crush/internal/tui/components/dialogs/filepicker"
35 "git.secluded.site/crush/internal/tui/components/dialogs/models"
36 "git.secluded.site/crush/internal/tui/components/dialogs/reasoning"
37 "git.secluded.site/crush/internal/tui/page"
38 "git.secluded.site/crush/internal/tui/styles"
39 "git.secluded.site/crush/internal/tui/util"
40 "git.secluded.site/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)
59
60const (
61 CompactModeWidthBreakpoint = 120 // Width at which the chat page switches to compact mode
62 CompactModeHeightBreakpoint = 30 // Height at which the chat page switches to compact mode
63 EditorHeight = 5 // Height of the editor input area including padding
64 SideBarWidth = 31 // Width of the sidebar
65 SideBarDetailsPadding = 1 // Padding for the sidebar details section
66 HeaderHeight = 1 // Height of the header
67
68 // Layout constants for borders and padding
69 BorderWidth = 1 // Width of component borders
70 LeftRightBorders = 2 // Left + right border width (1 + 1)
71 TopBottomBorders = 2 // Top + bottom border width (1 + 1)
72 DetailsPositioning = 2 // Positioning adjustment for details panel
73
74 // Timing constants
75 CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires
76)
77
78type ChatPage interface {
79 util.Model
80 layout.Help
81 IsChatFocused() bool
82}
83
84// cancelTimerCmd creates a command that expires the cancel timer
85func cancelTimerCmd() tea.Cmd {
86 return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg {
87 return CancelTimerExpiredMsg{}
88 })
89}
90
91type chatPage struct {
92 width, height int
93 detailsWidth, detailsHeight int
94 app *app.App
95 keyboardEnhancements tea.KeyboardEnhancementsMsg
96
97 // Layout state
98 compact bool
99 forceCompact bool
100 focusedPane PanelType
101
102 // Session
103 session session.Session
104 keyMap KeyMap
105
106 // Components
107 header header.Header
108 sidebar sidebar.Sidebar
109 chat chat.MessageListCmp
110 editor editor.Editor
111 splash splash.Splash
112
113 // Simple state flags
114 showingDetails bool
115 isCanceling bool
116 splashFullScreen bool
117 isOnboarding bool
118 isProjectInit bool
119}
120
121func New(app *app.App) ChatPage {
122 return &chatPage{
123 app: app,
124 keyMap: DefaultKeyMap(),
125 header: header.New(app.LSPClients),
126 sidebar: sidebar.New(app.History, app.LSPClients, false),
127 chat: chat.New(app),
128 editor: editor.New(app),
129 splash: splash.New(),
130 focusedPane: PanelTypeSplash,
131 }
132}
133
134func (p *chatPage) Init() tea.Cmd {
135 cfg := config.Get()
136 compact := cfg.Options.TUI.CompactMode
137 p.compact = compact
138 p.forceCompact = compact
139 p.sidebar.SetCompactMode(p.compact)
140
141 // Set splash state based on config
142 if !config.HasInitialDataConfig() {
143 // First-time setup: show model selection
144 p.splash.SetOnboarding(true)
145 p.isOnboarding = true
146 p.splashFullScreen = true
147 } else if b, _ := config.ProjectNeedsInitialization(); b {
148 // Project needs context initialization
149 p.splash.SetProjectInit(true)
150 p.isProjectInit = true
151 p.splashFullScreen = true
152 } else {
153 // Ready to chat: focus editor, splash in background
154 p.focusedPane = PanelTypeEditor
155 p.splashFullScreen = false
156 }
157
158 return tea.Batch(
159 p.header.Init(),
160 p.sidebar.Init(),
161 p.chat.Init(),
162 p.editor.Init(),
163 p.splash.Init(),
164 )
165}
166
167func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
168 var cmds []tea.Cmd
169 switch msg := msg.(type) {
170 case tea.KeyboardEnhancementsMsg:
171 p.keyboardEnhancements = msg
172 return p, nil
173 case tea.MouseWheelMsg:
174 if p.compact {
175 msg.Y -= 1
176 }
177 if p.isMouseOverChat(msg.X, msg.Y) {
178 u, cmd := p.chat.Update(msg)
179 p.chat = u.(chat.MessageListCmp)
180 return p, cmd
181 }
182 return p, nil
183 case tea.MouseClickMsg:
184 if p.isOnboarding {
185 return p, nil
186 }
187 if p.compact {
188 msg.Y -= 1
189 }
190 if p.isMouseOverChat(msg.X, msg.Y) {
191 p.focusedPane = PanelTypeChat
192 p.chat.Focus()
193 p.editor.Blur()
194 } else {
195 p.focusedPane = PanelTypeEditor
196 p.editor.Focus()
197 p.chat.Blur()
198 }
199 u, cmd := p.chat.Update(msg)
200 p.chat = u.(chat.MessageListCmp)
201 return p, cmd
202 case tea.MouseMotionMsg:
203 if p.compact {
204 msg.Y -= 1
205 }
206 if msg.Button == tea.MouseLeft {
207 u, cmd := p.chat.Update(msg)
208 p.chat = u.(chat.MessageListCmp)
209 return p, cmd
210 }
211 return p, nil
212 case tea.MouseReleaseMsg:
213 if p.isOnboarding {
214 return p, nil
215 }
216 if p.compact {
217 msg.Y -= 1
218 }
219 if msg.Button == tea.MouseLeft {
220 u, cmd := p.chat.Update(msg)
221 p.chat = u.(chat.MessageListCmp)
222 return p, cmd
223 }
224 return p, nil
225 case chat.SelectionCopyMsg:
226 u, cmd := p.chat.Update(msg)
227 p.chat = u.(chat.MessageListCmp)
228 return p, cmd
229 case tea.WindowSizeMsg:
230 u, cmd := p.editor.Update(msg)
231 p.editor = u.(editor.Editor)
232 return p, tea.Batch(p.SetSize(msg.Width, msg.Height), cmd)
233 case CancelTimerExpiredMsg:
234 p.isCanceling = false
235 return p, nil
236 case editor.OpenEditorMsg:
237 u, cmd := p.editor.Update(msg)
238 p.editor = u.(editor.Editor)
239 return p, cmd
240 case chat.SendMsg:
241 return p, p.sendMessage(msg.Text, msg.Attachments)
242 case chat.SessionSelectedMsg:
243 return p, p.setSession(msg)
244 case splash.SubmitAPIKeyMsg:
245 u, cmd := p.splash.Update(msg)
246 p.splash = u.(splash.Splash)
247 cmds = append(cmds, cmd)
248 return p, tea.Batch(cmds...)
249 case commands.ToggleCompactModeMsg:
250 p.forceCompact = !p.forceCompact
251 var cmd tea.Cmd
252 if p.forceCompact {
253 p.setCompactMode(true)
254 cmd = p.updateCompactConfig(true)
255 } else if p.width >= CompactModeWidthBreakpoint && p.height >= CompactModeHeightBreakpoint {
256 p.setCompactMode(false)
257 cmd = p.updateCompactConfig(false)
258 }
259 return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
260 case commands.ToggleThinkingMsg:
261 return p, p.toggleThinking()
262 case commands.OpenReasoningDialogMsg:
263 return p, p.openReasoningDialog()
264 case reasoning.ReasoningEffortSelectedMsg:
265 return p, p.handleReasoningEffortSelected(msg.Effort)
266 case commands.OpenExternalEditorMsg:
267 u, cmd := p.editor.Update(msg)
268 p.editor = u.(editor.Editor)
269 return p, cmd
270 case pubsub.Event[session.Session]:
271 u, cmd := p.header.Update(msg)
272 p.header = u.(header.Header)
273 cmds = append(cmds, cmd)
274 u, cmd = p.sidebar.Update(msg)
275 p.sidebar = u.(sidebar.Sidebar)
276 cmds = append(cmds, cmd)
277 return p, tea.Batch(cmds...)
278 case chat.SessionClearedMsg:
279 u, cmd := p.header.Update(msg)
280 p.header = u.(header.Header)
281 cmds = append(cmds, cmd)
282 u, cmd = p.sidebar.Update(msg)
283 p.sidebar = u.(sidebar.Sidebar)
284 cmds = append(cmds, cmd)
285 u, cmd = p.chat.Update(msg)
286 p.chat = u.(chat.MessageListCmp)
287 cmds = append(cmds, cmd)
288 return p, tea.Batch(cmds...)
289 case filepicker.FilePickedMsg,
290 completions.CompletionsClosedMsg,
291 completions.SelectCompletionMsg:
292 u, cmd := p.editor.Update(msg)
293 p.editor = u.(editor.Editor)
294 cmds = append(cmds, cmd)
295 return p, tea.Batch(cmds...)
296
297 case claude.ValidationCompletedMsg, claude.AuthenticationCompleteMsg:
298 if p.focusedPane == PanelTypeSplash {
299 u, cmd := p.splash.Update(msg)
300 p.splash = u.(splash.Splash)
301 cmds = append(cmds, cmd)
302 }
303 return p, tea.Batch(cmds...)
304 case models.APIKeyStateChangeMsg:
305 if p.focusedPane == PanelTypeSplash {
306 u, cmd := p.splash.Update(msg)
307 p.splash = u.(splash.Splash)
308 cmds = append(cmds, cmd)
309 }
310 return p, tea.Batch(cmds...)
311 case pubsub.Event[message.Message],
312 anim.StepMsg,
313 spinner.TickMsg:
314 if p.focusedPane == PanelTypeSplash {
315 u, cmd := p.splash.Update(msg)
316 p.splash = u.(splash.Splash)
317 cmds = append(cmds, cmd)
318 } else {
319 u, cmd := p.chat.Update(msg)
320 p.chat = u.(chat.MessageListCmp)
321 cmds = append(cmds, cmd)
322 }
323
324 return p, tea.Batch(cmds...)
325 case commands.ToggleYoloModeMsg:
326 // update the editor style
327 u, cmd := p.editor.Update(msg)
328 p.editor = u.(editor.Editor)
329 return p, cmd
330 case pubsub.Event[history.File], sidebar.SessionFilesMsg:
331 u, cmd := p.sidebar.Update(msg)
332 p.sidebar = u.(sidebar.Sidebar)
333 cmds = append(cmds, cmd)
334 return p, tea.Batch(cmds...)
335 case pubsub.Event[permission.PermissionNotification]:
336 u, cmd := p.chat.Update(msg)
337 p.chat = u.(chat.MessageListCmp)
338 cmds = append(cmds, cmd)
339 return p, tea.Batch(cmds...)
340
341 case commands.CommandRunCustomMsg:
342 if p.app.AgentCoordinator.IsBusy() {
343 return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
344 }
345
346 cmd := p.sendMessage(msg.Content, nil)
347 if cmd != nil {
348 return p, cmd
349 }
350 case splash.OnboardingCompleteMsg:
351 p.splashFullScreen = false
352 if b, _ := config.ProjectNeedsInitialization(); b {
353 p.splash.SetProjectInit(true)
354 p.splashFullScreen = true
355 return p, p.SetSize(p.width, p.height)
356 }
357 err := p.app.InitCoderAgent(context.TODO())
358 if err != nil {
359 return p, util.ReportError(err)
360 }
361 p.isOnboarding = false
362 p.isProjectInit = false
363 p.focusedPane = PanelTypeEditor
364 return p, p.SetSize(p.width, p.height)
365 case commands.NewSessionsMsg:
366 if p.app.AgentCoordinator.IsBusy() {
367 return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
368 }
369 return p, p.newSession()
370 case tea.KeyPressMsg:
371 switch {
372 case key.Matches(msg, p.keyMap.NewSession):
373 // if we have no agent do nothing
374 if p.app.AgentCoordinator == nil {
375 return p, nil
376 }
377 if p.app.AgentCoordinator.IsBusy() {
378 return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
379 }
380 return p, p.newSession()
381 case key.Matches(msg, p.keyMap.AddAttachment):
382 agentCfg := config.Get().Agents[config.AgentCoder]
383 model := config.Get().GetModelByType(agentCfg.Model)
384 if model.SupportsImages {
385 return p, util.CmdHandler(commands.OpenFilePickerMsg{})
386 } else {
387 return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
388 }
389 case key.Matches(msg, p.keyMap.Tab):
390 if p.session.ID == "" {
391 u, cmd := p.splash.Update(msg)
392 p.splash = u.(splash.Splash)
393 return p, cmd
394 }
395 p.changeFocus()
396 return p, nil
397 case key.Matches(msg, p.keyMap.Cancel):
398 if p.session.ID != "" && p.app.AgentCoordinator.IsBusy() {
399 return p, p.cancel()
400 }
401 case key.Matches(msg, p.keyMap.Details):
402 p.toggleDetails()
403 return p, nil
404 }
405
406 switch p.focusedPane {
407 case PanelTypeChat:
408 u, cmd := p.chat.Update(msg)
409 p.chat = u.(chat.MessageListCmp)
410 cmds = append(cmds, cmd)
411 case PanelTypeEditor:
412 u, cmd := p.editor.Update(msg)
413 p.editor = u.(editor.Editor)
414 cmds = append(cmds, cmd)
415 case PanelTypeSplash:
416 u, cmd := p.splash.Update(msg)
417 p.splash = u.(splash.Splash)
418 cmds = append(cmds, cmd)
419 }
420 case tea.PasteMsg:
421 switch p.focusedPane {
422 case PanelTypeEditor:
423 u, cmd := p.editor.Update(msg)
424 p.editor = u.(editor.Editor)
425 cmds = append(cmds, cmd)
426 return p, tea.Batch(cmds...)
427 case PanelTypeChat:
428 u, cmd := p.chat.Update(msg)
429 p.chat = u.(chat.MessageListCmp)
430 cmds = append(cmds, cmd)
431 return p, tea.Batch(cmds...)
432 case PanelTypeSplash:
433 u, cmd := p.splash.Update(msg)
434 p.splash = u.(splash.Splash)
435 cmds = append(cmds, cmd)
436 return p, tea.Batch(cmds...)
437 }
438 }
439 return p, tea.Batch(cmds...)
440}
441
442func (p *chatPage) Cursor() *tea.Cursor {
443 if p.header.ShowingDetails() {
444 return nil
445 }
446 switch p.focusedPane {
447 case PanelTypeEditor:
448 return p.editor.Cursor()
449 case PanelTypeSplash:
450 return p.splash.Cursor()
451 default:
452 return nil
453 }
454}
455
456func (p *chatPage) View() string {
457 var chatView string
458 t := styles.CurrentTheme()
459
460 if p.session.ID == "" {
461 splashView := p.splash.View()
462 // Full screen during onboarding or project initialization
463 if p.splashFullScreen {
464 chatView = splashView
465 } else {
466 // Show splash + editor for new message state
467 editorView := p.editor.View()
468 chatView = lipgloss.JoinVertical(
469 lipgloss.Left,
470 t.S().Base.Render(splashView),
471 editorView,
472 )
473 }
474 } else {
475 messagesView := p.chat.View()
476 editorView := p.editor.View()
477 if p.compact {
478 headerView := p.header.View()
479 chatView = lipgloss.JoinVertical(
480 lipgloss.Left,
481 headerView,
482 messagesView,
483 editorView,
484 )
485 } else {
486 sidebarView := p.sidebar.View()
487 messages := lipgloss.JoinHorizontal(
488 lipgloss.Left,
489 messagesView,
490 sidebarView,
491 )
492 chatView = lipgloss.JoinVertical(
493 lipgloss.Left,
494 messages,
495 p.editor.View(),
496 )
497 }
498 }
499
500 layers := []*lipgloss.Layer{
501 lipgloss.NewLayer(chatView).X(0).Y(0),
502 }
503
504 if p.showingDetails {
505 style := t.S().Base.
506 Width(p.detailsWidth).
507 Border(lipgloss.RoundedBorder()).
508 BorderForeground(t.BorderFocus)
509 version := t.S().Base.Foreground(t.Border).Width(p.detailsWidth - 4).AlignHorizontal(lipgloss.Right).Render(version.Version)
510 details := style.Render(
511 lipgloss.JoinVertical(
512 lipgloss.Left,
513 p.sidebar.View(),
514 version,
515 ),
516 )
517 layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
518 }
519 canvas := lipgloss.NewCanvas(
520 layers...,
521 )
522 return canvas.Render()
523}
524
525func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
526 return func() tea.Msg {
527 err := config.Get().SetCompactMode(compact)
528 if err != nil {
529 return util.InfoMsg{
530 Type: util.InfoTypeError,
531 Msg: "Failed to update compact mode configuration: " + err.Error(),
532 }
533 }
534 return nil
535 }
536}
537
538func (p *chatPage) toggleThinking() tea.Cmd {
539 return func() tea.Msg {
540 cfg := config.Get()
541 agentCfg := cfg.Agents[config.AgentCoder]
542 currentModel := cfg.Models[agentCfg.Model]
543
544 // Toggle the thinking mode
545 currentModel.Think = !currentModel.Think
546 if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
547 return util.InfoMsg{
548 Type: util.InfoTypeError,
549 Msg: "Failed to update thinking mode: " + err.Error(),
550 }
551 }
552
553 // Update the agent with the new configuration
554 go p.app.UpdateAgentModel(context.TODO())
555
556 status := "disabled"
557 if currentModel.Think {
558 status = "enabled"
559 }
560 return util.InfoMsg{
561 Type: util.InfoTypeInfo,
562 Msg: "Thinking mode " + status,
563 }
564 }
565}
566
567func (p *chatPage) openReasoningDialog() tea.Cmd {
568 return func() tea.Msg {
569 cfg := config.Get()
570 agentCfg := cfg.Agents[config.AgentCoder]
571 model := cfg.GetModelByType(agentCfg.Model)
572 providerCfg := cfg.GetProviderForModel(agentCfg.Model)
573
574 if providerCfg != nil && model != nil && len(model.ReasoningLevels) > 0 {
575 // Return the OpenDialogMsg directly so it bubbles up to the main TUI
576 return dialogs.OpenDialogMsg{
577 Model: reasoning.NewReasoningDialog(),
578 }
579 }
580 return nil
581 }
582}
583
584func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
585 return func() tea.Msg {
586 cfg := config.Get()
587 agentCfg := cfg.Agents[config.AgentCoder]
588 currentModel := cfg.Models[agentCfg.Model]
589
590 // Update the model configuration
591 currentModel.ReasoningEffort = effort
592 if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
593 return util.InfoMsg{
594 Type: util.InfoTypeError,
595 Msg: "Failed to update reasoning effort: " + err.Error(),
596 }
597 }
598
599 // Update the agent with the new configuration
600 if err := p.app.UpdateAgentModel(context.TODO()); err != nil {
601 return util.InfoMsg{
602 Type: util.InfoTypeError,
603 Msg: "Failed to update reasoning effort: " + err.Error(),
604 }
605 }
606
607 return util.InfoMsg{
608 Type: util.InfoTypeInfo,
609 Msg: "Reasoning effort set to " + effort,
610 }
611 }
612}
613
614func (p *chatPage) setCompactMode(compact bool) {
615 if p.compact == compact {
616 return
617 }
618 p.compact = compact
619 if compact {
620 p.sidebar.SetCompactMode(true)
621 } else {
622 p.setShowDetails(false)
623 }
624}
625
626func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
627 if p.forceCompact {
628 return
629 }
630 if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
631 p.setCompactMode(true)
632 }
633 if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
634 p.setCompactMode(false)
635 }
636}
637
638func (p *chatPage) SetSize(width, height int) tea.Cmd {
639 p.handleCompactMode(width, height)
640 p.width = width
641 p.height = height
642 var cmds []tea.Cmd
643
644 if p.session.ID == "" {
645 if p.splashFullScreen {
646 cmds = append(cmds, p.splash.SetSize(width, height))
647 } else {
648 cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
649 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
650 cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
651 }
652 } else {
653 if p.compact {
654 cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
655 p.detailsWidth = width - DetailsPositioning
656 cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
657 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
658 cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
659 } else {
660 cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
661 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
662 cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
663 }
664 cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
665 }
666 return tea.Batch(cmds...)
667}
668
669func (p *chatPage) newSession() tea.Cmd {
670 if p.session.ID == "" {
671 return nil
672 }
673
674 p.session = session.Session{}
675 p.focusedPane = PanelTypeEditor
676 p.editor.Focus()
677 p.chat.Blur()
678 p.isCanceling = false
679 return tea.Batch(
680 util.CmdHandler(chat.SessionClearedMsg{}),
681 p.SetSize(p.width, p.height),
682 )
683}
684
685func (p *chatPage) setSession(session session.Session) tea.Cmd {
686 if p.session.ID == session.ID {
687 return nil
688 }
689
690 var cmds []tea.Cmd
691 p.session = session
692
693 cmds = append(cmds, p.SetSize(p.width, p.height))
694 cmds = append(cmds, p.chat.SetSession(session))
695 cmds = append(cmds, p.sidebar.SetSession(session))
696 cmds = append(cmds, p.header.SetSession(session))
697 cmds = append(cmds, p.editor.SetSession(session))
698
699 return tea.Sequence(cmds...)
700}
701
702func (p *chatPage) changeFocus() {
703 if p.session.ID == "" {
704 return
705 }
706 switch p.focusedPane {
707 case PanelTypeChat:
708 p.focusedPane = PanelTypeEditor
709 p.editor.Focus()
710 p.chat.Blur()
711 case PanelTypeEditor:
712 p.focusedPane = PanelTypeChat
713 p.chat.Focus()
714 p.editor.Blur()
715 }
716}
717
718func (p *chatPage) cancel() tea.Cmd {
719 if p.isCanceling {
720 p.isCanceling = false
721 if p.app.AgentCoordinator != nil {
722 p.app.AgentCoordinator.Cancel(p.session.ID)
723 }
724 return nil
725 }
726
727 if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
728 p.app.AgentCoordinator.ClearQueue(p.session.ID)
729 return nil
730 }
731 p.isCanceling = true
732 return cancelTimerCmd()
733}
734
735func (p *chatPage) setShowDetails(show bool) {
736 p.showingDetails = show
737 p.header.SetDetailsOpen(p.showingDetails)
738 if !p.compact {
739 p.sidebar.SetCompactMode(false)
740 }
741}
742
743func (p *chatPage) toggleDetails() {
744 if p.session.ID == "" || !p.compact {
745 return
746 }
747 p.setShowDetails(!p.showingDetails)
748}
749
750func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
751 session := p.session
752 var cmds []tea.Cmd
753 if p.session.ID == "" {
754 newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
755 if err != nil {
756 return util.ReportError(err)
757 }
758 session = newSession
759 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
760 }
761 if p.app.AgentCoordinator == nil {
762 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
763 }
764 cmds = append(cmds, p.chat.GoToBottom())
765 cmds = append(cmds, func() tea.Msg {
766 _, err := p.app.AgentCoordinator.Run(context.Background(), session.ID, text, attachments...)
767 if err != nil {
768 isCancelErr := errors.Is(err, context.Canceled)
769 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
770 if isCancelErr || isPermissionErr {
771 return nil
772 }
773 return util.InfoMsg{
774 Type: util.InfoTypeError,
775 Msg: err.Error(),
776 }
777 }
778 return nil
779 })
780 return tea.Batch(cmds...)
781}
782
783func (p *chatPage) Bindings() []key.Binding {
784 bindings := []key.Binding{
785 p.keyMap.NewSession,
786 p.keyMap.AddAttachment,
787 }
788 if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
789 cancelBinding := p.keyMap.Cancel
790 if p.isCanceling {
791 cancelBinding = key.NewBinding(
792 key.WithKeys("esc", "alt+esc"),
793 key.WithHelp("esc", "press again to cancel"),
794 )
795 }
796 bindings = append([]key.Binding{cancelBinding}, bindings...)
797 }
798
799 switch p.focusedPane {
800 case PanelTypeChat:
801 bindings = append([]key.Binding{
802 key.NewBinding(
803 key.WithKeys("tab"),
804 key.WithHelp("tab", "focus editor"),
805 ),
806 }, bindings...)
807 bindings = append(bindings, p.chat.Bindings()...)
808 case PanelTypeEditor:
809 bindings = append([]key.Binding{
810 key.NewBinding(
811 key.WithKeys("tab"),
812 key.WithHelp("tab", "focus chat"),
813 ),
814 }, bindings...)
815 bindings = append(bindings, p.editor.Bindings()...)
816 case PanelTypeSplash:
817 bindings = append(bindings, p.splash.Bindings()...)
818 }
819
820 return bindings
821}
822
823func (p *chatPage) Help() help.KeyMap {
824 var shortList []key.Binding
825 var fullList [][]key.Binding
826 switch {
827 case p.isOnboarding && p.splash.IsShowingClaudeAuthMethodChooser():
828 shortList = append(shortList,
829 // Choose auth method
830 key.NewBinding(
831 key.WithKeys("left", "right", "tab"),
832 key.WithHelp("←→/tab", "choose"),
833 ),
834 // Accept selection
835 key.NewBinding(
836 key.WithKeys("enter"),
837 key.WithHelp("enter", "accept"),
838 ),
839 // Go back
840 key.NewBinding(
841 key.WithKeys("esc", "alt+esc"),
842 key.WithHelp("esc", "back"),
843 ),
844 // Quit
845 key.NewBinding(
846 key.WithKeys("ctrl+c"),
847 key.WithHelp("ctrl+c", "quit"),
848 ),
849 )
850 // keep them the same
851 for _, v := range shortList {
852 fullList = append(fullList, []key.Binding{v})
853 }
854 case p.isOnboarding && p.splash.IsShowingClaudeOAuth2():
855 if p.splash.IsClaudeOAuthURLState() {
856 shortList = append(shortList,
857 key.NewBinding(
858 key.WithKeys("enter"),
859 key.WithHelp("enter", "open"),
860 ),
861 key.NewBinding(
862 key.WithKeys("c"),
863 key.WithHelp("c", "copy url"),
864 ),
865 )
866 } else if p.splash.IsClaudeOAuthComplete() {
867 shortList = append(shortList,
868 key.NewBinding(
869 key.WithKeys("enter"),
870 key.WithHelp("enter", "continue"),
871 ),
872 )
873 } else {
874 shortList = append(shortList,
875 key.NewBinding(
876 key.WithKeys("enter"),
877 key.WithHelp("enter", "submit"),
878 ),
879 )
880 }
881 shortList = append(shortList,
882 // Quit
883 key.NewBinding(
884 key.WithKeys("ctrl+c"),
885 key.WithHelp("ctrl+c", "quit"),
886 ),
887 )
888 // keep them the same
889 for _, v := range shortList {
890 fullList = append(fullList, []key.Binding{v})
891 }
892 case p.isOnboarding && !p.splash.IsShowingAPIKey():
893 shortList = append(shortList,
894 // Choose model
895 key.NewBinding(
896 key.WithKeys("up", "down"),
897 key.WithHelp("↑/↓", "choose"),
898 ),
899 // Accept selection
900 key.NewBinding(
901 key.WithKeys("enter", "ctrl+y"),
902 key.WithHelp("enter", "accept"),
903 ),
904 // Quit
905 key.NewBinding(
906 key.WithKeys("ctrl+c"),
907 key.WithHelp("ctrl+c", "quit"),
908 ),
909 )
910 // keep them the same
911 for _, v := range shortList {
912 fullList = append(fullList, []key.Binding{v})
913 }
914 case p.isOnboarding && p.splash.IsShowingAPIKey():
915 if p.splash.IsAPIKeyValid() {
916 shortList = append(shortList,
917 key.NewBinding(
918 key.WithKeys("enter"),
919 key.WithHelp("enter", "continue"),
920 ),
921 )
922 } else {
923 shortList = append(shortList,
924 // Go back
925 key.NewBinding(
926 key.WithKeys("esc", "alt+esc"),
927 key.WithHelp("esc", "back"),
928 ),
929 )
930 }
931 shortList = append(shortList,
932 // Quit
933 key.NewBinding(
934 key.WithKeys("ctrl+c"),
935 key.WithHelp("ctrl+c", "quit"),
936 ),
937 )
938 // keep them the same
939 for _, v := range shortList {
940 fullList = append(fullList, []key.Binding{v})
941 }
942 case p.isProjectInit:
943 shortList = append(shortList,
944 key.NewBinding(
945 key.WithKeys("ctrl+c"),
946 key.WithHelp("ctrl+c", "quit"),
947 ),
948 )
949 // keep them the same
950 for _, v := range shortList {
951 fullList = append(fullList, []key.Binding{v})
952 }
953 default:
954 if p.editor.IsCompletionsOpen() {
955 shortList = append(shortList,
956 key.NewBinding(
957 key.WithKeys("tab", "enter"),
958 key.WithHelp("tab/enter", "complete"),
959 ),
960 key.NewBinding(
961 key.WithKeys("esc", "alt+esc"),
962 key.WithHelp("esc", "cancel"),
963 ),
964 key.NewBinding(
965 key.WithKeys("up", "down"),
966 key.WithHelp("↑/↓", "choose"),
967 ),
968 )
969 for _, v := range shortList {
970 fullList = append(fullList, []key.Binding{v})
971 }
972 return core.NewSimpleHelp(shortList, fullList)
973 }
974 if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
975 cancelBinding := key.NewBinding(
976 key.WithKeys("esc", "alt+esc"),
977 key.WithHelp("esc", "cancel"),
978 )
979 if p.isCanceling {
980 cancelBinding = key.NewBinding(
981 key.WithKeys("esc", "alt+esc"),
982 key.WithHelp("esc", "press again to cancel"),
983 )
984 }
985 if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
986 cancelBinding = key.NewBinding(
987 key.WithKeys("esc", "alt+esc"),
988 key.WithHelp("esc", "clear queue"),
989 )
990 }
991 shortList = append(shortList, cancelBinding)
992 fullList = append(fullList,
993 []key.Binding{
994 cancelBinding,
995 },
996 )
997 }
998 globalBindings := []key.Binding{}
999 // we are in a session
1000 if p.session.ID != "" {
1001 tabKey := key.NewBinding(
1002 key.WithKeys("tab"),
1003 key.WithHelp("tab", "focus chat"),
1004 )
1005 if p.focusedPane == PanelTypeChat {
1006 tabKey = key.NewBinding(
1007 key.WithKeys("tab"),
1008 key.WithHelp("tab", "focus editor"),
1009 )
1010 }
1011 shortList = append(shortList, tabKey)
1012 globalBindings = append(globalBindings, tabKey)
1013 }
1014 commandsBinding := key.NewBinding(
1015 key.WithKeys("ctrl+p"),
1016 key.WithHelp("ctrl+p", "commands"),
1017 )
1018 if p.focusedPane == PanelTypeEditor && p.editor.IsEmpty() {
1019 commandsBinding.SetHelp("/ or ctrl+p", "commands")
1020 }
1021 modelsBinding := key.NewBinding(
1022 key.WithKeys("ctrl+m", "ctrl+l"),
1023 key.WithHelp("ctrl+l", "models"),
1024 )
1025 if p.keyboardEnhancements.Flags > 0 {
1026 // non-zero flags mean we have at least key disambiguation
1027 modelsBinding.SetHelp("ctrl+m", "models")
1028 }
1029 helpBinding := key.NewBinding(
1030 key.WithKeys("ctrl+g"),
1031 key.WithHelp("ctrl+g", "more"),
1032 )
1033 globalBindings = append(globalBindings, commandsBinding, modelsBinding)
1034 globalBindings = append(globalBindings,
1035 key.NewBinding(
1036 key.WithKeys("ctrl+s"),
1037 key.WithHelp("ctrl+s", "sessions"),
1038 ),
1039 )
1040 if p.session.ID != "" {
1041 globalBindings = append(globalBindings,
1042 key.NewBinding(
1043 key.WithKeys("ctrl+n"),
1044 key.WithHelp("ctrl+n", "new sessions"),
1045 ))
1046 }
1047 shortList = append(shortList,
1048 // Commands
1049 commandsBinding,
1050 modelsBinding,
1051 )
1052 fullList = append(fullList, globalBindings)
1053
1054 switch p.focusedPane {
1055 case PanelTypeChat:
1056 shortList = append(shortList,
1057 key.NewBinding(
1058 key.WithKeys("up", "down"),
1059 key.WithHelp("↑↓", "scroll"),
1060 ),
1061 messages.CopyKey,
1062 )
1063 fullList = append(fullList,
1064 []key.Binding{
1065 key.NewBinding(
1066 key.WithKeys("up", "down"),
1067 key.WithHelp("↑↓", "scroll"),
1068 ),
1069 key.NewBinding(
1070 key.WithKeys("shift+up", "shift+down"),
1071 key.WithHelp("shift+↑↓", "next/prev item"),
1072 ),
1073 key.NewBinding(
1074 key.WithKeys("pgup", "b"),
1075 key.WithHelp("b/pgup", "page up"),
1076 ),
1077 key.NewBinding(
1078 key.WithKeys("pgdown", " ", "f"),
1079 key.WithHelp("f/pgdn", "page down"),
1080 ),
1081 },
1082 []key.Binding{
1083 key.NewBinding(
1084 key.WithKeys("u"),
1085 key.WithHelp("u", "half page up"),
1086 ),
1087 key.NewBinding(
1088 key.WithKeys("d"),
1089 key.WithHelp("d", "half page down"),
1090 ),
1091 key.NewBinding(
1092 key.WithKeys("g", "home"),
1093 key.WithHelp("g", "home"),
1094 ),
1095 key.NewBinding(
1096 key.WithKeys("G", "end"),
1097 key.WithHelp("G", "end"),
1098 ),
1099 },
1100 []key.Binding{
1101 messages.CopyKey,
1102 messages.ClearSelectionKey,
1103 },
1104 )
1105 case PanelTypeEditor:
1106 newLineBinding := key.NewBinding(
1107 key.WithKeys("shift+enter", "ctrl+j"),
1108 // "ctrl+j" is a common keybinding for newline in many editors. If
1109 // the terminal supports "shift+enter", we substitute the help text
1110 // to reflect that.
1111 key.WithHelp("ctrl+j", "newline"),
1112 )
1113 if p.keyboardEnhancements.Flags > 0 {
1114 // Non-zero flags mean we have at least key disambiguation.
1115 newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
1116 }
1117 shortList = append(shortList, newLineBinding)
1118 fullList = append(fullList,
1119 []key.Binding{
1120 newLineBinding,
1121 key.NewBinding(
1122 key.WithKeys("ctrl+f"),
1123 key.WithHelp("ctrl+f", "add image"),
1124 ),
1125 key.NewBinding(
1126 key.WithKeys("@"),
1127 key.WithHelp("@", "mention file"),
1128 ),
1129 key.NewBinding(
1130 key.WithKeys("ctrl+o"),
1131 key.WithHelp("ctrl+o", "open editor"),
1132 ),
1133 })
1134
1135 if p.editor.HasAttachments() {
1136 fullList = append(fullList, []key.Binding{
1137 key.NewBinding(
1138 key.WithKeys("ctrl+r"),
1139 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
1140 ),
1141 key.NewBinding(
1142 key.WithKeys("ctrl+r", "r"),
1143 key.WithHelp("ctrl+r+r", "delete all attachments"),
1144 ),
1145 key.NewBinding(
1146 key.WithKeys("esc", "alt+esc"),
1147 key.WithHelp("esc", "cancel delete mode"),
1148 ),
1149 })
1150 }
1151 }
1152 shortList = append(shortList,
1153 // Quit
1154 key.NewBinding(
1155 key.WithKeys("ctrl+c"),
1156 key.WithHelp("ctrl+c", "quit"),
1157 ),
1158 // Help
1159 helpBinding,
1160 )
1161 fullList = append(fullList, []key.Binding{
1162 key.NewBinding(
1163 key.WithKeys("ctrl+g"),
1164 key.WithHelp("ctrl+g", "less"),
1165 ),
1166 })
1167 }
1168
1169 return core.NewSimpleHelp(shortList, fullList)
1170}
1171
1172func (p *chatPage) IsChatFocused() bool {
1173 return p.focusedPane == PanelTypeChat
1174}
1175
1176// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
1177// Returns true if the mouse is over the chat area, false otherwise.
1178func (p *chatPage) isMouseOverChat(x, y int) bool {
1179 // No session means no chat area
1180 if p.session.ID == "" {
1181 return false
1182 }
1183
1184 var chatX, chatY, chatWidth, chatHeight int
1185
1186 if p.compact {
1187 // In compact mode: chat area starts after header and spans full width
1188 chatX = 0
1189 chatY = HeaderHeight
1190 chatWidth = p.width
1191 chatHeight = p.height - EditorHeight - HeaderHeight
1192 } else {
1193 // In non-compact mode: chat area spans from left edge to sidebar
1194 chatX = 0
1195 chatY = 0
1196 chatWidth = p.width - SideBarWidth
1197 chatHeight = p.height - EditorHeight
1198 }
1199
1200 // Check if mouse coordinates are within chat bounds
1201 return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1202}