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