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