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	switch p.focusedPane {
319	case PanelTypeEditor:
320		return p.editor.Cursor()
321	case PanelTypeSplash:
322		return p.splash.Cursor()
323	default:
324		return nil
325	}
326}
327
328func (p *chatPage) View() string {
329	var chatView string
330	t := styles.CurrentTheme()
331
332	if p.session.ID == "" {
333		splashView := p.splash.View()
334		// Full screen during onboarding or project initialization
335		if p.splashFullScreen {
336			chatView = splashView
337		} else {
338			// Show splash + editor for new message state
339			editorView := p.editor.View()
340			chatView = lipgloss.JoinVertical(
341				lipgloss.Left,
342				t.S().Base.Render(splashView),
343				editorView,
344			)
345		}
346	} else {
347		messagesView := p.chat.View()
348		editorView := p.editor.View()
349		if p.compact {
350			headerView := p.header.View()
351			chatView = lipgloss.JoinVertical(
352				lipgloss.Left,
353				headerView,
354				messagesView,
355				editorView,
356			)
357		} else {
358			sidebarView := p.sidebar.View()
359			messages := lipgloss.JoinHorizontal(
360				lipgloss.Left,
361				messagesView,
362				sidebarView,
363			)
364			chatView = lipgloss.JoinVertical(
365				lipgloss.Left,
366				messages,
367				p.editor.View(),
368			)
369		}
370	}
371
372	layers := []*lipgloss.Layer{
373		lipgloss.NewLayer(chatView).X(0).Y(0),
374	}
375
376	if p.showingDetails {
377		style := t.S().Base.
378			Width(p.detailsWidth).
379			Border(lipgloss.RoundedBorder()).
380			BorderForeground(t.BorderFocus)
381		version := t.S().Subtle.Width(p.detailsWidth - 2).AlignHorizontal(lipgloss.Right).Render(version.Version)
382		details := style.Render(
383			lipgloss.JoinVertical(
384				lipgloss.Left,
385				p.sidebar.View(),
386				version,
387			),
388		)
389		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
390	}
391	canvas := lipgloss.NewCanvas(
392		layers...,
393	)
394	return canvas.Render()
395}
396
397func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
398	return func() tea.Msg {
399		err := config.Get().SetCompactMode(compact)
400		if err != nil {
401			return util.InfoMsg{
402				Type: util.InfoTypeError,
403				Msg:  "Failed to update compact mode configuration: " + err.Error(),
404			}
405		}
406		return nil
407	}
408}
409
410func (p *chatPage) setCompactMode(compact bool) {
411	if p.compact == compact {
412		return
413	}
414	p.compact = compact
415	if compact {
416		p.compact = true
417		p.sidebar.SetCompactMode(true)
418	} else {
419		p.compact = false
420		p.showingDetails = false
421		p.sidebar.SetCompactMode(false)
422	}
423}
424
425func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
426	if p.forceCompact {
427		return
428	}
429	if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
430		p.setCompactMode(true)
431	}
432	if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
433		p.setCompactMode(false)
434	}
435}
436
437func (p *chatPage) SetSize(width, height int) tea.Cmd {
438	p.handleCompactMode(width, height)
439	p.width = width
440	p.height = height
441	var cmds []tea.Cmd
442
443	if p.session.ID == "" {
444		if p.splashFullScreen {
445			cmds = append(cmds, p.splash.SetSize(width, height))
446		} else {
447			cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
448			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
449			cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
450		}
451	} else {
452		if p.compact {
453			cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
454			p.detailsWidth = width - DetailsPositioning
455			cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
456			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
457			cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
458		} else {
459			cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
460			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
461			cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
462		}
463		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
464	}
465	return tea.Batch(cmds...)
466}
467
468func (p *chatPage) newSession() tea.Cmd {
469	if p.session.ID == "" {
470		return nil
471	}
472
473	p.session = session.Session{}
474	p.focusedPane = PanelTypeEditor
475	p.isCanceling = false
476	return tea.Batch(
477		util.CmdHandler(chat.SessionClearedMsg{}),
478		p.SetSize(p.width, p.height),
479	)
480}
481
482func (p *chatPage) setSession(session session.Session) tea.Cmd {
483	if p.session.ID == session.ID {
484		return nil
485	}
486
487	var cmds []tea.Cmd
488	p.session = session
489
490	cmds = append(cmds, p.SetSize(p.width, p.height))
491	cmds = append(cmds, p.chat.SetSession(session))
492	cmds = append(cmds, p.sidebar.SetSession(session))
493	cmds = append(cmds, p.header.SetSession(session))
494	cmds = append(cmds, p.editor.SetSession(session))
495
496	return tea.Sequence(cmds...)
497}
498
499func (p *chatPage) changeFocus() {
500	if p.session.ID == "" {
501		return
502	}
503	switch p.focusedPane {
504	case PanelTypeChat:
505		p.focusedPane = PanelTypeEditor
506		p.editor.Focus()
507		p.chat.Blur()
508	case PanelTypeEditor:
509		p.focusedPane = PanelTypeChat
510		p.chat.Focus()
511		p.editor.Blur()
512	}
513}
514
515func (p *chatPage) cancel() tea.Cmd {
516	if p.isCanceling {
517		p.isCanceling = false
518		p.app.CoderAgent.Cancel(p.session.ID)
519		return nil
520	}
521
522	p.isCanceling = true
523	return cancelTimerCmd()
524}
525
526func (p *chatPage) showDetails() {
527	if p.session.ID == "" || !p.compact {
528		return
529	}
530	p.showingDetails = !p.showingDetails
531	p.header.SetDetailsOpen(p.showingDetails)
532}
533
534func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
535	session := p.session
536	var cmds []tea.Cmd
537	if p.session.ID == "" {
538		newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
539		if err != nil {
540			return util.ReportError(err)
541		}
542		session = newSession
543		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
544	}
545	_, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
546	if err != nil {
547		return util.ReportError(err)
548	}
549	return tea.Batch(cmds...)
550}
551
552func (p *chatPage) Bindings() []key.Binding {
553	bindings := []key.Binding{
554		p.keyMap.NewSession,
555		p.keyMap.AddAttachment,
556	}
557	if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
558		cancelBinding := p.keyMap.Cancel
559		if p.isCanceling {
560			cancelBinding = key.NewBinding(
561				key.WithKeys("esc"),
562				key.WithHelp("esc", "press again to cancel"),
563			)
564		}
565		bindings = append([]key.Binding{cancelBinding}, bindings...)
566	}
567
568	switch p.focusedPane {
569	case PanelTypeChat:
570		bindings = append([]key.Binding{
571			key.NewBinding(
572				key.WithKeys("tab"),
573				key.WithHelp("tab", "focus editor"),
574			),
575		}, bindings...)
576		bindings = append(bindings, p.chat.Bindings()...)
577	case PanelTypeEditor:
578		bindings = append([]key.Binding{
579			key.NewBinding(
580				key.WithKeys("tab"),
581				key.WithHelp("tab", "focus chat"),
582			),
583		}, bindings...)
584		bindings = append(bindings, p.editor.Bindings()...)
585	case PanelTypeSplash:
586		bindings = append(bindings, p.splash.Bindings()...)
587	}
588
589	return bindings
590}
591
592func (a *chatPage) Help() help.KeyMap {
593	var shortList []key.Binding
594	var fullList [][]key.Binding
595	switch {
596	case a.isOnboarding && !a.splash.IsShowingAPIKey():
597		shortList = append(shortList,
598			// Choose model
599			key.NewBinding(
600				key.WithKeys("up", "down"),
601				key.WithHelp("↑/↓", "choose"),
602			),
603			// Accept selection
604			key.NewBinding(
605				key.WithKeys("enter", "ctrl+y"),
606				key.WithHelp("enter", "accept"),
607			),
608			// Quit
609			key.NewBinding(
610				key.WithKeys("ctrl+c"),
611				key.WithHelp("ctrl+c", "quit"),
612			),
613		)
614		// keep them the same
615		for _, v := range shortList {
616			fullList = append(fullList, []key.Binding{v})
617		}
618	case a.isOnboarding && a.splash.IsShowingAPIKey():
619		var pasteKey key.Binding
620		if runtime.GOOS != "darwin" {
621			pasteKey = key.NewBinding(
622				key.WithKeys("ctrl+v"),
623				key.WithHelp("ctrl+v", "paste API key"),
624			)
625		} else {
626			pasteKey = key.NewBinding(
627				key.WithKeys("cmd+v"),
628				key.WithHelp("cmd+v", "paste API key"),
629			)
630		}
631		shortList = append(shortList,
632			// Go back
633			key.NewBinding(
634				key.WithKeys("esc"),
635				key.WithHelp("esc", "back"),
636			),
637			// Paste
638			pasteKey,
639			// Quit
640			key.NewBinding(
641				key.WithKeys("ctrl+c"),
642				key.WithHelp("ctrl+c", "quit"),
643			),
644		)
645		// keep them the same
646		for _, v := range shortList {
647			fullList = append(fullList, []key.Binding{v})
648		}
649	case a.isProjectInit:
650		shortList = append(shortList,
651			key.NewBinding(
652				key.WithKeys("ctrl+c"),
653				key.WithHelp("ctrl+c", "quit"),
654			),
655		)
656		// keep them the same
657		for _, v := range shortList {
658			fullList = append(fullList, []key.Binding{v})
659		}
660	default:
661		if a.editor.IsCompletionsOpen() {
662			shortList = append(shortList,
663				key.NewBinding(
664					key.WithKeys("tab", "enter"),
665					key.WithHelp("tab/enter", "complete"),
666				),
667				key.NewBinding(
668					key.WithKeys("esc"),
669					key.WithHelp("esc", "cancel"),
670				),
671				key.NewBinding(
672					key.WithKeys("up", "down"),
673					key.WithHelp("↑/↓", "choose"),
674				),
675			)
676			for _, v := range shortList {
677				fullList = append(fullList, []key.Binding{v})
678			}
679			return core.NewSimpleHelp(shortList, fullList)
680		}
681		if a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() {
682			cancelBinding := key.NewBinding(
683				key.WithKeys("esc"),
684				key.WithHelp("esc", "cancel"),
685			)
686			if a.isCanceling {
687				cancelBinding = key.NewBinding(
688					key.WithKeys("esc"),
689					key.WithHelp("esc", "press again to cancel"),
690				)
691			}
692			shortList = append(shortList, cancelBinding)
693			fullList = append(fullList,
694				[]key.Binding{
695					cancelBinding,
696				},
697			)
698		}
699		globalBindings := []key.Binding{}
700		// we are in a session
701		if a.session.ID != "" {
702			tabKey := key.NewBinding(
703				key.WithKeys("tab"),
704				key.WithHelp("tab", "focus chat"),
705			)
706			if a.focusedPane == PanelTypeChat {
707				tabKey = key.NewBinding(
708					key.WithKeys("tab"),
709					key.WithHelp("tab", "focus editor"),
710				)
711			}
712			shortList = append(shortList, tabKey)
713			globalBindings = append(globalBindings, tabKey)
714		}
715		commandsBinding := key.NewBinding(
716			key.WithKeys("ctrl+p"),
717			key.WithHelp("ctrl+p", "commands"),
718		)
719		helpBinding := key.NewBinding(
720			key.WithKeys("ctrl+g"),
721			key.WithHelp("ctrl+g", "more"),
722		)
723		globalBindings = append(globalBindings, commandsBinding)
724		globalBindings = append(globalBindings,
725			key.NewBinding(
726				key.WithKeys("ctrl+s"),
727				key.WithHelp("ctrl+s", "sessions"),
728			),
729		)
730		if a.session.ID != "" {
731			globalBindings = append(globalBindings,
732				key.NewBinding(
733					key.WithKeys("ctrl+n"),
734					key.WithHelp("ctrl+n", "new sessions"),
735				))
736		}
737		shortList = append(shortList,
738			// Commands
739			commandsBinding,
740		)
741		fullList = append(fullList, globalBindings)
742
743		if a.focusedPane == PanelTypeChat {
744			shortList = append(shortList,
745				key.NewBinding(
746					key.WithKeys("up", "down"),
747					key.WithHelp("↑↓", "scroll"),
748				),
749			)
750			fullList = append(fullList,
751				[]key.Binding{
752					key.NewBinding(
753						key.WithKeys("up", "down"),
754						key.WithHelp("↑↓", "scroll"),
755					),
756					key.NewBinding(
757						key.WithKeys("shift+up", "shift+down"),
758						key.WithHelp("shift+↑↓", "next/prev item"),
759					),
760					key.NewBinding(
761						key.WithKeys("pgup", "b"),
762						key.WithHelp("b/pgup", "page up"),
763					),
764					key.NewBinding(
765						key.WithKeys("pgdown", " ", "f"),
766						key.WithHelp("f/pgdn", "page down"),
767					),
768				},
769				[]key.Binding{
770					key.NewBinding(
771						key.WithKeys("u"),
772						key.WithHelp("u", "half page up"),
773					),
774					key.NewBinding(
775						key.WithKeys("d"),
776						key.WithHelp("d", "half page down"),
777					),
778					key.NewBinding(
779						key.WithKeys("g", "home"),
780						key.WithHelp("g", "hone"),
781					),
782					key.NewBinding(
783						key.WithKeys("G", "end"),
784						key.WithHelp("G", "end"),
785					),
786				},
787			)
788		} else if a.focusedPane == PanelTypeEditor {
789			newLineBinding := key.NewBinding(
790				key.WithKeys("shift+enter", "ctrl+j"),
791				// "ctrl+j" is a common keybinding for newline in many editors. If
792				// the terminal supports "shift+enter", we substitute the help text
793				// to reflect that.
794				key.WithHelp("ctrl+j", "newline"),
795			)
796			if a.keyboardEnhancements.SupportsKeyDisambiguation() {
797				newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
798			}
799			shortList = append(shortList, newLineBinding)
800			fullList = append(fullList,
801				[]key.Binding{
802					newLineBinding,
803					key.NewBinding(
804						key.WithKeys("ctrl+f"),
805						key.WithHelp("ctrl+f", "add image"),
806					),
807					key.NewBinding(
808						key.WithKeys("/"),
809						key.WithHelp("/", "add file"),
810					),
811					key.NewBinding(
812						key.WithKeys("ctrl+v"),
813						key.WithHelp("ctrl+v", "open editor"),
814					),
815				})
816		}
817		shortList = append(shortList,
818			// Quit
819			key.NewBinding(
820				key.WithKeys("ctrl+c"),
821				key.WithHelp("ctrl+c", "quit"),
822			),
823			// Help
824			helpBinding,
825		)
826		fullList = append(fullList, []key.Binding{
827			key.NewBinding(
828				key.WithKeys("ctrl+g"),
829				key.WithHelp("ctrl+g", "less"),
830			),
831		})
832	}
833
834	return core.NewSimpleHelp(shortList, fullList)
835}
836
837func (p *chatPage) IsChatFocused() bool {
838	return p.focusedPane == PanelTypeChat
839}