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 // True if the chat input is focused, false otherwise
 39	}
 40	CancelTimerExpiredMsg struct{}
 41)
 42
 43type ChatState string
 44
 45const (
 46	ChatStateOnboarding  ChatState = "onboarding"
 47	ChatStateInitProject ChatState = "init_project"
 48	ChatStateNewMessage  ChatState = "new_message"
 49	ChatStateInSession   ChatState = "in_session"
 50)
 51
 52type PanelType string
 53
 54const (
 55	PanelTypeChat   PanelType = "chat"
 56	PanelTypeEditor PanelType = "editor"
 57	PanelTypeSplash PanelType = "splash"
 58)
 59
 60const (
 61	CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
 62	EditorHeight          = 5   // Height of the editor input area including padding
 63	SideBarWidth          = 31  // Width of the sidebar
 64	SideBarDetailsPadding = 1   // Padding for the sidebar details section
 65	HeaderHeight          = 1   // Height of the header
 66)
 67
 68type ChatPage interface {
 69	util.Model
 70	layout.Help
 71}
 72
 73// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds
 74func cancelTimerCmd() tea.Cmd {
 75	return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
 76		return CancelTimerExpiredMsg{}
 77	})
 78}
 79
 80type chatPage struct {
 81	width, height               int
 82	detailsWidth, detailsHeight int
 83	app                         *app.App
 84	state                       ChatState
 85	session                     session.Session
 86	keyMap                      KeyMap
 87	focusedPane                 PanelType
 88	// Compact mode
 89	compact        bool
 90	header         header.Header
 91	showingDetails bool
 92
 93	sidebar   sidebar.Sidebar
 94	chat      chat.MessageListCmp
 95	editor    editor.Editor
 96	splash    splash.Splash
 97	canceling bool
 98
 99	// This will force the compact mode even in big screens
100	// usually triggered by the user command
101	// this will also be set when the user config is set to compact mode
102	forceCompact bool
103}
104
105func New(app *app.App) ChatPage {
106	return &chatPage{
107		app:   app,
108		state: ChatStateOnboarding,
109
110		keyMap: DefaultKeyMap(),
111
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	if config.HasInitialDataConfig() {
124		if b, _ := config.ProjectNeedsInitialization(); b {
125			p.state = ChatStateInitProject
126		} else {
127			p.state = ChatStateNewMessage
128			p.focusedPane = PanelTypeEditor
129		}
130	}
131
132	compact := cfg.Options.TUI.CompactMode
133	p.compact = compact
134	p.forceCompact = compact
135	p.sidebar.SetCompactMode(p.compact)
136	return tea.Batch(
137		p.header.Init(),
138		p.sidebar.Init(),
139		p.chat.Init(),
140		p.editor.Init(),
141		p.splash.Init(),
142	)
143}
144
145func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
146	var cmds []tea.Cmd
147	switch msg := msg.(type) {
148	case tea.WindowSizeMsg:
149		return p, p.SetSize(msg.Width, msg.Height)
150	case CancelTimerExpiredMsg:
151		p.canceling = false
152		return p, nil
153	case chat.SendMsg:
154		return p, p.sendMessage(msg.Text, msg.Attachments)
155	case chat.SessionSelectedMsg:
156		return p, p.setSession(msg)
157	case commands.ToggleCompactModeMsg:
158		p.forceCompact = !p.forceCompact
159		var cmd tea.Cmd
160		if p.forceCompact {
161			p.setCompactMode(true)
162			cmd = p.updateCompactConfig(true)
163		} else if p.width >= CompactModeBreakpoint {
164			p.setCompactMode(false)
165			cmd = p.updateCompactConfig(false)
166		}
167		return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
168	case pubsub.Event[session.Session]:
169		// this needs to go to header/sidebar
170		u, cmd := p.header.Update(msg)
171		p.header = u.(header.Header)
172		cmds = append(cmds, cmd)
173		u, cmd = p.sidebar.Update(msg)
174		p.sidebar = u.(sidebar.Sidebar)
175		cmds = append(cmds, cmd)
176		return p, tea.Batch(cmds...)
177	case chat.SessionClearedMsg:
178		u, cmd := p.header.Update(msg)
179		p.header = u.(header.Header)
180		cmds = append(cmds, cmd)
181		u, cmd = p.sidebar.Update(msg)
182		p.sidebar = u.(sidebar.Sidebar)
183		cmds = append(cmds, cmd)
184		u, cmd = p.chat.Update(msg)
185		p.chat = u.(chat.MessageListCmp)
186		cmds = append(cmds, cmd)
187		return p, tea.Batch(cmds...)
188	case filepicker.FilePickedMsg,
189		completions.CompletionsClosedMsg,
190		completions.SelectCompletionMsg:
191		u, cmd := p.editor.Update(msg)
192		p.editor = u.(editor.Editor)
193		cmds = append(cmds, cmd)
194		return p, tea.Batch(cmds...)
195
196	case pubsub.Event[message.Message],
197		anim.StepMsg,
198		spinner.TickMsg:
199		// this needs to go to chat
200		u, cmd := p.chat.Update(msg)
201		p.chat = u.(chat.MessageListCmp)
202		cmds = append(cmds, cmd)
203		return p, tea.Batch(cmds...)
204
205	case pubsub.Event[history.File], sidebar.SessionFilesMsg:
206		// this needs to go to sidebar
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
212	case commands.CommandRunCustomMsg:
213		// Check if the agent is busy before executing custom commands
214		if p.app.CoderAgent.IsBusy() {
215			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
216		}
217
218		// Handle custom command execution
219		cmd := p.sendMessage(msg.Content, nil)
220		if cmd != nil {
221			return p, cmd
222		}
223	case tea.KeyPressMsg:
224		switch {
225		case key.Matches(msg, p.keyMap.NewSession):
226			return p, p.newSession()
227		case key.Matches(msg, p.keyMap.AddAttachment):
228			agentCfg := config.Get().Agents["coder"]
229			model := config.Get().GetModelByType(agentCfg.Model)
230			if model.SupportsImages {
231				return p, util.CmdHandler(OpenFilePickerMsg{})
232			} else {
233				return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
234			}
235		case key.Matches(msg, p.keyMap.Tab):
236			p.changeFocus()
237			return p, nil
238		case key.Matches(msg, p.keyMap.Cancel):
239			return p, p.cancel()
240		case key.Matches(msg, p.keyMap.Details):
241			p.showDetails()
242			return p, nil
243		}
244
245		// Send the key press to the focused pane
246		switch p.focusedPane {
247		case PanelTypeChat:
248			u, cmd := p.chat.Update(msg)
249			p.chat = u.(chat.MessageListCmp)
250			cmds = append(cmds, cmd)
251		case PanelTypeEditor:
252			u, cmd := p.editor.Update(msg)
253			p.editor = u.(editor.Editor)
254			cmds = append(cmds, cmd)
255		case PanelTypeSplash:
256			u, cmd := p.splash.Update(msg)
257			p.splash = u.(splash.Splash)
258			cmds = append(cmds, cmd)
259		}
260	}
261	return p, tea.Batch(cmds...)
262}
263
264func (p *chatPage) View() tea.View {
265	var chatView tea.View
266	t := styles.CurrentTheme()
267	switch p.state {
268	case ChatStateOnboarding, ChatStateInitProject:
269		chatView = p.splash.View()
270	case ChatStateNewMessage:
271		editorView := p.editor.View()
272		chatView = tea.NewView(
273			lipgloss.JoinVertical(
274				lipgloss.Left,
275				t.S().Base.Render(
276					p.splash.View().String(),
277				),
278				editorView.String(),
279			),
280		)
281		chatView.SetCursor(editorView.Cursor())
282	case ChatStateInSession:
283		messagesView := p.chat.View()
284		editorView := p.editor.View()
285		if p.compact {
286			headerView := p.header.View()
287			chatView = tea.NewView(
288				lipgloss.JoinVertical(
289					lipgloss.Left,
290					headerView.String(),
291					messagesView.String(),
292					editorView.String(),
293				),
294			)
295			chatView.SetCursor(editorView.Cursor())
296		} else {
297			sidebarView := p.sidebar.View()
298			messages := lipgloss.JoinHorizontal(
299				lipgloss.Left,
300				messagesView.String(),
301				sidebarView.String(),
302			)
303			chatView = tea.NewView(
304				lipgloss.JoinVertical(
305					lipgloss.Left,
306					messages,
307					p.editor.View().String(),
308				),
309			)
310			chatView.SetCursor(editorView.Cursor())
311		}
312	default:
313		chatView = tea.NewView("Unknown chat state")
314	}
315
316	layers := []*lipgloss.Layer{
317		lipgloss.NewLayer(chatView.String()).X(0).Y(0),
318	}
319
320	if p.showingDetails {
321		style := t.S().Base.
322			Width(p.detailsWidth).
323			Border(lipgloss.RoundedBorder()).
324			BorderForeground(t.BorderFocus)
325		version := t.S().Subtle.Width(p.detailsWidth - 2).AlignHorizontal(lipgloss.Right).Render(version.Version)
326		details := style.Render(
327			lipgloss.JoinVertical(
328				lipgloss.Left,
329				p.sidebar.View().String(),
330				version,
331			),
332		)
333		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
334	}
335	canvas := lipgloss.NewCanvas(
336		layers...,
337	)
338	view := tea.NewView(canvas.Render())
339	view.SetCursor(chatView.Cursor())
340	return view
341}
342
343func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
344	return func() tea.Msg {
345		err := config.Get().SetCompactMode(compact)
346		if err != nil {
347			return util.InfoMsg{
348				Type: util.InfoTypeError,
349				Msg:  "Failed to update compact mode configuration: " + err.Error(),
350			}
351		}
352		return nil
353	}
354}
355
356func (p *chatPage) setCompactMode(compact bool) {
357	if p.compact == compact {
358		return
359	}
360	p.compact = compact
361	if compact {
362		p.compact = true
363		p.sidebar.SetCompactMode(true)
364	} else {
365		p.compact = false
366		p.showingDetails = false
367		p.sidebar.SetCompactMode(false)
368	}
369}
370
371func (p *chatPage) handleCompactMode(newWidth int) {
372	if p.forceCompact {
373		return
374	}
375	if newWidth < CompactModeBreakpoint && !p.compact {
376		p.setCompactMode(true)
377	}
378	if newWidth >= CompactModeBreakpoint && p.compact {
379		p.setCompactMode(false)
380	}
381}
382
383func (p *chatPage) SetSize(width, height int) tea.Cmd {
384	p.handleCompactMode(width)
385	p.width = width
386	p.height = height
387	var cmds []tea.Cmd
388	switch p.state {
389	case ChatStateOnboarding, ChatStateInitProject:
390		// here we should just have the splash screen
391		cmds = append(cmds, p.splash.SetSize(width, height))
392	case ChatStateNewMessage:
393		cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
394		cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
395		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
396	case ChatStateInSession:
397		if p.compact {
398			cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
399			// In compact mode, the sidebar is shown in the details section, the width needs to be adjusted for the padding and border
400			p.detailsWidth = width - 2                                                  // because of position
401			cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-2, p.detailsHeight-2)) // adjust for border
402			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
403			cmds = append(cmds, p.header.SetWidth(width-1))
404		} else {
405			cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
406			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
407			cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
408		}
409		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
410	}
411	return tea.Batch(cmds...)
412}
413
414func (p *chatPage) newSession() tea.Cmd {
415	if p.state != ChatStateInSession {
416		// Cannot start a new session if we are not in the session state
417		return nil
418	}
419	// blank session
420	p.session = session.Session{}
421	p.state = ChatStateNewMessage
422	p.focusedPane = PanelTypeEditor
423	p.canceling = false
424	// Reset the chat and editor components
425	return tea.Batch(
426		util.CmdHandler(chat.SessionClearedMsg{}),
427		p.SetSize(p.width, p.height),
428	)
429}
430
431func (p *chatPage) setSession(session session.Session) tea.Cmd {
432	if p.session.ID == session.ID {
433		return nil
434	}
435
436	var cmds []tea.Cmd
437	p.session = session
438	// We want to first resize the components
439	if p.state != ChatStateInSession {
440		p.state = ChatStateInSession
441		cmds = append(cmds, p.SetSize(p.width, p.height))
442	}
443	cmds = append(cmds, p.chat.SetSession(session))
444	cmds = append(cmds, p.sidebar.SetSession(session))
445	cmds = append(cmds, p.header.SetSession(session))
446	cmds = append(cmds, p.editor.SetSession(session))
447
448	return tea.Sequence(cmds...)
449}
450
451func (p *chatPage) changeFocus() {
452	if p.state != ChatStateInSession {
453		// Cannot change focus if we are not in the session state
454		return
455	}
456	switch p.focusedPane {
457	case PanelTypeChat:
458		p.focusedPane = PanelTypeEditor
459	case PanelTypeEditor:
460		p.focusedPane = PanelTypeChat
461	}
462}
463
464func (p *chatPage) cancel() tea.Cmd {
465	if p.state != ChatStateInSession || !p.app.CoderAgent.IsBusy() {
466		// Cannot cancel if we are not in the session state
467		return nil
468	}
469
470	// second press of cancel key will actually cancel the session
471	if p.canceling {
472		p.canceling = false
473		p.app.CoderAgent.Cancel(p.session.ID)
474		return nil
475	}
476
477	p.canceling = true
478	return cancelTimerCmd()
479}
480
481func (p *chatPage) showDetails() {
482	if p.state != ChatStateInSession || !p.compact {
483		// Cannot show details if we are not in the session state or if we are not in compact mode
484		return
485	}
486	p.showingDetails = !p.showingDetails
487	p.header.SetDetailsOpen(p.showingDetails)
488}
489
490func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
491	session := p.session
492	var cmds []tea.Cmd
493	if p.state != ChatStateInSession {
494		// branch new session
495		newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
496		if err != nil {
497			return util.ReportError(err)
498		}
499		session = newSession
500		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
501	}
502	_, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
503	if err != nil {
504		return util.ReportError(err)
505	}
506	return tea.Batch(cmds...)
507}
508
509func (p *chatPage) Bindings() []key.Binding {
510	bindings := []key.Binding{
511		p.keyMap.NewSession,
512		p.keyMap.AddAttachment,
513	}
514	if p.app.CoderAgent.IsBusy() {
515		cancelBinding := p.keyMap.Cancel
516		if p.canceling {
517			cancelBinding = key.NewBinding(
518				key.WithKeys("esc"),
519				key.WithHelp("esc", "press again to cancel"),
520			)
521		}
522		bindings = append([]key.Binding{cancelBinding}, bindings...)
523	}
524
525	switch p.focusedPane {
526	case PanelTypeChat:
527		bindings = append([]key.Binding{
528			key.NewBinding(
529				key.WithKeys("tab"),
530				key.WithHelp("tab", "focus editor"),
531			),
532		}, bindings...)
533		bindings = append(bindings, p.chat.Bindings()...)
534	case PanelTypeEditor:
535		bindings = append([]key.Binding{
536			key.NewBinding(
537				key.WithKeys("tab"),
538				key.WithHelp("tab", "focus chat"),
539			),
540		}, bindings...)
541		bindings = append(bindings, p.editor.Bindings()...)
542	case PanelTypeSplash:
543		bindings = append(bindings, p.splash.Bindings()...)
544	}
545
546	return bindings
547}