chat.go

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