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),
121		sidebar:     sidebar.New(app, false),
122		chat:        chat.New(app),
123		editor:      editor.New(app),
124		splash:      splash.New(app.Config()),
125		focusedPane: PanelTypeSplash,
126	}
127}
128
129func (p *chatPage) Init() tea.Cmd {
130	compact := p.app.Config().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(p.app.Config()) {
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(p.app.Config()); 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(p.app.Config()); 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 tea.KeyPressMsg:
291		switch {
292		case key.Matches(msg, p.keyMap.NewSession):
293			if p.app.CoderAgent.IsBusy() {
294				return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
295			}
296			return p, p.newSession()
297		case key.Matches(msg, p.keyMap.AddAttachment):
298			model := p.app.CoderAgent.Model()
299			if model.SupportsImages {
300				return p, util.CmdHandler(OpenFilePickerMsg{})
301			} else {
302				return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
303			}
304		case key.Matches(msg, p.keyMap.Tab):
305			if p.session.ID == "" {
306				u, cmd := p.splash.Update(msg)
307				p.splash = u.(splash.Splash)
308				return p, cmd
309			}
310			p.changeFocus()
311			return p, nil
312		case key.Matches(msg, p.keyMap.Cancel):
313			if p.session.ID != "" && p.app.CoderAgent.IsBusy() {
314				return p, p.cancel()
315			}
316		case key.Matches(msg, p.keyMap.Details):
317			p.toggleDetails()
318			return p, nil
319		}
320
321		switch p.focusedPane {
322		case PanelTypeChat:
323			u, cmd := p.chat.Update(msg)
324			p.chat = u.(chat.MessageListCmp)
325			cmds = append(cmds, cmd)
326		case PanelTypeEditor:
327			u, cmd := p.editor.Update(msg)
328			p.editor = u.(editor.Editor)
329			cmds = append(cmds, cmd)
330		case PanelTypeSplash:
331			u, cmd := p.splash.Update(msg)
332			p.splash = u.(splash.Splash)
333			cmds = append(cmds, cmd)
334		}
335	case tea.PasteMsg:
336		switch p.focusedPane {
337		case PanelTypeEditor:
338			u, cmd := p.editor.Update(msg)
339			p.editor = u.(editor.Editor)
340			cmds = append(cmds, cmd)
341			return p, tea.Batch(cmds...)
342		case PanelTypeChat:
343			u, cmd := p.chat.Update(msg)
344			p.chat = u.(chat.MessageListCmp)
345			cmds = append(cmds, cmd)
346			return p, tea.Batch(cmds...)
347		case PanelTypeSplash:
348			u, cmd := p.splash.Update(msg)
349			p.splash = u.(splash.Splash)
350			cmds = append(cmds, cmd)
351			return p, tea.Batch(cmds...)
352		}
353	}
354	return p, tea.Batch(cmds...)
355}
356
357func (p *chatPage) Cursor() *tea.Cursor {
358	if p.header.ShowingDetails() {
359		return nil
360	}
361	switch p.focusedPane {
362	case PanelTypeEditor:
363		return p.editor.Cursor()
364	case PanelTypeSplash:
365		return p.splash.Cursor()
366	default:
367		return nil
368	}
369}
370
371func (p *chatPage) View() string {
372	var chatView string
373	t := styles.CurrentTheme()
374
375	if p.session.ID == "" {
376		splashView := p.splash.View()
377		// Full screen during onboarding or project initialization
378		if p.splashFullScreen {
379			chatView = splashView
380		} else {
381			// Show splash + editor for new message state
382			editorView := p.editor.View()
383			chatView = lipgloss.JoinVertical(
384				lipgloss.Left,
385				t.S().Base.Render(splashView),
386				editorView,
387			)
388		}
389	} else {
390		messagesView := p.chat.View()
391		editorView := p.editor.View()
392		if p.compact {
393			headerView := p.header.View()
394			chatView = lipgloss.JoinVertical(
395				lipgloss.Left,
396				headerView,
397				messagesView,
398				editorView,
399			)
400		} else {
401			sidebarView := p.sidebar.View()
402			messages := lipgloss.JoinHorizontal(
403				lipgloss.Left,
404				messagesView,
405				sidebarView,
406			)
407			chatView = lipgloss.JoinVertical(
408				lipgloss.Left,
409				messages,
410				p.editor.View(),
411			)
412		}
413	}
414
415	layers := []*lipgloss.Layer{
416		lipgloss.NewLayer(chatView).X(0).Y(0),
417	}
418
419	if p.showingDetails {
420		style := t.S().Base.
421			Width(p.detailsWidth).
422			Border(lipgloss.RoundedBorder()).
423			BorderForeground(t.BorderFocus)
424		version := t.S().Base.Foreground(t.Border).Width(p.detailsWidth - 4).AlignHorizontal(lipgloss.Right).Render(version.Version)
425		details := style.Render(
426			lipgloss.JoinVertical(
427				lipgloss.Left,
428				p.sidebar.View(),
429				version,
430			),
431		)
432		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
433	}
434	canvas := lipgloss.NewCanvas(
435		layers...,
436	)
437	return canvas.Render()
438}
439
440func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
441	return func() tea.Msg {
442		err := p.app.Config().SetCompactMode(compact)
443		if err != nil {
444			return util.InfoMsg{
445				Type: util.InfoTypeError,
446				Msg:  "Failed to update compact mode configuration: " + err.Error(),
447			}
448		}
449		return nil
450	}
451}
452
453func (p *chatPage) toggleThinking() tea.Cmd {
454	return func() tea.Msg {
455		currentModel := p.app.CoderAgent.ModelConfig()
456
457		// Toggle the thinking mode
458		currentModel.Think = !currentModel.Think
459		p.app.Config().Models[config.SelectedModelTypeLarge] = 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				key.NewBinding(
825					key.WithKeys("c", "y"),
826					key.WithHelp("c/y", "copy"),
827				),
828			)
829			fullList = append(fullList,
830				[]key.Binding{
831					key.NewBinding(
832						key.WithKeys("up", "down"),
833						key.WithHelp("↑↓", "scroll"),
834					),
835					key.NewBinding(
836						key.WithKeys("shift+up", "shift+down"),
837						key.WithHelp("shift+↑↓", "next/prev item"),
838					),
839					key.NewBinding(
840						key.WithKeys("pgup", "b"),
841						key.WithHelp("b/pgup", "page up"),
842					),
843					key.NewBinding(
844						key.WithKeys("pgdown", " ", "f"),
845						key.WithHelp("f/pgdn", "page down"),
846					),
847				},
848				[]key.Binding{
849					key.NewBinding(
850						key.WithKeys("u"),
851						key.WithHelp("u", "half page up"),
852					),
853					key.NewBinding(
854						key.WithKeys("d"),
855						key.WithHelp("d", "half page down"),
856					),
857					key.NewBinding(
858						key.WithKeys("g", "home"),
859						key.WithHelp("g", "home"),
860					),
861					key.NewBinding(
862						key.WithKeys("G", "end"),
863						key.WithHelp("G", "end"),
864					),
865				},
866			)
867		case PanelTypeEditor:
868			newLineBinding := key.NewBinding(
869				key.WithKeys("shift+enter", "ctrl+j"),
870				// "ctrl+j" is a common keybinding for newline in many editors. If
871				// the terminal supports "shift+enter", we substitute the help text
872				// to reflect that.
873				key.WithHelp("ctrl+j", "newline"),
874			)
875			if p.keyboardEnhancements.SupportsKeyDisambiguation() {
876				newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
877			}
878			shortList = append(shortList, newLineBinding)
879			fullList = append(fullList,
880				[]key.Binding{
881					newLineBinding,
882					key.NewBinding(
883						key.WithKeys("ctrl+f"),
884						key.WithHelp("ctrl+f", "add image"),
885					),
886					key.NewBinding(
887						key.WithKeys("/"),
888						key.WithHelp("/", "add file"),
889					),
890					key.NewBinding(
891						key.WithKeys("ctrl+v"),
892						key.WithHelp("ctrl+v", "open editor"),
893					),
894					key.NewBinding(
895						key.WithKeys("ctrl+r"),
896						key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
897					),
898					key.NewBinding(
899						key.WithKeys("ctrl+r", "r"),
900						key.WithHelp("ctrl+r+r", "delete all attachments"),
901					),
902					key.NewBinding(
903						key.WithKeys("esc"),
904						key.WithHelp("esc", "cancel delete mode"),
905					),
906				})
907		}
908		shortList = append(shortList,
909			// Quit
910			key.NewBinding(
911				key.WithKeys("ctrl+c"),
912				key.WithHelp("ctrl+c", "quit"),
913			),
914			// Help
915			helpBinding,
916		)
917		fullList = append(fullList, []key.Binding{
918			key.NewBinding(
919				key.WithKeys("ctrl+g"),
920				key.WithHelp("ctrl+g", "less"),
921			),
922		})
923	}
924
925	return core.NewSimpleHelp(shortList, fullList)
926}
927
928func (p *chatPage) IsChatFocused() bool {
929	return p.focusedPane == PanelTypeChat
930}
931
932// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
933// Returns true if the mouse is over the chat area, false otherwise.
934func (p *chatPage) isMouseOverChat(x, y int) bool {
935	// No session means no chat area
936	if p.session.ID == "" {
937		return false
938	}
939
940	var chatX, chatY, chatWidth, chatHeight int
941
942	if p.compact {
943		// In compact mode: chat area starts after header and spans full width
944		chatX = 0
945		chatY = HeaderHeight
946		chatWidth = p.width
947		chatHeight = p.height - EditorHeight - HeaderHeight
948	} else {
949		// In non-compact mode: chat area spans from left edge to sidebar
950		chatX = 0
951		chatY = 0
952		chatWidth = p.width - SideBarWidth
953		chatHeight = p.height - EditorHeight
954	}
955
956	// Check if mouse coordinates are within chat bounds
957	return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
958}