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