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