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