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