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", "home"),
877					),
878					key.NewBinding(
879						key.WithKeys("G", "end"),
880						key.WithHelp("G/end", "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}