chat.go

  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/page"
 29	"github.com/charmbracelet/crush/internal/tui/styles"
 30	"github.com/charmbracelet/crush/internal/tui/util"
 31	"github.com/charmbracelet/crush/internal/version"
 32	"github.com/charmbracelet/lipgloss/v2"
 33)
 34
 35var ChatPageID page.PageID = "chat"
 36
 37type (
 38	OpenFilePickerMsg struct{}
 39	ChatFocusedMsg    struct {
 40		Focused bool
 41	}
 42	CancelTimerExpiredMsg struct{}
 43)
 44
 45type PanelType string
 46
 47const (
 48	PanelTypeChat   PanelType = "chat"
 49	PanelTypeEditor PanelType = "editor"
 50	PanelTypeSplash PanelType = "splash"
 51)
 52
 53const (
 54	CompactModeWidthBreakpoint  = 120 // Width at which the chat page switches to compact mode
 55	CompactModeHeightBreakpoint = 30  // Height 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 >= CompactModeWidthBreakpoint && p.height >= CompactModeHeightBreakpoint {
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.toggleDetails()
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	if p.header.ShowingDetails() {
318		return nil
319	}
320	switch p.focusedPane {
321	case PanelTypeEditor:
322		return p.editor.Cursor()
323	case PanelTypeSplash:
324		return p.splash.Cursor()
325	default:
326		return nil
327	}
328}
329
330func (p *chatPage) View() string {
331	var chatView string
332	t := styles.CurrentTheme()
333
334	if p.session.ID == "" {
335		splashView := p.splash.View()
336		// Full screen during onboarding or project initialization
337		if p.splashFullScreen {
338			chatView = splashView
339		} else {
340			// Show splash + editor for new message state
341			editorView := p.editor.View()
342			chatView = lipgloss.JoinVertical(
343				lipgloss.Left,
344				t.S().Base.Render(splashView),
345				editorView,
346			)
347		}
348	} else {
349		messagesView := p.chat.View()
350		editorView := p.editor.View()
351		if p.compact {
352			headerView := p.header.View()
353			chatView = lipgloss.JoinVertical(
354				lipgloss.Left,
355				headerView,
356				messagesView,
357				editorView,
358			)
359		} else {
360			sidebarView := p.sidebar.View()
361			messages := lipgloss.JoinHorizontal(
362				lipgloss.Left,
363				messagesView,
364				sidebarView,
365			)
366			chatView = lipgloss.JoinVertical(
367				lipgloss.Left,
368				messages,
369				p.editor.View(),
370			)
371		}
372	}
373
374	layers := []*lipgloss.Layer{
375		lipgloss.NewLayer(chatView).X(0).Y(0),
376	}
377
378	if p.showingDetails {
379		style := t.S().Base.
380			Width(p.detailsWidth).
381			Border(lipgloss.RoundedBorder()).
382			BorderForeground(t.BorderFocus)
383		version := t.S().Subtle.Width(p.detailsWidth - 2).AlignHorizontal(lipgloss.Right).Render(version.Version)
384		details := style.Render(
385			lipgloss.JoinVertical(
386				lipgloss.Left,
387				p.sidebar.View(),
388				version,
389			),
390		)
391		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
392	}
393	canvas := lipgloss.NewCanvas(
394		layers...,
395	)
396	return canvas.Render()
397}
398
399func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
400	return func() tea.Msg {
401		err := config.Get().SetCompactMode(compact)
402		if err != nil {
403			return util.InfoMsg{
404				Type: util.InfoTypeError,
405				Msg:  "Failed to update compact mode configuration: " + err.Error(),
406			}
407		}
408		return nil
409	}
410}
411
412func (p *chatPage) setCompactMode(compact bool) {
413	if p.compact == compact {
414		return
415	}
416	p.compact = compact
417	if compact {
418		p.sidebar.SetCompactMode(true)
419	} else {
420		p.setShowDetails(false)
421	}
422}
423
424func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
425	if p.forceCompact {
426		return
427	}
428	if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
429		p.setCompactMode(true)
430	}
431	if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
432		p.setCompactMode(false)
433	}
434}
435
436func (p *chatPage) SetSize(width, height int) tea.Cmd {
437	p.handleCompactMode(width, height)
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) setShowDetails(show bool) {
526	p.showingDetails = show
527	p.header.SetDetailsOpen(p.showingDetails)
528	if !p.compact {
529		p.sidebar.SetCompactMode(false)
530	}
531}
532
533func (p *chatPage) toggleDetails() {
534	if p.session.ID == "" || !p.compact {
535		return
536	}
537	p.setShowDetails(!p.showingDetails)
538}
539
540func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
541	session := p.session
542	var cmds []tea.Cmd
543	if p.session.ID == "" {
544		newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
545		if err != nil {
546			return util.ReportError(err)
547		}
548		session = newSession
549		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
550	}
551	_, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
552	if err != nil {
553		return util.ReportError(err)
554	}
555	return tea.Batch(cmds...)
556}
557
558func (p *chatPage) Bindings() []key.Binding {
559	bindings := []key.Binding{
560		p.keyMap.NewSession,
561		p.keyMap.AddAttachment,
562	}
563	if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
564		cancelBinding := p.keyMap.Cancel
565		if p.isCanceling {
566			cancelBinding = key.NewBinding(
567				key.WithKeys("esc"),
568				key.WithHelp("esc", "press again to cancel"),
569			)
570		}
571		bindings = append([]key.Binding{cancelBinding}, bindings...)
572	}
573
574	switch p.focusedPane {
575	case PanelTypeChat:
576		bindings = append([]key.Binding{
577			key.NewBinding(
578				key.WithKeys("tab"),
579				key.WithHelp("tab", "focus editor"),
580			),
581		}, bindings...)
582		bindings = append(bindings, p.chat.Bindings()...)
583	case PanelTypeEditor:
584		bindings = append([]key.Binding{
585			key.NewBinding(
586				key.WithKeys("tab"),
587				key.WithHelp("tab", "focus chat"),
588			),
589		}, bindings...)
590		bindings = append(bindings, p.editor.Bindings()...)
591	case PanelTypeSplash:
592		bindings = append(bindings, p.splash.Bindings()...)
593	}
594
595	return bindings
596}
597
598func (p *chatPage) Help() help.KeyMap {
599	var shortList []key.Binding
600	var fullList [][]key.Binding
601	switch {
602	case p.isOnboarding && !p.splash.IsShowingAPIKey():
603		shortList = append(shortList,
604			// Choose model
605			key.NewBinding(
606				key.WithKeys("up", "down"),
607				key.WithHelp("↑/↓", "choose"),
608			),
609			// Accept selection
610			key.NewBinding(
611				key.WithKeys("enter", "ctrl+y"),
612				key.WithHelp("enter", "accept"),
613			),
614			// Quit
615			key.NewBinding(
616				key.WithKeys("ctrl+c"),
617				key.WithHelp("ctrl+c", "quit"),
618			),
619		)
620		// keep them the same
621		for _, v := range shortList {
622			fullList = append(fullList, []key.Binding{v})
623		}
624	case p.isOnboarding && p.splash.IsShowingAPIKey():
625		shortList = append(shortList,
626			// Go back
627			key.NewBinding(
628				key.WithKeys("esc"),
629				key.WithHelp("esc", "back"),
630			),
631			// Quit
632			key.NewBinding(
633				key.WithKeys("ctrl+c"),
634				key.WithHelp("ctrl+c", "quit"),
635			),
636		)
637		// keep them the same
638		for _, v := range shortList {
639			fullList = append(fullList, []key.Binding{v})
640		}
641	case p.isProjectInit:
642		shortList = append(shortList,
643			key.NewBinding(
644				key.WithKeys("ctrl+c"),
645				key.WithHelp("ctrl+c", "quit"),
646			),
647		)
648		// keep them the same
649		for _, v := range shortList {
650			fullList = append(fullList, []key.Binding{v})
651		}
652	default:
653		if p.editor.IsCompletionsOpen() {
654			shortList = append(shortList,
655				key.NewBinding(
656					key.WithKeys("tab", "enter"),
657					key.WithHelp("tab/enter", "complete"),
658				),
659				key.NewBinding(
660					key.WithKeys("esc"),
661					key.WithHelp("esc", "cancel"),
662				),
663				key.NewBinding(
664					key.WithKeys("up", "down"),
665					key.WithHelp("↑/↓", "choose"),
666				),
667			)
668			for _, v := range shortList {
669				fullList = append(fullList, []key.Binding{v})
670			}
671			return core.NewSimpleHelp(shortList, fullList)
672		}
673		if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
674			cancelBinding := key.NewBinding(
675				key.WithKeys("esc"),
676				key.WithHelp("esc", "cancel"),
677			)
678			if p.isCanceling {
679				cancelBinding = key.NewBinding(
680					key.WithKeys("esc"),
681					key.WithHelp("esc", "press again to cancel"),
682				)
683			}
684			shortList = append(shortList, cancelBinding)
685			fullList = append(fullList,
686				[]key.Binding{
687					cancelBinding,
688				},
689			)
690		}
691		globalBindings := []key.Binding{}
692		// we are in a session
693		if p.session.ID != "" {
694			tabKey := key.NewBinding(
695				key.WithKeys("tab"),
696				key.WithHelp("tab", "focus chat"),
697			)
698			if p.focusedPane == PanelTypeChat {
699				tabKey = key.NewBinding(
700					key.WithKeys("tab"),
701					key.WithHelp("tab", "focus editor"),
702				)
703			}
704			shortList = append(shortList, tabKey)
705			globalBindings = append(globalBindings, tabKey)
706		}
707		commandsBinding := key.NewBinding(
708			key.WithKeys("ctrl+p"),
709			key.WithHelp("ctrl+p", "commands"),
710		)
711		helpBinding := key.NewBinding(
712			key.WithKeys("ctrl+g"),
713			key.WithHelp("ctrl+g", "more"),
714		)
715		globalBindings = append(globalBindings, commandsBinding)
716		globalBindings = append(globalBindings,
717			key.NewBinding(
718				key.WithKeys("ctrl+s"),
719				key.WithHelp("ctrl+s", "sessions"),
720			),
721		)
722		if p.session.ID != "" {
723			globalBindings = append(globalBindings,
724				key.NewBinding(
725					key.WithKeys("ctrl+n"),
726					key.WithHelp("ctrl+n", "new sessions"),
727				))
728		}
729		shortList = append(shortList,
730			// Commands
731			commandsBinding,
732		)
733		fullList = append(fullList, globalBindings)
734
735		switch p.focusedPane {
736		case PanelTypeChat:
737			shortList = append(shortList,
738				key.NewBinding(
739					key.WithKeys("up", "down"),
740					key.WithHelp("↑↓", "scroll"),
741				),
742			)
743			fullList = append(fullList,
744				[]key.Binding{
745					key.NewBinding(
746						key.WithKeys("up", "down"),
747						key.WithHelp("↑↓", "scroll"),
748					),
749					key.NewBinding(
750						key.WithKeys("shift+up", "shift+down"),
751						key.WithHelp("shift+↑↓", "next/prev item"),
752					),
753					key.NewBinding(
754						key.WithKeys("pgup", "b"),
755						key.WithHelp("b/pgup", "page up"),
756					),
757					key.NewBinding(
758						key.WithKeys("pgdown", " ", "f"),
759						key.WithHelp("f/pgdn", "page down"),
760					),
761				},
762				[]key.Binding{
763					key.NewBinding(
764						key.WithKeys("u"),
765						key.WithHelp("u", "half page up"),
766					),
767					key.NewBinding(
768						key.WithKeys("d"),
769						key.WithHelp("d", "half page down"),
770					),
771					key.NewBinding(
772						key.WithKeys("g", "home"),
773						key.WithHelp("g", "hone"),
774					),
775					key.NewBinding(
776						key.WithKeys("G", "end"),
777						key.WithHelp("G", "end"),
778					),
779				},
780			)
781		case PanelTypeEditor:
782			newLineBinding := key.NewBinding(
783				key.WithKeys("shift+enter", "ctrl+j"),
784				// "ctrl+j" is a common keybinding for newline in many editors. If
785				// the terminal supports "shift+enter", we substitute the help text
786				// to reflect that.
787				key.WithHelp("ctrl+j", "newline"),
788			)
789			if p.keyboardEnhancements.SupportsKeyDisambiguation() {
790				newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
791			}
792			shortList = append(shortList, newLineBinding)
793			fullList = append(fullList,
794				[]key.Binding{
795					newLineBinding,
796					key.NewBinding(
797						key.WithKeys("ctrl+f"),
798						key.WithHelp("ctrl+f", "add image"),
799					),
800					key.NewBinding(
801						key.WithKeys("/"),
802						key.WithHelp("/", "add file"),
803					),
804					key.NewBinding(
805						key.WithKeys("ctrl+v"),
806						key.WithHelp("ctrl+v", "open editor"),
807					),
808				})
809		}
810		shortList = append(shortList,
811			// Quit
812			key.NewBinding(
813				key.WithKeys("ctrl+c"),
814				key.WithHelp("ctrl+c", "quit"),
815			),
816			// Help
817			helpBinding,
818		)
819		fullList = append(fullList, []key.Binding{
820			key.NewBinding(
821				key.WithKeys("ctrl+g"),
822				key.WithHelp("ctrl+g", "less"),
823			),
824		})
825	}
826
827	return core.NewSimpleHelp(shortList, fullList)
828}
829
830func (p *chatPage) IsChatFocused() bool {
831	return p.focusedPane == PanelTypeChat
832}