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.WindowSizeMsg:
156		return p, p.SetSize(msg.Width, msg.Height)
157	case CancelTimerExpiredMsg:
158		p.isCanceling = false
159		return p, nil
160	case chat.SendMsg:
161		return p, p.sendMessage(msg.Text, msg.Attachments)
162	case chat.SessionSelectedMsg:
163		return p, p.setSession(msg)
164	case commands.ToggleCompactModeMsg:
165		p.forceCompact = !p.forceCompact
166		var cmd tea.Cmd
167		if p.forceCompact {
168			p.setCompactMode(true)
169			cmd = p.updateCompactConfig(true)
170		} else if p.width >= CompactModeBreakpoint {
171			p.setCompactMode(false)
172			cmd = p.updateCompactConfig(false)
173		}
174		return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
175	case pubsub.Event[session.Session]:
176		u, cmd := p.header.Update(msg)
177		p.header = u.(header.Header)
178		cmds = append(cmds, cmd)
179		u, cmd = p.sidebar.Update(msg)
180		p.sidebar = u.(sidebar.Sidebar)
181		cmds = append(cmds, cmd)
182		return p, tea.Batch(cmds...)
183	case chat.SessionClearedMsg:
184		u, cmd := p.header.Update(msg)
185		p.header = u.(header.Header)
186		cmds = append(cmds, cmd)
187		u, cmd = p.sidebar.Update(msg)
188		p.sidebar = u.(sidebar.Sidebar)
189		cmds = append(cmds, cmd)
190		u, cmd = p.chat.Update(msg)
191		p.chat = u.(chat.MessageListCmp)
192		cmds = append(cmds, cmd)
193		return p, tea.Batch(cmds...)
194	case filepicker.FilePickedMsg,
195		completions.CompletionsClosedMsg,
196		completions.SelectCompletionMsg:
197		u, cmd := p.editor.Update(msg)
198		p.editor = u.(editor.Editor)
199		cmds = append(cmds, cmd)
200		return p, tea.Batch(cmds...)
201
202	case pubsub.Event[message.Message],
203		anim.StepMsg,
204		spinner.TickMsg:
205		u, cmd := p.chat.Update(msg)
206		p.chat = u.(chat.MessageListCmp)
207		cmds = append(cmds, cmd)
208		return p, tea.Batch(cmds...)
209
210	case pubsub.Event[history.File], sidebar.SessionFilesMsg:
211		u, cmd := p.sidebar.Update(msg)
212		p.sidebar = u.(sidebar.Sidebar)
213		cmds = append(cmds, cmd)
214		return p, tea.Batch(cmds...)
215
216	case commands.CommandRunCustomMsg:
217		if p.app.CoderAgent.IsBusy() {
218			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
219		}
220
221		cmd := p.sendMessage(msg.Content, nil)
222		if cmd != nil {
223			return p, cmd
224		}
225	case splash.OnboardingCompleteMsg:
226		p.splashFullScreen = false
227		if b, _ := config.ProjectNeedsInitialization(); b {
228			p.splash.SetProjectInit(true)
229			p.splashFullScreen = true
230			return p, p.SetSize(p.width, p.height)
231		}
232		err := p.app.InitCoderAgent()
233		if err != nil {
234			return p, util.ReportError(err)
235		}
236		p.focusedPane = PanelTypeEditor
237		return p, p.SetSize(p.width, p.height)
238	case tea.KeyPressMsg:
239		switch {
240		case key.Matches(msg, p.keyMap.NewSession):
241			return p, p.newSession()
242		case key.Matches(msg, p.keyMap.AddAttachment):
243			agentCfg := config.Get().Agents["coder"]
244			model := config.Get().GetModelByType(agentCfg.Model)
245			if model.SupportsImages {
246				return p, util.CmdHandler(OpenFilePickerMsg{})
247			} else {
248				return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Model)
249			}
250		case key.Matches(msg, p.keyMap.Tab):
251			if p.session.ID == "" {
252				u, cmd := p.splash.Update(msg)
253				p.splash = u.(splash.Splash)
254				return p, cmd
255			}
256			p.changeFocus()
257			return p, nil
258		case key.Matches(msg, p.keyMap.Cancel):
259			if p.session.ID != "" && p.app.CoderAgent.IsBusy() {
260				return p, p.cancel()
261			}
262		case key.Matches(msg, p.keyMap.Details):
263			p.showDetails()
264			return p, nil
265		}
266
267		switch p.focusedPane {
268		case PanelTypeChat:
269			u, cmd := p.chat.Update(msg)
270			p.chat = u.(chat.MessageListCmp)
271			cmds = append(cmds, cmd)
272		case PanelTypeEditor:
273			u, cmd := p.editor.Update(msg)
274			p.editor = u.(editor.Editor)
275			cmds = append(cmds, cmd)
276		case PanelTypeSplash:
277			u, cmd := p.splash.Update(msg)
278			p.splash = u.(splash.Splash)
279			cmds = append(cmds, cmd)
280		}
281	case tea.PasteMsg:
282		switch p.focusedPane {
283		case PanelTypeEditor:
284			u, cmd := p.editor.Update(msg)
285			p.editor = u.(editor.Editor)
286			cmds = append(cmds, cmd)
287			return p, tea.Batch(cmds...)
288		case PanelTypeChat:
289			u, cmd := p.chat.Update(msg)
290			p.chat = u.(chat.MessageListCmp)
291			cmds = append(cmds, cmd)
292			return p, tea.Batch(cmds...)
293		case PanelTypeSplash:
294			u, cmd := p.splash.Update(msg)
295			p.splash = u.(splash.Splash)
296			cmds = append(cmds, cmd)
297			return p, tea.Batch(cmds...)
298		}
299	}
300	return p, tea.Batch(cmds...)
301}
302
303func (p *chatPage) Cursor() *tea.Cursor {
304	switch p.focusedPane {
305	case PanelTypeEditor:
306		return p.editor.Cursor()
307	case PanelTypeSplash:
308		return p.splash.Cursor()
309	default:
310		return nil
311	}
312}
313
314func (p *chatPage) View() string {
315	var chatView string
316	t := styles.CurrentTheme()
317
318	if p.session.ID == "" {
319		splashView := p.splash.View()
320		// Full screen during onboarding or project initialization
321		if p.splashFullScreen {
322			chatView = splashView
323		} else {
324			// Show splash + editor for new message state
325			editorView := p.editor.View()
326			chatView = lipgloss.JoinVertical(
327				lipgloss.Left,
328				t.S().Base.Render(splashView),
329				editorView,
330			)
331		}
332	} else {
333		messagesView := p.chat.View()
334		editorView := p.editor.View()
335		if p.compact {
336			headerView := p.header.View()
337			chatView = lipgloss.JoinVertical(
338				lipgloss.Left,
339				headerView,
340				messagesView,
341				editorView,
342			)
343		} else {
344			sidebarView := p.sidebar.View()
345			messages := lipgloss.JoinHorizontal(
346				lipgloss.Left,
347				messagesView,
348				sidebarView,
349			)
350			chatView = lipgloss.JoinVertical(
351				lipgloss.Left,
352				messages,
353				p.editor.View(),
354			)
355		}
356	}
357
358	layers := []*lipgloss.Layer{
359		lipgloss.NewLayer(chatView).X(0).Y(0),
360	}
361
362	if p.showingDetails {
363		style := t.S().Base.
364			Width(p.detailsWidth).
365			Border(lipgloss.RoundedBorder()).
366			BorderForeground(t.BorderFocus)
367		version := t.S().Subtle.Width(p.detailsWidth - 2).AlignHorizontal(lipgloss.Right).Render(version.Version)
368		details := style.Render(
369			lipgloss.JoinVertical(
370				lipgloss.Left,
371				p.sidebar.View(),
372				version,
373			),
374		)
375		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
376	}
377	canvas := lipgloss.NewCanvas(
378		layers...,
379	)
380	return canvas.Render()
381}
382
383func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
384	return func() tea.Msg {
385		err := config.Get().SetCompactMode(compact)
386		if err != nil {
387			return util.InfoMsg{
388				Type: util.InfoTypeError,
389				Msg:  "Failed to update compact mode configuration: " + err.Error(),
390			}
391		}
392		return nil
393	}
394}
395
396func (p *chatPage) setCompactMode(compact bool) {
397	if p.compact == compact {
398		return
399	}
400	p.compact = compact
401	if compact {
402		p.compact = true
403		p.sidebar.SetCompactMode(true)
404	} else {
405		p.compact = false
406		p.showingDetails = false
407		p.sidebar.SetCompactMode(false)
408	}
409}
410
411func (p *chatPage) handleCompactMode(newWidth int) {
412	if p.forceCompact {
413		return
414	}
415	if newWidth < CompactModeBreakpoint && !p.compact {
416		p.setCompactMode(true)
417	}
418	if newWidth >= CompactModeBreakpoint && p.compact {
419		p.setCompactMode(false)
420	}
421}
422
423func (p *chatPage) SetSize(width, height int) tea.Cmd {
424	p.handleCompactMode(width)
425	p.width = width
426	p.height = height
427	var cmds []tea.Cmd
428
429	if p.session.ID == "" {
430		if p.splashFullScreen {
431			cmds = append(cmds, p.splash.SetSize(width, height))
432		} else {
433			cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
434			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
435			cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
436		}
437	} else {
438		if p.compact {
439			cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
440			p.detailsWidth = width - DetailsPositioning
441			cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
442			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
443			cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
444		} else {
445			cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
446			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
447			cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
448		}
449		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
450	}
451	return tea.Batch(cmds...)
452}
453
454func (p *chatPage) newSession() tea.Cmd {
455	if p.session.ID == "" {
456		return nil
457	}
458
459	p.session = session.Session{}
460	p.focusedPane = PanelTypeEditor
461	p.isCanceling = false
462	return tea.Batch(
463		util.CmdHandler(chat.SessionClearedMsg{}),
464		p.SetSize(p.width, p.height),
465	)
466}
467
468func (p *chatPage) setSession(session session.Session) tea.Cmd {
469	if p.session.ID == session.ID {
470		return nil
471	}
472
473	var cmds []tea.Cmd
474	p.session = session
475
476	cmds = append(cmds, p.SetSize(p.width, p.height))
477	cmds = append(cmds, p.chat.SetSession(session))
478	cmds = append(cmds, p.sidebar.SetSession(session))
479	cmds = append(cmds, p.header.SetSession(session))
480	cmds = append(cmds, p.editor.SetSession(session))
481
482	return tea.Sequence(cmds...)
483}
484
485func (p *chatPage) changeFocus() {
486	if p.session.ID == "" {
487		return
488	}
489	switch p.focusedPane {
490	case PanelTypeChat:
491		p.focusedPane = PanelTypeEditor
492		p.editor.Focus()
493		p.chat.Blur()
494	case PanelTypeEditor:
495		p.focusedPane = PanelTypeChat
496		p.chat.Focus()
497		p.editor.Blur()
498	}
499}
500
501func (p *chatPage) cancel() tea.Cmd {
502	if p.isCanceling {
503		p.isCanceling = false
504		p.app.CoderAgent.Cancel(p.session.ID)
505		return nil
506	}
507
508	p.isCanceling = true
509	return cancelTimerCmd()
510}
511
512func (p *chatPage) showDetails() {
513	if p.session.ID == "" || !p.compact {
514		return
515	}
516	p.showingDetails = !p.showingDetails
517	p.header.SetDetailsOpen(p.showingDetails)
518}
519
520func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
521	session := p.session
522	var cmds []tea.Cmd
523	if p.session.ID == "" {
524		newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
525		if err != nil {
526			return util.ReportError(err)
527		}
528		session = newSession
529		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
530	}
531	_, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
532	if err != nil {
533		return util.ReportError(err)
534	}
535	return tea.Batch(cmds...)
536}
537
538func (p *chatPage) Bindings() []key.Binding {
539	bindings := []key.Binding{
540		p.keyMap.NewSession,
541		p.keyMap.AddAttachment,
542	}
543	if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
544		cancelBinding := p.keyMap.Cancel
545		if p.isCanceling {
546			cancelBinding = key.NewBinding(
547				key.WithKeys("esc"),
548				key.WithHelp("esc", "press again to cancel"),
549			)
550		}
551		bindings = append([]key.Binding{cancelBinding}, bindings...)
552	}
553
554	switch p.focusedPane {
555	case PanelTypeChat:
556		bindings = append([]key.Binding{
557			key.NewBinding(
558				key.WithKeys("tab"),
559				key.WithHelp("tab", "focus editor"),
560			),
561		}, bindings...)
562		bindings = append(bindings, p.chat.Bindings()...)
563	case PanelTypeEditor:
564		bindings = append([]key.Binding{
565			key.NewBinding(
566				key.WithKeys("tab"),
567				key.WithHelp("tab", "focus chat"),
568			),
569		}, bindings...)
570		bindings = append(bindings, p.editor.Bindings()...)
571	case PanelTypeSplash:
572		bindings = append(bindings, p.splash.Bindings()...)
573	}
574
575	return bindings
576}
577
578func (p *chatPage) IsChatFocused() bool {
579	return p.focusedPane == PanelTypeChat
580}