chat.go

  1package chat
  2
  3import (
  4	"context"
  5	"strings"
  6	"time"
  7
  8	"github.com/charmbracelet/bubbles/v2/key"
  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/llm/models"
 13	"github.com/charmbracelet/crush/internal/message"
 14	"github.com/charmbracelet/crush/internal/session"
 15	"github.com/charmbracelet/crush/internal/tui/components/chat"
 16	"github.com/charmbracelet/crush/internal/tui/components/chat/editor"
 17	"github.com/charmbracelet/crush/internal/tui/components/chat/header"
 18	"github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
 19	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 20	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
 21	"github.com/charmbracelet/crush/internal/tui/page"
 22	"github.com/charmbracelet/crush/internal/tui/styles"
 23	"github.com/charmbracelet/crush/internal/tui/util"
 24	"github.com/charmbracelet/crush/internal/version"
 25	"github.com/charmbracelet/lipgloss/v2"
 26)
 27
 28var ChatPageID page.PageID = "chat"
 29
 30const CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
 31
 32type (
 33	OpenFilePickerMsg struct{}
 34	ChatFocusedMsg    struct {
 35		Focused bool // True if the chat input is focused, false otherwise
 36	}
 37	CancelTimerExpiredMsg struct{}
 38)
 39
 40type ChatPage interface {
 41	util.Model
 42	layout.Help
 43}
 44
 45type chatPage struct {
 46	wWidth, wHeight int // Window dimensions
 47	app             *app.App
 48
 49	layout layout.SplitPaneLayout
 50
 51	session session.Session
 52
 53	keyMap KeyMap
 54
 55	chatFocused bool
 56
 57	compactMode      bool
 58	forceCompactMode bool // Force compact mode regardless of window size
 59	showDetails      bool // Show details in the header
 60	header           header.Header
 61	compactSidebar   layout.Container
 62
 63	cancelPending bool // True if ESC was pressed once and waiting for second press
 64}
 65
 66func (p *chatPage) Init() tea.Cmd {
 67	cmd := p.layout.Init()
 68	return tea.Batch(
 69		cmd,
 70		p.layout.FocusPanel(layout.BottomPanel), // Focus on the bottom panel (editor)
 71	)
 72}
 73
 74// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds
 75func (p *chatPage) cancelTimerCmd() tea.Cmd {
 76	return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
 77		return CancelTimerExpiredMsg{}
 78	})
 79}
 80
 81func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 82	var cmds []tea.Cmd
 83	switch msg := msg.(type) {
 84	case CancelTimerExpiredMsg:
 85		p.cancelPending = false
 86		return p, nil
 87	case tea.WindowSizeMsg:
 88		h, cmd := p.header.Update(msg)
 89		cmds = append(cmds, cmd)
 90		p.header = h.(header.Header)
 91		if p.compactMode && p.showDetails {
 92			cmds = append(cmds, p.compactSidebar.SetSize(msg.Width-4, 0))
 93		}
 94		// the mode is only relevant when there is a  session
 95		if p.session.ID != "" {
 96			// Only auto-switch to compact mode if not forced
 97			if !p.forceCompactMode {
 98				if msg.Width <= CompactModeBreakpoint && p.wWidth > CompactModeBreakpoint {
 99					p.wWidth = msg.Width
100					p.wHeight = msg.Height
101					cmds = append(cmds, p.setCompactMode(true))
102					return p, tea.Batch(cmds...)
103				} else if msg.Width > CompactModeBreakpoint && p.wWidth <= CompactModeBreakpoint {
104					p.wWidth = msg.Width
105					p.wHeight = msg.Height
106					return p, p.setCompactMode(false)
107				}
108			}
109		}
110		p.wWidth = msg.Width
111		p.wHeight = msg.Height
112		layoutHeight := msg.Height
113		if p.compactMode {
114			// make space for the header
115			layoutHeight -= 1
116		}
117		cmd = p.layout.SetSize(msg.Width, layoutHeight)
118		cmds = append(cmds, cmd)
119		return p, tea.Batch(cmds...)
120
121	case chat.SendMsg:
122		cmd := p.sendMessage(msg.Text, msg.Attachments)
123		if cmd != nil {
124			return p, cmd
125		}
126	case commands.ToggleCompactModeMsg:
127		// Only allow toggling if window width is larger than compact breakpoint
128		if p.wWidth > CompactModeBreakpoint {
129			p.forceCompactMode = !p.forceCompactMode
130			// If force compact mode is enabled, switch to compact mode
131			// If force compact mode is disabled, switch based on window size
132			if p.forceCompactMode {
133				return p, p.setCompactMode(true)
134			} else {
135				// Return to auto mode based on window size
136				shouldBeCompact := p.wWidth <= CompactModeBreakpoint
137				return p, p.setCompactMode(shouldBeCompact)
138			}
139		}
140	case commands.CommandRunCustomMsg:
141		// Check if the agent is busy before executing custom commands
142		if p.app.CoderAgent.IsBusy() {
143			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
144		}
145
146		// Handle custom command execution
147		cmd := p.sendMessage(msg.Content, nil)
148		if cmd != nil {
149			return p, cmd
150		}
151	case chat.SessionSelectedMsg:
152		if p.session.ID == "" {
153			cmd := p.setMessages()
154			if cmd != nil {
155				cmds = append(cmds, cmd)
156			}
157		}
158		needsModeChange := p.session.ID == ""
159		p.session = msg
160		p.header.SetSession(msg)
161		if needsModeChange && (p.wWidth <= CompactModeBreakpoint || p.forceCompactMode) {
162			cmds = append(cmds, p.setCompactMode(true))
163		}
164	case tea.KeyPressMsg:
165		switch {
166		case key.Matches(msg, p.keyMap.NewSession):
167			p.session = session.Session{}
168			return p, tea.Batch(
169				p.clearMessages(),
170				util.CmdHandler(chat.SessionClearedMsg{}),
171				p.setCompactMode(false),
172			)
173		case key.Matches(msg, p.keyMap.AddAttachment):
174			cfg := config.Get()
175			agentCfg := cfg.Agents[config.AgentCoder]
176			selectedModelID := agentCfg.Model
177			model := models.SupportedModels[selectedModelID]
178			if model.SupportsAttachments {
179				return p, util.CmdHandler(OpenFilePickerMsg{})
180			} else {
181				return p, util.ReportWarn("File attachments are not supported by the current model: " + string(selectedModelID))
182			}
183		case key.Matches(msg, p.keyMap.Tab):
184			if p.session.ID == "" {
185				return p, nil
186			}
187			p.chatFocused = !p.chatFocused
188			if p.chatFocused {
189				cmds = append(cmds, p.layout.FocusPanel(layout.LeftPanel))
190				cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: true}))
191			} else {
192				cmds = append(cmds, p.layout.FocusPanel(layout.BottomPanel))
193				cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: false}))
194			}
195			return p, tea.Batch(cmds...)
196		case key.Matches(msg, p.keyMap.Cancel):
197			if p.session.ID != "" {
198				if p.cancelPending {
199					// Second ESC press - actually cancel the session
200					p.cancelPending = false
201					p.app.CoderAgent.Cancel(p.session.ID)
202					return p, nil
203				} else {
204					// First ESC press - start the timer
205					p.cancelPending = true
206					return p, p.cancelTimerCmd()
207				}
208			}
209		case key.Matches(msg, p.keyMap.Details):
210			if p.session.ID == "" || !p.compactMode {
211				return p, nil // No session to show details for
212			}
213			p.showDetails = !p.showDetails
214			p.header.SetDetailsOpen(p.showDetails)
215			if p.showDetails {
216				p.compactSidebar = sidebarCmp(p.app, true)
217				c, cmd := p.compactSidebar.Update(chat.SessionSelectedMsg(p.session))
218				p.compactSidebar = c.(layout.Container)
219				return p, tea.Batch(
220					cmd,
221					p.compactSidebar.SetSize(p.wWidth-4, 0),
222				)
223			}
224
225			return p, nil
226		}
227	}
228	u, cmd := p.layout.Update(msg)
229	cmds = append(cmds, cmd)
230	p.layout = u.(layout.SplitPaneLayout)
231
232	if p.compactMode && p.showDetails {
233		s, cmd := p.compactSidebar.Update(msg)
234		p.compactSidebar = s.(layout.Container)
235		cmds = append(cmds, cmd)
236	}
237	return p, tea.Batch(cmds...)
238}
239
240func (p *chatPage) setMessages() tea.Cmd {
241	messagesContainer := layout.NewContainer(
242		chat.NewMessagesListCmp(p.app),
243		layout.WithPadding(1, 1, 0, 1),
244	)
245	return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
246}
247
248func (p *chatPage) setSidebar() tea.Cmd {
249	sidebarContainer := sidebarCmp(p.app, false)
250	sidebarContainer.Init()
251	return p.layout.SetRightPanel(sidebarContainer)
252}
253
254func (p *chatPage) clearMessages() tea.Cmd {
255	return p.layout.ClearLeftPanel()
256}
257
258func (p *chatPage) setCompactMode(compact bool) tea.Cmd {
259	p.compactMode = compact
260	var cmds []tea.Cmd
261	if compact {
262		// add offset for the header
263		p.layout.SetOffset(0, 1)
264		// make space for the header
265		cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight-1))
266		// remove the sidebar
267		cmds = append(cmds, p.layout.ClearRightPanel())
268		return tea.Batch(cmds...)
269	} else {
270		// remove the offset for the header
271		p.layout.SetOffset(0, 0)
272		// restore the original size
273		cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight))
274		// set the sidebar
275		cmds = append(cmds, p.setSidebar())
276		l, cmd := p.layout.Update(chat.SessionSelectedMsg(p.session))
277		p.layout = l.(layout.SplitPaneLayout)
278		cmds = append(cmds, cmd)
279
280		return tea.Batch(cmds...)
281	}
282}
283
284func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
285	var cmds []tea.Cmd
286	if p.session.ID == "" {
287		session, err := p.app.Sessions.Create(context.Background(), "New Session")
288		if err != nil {
289			return util.ReportError(err)
290		}
291
292		p.session = session
293		cmd := p.setMessages()
294		if cmd != nil {
295			cmds = append(cmds, cmd)
296		}
297		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
298	}
299
300	_, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
301	if err != nil {
302		return util.ReportError(err)
303	}
304	return tea.Batch(cmds...)
305}
306
307func (p *chatPage) SetSize(width, height int) tea.Cmd {
308	return p.layout.SetSize(width, height)
309}
310
311func (p *chatPage) GetSize() (int, int) {
312	return p.layout.GetSize()
313}
314
315func (p *chatPage) View() tea.View {
316	if !p.compactMode || p.session.ID == "" {
317		// If not in compact mode or there is no session, we don't show the header
318		return p.layout.View()
319	}
320	layoutView := p.layout.View()
321	chatView := strings.Join(
322		[]string{
323			p.header.View().String(),
324			layoutView.String(),
325		}, "\n",
326	)
327	layers := []*lipgloss.Layer{
328		lipgloss.NewLayer(chatView).X(0).Y(0),
329	}
330	if p.showDetails {
331		t := styles.CurrentTheme()
332		style := t.S().Base.
333			Border(lipgloss.RoundedBorder()).
334			BorderForeground(t.BorderFocus)
335		version := t.S().Subtle.Padding(0, 1).AlignHorizontal(lipgloss.Right).Width(p.wWidth - 4).Render(version.Version)
336		details := style.Render(
337			lipgloss.JoinVertical(
338				lipgloss.Left,
339				p.compactSidebar.View().String(),
340				version,
341			),
342		)
343		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
344	}
345	canvas := lipgloss.NewCanvas(
346		layers...,
347	)
348	view := tea.NewView(canvas.Render())
349	view.SetCursor(layoutView.Cursor())
350	return view
351}
352
353func (p *chatPage) Bindings() []key.Binding {
354	bindings := []key.Binding{
355		p.keyMap.NewSession,
356		p.keyMap.AddAttachment,
357	}
358	if p.app.CoderAgent.IsBusy() {
359		cancelBinding := p.keyMap.Cancel
360		if p.cancelPending {
361			cancelBinding = key.NewBinding(
362				key.WithKeys("esc"),
363				key.WithHelp("esc", "press again to cancel"),
364			)
365		}
366		bindings = append([]key.Binding{cancelBinding}, bindings...)
367	}
368
369	if p.chatFocused {
370		bindings = append([]key.Binding{
371			key.NewBinding(
372				key.WithKeys("tab"),
373				key.WithHelp("tab", "focus editor"),
374			),
375		}, bindings...)
376	} else {
377		bindings = append([]key.Binding{
378			key.NewBinding(
379				key.WithKeys("tab"),
380				key.WithHelp("tab", "focus chat"),
381			),
382		}, bindings...)
383	}
384
385	bindings = append(bindings, p.layout.Bindings()...)
386	return bindings
387}
388
389func sidebarCmp(app *app.App, compact bool) layout.Container {
390	padding := layout.WithPadding(1, 1, 1, 1)
391	if compact {
392		padding = layout.WithPadding(0, 1, 1, 1)
393	}
394	return layout.NewContainer(
395		sidebar.NewSidebarCmp(app.History, app.LSPClients, compact),
396		padding,
397	)
398}
399
400func NewChatPage(app *app.App) ChatPage {
401	editorContainer := layout.NewContainer(
402		editor.NewEditorCmp(app),
403	)
404	return &chatPage{
405		app: app,
406		layout: layout.NewSplitPane(
407			layout.WithRightPanel(sidebarCmp(app, false)),
408			layout.WithBottomPanel(editorContainer),
409			layout.WithFixedBottomHeight(5),
410			layout.WithFixedRightWidth(31),
411		),
412		keyMap: DefaultKeyMap(),
413		header: header.New(app.LSPClients),
414	}
415}