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