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