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 splash.OnboardingCompleteMsg:
224		p.state = ChatStateNewMessage
225		err := p.app.InitCoderAgent()
226		if err != nil {
227			return p, util.ReportError(err)
228		}
229		p.focusedPane = PanelTypeEditor
230		return p, p.SetSize(p.width, p.height)
231	case tea.KeyPressMsg:
232		switch {
233		case key.Matches(msg, p.keyMap.NewSession):
234			return p, p.newSession()
235		case key.Matches(msg, p.keyMap.AddAttachment):
236			agentCfg := config.Get().Agents["coder"]
237			model := config.Get().GetModelByType(agentCfg.Model)
238			if model.SupportsImages {
239				return p, util.CmdHandler(OpenFilePickerMsg{})
240			} else {
241				return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
242			}
243		case key.Matches(msg, p.keyMap.Tab):
244			if p.state == ChatStateOnboarding || p.state == ChatStateInitProject {
245				u, cmd := p.splash.Update(msg)
246				p.splash = u.(splash.Splash)
247				return p, cmd
248			}
249			p.changeFocus()
250			return p, nil
251		case key.Matches(msg, p.keyMap.Cancel):
252			return p, p.cancel()
253		case key.Matches(msg, p.keyMap.Details):
254			p.showDetails()
255			return p, nil
256		}
257
258		// Send the key press to the focused pane
259		switch p.focusedPane {
260		case PanelTypeChat:
261			u, cmd := p.chat.Update(msg)
262			p.chat = u.(chat.MessageListCmp)
263			cmds = append(cmds, cmd)
264		case PanelTypeEditor:
265			u, cmd := p.editor.Update(msg)
266			p.editor = u.(editor.Editor)
267			cmds = append(cmds, cmd)
268		case PanelTypeSplash:
269			u, cmd := p.splash.Update(msg)
270			p.splash = u.(splash.Splash)
271			cmds = append(cmds, cmd)
272		}
273	}
274	return p, tea.Batch(cmds...)
275}
276
277func (p *chatPage) View() tea.View {
278	var chatView tea.View
279	t := styles.CurrentTheme()
280	switch p.state {
281	case ChatStateOnboarding, ChatStateInitProject:
282		chatView = p.splash.View()
283	case ChatStateNewMessage:
284		editorView := p.editor.View()
285		chatView = tea.NewView(
286			lipgloss.JoinVertical(
287				lipgloss.Left,
288				t.S().Base.Render(
289					p.splash.View().String(),
290				),
291				editorView.String(),
292			),
293		)
294		chatView.SetCursor(editorView.Cursor())
295	case ChatStateInSession:
296		messagesView := p.chat.View()
297		editorView := p.editor.View()
298		if p.compact {
299			headerView := p.header.View()
300			chatView = tea.NewView(
301				lipgloss.JoinVertical(
302					lipgloss.Left,
303					headerView.String(),
304					messagesView.String(),
305					editorView.String(),
306				),
307			)
308			chatView.SetCursor(editorView.Cursor())
309		} else {
310			sidebarView := p.sidebar.View()
311			messages := lipgloss.JoinHorizontal(
312				lipgloss.Left,
313				messagesView.String(),
314				sidebarView.String(),
315			)
316			chatView = tea.NewView(
317				lipgloss.JoinVertical(
318					lipgloss.Left,
319					messages,
320					p.editor.View().String(),
321				),
322			)
323			chatView.SetCursor(editorView.Cursor())
324		}
325	default:
326		chatView = tea.NewView("Unknown chat state")
327	}
328
329	layers := []*lipgloss.Layer{
330		lipgloss.NewLayer(chatView.String()).X(0).Y(0),
331	}
332
333	if p.showingDetails {
334		style := t.S().Base.
335			Width(p.detailsWidth).
336			Border(lipgloss.RoundedBorder()).
337			BorderForeground(t.BorderFocus)
338		version := t.S().Subtle.Width(p.detailsWidth - 2).AlignHorizontal(lipgloss.Right).Render(version.Version)
339		details := style.Render(
340			lipgloss.JoinVertical(
341				lipgloss.Left,
342				p.sidebar.View().String(),
343				version,
344			),
345		)
346		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
347	}
348	canvas := lipgloss.NewCanvas(
349		layers...,
350	)
351	view := tea.NewView(canvas.Render())
352	view.SetCursor(chatView.Cursor())
353	return view
354}
355
356func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
357	return func() tea.Msg {
358		err := config.Get().SetCompactMode(compact)
359		if err != nil {
360			return util.InfoMsg{
361				Type: util.InfoTypeError,
362				Msg:  "Failed to update compact mode configuration: " + err.Error(),
363			}
364		}
365		return nil
366	}
367}
368
369func (p *chatPage) setCompactMode(compact bool) {
370	if p.compact == compact {
371		return
372	}
373	p.compact = compact
374	if compact {
375		p.compact = true
376		p.sidebar.SetCompactMode(true)
377	} else {
378		p.compact = false
379		p.showingDetails = false
380		p.sidebar.SetCompactMode(false)
381	}
382}
383
384func (p *chatPage) handleCompactMode(newWidth int) {
385	if p.forceCompact {
386		return
387	}
388	if newWidth < CompactModeBreakpoint && !p.compact {
389		p.setCompactMode(true)
390	}
391	if newWidth >= CompactModeBreakpoint && p.compact {
392		p.setCompactMode(false)
393	}
394}
395
396func (p *chatPage) SetSize(width, height int) tea.Cmd {
397	p.handleCompactMode(width)
398	p.width = width
399	p.height = height
400	var cmds []tea.Cmd
401	switch p.state {
402	case ChatStateOnboarding, ChatStateInitProject:
403		// here we should just have the splash screen
404		cmds = append(cmds, p.splash.SetSize(width, height))
405	case ChatStateNewMessage:
406		cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
407		cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
408		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
409	case ChatStateInSession:
410		if p.compact {
411			cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
412			// In compact mode, the sidebar is shown in the details section, the width needs to be adjusted for the padding and border
413			p.detailsWidth = width - 2                                                  // because of position
414			cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-2, p.detailsHeight-2)) // adjust for border
415			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
416			cmds = append(cmds, p.header.SetWidth(width-1))
417		} else {
418			cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
419			cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
420			cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
421		}
422		cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
423	}
424	return tea.Batch(cmds...)
425}
426
427func (p *chatPage) newSession() tea.Cmd {
428	if p.state != ChatStateInSession {
429		// Cannot start a new session if we are not in the session state
430		return nil
431	}
432
433	// blank session
434	p.session = session.Session{}
435	p.state = ChatStateNewMessage
436	p.focusedPane = PanelTypeEditor
437	p.canceling = false
438	// Reset the chat and editor components
439	return tea.Batch(
440		util.CmdHandler(chat.SessionClearedMsg{}),
441		p.SetSize(p.width, p.height),
442	)
443}
444
445func (p *chatPage) setSession(session session.Session) tea.Cmd {
446	if p.session.ID == session.ID {
447		return nil
448	}
449
450	var cmds []tea.Cmd
451	p.session = session
452	// We want to first resize the components
453	if p.state != ChatStateInSession {
454		p.state = ChatStateInSession
455		cmds = append(cmds, p.SetSize(p.width, p.height))
456	}
457	cmds = append(cmds, p.chat.SetSession(session))
458	cmds = append(cmds, p.sidebar.SetSession(session))
459	cmds = append(cmds, p.header.SetSession(session))
460	cmds = append(cmds, p.editor.SetSession(session))
461
462	return tea.Sequence(cmds...)
463}
464
465func (p *chatPage) changeFocus() {
466	if p.state != ChatStateInSession {
467		// Cannot change focus if we are not in the session state
468		return
469	}
470	switch p.focusedPane {
471	case PanelTypeChat:
472		p.focusedPane = PanelTypeEditor
473		p.editor.Focus()
474		p.chat.Blur()
475	case PanelTypeEditor:
476		p.focusedPane = PanelTypeChat
477		p.chat.Focus()
478		p.editor.Blur()
479	}
480}
481
482func (p *chatPage) cancel() tea.Cmd {
483	if p.state != ChatStateInSession || !p.app.CoderAgent.IsBusy() {
484		// Cannot cancel if we are not in the session state
485		return nil
486	}
487
488	// second press of cancel key will actually cancel the session
489	if p.canceling {
490		p.canceling = false
491		p.app.CoderAgent.Cancel(p.session.ID)
492		return nil
493	}
494
495	p.canceling = true
496	return cancelTimerCmd()
497}
498
499func (p *chatPage) showDetails() {
500	if p.state != ChatStateInSession || !p.compact {
501		// Cannot show details if we are not in the session state or if we are not in compact mode
502		return
503	}
504	p.showingDetails = !p.showingDetails
505	p.header.SetDetailsOpen(p.showingDetails)
506}
507
508func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
509	session := p.session
510	var cmds []tea.Cmd
511	if p.state != ChatStateInSession {
512		// branch new session
513		newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
514		if err != nil {
515			return util.ReportError(err)
516		}
517		session = newSession
518		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
519	}
520	_, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
521	if err != nil {
522		return util.ReportError(err)
523	}
524	return tea.Batch(cmds...)
525}
526
527func (p *chatPage) Bindings() []key.Binding {
528	bindings := []key.Binding{
529		p.keyMap.NewSession,
530		p.keyMap.AddAttachment,
531	}
532	if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
533		cancelBinding := p.keyMap.Cancel
534		if p.canceling {
535			cancelBinding = key.NewBinding(
536				key.WithKeys("esc"),
537				key.WithHelp("esc", "press again to cancel"),
538			)
539		}
540		bindings = append([]key.Binding{cancelBinding}, bindings...)
541	}
542
543	switch p.focusedPane {
544	case PanelTypeChat:
545		bindings = append([]key.Binding{
546			key.NewBinding(
547				key.WithKeys("tab"),
548				key.WithHelp("tab", "focus editor"),
549			),
550		}, bindings...)
551		bindings = append(bindings, p.chat.Bindings()...)
552	case PanelTypeEditor:
553		bindings = append([]key.Binding{
554			key.NewBinding(
555				key.WithKeys("tab"),
556				key.WithHelp("tab", "focus chat"),
557			),
558		}, bindings...)
559		bindings = append(bindings, p.editor.Bindings()...)
560	case PanelTypeSplash:
561		bindings = append(bindings, p.splash.Bindings()...)
562	}
563
564	return bindings
565}