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