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