chat.go

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