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