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.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() {
661			cancelBinding := key.NewBinding(
662				key.WithKeys("esc"),
663				key.WithHelp("esc", "cancel"),
664			)
665			if a.isCanceling {
666				cancelBinding = key.NewBinding(
667					key.WithKeys("esc"),
668					key.WithHelp("esc", "press again to cancel"),
669				)
670			}
671			shortList = append(shortList, cancelBinding)
672			fullList = append(fullList,
673				[]key.Binding{
674					cancelBinding,
675				},
676			)
677		}
678		globalBindings := []key.Binding{}
679		// we are in a session
680		if a.session.ID != "" {
681			tabKey := key.NewBinding(
682				key.WithKeys("tab"),
683				key.WithHelp("tab", "focus chat"),
684			)
685			if a.focusedPane == PanelTypeChat {
686				tabKey = key.NewBinding(
687					key.WithKeys("tab"),
688					key.WithHelp("tab", "focus editor"),
689				)
690			}
691			shortList = append(shortList, tabKey)
692			globalBindings = append(globalBindings, tabKey)
693		}
694		commandsBinding := key.NewBinding(
695			key.WithKeys("ctrl+p"),
696			key.WithHelp("ctrl+p", "commands"),
697		)
698		helpBinding := key.NewBinding(
699			key.WithKeys("ctrl+g"),
700			key.WithHelp("ctrl+g", "more"),
701		)
702		globalBindings = append(globalBindings, commandsBinding)
703		globalBindings = append(globalBindings,
704			key.NewBinding(
705				key.WithKeys("ctrl+s"),
706				key.WithHelp("ctrl+s", "sessions"),
707			),
708		)
709		if a.session.ID != "" {
710			globalBindings = append(globalBindings,
711				key.NewBinding(
712					key.WithKeys("ctrl+n"),
713					key.WithHelp("ctrl+n", "new sessions"),
714				))
715		}
716		shortList = append(shortList,
717			// Commands
718			commandsBinding,
719		)
720		fullList = append(fullList, globalBindings)
721
722		if a.focusedPane == PanelTypeChat {
723			shortList = append(shortList,
724				key.NewBinding(
725					key.WithKeys("up", "down"),
726					key.WithHelp("↑↓", "scroll"),
727				),
728			)
729			fullList = append(fullList,
730				[]key.Binding{
731					key.NewBinding(
732						key.WithKeys("up", "down"),
733						key.WithHelp("↑↓", "scroll"),
734					),
735					key.NewBinding(
736						key.WithKeys("shift+up", "shift+down"),
737						key.WithHelp("shift+↑↓", "next/prev item"),
738					),
739					key.NewBinding(
740						key.WithKeys("pgup", "b"),
741						key.WithHelp("b/pgup", "page up"),
742					),
743					key.NewBinding(
744						key.WithKeys("pgdown", " ", "f"),
745						key.WithHelp("f/pgdn", "page down"),
746					),
747				},
748				[]key.Binding{
749					key.NewBinding(
750						key.WithKeys("u"),
751						key.WithHelp("u", "half page up"),
752					),
753					key.NewBinding(
754						key.WithKeys("d"),
755						key.WithHelp("d", "half page down"),
756					),
757					key.NewBinding(
758						key.WithKeys("g", "home"),
759						key.WithHelp("g", "hone"),
760					),
761					key.NewBinding(
762						key.WithKeys("G", "end"),
763						key.WithHelp("G", "end"),
764					),
765				},
766			)
767		} else if a.focusedPane == PanelTypeEditor {
768			newLineBinding := key.NewBinding(
769				key.WithKeys("shift+enter", "ctrl+j"),
770				// "ctrl+j" is a common keybinding for newline in many editors. If
771				// the terminal supports "shift+enter", we substitute the help text
772				// to reflect that.
773				key.WithHelp("ctrl+j", "newline"),
774			)
775			if a.keyboardEnhancements.SupportsKeyDisambiguation() {
776				newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
777			}
778			shortList = append(shortList, newLineBinding)
779			fullList = append(fullList,
780				[]key.Binding{
781					newLineBinding,
782					key.NewBinding(
783						key.WithKeys("ctrl+f"),
784						key.WithHelp("ctrl+f", "add image"),
785					),
786					key.NewBinding(
787						key.WithKeys("/"),
788						key.WithHelp("/", "add file"),
789					),
790					key.NewBinding(
791						key.WithKeys("ctrl+e"),
792						key.WithHelp("ctrl+e", "open editor"),
793					),
794				})
795		}
796		shortList = append(shortList,
797			// Quit
798			key.NewBinding(
799				key.WithKeys("ctrl+c"),
800				key.WithHelp("ctrl+c", "quit"),
801			),
802			// Help
803			helpBinding,
804		)
805		fullList = append(fullList, []key.Binding{
806			key.NewBinding(
807				key.WithKeys("ctrl+g"),
808				key.WithHelp("ctrl+g", "less"),
809			),
810		})
811	}
812
813	return core.NewSimpleHelp(shortList, fullList)
814}
815
816func (p *chatPage) IsChatFocused() bool {
817	return p.focusedPane == PanelTypeChat
818}