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		switch p.focusedPane {
304		case PanelTypeChat:
305			u, cmd := p.chat.Update(msg)
306			p.chat = u.(chat.MessageListCmp)
307			cmds = append(cmds, cmd)
308		case PanelTypeEditor:
309			u, cmd := p.editor.Update(msg)
310			p.editor = u.(editor.Editor)
311			cmds = append(cmds, cmd)
312		case PanelTypeSplash:
313			u, cmd := p.splash.Update(msg)
314			p.splash = u.(splash.Splash)
315			cmds = append(cmds, cmd)
316		}
317	case tea.PasteMsg:
318		switch p.focusedPane {
319		case PanelTypeEditor:
320			u, cmd := p.editor.Update(msg)
321			p.editor = u.(editor.Editor)
322			cmds = append(cmds, cmd)
323			return p, tea.Batch(cmds...)
324		case PanelTypeChat:
325			u, cmd := p.chat.Update(msg)
326			p.chat = u.(chat.MessageListCmp)
327			cmds = append(cmds, cmd)
328			return p, tea.Batch(cmds...)
329		case PanelTypeSplash:
330			u, cmd := p.splash.Update(msg)
331			p.splash = u.(splash.Splash)
332			cmds = append(cmds, cmd)
333			return p, tea.Batch(cmds...)
334		}
335	}
336	return p, tea.Batch(cmds...)
337}
338
339func (p *chatPage) Cursor() *tea.Cursor {
340	if p.header.ShowingDetails() {
341		return nil
342	}
343	switch p.focusedPane {
344	case PanelTypeEditor:
345		return p.editor.Cursor()
346	case PanelTypeSplash:
347		return p.splash.Cursor()
348	default:
349		return nil
350	}
351}
352
353func (p *chatPage) View() string {
354	var chatView string
355	t := styles.CurrentTheme()
356
357	if p.session.ID == "" {
358		splashView := p.splash.View()
359		// Full screen during onboarding or project initialization
360		if p.splashFullScreen {
361			chatView = splashView
362		} else {
363			// Show splash + editor for new message state
364			editorView := p.editor.View()
365			chatView = lipgloss.JoinVertical(
366				lipgloss.Left,
367				t.S().Base.Render(splashView),
368				editorView,
369			)
370		}
371	} else {
372		messagesView := p.chat.View()
373		editorView := p.editor.View()
374		if p.compact {
375			headerView := p.header.View()
376			chatView = lipgloss.JoinVertical(
377				lipgloss.Left,
378				headerView,
379				messagesView,
380				editorView,
381			)
382		} else {
383			sidebarView := p.sidebar.View()
384			messages := lipgloss.JoinHorizontal(
385				lipgloss.Left,
386				messagesView,
387				sidebarView,
388			)
389			chatView = lipgloss.JoinVertical(
390				lipgloss.Left,
391				messages,
392				p.editor.View(),
393			)
394		}
395	}
396
397	layers := []*lipgloss.Layer{
398		lipgloss.NewLayer(chatView).X(0).Y(0),
399	}
400
401	if p.showingDetails {
402		style := t.S().Base.
403			Width(p.detailsWidth).
404			Border(lipgloss.RoundedBorder()).
405			BorderForeground(t.BorderFocus)
406		version := t.S().Base.Foreground(t.Border).Width(p.detailsWidth - 4).AlignHorizontal(lipgloss.Right).Render(version.Version)
407		details := style.Render(
408			lipgloss.JoinVertical(
409				lipgloss.Left,
410				p.sidebar.View(),
411				version,
412			),
413		)
414		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
415	}
416	canvas := lipgloss.NewCanvas(
417		layers...,
418	)
419	return canvas.Render()
420}
421
422func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
423	return func() tea.Msg {
424		err := config.Get().SetCompactMode(compact)
425		if err != nil {
426			return util.InfoMsg{
427				Type: util.InfoTypeError,
428				Msg:  "Failed to update compact mode configuration: " + err.Error(),
429			}
430		}
431		return nil
432	}
433}
434
435func (p *chatPage) toggleThinking() tea.Cmd {
436	return func() tea.Msg {
437		cfg := config.Get()
438		agentCfg := cfg.Agents["coder"]
439		currentModel := cfg.Models[agentCfg.Model]
440
441		// Toggle the thinking mode
442		currentModel.Think = !currentModel.Think
443		cfg.Models[agentCfg.Model] = currentModel
444
445		// Update the agent with the new configuration
446		if err := p.app.UpdateAgentModel(); err != nil {
447			return util.InfoMsg{
448				Type: util.InfoTypeError,
449				Msg:  "Failed to update thinking mode: " + err.Error(),
450			}
451		}
452
453		status := "disabled"
454		if currentModel.Think {
455			status = "enabled"
456		}
457		return util.InfoMsg{
458			Type: util.InfoTypeInfo,
459			Msg:  "Thinking mode " + status,
460		}
461	}
462}
463
464func (p *chatPage) setCompactMode(compact bool) {
465	if p.compact == compact {
466		return
467	}
468	p.compact = compact
469	if compact {
470		p.sidebar.SetCompactMode(true)
471	} else {
472		p.setShowDetails(false)
473	}
474}
475
476func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
477	if p.forceCompact {
478		return
479	}
480	if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
481		p.setCompactMode(true)
482	}
483	if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
484		p.setCompactMode(false)
485	}
486}
487
488func (p *chatPage) SetSize(width, height int) tea.Cmd {
489	p.handleCompactMode(width, height)
490	p.width = width
491	p.height = height
492	var cmds []tea.Cmd
493
494	if p.session.ID == "" {
495		if p.splashFullScreen {
496			cmds = append(cmds, p.splash.SetSize(width, height))
497		} else {
498			cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
499			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
500			cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
501		}
502	} else {
503		if p.compact {
504			cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
505			p.detailsWidth = width - DetailsPositioning
506			cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
507			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
508			cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
509		} else {
510			cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
511			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
512			cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
513		}
514		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
515	}
516	return tea.Batch(cmds...)
517}
518
519func (p *chatPage) newSession() tea.Cmd {
520	if p.session.ID == "" {
521		return nil
522	}
523
524	p.session = session.Session{}
525	p.focusedPane = PanelTypeEditor
526	p.editor.Focus()
527	p.chat.Blur()
528	p.isCanceling = false
529	return tea.Batch(
530		util.CmdHandler(chat.SessionClearedMsg{}),
531		p.SetSize(p.width, p.height),
532	)
533}
534
535func (p *chatPage) setSession(session session.Session) tea.Cmd {
536	if p.session.ID == session.ID {
537		return nil
538	}
539
540	var cmds []tea.Cmd
541	p.session = session
542
543	cmds = append(cmds, p.SetSize(p.width, p.height))
544	cmds = append(cmds, p.chat.SetSession(session))
545	cmds = append(cmds, p.sidebar.SetSession(session))
546	cmds = append(cmds, p.header.SetSession(session))
547	cmds = append(cmds, p.editor.SetSession(session))
548
549	return tea.Sequence(cmds...)
550}
551
552func (p *chatPage) changeFocus() {
553	if p.session.ID == "" {
554		return
555	}
556	switch p.focusedPane {
557	case PanelTypeChat:
558		p.focusedPane = PanelTypeEditor
559		p.editor.Focus()
560		p.chat.Blur()
561	case PanelTypeEditor:
562		p.focusedPane = PanelTypeChat
563		p.chat.Focus()
564		p.editor.Blur()
565	}
566}
567
568func (p *chatPage) cancel() tea.Cmd {
569	if p.isCanceling {
570		p.isCanceling = false
571		p.app.CoderAgent.Cancel(p.session.ID)
572		return nil
573	}
574
575	p.isCanceling = true
576	return cancelTimerCmd()
577}
578
579func (p *chatPage) setShowDetails(show bool) {
580	p.showingDetails = show
581	p.header.SetDetailsOpen(p.showingDetails)
582	if !p.compact {
583		p.sidebar.SetCompactMode(false)
584	}
585}
586
587func (p *chatPage) toggleDetails() {
588	if p.session.ID == "" || !p.compact {
589		return
590	}
591	p.setShowDetails(!p.showingDetails)
592}
593
594func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
595	session := p.session
596	var cmds []tea.Cmd
597	if p.session.ID == "" {
598		newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
599		if err != nil {
600			return util.ReportError(err)
601		}
602		session = newSession
603		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
604	}
605	_, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
606	if err != nil {
607		return util.ReportError(err)
608	}
609	return tea.Batch(cmds...)
610}
611
612func (p *chatPage) Bindings() []key.Binding {
613	bindings := []key.Binding{
614		p.keyMap.NewSession,
615		p.keyMap.AddAttachment,
616	}
617	if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
618		cancelBinding := p.keyMap.Cancel
619		if p.isCanceling {
620			cancelBinding = key.NewBinding(
621				key.WithKeys("esc"),
622				key.WithHelp("esc", "press again to cancel"),
623			)
624		}
625		bindings = append([]key.Binding{cancelBinding}, bindings...)
626	}
627
628	switch p.focusedPane {
629	case PanelTypeChat:
630		bindings = append([]key.Binding{
631			key.NewBinding(
632				key.WithKeys("tab"),
633				key.WithHelp("tab", "focus editor"),
634			),
635		}, bindings...)
636		bindings = append(bindings, p.chat.Bindings()...)
637	case PanelTypeEditor:
638		bindings = append([]key.Binding{
639			key.NewBinding(
640				key.WithKeys("tab"),
641				key.WithHelp("tab", "focus chat"),
642			),
643		}, bindings...)
644		bindings = append(bindings, p.editor.Bindings()...)
645	case PanelTypeSplash:
646		bindings = append(bindings, p.splash.Bindings()...)
647	}
648
649	return bindings
650}
651
652func (p *chatPage) Help() help.KeyMap {
653	var shortList []key.Binding
654	var fullList [][]key.Binding
655	switch {
656	case p.isOnboarding && !p.splash.IsShowingAPIKey():
657		shortList = append(shortList,
658			// Choose model
659			key.NewBinding(
660				key.WithKeys("up", "down"),
661				key.WithHelp("↑/↓", "choose"),
662			),
663			// Accept selection
664			key.NewBinding(
665				key.WithKeys("enter", "ctrl+y"),
666				key.WithHelp("enter", "accept"),
667			),
668			// Quit
669			key.NewBinding(
670				key.WithKeys("ctrl+c"),
671				key.WithHelp("ctrl+c", "quit"),
672			),
673		)
674		// keep them the same
675		for _, v := range shortList {
676			fullList = append(fullList, []key.Binding{v})
677		}
678	case p.isOnboarding && p.splash.IsShowingAPIKey():
679		if p.splash.IsAPIKeyValid() {
680			shortList = append(shortList,
681				key.NewBinding(
682					key.WithKeys("enter"),
683					key.WithHelp("enter", "continue"),
684				),
685			)
686		} else {
687			shortList = append(shortList,
688				// Go back
689				key.NewBinding(
690					key.WithKeys("esc"),
691					key.WithHelp("esc", "back"),
692				),
693			)
694		}
695		shortList = append(shortList,
696			// Quit
697			key.NewBinding(
698				key.WithKeys("ctrl+c"),
699				key.WithHelp("ctrl+c", "quit"),
700			),
701		)
702		// keep them the same
703		for _, v := range shortList {
704			fullList = append(fullList, []key.Binding{v})
705		}
706	case p.isProjectInit:
707		shortList = append(shortList,
708			key.NewBinding(
709				key.WithKeys("ctrl+c"),
710				key.WithHelp("ctrl+c", "quit"),
711			),
712		)
713		// keep them the same
714		for _, v := range shortList {
715			fullList = append(fullList, []key.Binding{v})
716		}
717	default:
718		if p.editor.IsCompletionsOpen() {
719			shortList = append(shortList,
720				key.NewBinding(
721					key.WithKeys("tab", "enter"),
722					key.WithHelp("tab/enter", "complete"),
723				),
724				key.NewBinding(
725					key.WithKeys("esc"),
726					key.WithHelp("esc", "cancel"),
727				),
728				key.NewBinding(
729					key.WithKeys("up", "down"),
730					key.WithHelp("↑/↓", "choose"),
731				),
732			)
733			for _, v := range shortList {
734				fullList = append(fullList, []key.Binding{v})
735			}
736			return core.NewSimpleHelp(shortList, fullList)
737		}
738		if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
739			cancelBinding := key.NewBinding(
740				key.WithKeys("esc"),
741				key.WithHelp("esc", "cancel"),
742			)
743			if p.isCanceling {
744				cancelBinding = key.NewBinding(
745					key.WithKeys("esc"),
746					key.WithHelp("esc", "press again to cancel"),
747				)
748			}
749			shortList = append(shortList, cancelBinding)
750			fullList = append(fullList,
751				[]key.Binding{
752					cancelBinding,
753				},
754			)
755		}
756		globalBindings := []key.Binding{}
757		// we are in a session
758		if p.session.ID != "" {
759			tabKey := key.NewBinding(
760				key.WithKeys("tab"),
761				key.WithHelp("tab", "focus chat"),
762			)
763			if p.focusedPane == PanelTypeChat {
764				tabKey = key.NewBinding(
765					key.WithKeys("tab"),
766					key.WithHelp("tab", "focus editor"),
767				)
768			}
769			shortList = append(shortList, tabKey)
770			globalBindings = append(globalBindings, tabKey)
771		}
772		commandsBinding := key.NewBinding(
773			key.WithKeys("ctrl+p"),
774			key.WithHelp("ctrl+p", "commands"),
775		)
776		helpBinding := key.NewBinding(
777			key.WithKeys("ctrl+g"),
778			key.WithHelp("ctrl+g", "more"),
779		)
780		globalBindings = append(globalBindings, commandsBinding)
781		globalBindings = append(globalBindings,
782			key.NewBinding(
783				key.WithKeys("ctrl+s"),
784				key.WithHelp("ctrl+s", "sessions"),
785			),
786		)
787		if p.session.ID != "" {
788			globalBindings = append(globalBindings,
789				key.NewBinding(
790					key.WithKeys("ctrl+n"),
791					key.WithHelp("ctrl+n", "new sessions"),
792				))
793		}
794		shortList = append(shortList,
795			// Commands
796			commandsBinding,
797		)
798		fullList = append(fullList, globalBindings)
799
800		switch p.focusedPane {
801		case PanelTypeChat:
802			shortList = append(shortList,
803				key.NewBinding(
804					key.WithKeys("up", "down"),
805					key.WithHelp("↑↓", "scroll"),
806				),
807			)
808			fullList = append(fullList,
809				[]key.Binding{
810					key.NewBinding(
811						key.WithKeys("up", "down"),
812						key.WithHelp("↑↓", "scroll"),
813					),
814					key.NewBinding(
815						key.WithKeys("shift+up", "shift+down"),
816						key.WithHelp("shift+↑↓", "next/prev item"),
817					),
818					key.NewBinding(
819						key.WithKeys("pgup", "b"),
820						key.WithHelp("b/pgup", "page up"),
821					),
822					key.NewBinding(
823						key.WithKeys("pgdown", " ", "f"),
824						key.WithHelp("f/pgdn", "page down"),
825					),
826				},
827				[]key.Binding{
828					key.NewBinding(
829						key.WithKeys("u"),
830						key.WithHelp("u", "half page up"),
831					),
832					key.NewBinding(
833						key.WithKeys("d"),
834						key.WithHelp("d", "half page down"),
835					),
836					key.NewBinding(
837						key.WithKeys("g", "home"),
838						key.WithHelp("g", "home"),
839					),
840					key.NewBinding(
841						key.WithKeys("G", "end"),
842						key.WithHelp("G", "end"),
843					),
844				},
845			)
846		case PanelTypeEditor:
847			newLineBinding := key.NewBinding(
848				key.WithKeys("shift+enter", "ctrl+j"),
849				// "ctrl+j" is a common keybinding for newline in many editors. If
850				// the terminal supports "shift+enter", we substitute the help text
851				// to reflect that.
852				key.WithHelp("ctrl+j", "newline"),
853			)
854			if p.keyboardEnhancements.SupportsKeyDisambiguation() {
855				newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
856			}
857			shortList = append(shortList, newLineBinding)
858			fullList = append(fullList,
859				[]key.Binding{
860					newLineBinding,
861					key.NewBinding(
862						key.WithKeys("ctrl+f"),
863						key.WithHelp("ctrl+f", "add image"),
864					),
865					key.NewBinding(
866						key.WithKeys("/"),
867						key.WithHelp("/", "add file"),
868					),
869					key.NewBinding(
870						key.WithKeys("ctrl+v"),
871						key.WithHelp("ctrl+v", "open editor"),
872					),
873					key.NewBinding(
874						key.WithKeys("ctrl+r"),
875						key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
876					),
877					key.NewBinding(
878						key.WithKeys("ctrl+r", "r"),
879						key.WithHelp("ctrl+r+r", "delete all attachments"),
880					),
881					key.NewBinding(
882						key.WithKeys("esc"),
883						key.WithHelp("esc", "cancel delete mode"),
884					),
885				})
886		}
887		shortList = append(shortList,
888			// Quit
889			key.NewBinding(
890				key.WithKeys("ctrl+c"),
891				key.WithHelp("ctrl+c", "quit"),
892			),
893			// Help
894			helpBinding,
895		)
896		fullList = append(fullList, []key.Binding{
897			key.NewBinding(
898				key.WithKeys("ctrl+g"),
899				key.WithHelp("ctrl+g", "less"),
900			),
901		})
902	}
903
904	return core.NewSimpleHelp(shortList, fullList)
905}
906
907func (p *chatPage) IsChatFocused() bool {
908	return p.focusedPane == PanelTypeChat
909}