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