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