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 cmds = append(cmds, p.chat.GoToBottom())
608 return tea.Batch(cmds...)
609}
610
611func (p *chatPage) Bindings() []key.Binding {
612 bindings := []key.Binding{
613 p.keyMap.NewSession,
614 p.keyMap.AddAttachment,
615 }
616 if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
617 cancelBinding := p.keyMap.Cancel
618 if p.isCanceling {
619 cancelBinding = key.NewBinding(
620 key.WithKeys("esc"),
621 key.WithHelp("esc", "press again to cancel"),
622 )
623 }
624 bindings = append([]key.Binding{cancelBinding}, bindings...)
625 }
626
627 switch p.focusedPane {
628 case PanelTypeChat:
629 bindings = append([]key.Binding{
630 key.NewBinding(
631 key.WithKeys("tab"),
632 key.WithHelp("tab", "focus editor"),
633 ),
634 }, bindings...)
635 bindings = append(bindings, p.chat.Bindings()...)
636 case PanelTypeEditor:
637 bindings = append([]key.Binding{
638 key.NewBinding(
639 key.WithKeys("tab"),
640 key.WithHelp("tab", "focus chat"),
641 ),
642 }, bindings...)
643 bindings = append(bindings, p.editor.Bindings()...)
644 case PanelTypeSplash:
645 bindings = append(bindings, p.splash.Bindings()...)
646 }
647
648 return bindings
649}
650
651func (p *chatPage) Help() help.KeyMap {
652 var shortList []key.Binding
653 var fullList [][]key.Binding
654 switch {
655 case p.isOnboarding && !p.splash.IsShowingAPIKey():
656 shortList = append(shortList,
657 // Choose model
658 key.NewBinding(
659 key.WithKeys("up", "down"),
660 key.WithHelp("↑/↓", "choose"),
661 ),
662 // Accept selection
663 key.NewBinding(
664 key.WithKeys("enter", "ctrl+y"),
665 key.WithHelp("enter", "accept"),
666 ),
667 // Quit
668 key.NewBinding(
669 key.WithKeys("ctrl+c"),
670 key.WithHelp("ctrl+c", "quit"),
671 ),
672 )
673 // keep them the same
674 for _, v := range shortList {
675 fullList = append(fullList, []key.Binding{v})
676 }
677 case p.isOnboarding && p.splash.IsShowingAPIKey():
678 if p.splash.IsAPIKeyValid() {
679 shortList = append(shortList,
680 key.NewBinding(
681 key.WithKeys("enter"),
682 key.WithHelp("enter", "continue"),
683 ),
684 )
685 } else {
686 shortList = append(shortList,
687 // Go back
688 key.NewBinding(
689 key.WithKeys("esc"),
690 key.WithHelp("esc", "back"),
691 ),
692 )
693 }
694 shortList = append(shortList,
695 // Quit
696 key.NewBinding(
697 key.WithKeys("ctrl+c"),
698 key.WithHelp("ctrl+c", "quit"),
699 ),
700 )
701 // keep them the same
702 for _, v := range shortList {
703 fullList = append(fullList, []key.Binding{v})
704 }
705 case p.isProjectInit:
706 shortList = append(shortList,
707 key.NewBinding(
708 key.WithKeys("ctrl+c"),
709 key.WithHelp("ctrl+c", "quit"),
710 ),
711 )
712 // keep them the same
713 for _, v := range shortList {
714 fullList = append(fullList, []key.Binding{v})
715 }
716 default:
717 if p.editor.IsCompletionsOpen() {
718 shortList = append(shortList,
719 key.NewBinding(
720 key.WithKeys("tab", "enter"),
721 key.WithHelp("tab/enter", "complete"),
722 ),
723 key.NewBinding(
724 key.WithKeys("esc"),
725 key.WithHelp("esc", "cancel"),
726 ),
727 key.NewBinding(
728 key.WithKeys("up", "down"),
729 key.WithHelp("↑/↓", "choose"),
730 ),
731 )
732 for _, v := range shortList {
733 fullList = append(fullList, []key.Binding{v})
734 }
735 return core.NewSimpleHelp(shortList, fullList)
736 }
737 if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
738 cancelBinding := key.NewBinding(
739 key.WithKeys("esc"),
740 key.WithHelp("esc", "cancel"),
741 )
742 if p.isCanceling {
743 cancelBinding = key.NewBinding(
744 key.WithKeys("esc"),
745 key.WithHelp("esc", "press again to cancel"),
746 )
747 }
748 shortList = append(shortList, cancelBinding)
749 fullList = append(fullList,
750 []key.Binding{
751 cancelBinding,
752 },
753 )
754 }
755 globalBindings := []key.Binding{}
756 // we are in a session
757 if p.session.ID != "" {
758 tabKey := key.NewBinding(
759 key.WithKeys("tab"),
760 key.WithHelp("tab", "focus chat"),
761 )
762 if p.focusedPane == PanelTypeChat {
763 tabKey = key.NewBinding(
764 key.WithKeys("tab"),
765 key.WithHelp("tab", "focus editor"),
766 )
767 }
768 shortList = append(shortList, tabKey)
769 globalBindings = append(globalBindings, tabKey)
770 }
771 commandsBinding := key.NewBinding(
772 key.WithKeys("ctrl+p"),
773 key.WithHelp("ctrl+p", "commands"),
774 )
775 helpBinding := key.NewBinding(
776 key.WithKeys("ctrl+g"),
777 key.WithHelp("ctrl+g", "more"),
778 )
779 globalBindings = append(globalBindings, commandsBinding)
780 globalBindings = append(globalBindings,
781 key.NewBinding(
782 key.WithKeys("ctrl+s"),
783 key.WithHelp("ctrl+s", "sessions"),
784 ),
785 )
786 if p.session.ID != "" {
787 globalBindings = append(globalBindings,
788 key.NewBinding(
789 key.WithKeys("ctrl+n"),
790 key.WithHelp("ctrl+n", "new sessions"),
791 ))
792 }
793 shortList = append(shortList,
794 // Commands
795 commandsBinding,
796 )
797 fullList = append(fullList, globalBindings)
798
799 switch p.focusedPane {
800 case PanelTypeChat:
801 shortList = append(shortList,
802 key.NewBinding(
803 key.WithKeys("up", "down"),
804 key.WithHelp("↑↓", "scroll"),
805 ),
806 )
807 fullList = append(fullList,
808 []key.Binding{
809 key.NewBinding(
810 key.WithKeys("up", "down"),
811 key.WithHelp("↑↓", "scroll"),
812 ),
813 key.NewBinding(
814 key.WithKeys("shift+up", "shift+down"),
815 key.WithHelp("shift+↑↓", "next/prev item"),
816 ),
817 key.NewBinding(
818 key.WithKeys("pgup", "b"),
819 key.WithHelp("b/pgup", "page up"),
820 ),
821 key.NewBinding(
822 key.WithKeys("pgdown", " ", "f"),
823 key.WithHelp("f/pgdn", "page down"),
824 ),
825 },
826 []key.Binding{
827 key.NewBinding(
828 key.WithKeys("u"),
829 key.WithHelp("u", "half page up"),
830 ),
831 key.NewBinding(
832 key.WithKeys("d"),
833 key.WithHelp("d", "half page down"),
834 ),
835 key.NewBinding(
836 key.WithKeys("g", "home"),
837 key.WithHelp("g", "hone"),
838 ),
839 key.NewBinding(
840 key.WithKeys("G", "end"),
841 key.WithHelp("G", "end"),
842 ),
843 },
844 )
845 case PanelTypeEditor:
846 newLineBinding := key.NewBinding(
847 key.WithKeys("shift+enter", "ctrl+j"),
848 // "ctrl+j" is a common keybinding for newline in many editors. If
849 // the terminal supports "shift+enter", we substitute the help text
850 // to reflect that.
851 key.WithHelp("ctrl+j", "newline"),
852 )
853 if p.keyboardEnhancements.SupportsKeyDisambiguation() {
854 newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
855 }
856 shortList = append(shortList, newLineBinding)
857 fullList = append(fullList,
858 []key.Binding{
859 newLineBinding,
860 key.NewBinding(
861 key.WithKeys("ctrl+f"),
862 key.WithHelp("ctrl+f", "add image"),
863 ),
864 key.NewBinding(
865 key.WithKeys("/"),
866 key.WithHelp("/", "add file"),
867 ),
868 key.NewBinding(
869 key.WithKeys("ctrl+v"),
870 key.WithHelp("ctrl+v", "open editor"),
871 ),
872 key.NewBinding(
873 key.WithKeys("ctrl+r"),
874 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
875 ),
876 key.NewBinding(
877 key.WithKeys("ctrl+r", "r"),
878 key.WithHelp("ctrl+r+r", "delete all attachments"),
879 ),
880 key.NewBinding(
881 key.WithKeys("esc"),
882 key.WithHelp("esc", "cancel delete mode"),
883 ),
884 })
885 }
886 shortList = append(shortList,
887 // Quit
888 key.NewBinding(
889 key.WithKeys("ctrl+c"),
890 key.WithHelp("ctrl+c", "quit"),
891 ),
892 // Help
893 helpBinding,
894 )
895 fullList = append(fullList, []key.Binding{
896 key.NewBinding(
897 key.WithKeys("ctrl+g"),
898 key.WithHelp("ctrl+g", "less"),
899 ),
900 })
901 }
902
903 return core.NewSimpleHelp(shortList, fullList)
904}
905
906func (p *chatPage) IsChatFocused() bool {
907 return p.focusedPane == PanelTypeChat
908}