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