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().Subtle.Width(p.detailsWidth - 2).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.isCanceling = false
509	return tea.Batch(
510		util.CmdHandler(chat.SessionClearedMsg{}),
511		p.SetSize(p.width, p.height),
512	)
513}
514
515func (p *chatPage) setSession(session session.Session) tea.Cmd {
516	if p.session.ID == session.ID {
517		return nil
518	}
519
520	var cmds []tea.Cmd
521	p.session = session
522
523	cmds = append(cmds, p.SetSize(p.width, p.height))
524	cmds = append(cmds, p.chat.SetSession(session))
525	cmds = append(cmds, p.sidebar.SetSession(session))
526	cmds = append(cmds, p.header.SetSession(session))
527	cmds = append(cmds, p.editor.SetSession(session))
528
529	return tea.Sequence(cmds...)
530}
531
532func (p *chatPage) changeFocus() {
533	if p.session.ID == "" {
534		return
535	}
536	switch p.focusedPane {
537	case PanelTypeChat:
538		p.focusedPane = PanelTypeEditor
539		p.editor.Focus()
540		p.chat.Blur()
541	case PanelTypeEditor:
542		p.focusedPane = PanelTypeChat
543		p.chat.Focus()
544		p.editor.Blur()
545	}
546}
547
548func (p *chatPage) cancel() tea.Cmd {
549	if p.isCanceling {
550		p.isCanceling = false
551		p.app.CoderAgent.Cancel(p.session.ID)
552		return nil
553	}
554
555	p.isCanceling = true
556	return cancelTimerCmd()
557}
558
559func (p *chatPage) showDetails() {
560	if p.session.ID == "" || !p.compact {
561		return
562	}
563	p.showingDetails = !p.showingDetails
564	p.header.SetDetailsOpen(p.showingDetails)
565}
566
567func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
568	session := p.session
569	var cmds []tea.Cmd
570	if p.session.ID == "" {
571		newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
572		if err != nil {
573			return util.ReportError(err)
574		}
575		session = newSession
576		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
577	}
578	_, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
579	if err != nil {
580		return util.ReportError(err)
581	}
582	return tea.Batch(cmds...)
583}
584
585func (p *chatPage) Bindings() []key.Binding {
586	bindings := []key.Binding{
587		p.keyMap.NewSession,
588		p.keyMap.AddAttachment,
589	}
590	if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
591		cancelBinding := p.keyMap.Cancel
592		if p.isCanceling {
593			cancelBinding = key.NewBinding(
594				key.WithKeys("esc"),
595				key.WithHelp("esc", "press again to cancel"),
596			)
597		}
598		bindings = append([]key.Binding{cancelBinding}, bindings...)
599	}
600
601	switch p.focusedPane {
602	case PanelTypeChat:
603		bindings = append([]key.Binding{
604			key.NewBinding(
605				key.WithKeys("tab"),
606				key.WithHelp("tab", "focus editor"),
607			),
608		}, bindings...)
609		bindings = append(bindings, p.chat.Bindings()...)
610	case PanelTypeEditor:
611		bindings = append([]key.Binding{
612			key.NewBinding(
613				key.WithKeys("tab"),
614				key.WithHelp("tab", "focus chat"),
615			),
616		}, bindings...)
617		bindings = append(bindings, p.editor.Bindings()...)
618	case PanelTypeSplash:
619		bindings = append(bindings, p.splash.Bindings()...)
620	}
621
622	return bindings
623}
624
625func (a *chatPage) Help() help.KeyMap {
626	var shortList []key.Binding
627	var fullList [][]key.Binding
628	switch {
629	case a.isOnboarding && !a.splash.IsShowingAPIKey():
630		shortList = append(shortList,
631			// Choose model
632			key.NewBinding(
633				key.WithKeys("up", "down"),
634				key.WithHelp("↑/↓", "choose"),
635			),
636			// Accept selection
637			key.NewBinding(
638				key.WithKeys("enter", "ctrl+y"),
639				key.WithHelp("enter", "accept"),
640			),
641			// Quit
642			key.NewBinding(
643				key.WithKeys("ctrl+c"),
644				key.WithHelp("ctrl+c", "quit"),
645			),
646		)
647		// keep them the same
648		for _, v := range shortList {
649			fullList = append(fullList, []key.Binding{v})
650		}
651	case a.isOnboarding && a.splash.IsShowingAPIKey():
652		shortList = append(shortList,
653			// Go back
654			key.NewBinding(
655				key.WithKeys("esc"),
656				key.WithHelp("esc", "back"),
657			),
658			// Quit
659			key.NewBinding(
660				key.WithKeys("ctrl+c"),
661				key.WithHelp("ctrl+c", "quit"),
662			),
663		)
664		// keep them the same
665		for _, v := range shortList {
666			fullList = append(fullList, []key.Binding{v})
667		}
668	case a.isProjectInit:
669		shortList = append(shortList,
670			key.NewBinding(
671				key.WithKeys("ctrl+c"),
672				key.WithHelp("ctrl+c", "quit"),
673			),
674		)
675		// keep them the same
676		for _, v := range shortList {
677			fullList = append(fullList, []key.Binding{v})
678		}
679	default:
680		if a.editor.IsCompletionsOpen() {
681			shortList = append(shortList,
682				key.NewBinding(
683					key.WithKeys("tab", "enter"),
684					key.WithHelp("tab/enter", "complete"),
685				),
686				key.NewBinding(
687					key.WithKeys("esc"),
688					key.WithHelp("esc", "cancel"),
689				),
690				key.NewBinding(
691					key.WithKeys("up", "down"),
692					key.WithHelp("↑/↓", "choose"),
693				),
694			)
695			for _, v := range shortList {
696				fullList = append(fullList, []key.Binding{v})
697			}
698			return core.NewSimpleHelp(shortList, fullList)
699		}
700		if a.app.CoderAgent != nil && a.app.CoderAgent.IsBusy() {
701			cancelBinding := key.NewBinding(
702				key.WithKeys("esc"),
703				key.WithHelp("esc", "cancel"),
704			)
705			if a.isCanceling {
706				cancelBinding = key.NewBinding(
707					key.WithKeys("esc"),
708					key.WithHelp("esc", "press again to cancel"),
709				)
710			}
711			shortList = append(shortList, cancelBinding)
712			fullList = append(fullList,
713				[]key.Binding{
714					cancelBinding,
715				},
716			)
717		}
718		globalBindings := []key.Binding{}
719		// we are in a session
720		if a.session.ID != "" {
721			tabKey := key.NewBinding(
722				key.WithKeys("tab"),
723				key.WithHelp("tab", "focus chat"),
724			)
725			if a.focusedPane == PanelTypeChat {
726				tabKey = key.NewBinding(
727					key.WithKeys("tab"),
728					key.WithHelp("tab", "focus editor"),
729				)
730			}
731			shortList = append(shortList, tabKey)
732			globalBindings = append(globalBindings, tabKey)
733		}
734		commandsBinding := key.NewBinding(
735			key.WithKeys("ctrl+p"),
736			key.WithHelp("ctrl+p", "commands"),
737		)
738		helpBinding := key.NewBinding(
739			key.WithKeys("ctrl+g"),
740			key.WithHelp("ctrl+g", "more"),
741		)
742		globalBindings = append(globalBindings, commandsBinding)
743		globalBindings = append(globalBindings,
744			key.NewBinding(
745				key.WithKeys("ctrl+s"),
746				key.WithHelp("ctrl+s", "sessions"),
747			),
748		)
749		if a.session.ID != "" {
750			globalBindings = append(globalBindings,
751				key.NewBinding(
752					key.WithKeys("ctrl+n"),
753					key.WithHelp("ctrl+n", "new sessions"),
754				))
755		}
756		shortList = append(shortList,
757			// Commands
758			commandsBinding,
759		)
760		fullList = append(fullList, globalBindings)
761
762		if a.focusedPane == PanelTypeChat {
763			shortList = append(shortList,
764				key.NewBinding(
765					key.WithKeys("up", "down"),
766					key.WithHelp("↑↓", "scroll"),
767				),
768			)
769			fullList = append(fullList,
770				[]key.Binding{
771					key.NewBinding(
772						key.WithKeys("up", "down"),
773						key.WithHelp("↑↓", "scroll"),
774					),
775					key.NewBinding(
776						key.WithKeys("shift+up", "shift+down"),
777						key.WithHelp("shift+↑↓", "next/prev item"),
778					),
779					key.NewBinding(
780						key.WithKeys("pgup", "b"),
781						key.WithHelp("b/pgup", "page up"),
782					),
783					key.NewBinding(
784						key.WithKeys("pgdown", " ", "f"),
785						key.WithHelp("f/pgdn", "page down"),
786					),
787				},
788				[]key.Binding{
789					key.NewBinding(
790						key.WithKeys("u"),
791						key.WithHelp("u", "half page up"),
792					),
793					key.NewBinding(
794						key.WithKeys("d"),
795						key.WithHelp("d", "half page down"),
796					),
797					key.NewBinding(
798						key.WithKeys("g", "home"),
799						key.WithHelp("g", "hone"),
800					),
801					key.NewBinding(
802						key.WithKeys("G", "end"),
803						key.WithHelp("G", "end"),
804					),
805				},
806			)
807		} else if a.focusedPane == PanelTypeEditor {
808			newLineBinding := key.NewBinding(
809				key.WithKeys("shift+enter", "ctrl+j"),
810				// "ctrl+j" is a common keybinding for newline in many editors. If
811				// the terminal supports "shift+enter", we substitute the help text
812				// to reflect that.
813				key.WithHelp("ctrl+j", "newline"),
814			)
815			if a.keyboardEnhancements.SupportsKeyDisambiguation() {
816				newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
817			}
818			shortList = append(shortList, newLineBinding)
819			fullList = append(fullList,
820				[]key.Binding{
821					newLineBinding,
822					key.NewBinding(
823						key.WithKeys("ctrl+f"),
824						key.WithHelp("ctrl+f", "add image"),
825					),
826					key.NewBinding(
827						key.WithKeys("/"),
828						key.WithHelp("/", "add file"),
829					),
830					key.NewBinding(
831						key.WithKeys("ctrl+v"),
832						key.WithHelp("ctrl+v", "open editor"),
833					),
834				})
835		}
836		shortList = append(shortList,
837			// Quit
838			key.NewBinding(
839				key.WithKeys("ctrl+c"),
840				key.WithHelp("ctrl+c", "quit"),
841			),
842			// Help
843			helpBinding,
844		)
845		fullList = append(fullList, []key.Binding{
846			key.NewBinding(
847				key.WithKeys("ctrl+g"),
848				key.WithHelp("ctrl+g", "less"),
849			),
850		})
851	}
852
853	return core.NewSimpleHelp(shortList, fullList)
854}
855
856func (p *chatPage) IsChatFocused() bool {
857	return p.focusedPane == PanelTypeChat
858}