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