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