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