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