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