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