chat.go

  1package chat
  2
  3import (
  4	"context"
  5	"time"
  6
  7	"github.com/charmbracelet/bubbles/v2/key"
  8	"github.com/charmbracelet/bubbles/v2/spinner"
  9	tea "github.com/charmbracelet/bubbletea/v2"
 10	"github.com/charmbracelet/crush/internal/app"
 11	"github.com/charmbracelet/crush/internal/config"
 12	"github.com/charmbracelet/crush/internal/history"
 13	"github.com/charmbracelet/crush/internal/message"
 14	"github.com/charmbracelet/crush/internal/pubsub"
 15	"github.com/charmbracelet/crush/internal/session"
 16	"github.com/charmbracelet/crush/internal/tui/components/anim"
 17	"github.com/charmbracelet/crush/internal/tui/components/chat"
 18	"github.com/charmbracelet/crush/internal/tui/components/chat/editor"
 19	"github.com/charmbracelet/crush/internal/tui/components/chat/header"
 20	"github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
 21	"github.com/charmbracelet/crush/internal/tui/components/chat/splash"
 22	"github.com/charmbracelet/crush/internal/tui/components/completions"
 23	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 24	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
 25	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 26	"github.com/charmbracelet/crush/internal/tui/page"
 27	"github.com/charmbracelet/crush/internal/tui/styles"
 28	"github.com/charmbracelet/crush/internal/tui/util"
 29	"github.com/charmbracelet/crush/internal/version"
 30	"github.com/charmbracelet/lipgloss/v2"
 31)
 32
 33var ChatPageID page.PageID = "chat"
 34
 35type (
 36	OpenFilePickerMsg struct{}
 37	ChatFocusedMsg    struct {
 38		Focused bool
 39	}
 40	CancelTimerExpiredMsg struct{}
 41)
 42
 43type PanelType string
 44
 45const (
 46	PanelTypeChat   PanelType = "chat"
 47	PanelTypeEditor PanelType = "editor"
 48	PanelTypeSplash PanelType = "splash"
 49)
 50
 51const (
 52	CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
 53	EditorHeight          = 5   // Height of the editor input area including padding
 54	SideBarWidth          = 31  // Width of the sidebar
 55	SideBarDetailsPadding = 1   // Padding for the sidebar details section
 56	HeaderHeight          = 1   // Height of the header
 57
 58	// Layout constants for borders and padding
 59	BorderWidth        = 1 // Width of component borders
 60	LeftRightBorders   = 2 // Left + right border width (1 + 1)
 61	TopBottomBorders   = 2 // Top + bottom border width (1 + 1)
 62	DetailsPositioning = 2 // Positioning adjustment for details panel
 63
 64	// Timing constants
 65	CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires
 66)
 67
 68type ChatPage interface {
 69	util.Model
 70	layout.Help
 71	IsChatFocused() bool
 72}
 73
 74// cancelTimerCmd creates a command that expires the cancel timer
 75func cancelTimerCmd() tea.Cmd {
 76	return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg {
 77		return CancelTimerExpiredMsg{}
 78	})
 79}
 80
 81type chatPage struct {
 82	width, height               int
 83	detailsWidth, detailsHeight int
 84	app                         *app.App
 85
 86	// Layout state
 87	compact      bool
 88	forceCompact bool
 89	focusedPane  PanelType
 90
 91	// Session
 92	session session.Session
 93	keyMap  KeyMap
 94
 95	// Components
 96	header  header.Header
 97	sidebar sidebar.Sidebar
 98	chat    chat.MessageListCmp
 99	editor  editor.Editor
100	splash  splash.Splash
101
102	// Simple state flags
103	showingDetails   bool
104	isCanceling      bool
105	splashFullScreen bool
106}
107
108func New(app *app.App) ChatPage {
109	return &chatPage{
110		app:         app,
111		keyMap:      DefaultKeyMap(),
112		header:      header.New(app.LSPClients),
113		sidebar:     sidebar.New(app.History, app.LSPClients, false),
114		chat:        chat.New(app),
115		editor:      editor.New(app),
116		splash:      splash.New(),
117		focusedPane: PanelTypeSplash,
118	}
119}
120
121func (p *chatPage) Init() tea.Cmd {
122	cfg := config.Get()
123	compact := cfg.Options.TUI.CompactMode
124	p.compact = compact
125	p.forceCompact = compact
126	p.sidebar.SetCompactMode(p.compact)
127
128	// Set splash state based on config
129	if !config.HasInitialDataConfig() {
130		// First-time setup: show model selection
131		p.splash.SetOnboarding(true)
132		p.splashFullScreen = true
133	} else if b, _ := config.ProjectNeedsInitialization(); b {
134		// Project needs CRUSH.md initialization
135		p.splash.SetProjectInit(true)
136		p.splashFullScreen = true
137	} else {
138		// Ready to chat: focus editor, splash in background
139		p.focusedPane = PanelTypeEditor
140		p.splashFullScreen = false
141	}
142
143	return tea.Batch(
144		p.header.Init(),
145		p.sidebar.Init(),
146		p.chat.Init(),
147		p.editor.Init(),
148		p.splash.Init(),
149	)
150}
151
152func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
153	var cmds []tea.Cmd
154	switch msg := msg.(type) {
155	case tea.KeyboardEnhancementsMsg:
156		m, cmd := p.editor.Update(msg)
157		p.editor = m.(editor.Editor)
158		return p, cmd
159	case tea.WindowSizeMsg:
160		return p, p.SetSize(msg.Width, msg.Height)
161	case CancelTimerExpiredMsg:
162		p.isCanceling = false
163		return p, nil
164	case chat.SendMsg:
165		return p, p.sendMessage(msg.Text, msg.Attachments)
166	case chat.SessionSelectedMsg:
167		return p, p.setSession(msg)
168	case commands.ToggleCompactModeMsg:
169		p.forceCompact = !p.forceCompact
170		var cmd tea.Cmd
171		if p.forceCompact {
172			p.setCompactMode(true)
173			cmd = p.updateCompactConfig(true)
174		} else if p.width >= CompactModeBreakpoint {
175			p.setCompactMode(false)
176			cmd = p.updateCompactConfig(false)
177		}
178		return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
179	case pubsub.Event[session.Session]:
180		u, cmd := p.header.Update(msg)
181		p.header = u.(header.Header)
182		cmds = append(cmds, cmd)
183		u, cmd = p.sidebar.Update(msg)
184		p.sidebar = u.(sidebar.Sidebar)
185		cmds = append(cmds, cmd)
186		return p, tea.Batch(cmds...)
187	case chat.SessionClearedMsg:
188		u, cmd := p.header.Update(msg)
189		p.header = u.(header.Header)
190		cmds = append(cmds, cmd)
191		u, cmd = p.sidebar.Update(msg)
192		p.sidebar = u.(sidebar.Sidebar)
193		cmds = append(cmds, cmd)
194		u, cmd = p.chat.Update(msg)
195		p.chat = u.(chat.MessageListCmp)
196		cmds = append(cmds, cmd)
197		return p, tea.Batch(cmds...)
198	case filepicker.FilePickedMsg,
199		completions.CompletionsClosedMsg,
200		completions.SelectCompletionMsg:
201		u, cmd := p.editor.Update(msg)
202		p.editor = u.(editor.Editor)
203		cmds = append(cmds, cmd)
204		return p, tea.Batch(cmds...)
205
206	case pubsub.Event[message.Message],
207		anim.StepMsg,
208		spinner.TickMsg:
209		u, cmd := p.chat.Update(msg)
210		p.chat = u.(chat.MessageListCmp)
211		cmds = append(cmds, cmd)
212		return p, tea.Batch(cmds...)
213
214	case pubsub.Event[history.File], sidebar.SessionFilesMsg:
215		u, cmd := p.sidebar.Update(msg)
216		p.sidebar = u.(sidebar.Sidebar)
217		cmds = append(cmds, cmd)
218		return p, tea.Batch(cmds...)
219
220	case commands.CommandRunCustomMsg:
221		if p.app.CoderAgent.IsBusy() {
222			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
223		}
224
225		cmd := p.sendMessage(msg.Content, nil)
226		if cmd != nil {
227			return p, cmd
228		}
229	case splash.OnboardingCompleteMsg:
230		p.splashFullScreen = false
231		if b, _ := config.ProjectNeedsInitialization(); b {
232			p.splash.SetProjectInit(true)
233			p.splashFullScreen = true
234			return p, p.SetSize(p.width, p.height)
235		}
236		err := p.app.InitCoderAgent()
237		if err != nil {
238			return p, util.ReportError(err)
239		}
240		p.focusedPane = PanelTypeEditor
241		return p, p.SetSize(p.width, p.height)
242	case tea.KeyPressMsg:
243		switch {
244		case key.Matches(msg, p.keyMap.NewSession):
245			return p, p.newSession()
246		case key.Matches(msg, p.keyMap.AddAttachment):
247			agentCfg := config.Get().Agents["coder"]
248			model := config.Get().GetModelByType(agentCfg.Model)
249			if model.SupportsImages {
250				return p, util.CmdHandler(OpenFilePickerMsg{})
251			} else {
252				return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Model)
253			}
254		case key.Matches(msg, p.keyMap.Tab):
255			if p.session.ID == "" {
256				u, cmd := p.splash.Update(msg)
257				p.splash = u.(splash.Splash)
258				return p, cmd
259			}
260			p.changeFocus()
261			return p, nil
262		case key.Matches(msg, p.keyMap.Cancel):
263			if p.session.ID != "" && p.app.CoderAgent.IsBusy() {
264				return p, p.cancel()
265			}
266		case key.Matches(msg, p.keyMap.Details):
267			p.showDetails()
268			return p, nil
269		}
270
271		switch p.focusedPane {
272		case PanelTypeChat:
273			u, cmd := p.chat.Update(msg)
274			p.chat = u.(chat.MessageListCmp)
275			cmds = append(cmds, cmd)
276		case PanelTypeEditor:
277			u, cmd := p.editor.Update(msg)
278			p.editor = u.(editor.Editor)
279			cmds = append(cmds, cmd)
280		case PanelTypeSplash:
281			u, cmd := p.splash.Update(msg)
282			p.splash = u.(splash.Splash)
283			cmds = append(cmds, cmd)
284		}
285	case tea.PasteMsg:
286		switch p.focusedPane {
287		case PanelTypeEditor:
288			u, cmd := p.editor.Update(msg)
289			p.editor = u.(editor.Editor)
290			cmds = append(cmds, cmd)
291			return p, tea.Batch(cmds...)
292		case PanelTypeChat:
293			u, cmd := p.chat.Update(msg)
294			p.chat = u.(chat.MessageListCmp)
295			cmds = append(cmds, cmd)
296			return p, tea.Batch(cmds...)
297		case PanelTypeSplash:
298			u, cmd := p.splash.Update(msg)
299			p.splash = u.(splash.Splash)
300			cmds = append(cmds, cmd)
301			return p, tea.Batch(cmds...)
302		}
303	}
304	return p, tea.Batch(cmds...)
305}
306
307func (p *chatPage) Cursor() *tea.Cursor {
308	switch p.focusedPane {
309	case PanelTypeEditor:
310		return p.editor.Cursor()
311	case PanelTypeSplash:
312		return p.splash.Cursor()
313	default:
314		return nil
315	}
316}
317
318func (p *chatPage) View() string {
319	var chatView string
320	t := styles.CurrentTheme()
321
322	if p.session.ID == "" {
323		splashView := p.splash.View()
324		// Full screen during onboarding or project initialization
325		if p.splashFullScreen {
326			chatView = splashView
327		} else {
328			// Show splash + editor for new message state
329			editorView := p.editor.View()
330			chatView = lipgloss.JoinVertical(
331				lipgloss.Left,
332				t.S().Base.Render(splashView),
333				editorView,
334			)
335		}
336	} else {
337		messagesView := p.chat.View()
338		editorView := p.editor.View()
339		if p.compact {
340			headerView := p.header.View()
341			chatView = lipgloss.JoinVertical(
342				lipgloss.Left,
343				headerView,
344				messagesView,
345				editorView,
346			)
347		} else {
348			sidebarView := p.sidebar.View()
349			messages := lipgloss.JoinHorizontal(
350				lipgloss.Left,
351				messagesView,
352				sidebarView,
353			)
354			chatView = lipgloss.JoinVertical(
355				lipgloss.Left,
356				messages,
357				p.editor.View(),
358			)
359		}
360	}
361
362	layers := []*lipgloss.Layer{
363		lipgloss.NewLayer(chatView).X(0).Y(0),
364	}
365
366	if p.showingDetails {
367		style := t.S().Base.
368			Width(p.detailsWidth).
369			Border(lipgloss.RoundedBorder()).
370			BorderForeground(t.BorderFocus)
371		version := t.S().Subtle.Width(p.detailsWidth - 2).AlignHorizontal(lipgloss.Right).Render(version.Version)
372		details := style.Render(
373			lipgloss.JoinVertical(
374				lipgloss.Left,
375				p.sidebar.View(),
376				version,
377			),
378		)
379		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
380	}
381	canvas := lipgloss.NewCanvas(
382		layers...,
383	)
384	return canvas.Render()
385}
386
387func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
388	return func() tea.Msg {
389		err := config.Get().SetCompactMode(compact)
390		if err != nil {
391			return util.InfoMsg{
392				Type: util.InfoTypeError,
393				Msg:  "Failed to update compact mode configuration: " + err.Error(),
394			}
395		}
396		return nil
397	}
398}
399
400func (p *chatPage) setCompactMode(compact bool) {
401	if p.compact == compact {
402		return
403	}
404	p.compact = compact
405	if compact {
406		p.compact = true
407		p.sidebar.SetCompactMode(true)
408	} else {
409		p.compact = false
410		p.showingDetails = false
411		p.sidebar.SetCompactMode(false)
412	}
413}
414
415func (p *chatPage) handleCompactMode(newWidth int) {
416	if p.forceCompact {
417		return
418	}
419	if newWidth < CompactModeBreakpoint && !p.compact {
420		p.setCompactMode(true)
421	}
422	if newWidth >= CompactModeBreakpoint && p.compact {
423		p.setCompactMode(false)
424	}
425}
426
427func (p *chatPage) SetSize(width, height int) tea.Cmd {
428	p.handleCompactMode(width)
429	p.width = width
430	p.height = height
431	var cmds []tea.Cmd
432
433	if p.session.ID == "" {
434		if p.splashFullScreen {
435			cmds = append(cmds, p.splash.SetSize(width, height))
436		} else {
437			cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
438			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
439			cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
440		}
441	} else {
442		if p.compact {
443			cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
444			p.detailsWidth = width - DetailsPositioning
445			cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
446			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
447			cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
448		} else {
449			cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
450			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
451			cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
452		}
453		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
454	}
455	return tea.Batch(cmds...)
456}
457
458func (p *chatPage) newSession() tea.Cmd {
459	if p.session.ID == "" {
460		return nil
461	}
462
463	p.session = session.Session{}
464	p.focusedPane = PanelTypeEditor
465	p.isCanceling = false
466	return tea.Batch(
467		util.CmdHandler(chat.SessionClearedMsg{}),
468		p.SetSize(p.width, p.height),
469	)
470}
471
472func (p *chatPage) setSession(session session.Session) tea.Cmd {
473	if p.session.ID == session.ID {
474		return nil
475	}
476
477	var cmds []tea.Cmd
478	p.session = session
479
480	cmds = append(cmds, p.SetSize(p.width, p.height))
481	cmds = append(cmds, p.chat.SetSession(session))
482	cmds = append(cmds, p.sidebar.SetSession(session))
483	cmds = append(cmds, p.header.SetSession(session))
484	cmds = append(cmds, p.editor.SetSession(session))
485
486	return tea.Sequence(cmds...)
487}
488
489func (p *chatPage) changeFocus() {
490	if p.session.ID == "" {
491		return
492	}
493	switch p.focusedPane {
494	case PanelTypeChat:
495		p.focusedPane = PanelTypeEditor
496		p.editor.Focus()
497		p.chat.Blur()
498	case PanelTypeEditor:
499		p.focusedPane = PanelTypeChat
500		p.chat.Focus()
501		p.editor.Blur()
502	}
503}
504
505func (p *chatPage) cancel() tea.Cmd {
506	if p.isCanceling {
507		p.isCanceling = false
508		p.app.CoderAgent.Cancel(p.session.ID)
509		return nil
510	}
511
512	p.isCanceling = true
513	return cancelTimerCmd()
514}
515
516func (p *chatPage) showDetails() {
517	if p.session.ID == "" || !p.compact {
518		return
519	}
520	p.showingDetails = !p.showingDetails
521	p.header.SetDetailsOpen(p.showingDetails)
522}
523
524func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
525	session := p.session
526	var cmds []tea.Cmd
527	if p.session.ID == "" {
528		newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
529		if err != nil {
530			return util.ReportError(err)
531		}
532		session = newSession
533		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
534	}
535	_, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
536	if err != nil {
537		return util.ReportError(err)
538	}
539	return tea.Batch(cmds...)
540}
541
542func (p *chatPage) Bindings() []key.Binding {
543	bindings := []key.Binding{
544		p.keyMap.NewSession,
545		p.keyMap.AddAttachment,
546	}
547	if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
548		cancelBinding := p.keyMap.Cancel
549		if p.isCanceling {
550			cancelBinding = key.NewBinding(
551				key.WithKeys("esc"),
552				key.WithHelp("esc", "press again to cancel"),
553			)
554		}
555		bindings = append([]key.Binding{cancelBinding}, bindings...)
556	}
557
558	switch p.focusedPane {
559	case PanelTypeChat:
560		bindings = append([]key.Binding{
561			key.NewBinding(
562				key.WithKeys("tab"),
563				key.WithHelp("tab", "focus editor"),
564			),
565		}, bindings...)
566		bindings = append(bindings, p.chat.Bindings()...)
567	case PanelTypeEditor:
568		bindings = append([]key.Binding{
569			key.NewBinding(
570				key.WithKeys("tab"),
571				key.WithHelp("tab", "focus chat"),
572			),
573		}, bindings...)
574		bindings = append(bindings, p.editor.Bindings()...)
575	case PanelTypeSplash:
576		bindings = append(bindings, p.splash.Bindings()...)
577	}
578
579	return bindings
580}
581
582func (p *chatPage) IsChatFocused() bool {
583	return p.focusedPane == PanelTypeChat
584}