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