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