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