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