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.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	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.compact = true
419		p.sidebar.SetCompactMode(true)
420	} else {
421		p.compact = false
422		p.showingDetails = false
423		p.sidebar.SetCompactMode(false)
424	}
425}
426
427func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
428	if p.forceCompact {
429		return
430	}
431	if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
432		p.setCompactMode(true)
433	}
434	if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
435		p.setCompactMode(false)
436	}
437}
438
439func (p *chatPage) SetSize(width, height int) tea.Cmd {
440	p.handleCompactMode(width, height)
441	p.width = width
442	p.height = height
443	var cmds []tea.Cmd
444
445	if p.session.ID == "" {
446		if p.splashFullScreen {
447			cmds = append(cmds, p.splash.SetSize(width, height))
448		} else {
449			cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
450			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
451			cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
452		}
453	} else {
454		if p.compact {
455			cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
456			p.detailsWidth = width - DetailsPositioning
457			cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
458			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
459			cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
460		} else {
461			cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
462			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
463			cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
464		}
465		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
466	}
467	return tea.Batch(cmds...)
468}
469
470func (p *chatPage) newSession() tea.Cmd {
471	if p.session.ID == "" {
472		return nil
473	}
474
475	p.session = session.Session{}
476	p.focusedPane = PanelTypeEditor
477	p.isCanceling = false
478	return tea.Batch(
479		util.CmdHandler(chat.SessionClearedMsg{}),
480		p.SetSize(p.width, p.height),
481	)
482}
483
484func (p *chatPage) setSession(session session.Session) tea.Cmd {
485	if p.session.ID == session.ID {
486		return nil
487	}
488
489	var cmds []tea.Cmd
490	p.session = session
491
492	cmds = append(cmds, p.SetSize(p.width, p.height))
493	cmds = append(cmds, p.chat.SetSession(session))
494	cmds = append(cmds, p.sidebar.SetSession(session))
495	cmds = append(cmds, p.header.SetSession(session))
496	cmds = append(cmds, p.editor.SetSession(session))
497
498	return tea.Sequence(cmds...)
499}
500
501func (p *chatPage) changeFocus() {
502	if p.session.ID == "" {
503		return
504	}
505	switch p.focusedPane {
506	case PanelTypeChat:
507		p.focusedPane = PanelTypeEditor
508		p.editor.Focus()
509		p.chat.Blur()
510	case PanelTypeEditor:
511		p.focusedPane = PanelTypeChat
512		p.chat.Focus()
513		p.editor.Blur()
514	}
515}
516
517func (p *chatPage) cancel() tea.Cmd {
518	if p.isCanceling {
519		p.isCanceling = false
520		p.app.CoderAgent.Cancel(p.session.ID)
521		return nil
522	}
523
524	p.isCanceling = true
525	return cancelTimerCmd()
526}
527
528func (p *chatPage) showDetails() {
529	if p.session.ID == "" || !p.compact {
530		return
531	}
532	p.showingDetails = !p.showingDetails
533	p.header.SetDetailsOpen(p.showingDetails)
534}
535
536func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
537	session := p.session
538	var cmds []tea.Cmd
539	if p.session.ID == "" {
540		newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
541		if err != nil {
542			return util.ReportError(err)
543		}
544		session = newSession
545		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
546	}
547	_, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
548	if err != nil {
549		return util.ReportError(err)
550	}
551	return tea.Batch(cmds...)
552}
553
554func (p *chatPage) Bindings() []key.Binding {
555	bindings := []key.Binding{
556		p.keyMap.NewSession,
557		p.keyMap.AddAttachment,
558	}
559	if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
560		cancelBinding := p.keyMap.Cancel
561		if p.isCanceling {
562			cancelBinding = key.NewBinding(
563				key.WithKeys("esc"),
564				key.WithHelp("esc", "press again to cancel"),
565			)
566		}
567		bindings = append([]key.Binding{cancelBinding}, bindings...)
568	}
569
570	switch p.focusedPane {
571	case PanelTypeChat:
572		bindings = append([]key.Binding{
573			key.NewBinding(
574				key.WithKeys("tab"),
575				key.WithHelp("tab", "focus editor"),
576			),
577		}, bindings...)
578		bindings = append(bindings, p.chat.Bindings()...)
579	case PanelTypeEditor:
580		bindings = append([]key.Binding{
581			key.NewBinding(
582				key.WithKeys("tab"),
583				key.WithHelp("tab", "focus chat"),
584			),
585		}, bindings...)
586		bindings = append(bindings, p.editor.Bindings()...)
587	case PanelTypeSplash:
588		bindings = append(bindings, p.splash.Bindings()...)
589	}
590
591	return bindings
592}
593
594func (a *chatPage) Help() help.KeyMap {
595	var shortList []key.Binding
596	var fullList [][]key.Binding
597	switch {
598	case a.isOnboarding && !a.splash.IsShowingAPIKey():
599		shortList = append(shortList,
600			// Choose model
601			key.NewBinding(
602				key.WithKeys("up", "down"),
603				key.WithHelp("↑/↓", "choose"),
604			),
605			// Accept selection
606			key.NewBinding(
607				key.WithKeys("enter", "ctrl+y"),
608				key.WithHelp("enter", "accept"),
609			),
610			// Quit
611			key.NewBinding(
612				key.WithKeys("ctrl+c"),
613				key.WithHelp("ctrl+c", "quit"),
614			),
615		)
616		// keep them the same
617		for _, v := range shortList {
618			fullList = append(fullList, []key.Binding{v})
619		}
620	case a.isOnboarding && a.splash.IsShowingAPIKey():
621		shortList = append(shortList,
622			// Go back
623			key.NewBinding(
624				key.WithKeys("esc"),
625				key.WithHelp("esc", "back"),
626			),
627			// Quit
628			key.NewBinding(
629				key.WithKeys("ctrl+c"),
630				key.WithHelp("ctrl+c", "quit"),
631			),
632		)
633		// keep them the same
634		for _, v := range shortList {
635			fullList = append(fullList, []key.Binding{v})
636		}
637	case a.isProjectInit:
638		shortList = append(shortList,
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	default:
649		if a.editor.IsCompletionsOpen() {
650			shortList = append(shortList,
651				key.NewBinding(
652					key.WithKeys("tab", "enter"),
653					key.WithHelp("tab/enter", "complete"),
654				),
655				key.NewBinding(
656					key.WithKeys("esc"),
657					key.WithHelp("esc", "cancel"),
658				),
659				key.NewBinding(
660					key.WithKeys("up", "down"),
661					key.WithHelp("↑/↓", "choose"),
662				),
663			)
664			for _, v := range shortList {
665				fullList = append(fullList, []key.Binding{v})
666			}
667			return core.NewSimpleHelp(shortList, fullList)
668		}
669		if a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() {
670			cancelBinding := key.NewBinding(
671				key.WithKeys("esc"),
672				key.WithHelp("esc", "cancel"),
673			)
674			if a.isCanceling {
675				cancelBinding = key.NewBinding(
676					key.WithKeys("esc"),
677					key.WithHelp("esc", "press again to cancel"),
678				)
679			}
680			shortList = append(shortList, cancelBinding)
681			fullList = append(fullList,
682				[]key.Binding{
683					cancelBinding,
684				},
685			)
686		}
687		globalBindings := []key.Binding{}
688		// we are in a session
689		if a.session.ID != "" {
690			tabKey := key.NewBinding(
691				key.WithKeys("tab"),
692				key.WithHelp("tab", "focus chat"),
693			)
694			if a.focusedPane == PanelTypeChat {
695				tabKey = key.NewBinding(
696					key.WithKeys("tab"),
697					key.WithHelp("tab", "focus editor"),
698				)
699			}
700			shortList = append(shortList, tabKey)
701			globalBindings = append(globalBindings, tabKey)
702		}
703		commandsBinding := key.NewBinding(
704			key.WithKeys("ctrl+p"),
705			key.WithHelp("ctrl+p", "commands"),
706		)
707		helpBinding := key.NewBinding(
708			key.WithKeys("ctrl+g"),
709			key.WithHelp("ctrl+g", "more"),
710		)
711		globalBindings = append(globalBindings, commandsBinding)
712		globalBindings = append(globalBindings,
713			key.NewBinding(
714				key.WithKeys("ctrl+s"),
715				key.WithHelp("ctrl+s", "sessions"),
716			),
717		)
718		if a.session.ID != "" {
719			globalBindings = append(globalBindings,
720				key.NewBinding(
721					key.WithKeys("ctrl+n"),
722					key.WithHelp("ctrl+n", "new sessions"),
723				))
724		}
725		shortList = append(shortList,
726			// Commands
727			commandsBinding,
728		)
729		fullList = append(fullList, globalBindings)
730
731		if a.focusedPane == PanelTypeChat {
732			shortList = append(shortList,
733				key.NewBinding(
734					key.WithKeys("up", "down"),
735					key.WithHelp("↑↓", "scroll"),
736				),
737			)
738			fullList = append(fullList,
739				[]key.Binding{
740					key.NewBinding(
741						key.WithKeys("up", "down"),
742						key.WithHelp("↑↓", "scroll"),
743					),
744					key.NewBinding(
745						key.WithKeys("shift+up", "shift+down"),
746						key.WithHelp("shift+↑↓", "next/prev item"),
747					),
748					key.NewBinding(
749						key.WithKeys("pgup", "b"),
750						key.WithHelp("b/pgup", "page up"),
751					),
752					key.NewBinding(
753						key.WithKeys("pgdown", " ", "f"),
754						key.WithHelp("f/pgdn", "page down"),
755					),
756				},
757				[]key.Binding{
758					key.NewBinding(
759						key.WithKeys("u"),
760						key.WithHelp("u", "half page up"),
761					),
762					key.NewBinding(
763						key.WithKeys("d"),
764						key.WithHelp("d", "half page down"),
765					),
766					key.NewBinding(
767						key.WithKeys("g", "home"),
768						key.WithHelp("g", "hone"),
769					),
770					key.NewBinding(
771						key.WithKeys("G", "end"),
772						key.WithHelp("G", "end"),
773					),
774				},
775			)
776		} else if a.focusedPane == PanelTypeEditor {
777			newLineBinding := key.NewBinding(
778				key.WithKeys("shift+enter", "ctrl+j"),
779				// "ctrl+j" is a common keybinding for newline in many editors. If
780				// the terminal supports "shift+enter", we substitute the help text
781				// to reflect that.
782				key.WithHelp("ctrl+j", "newline"),
783			)
784			if a.keyboardEnhancements.SupportsKeyDisambiguation() {
785				newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
786			}
787			shortList = append(shortList, newLineBinding)
788			fullList = append(fullList,
789				[]key.Binding{
790					newLineBinding,
791					key.NewBinding(
792						key.WithKeys("ctrl+f"),
793						key.WithHelp("ctrl+f", "add image"),
794					),
795					key.NewBinding(
796						key.WithKeys("/"),
797						key.WithHelp("/", "add file"),
798					),
799					key.NewBinding(
800						key.WithKeys("ctrl+v"),
801						key.WithHelp("ctrl+v", "open editor"),
802					),
803				})
804		}
805		shortList = append(shortList,
806			// Quit
807			key.NewBinding(
808				key.WithKeys("ctrl+c"),
809				key.WithHelp("ctrl+c", "quit"),
810			),
811			// Help
812			helpBinding,
813		)
814		fullList = append(fullList, []key.Binding{
815			key.NewBinding(
816				key.WithKeys("ctrl+g"),
817				key.WithHelp("ctrl+g", "less"),
818			),
819		})
820	}
821
822	return core.NewSimpleHelp(shortList, fullList)
823}
824
825func (p *chatPage) IsChatFocused() bool {
826	return p.focusedPane == PanelTypeChat
827}