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