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	return tea.Batch(
 68		p.layout.Init(),
 69		p.compactSidebar.Init(),
 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		cmds = append(cmds, p.compactSidebar.SetSize(msg.Width-4, 0))
 92		// the mode is only relevant when there is a  session
 93		if p.session.ID != "" {
 94			// Only auto-switch to compact mode if not forced
 95			if !p.forceCompactMode {
 96				if msg.Width <= CompactModeBreakpoint && p.wWidth > CompactModeBreakpoint {
 97					p.wWidth = msg.Width
 98					p.wHeight = msg.Height
 99					cmds = append(cmds, p.setCompactMode(true))
100					return p, tea.Batch(cmds...)
101				} else if msg.Width > CompactModeBreakpoint && p.wWidth <= CompactModeBreakpoint {
102					p.wWidth = msg.Width
103					p.wHeight = msg.Height
104					return p, p.setCompactMode(false)
105				}
106			}
107		}
108		p.wWidth = msg.Width
109		p.wHeight = msg.Height
110		layoutHeight := msg.Height
111		if p.compactMode {
112			// make space for the header
113			layoutHeight -= 1
114		}
115		cmd = p.layout.SetSize(msg.Width, layoutHeight)
116		cmds = append(cmds, cmd)
117		return p, tea.Batch(cmds...)
118
119	case chat.SendMsg:
120		cmd := p.sendMessage(msg.Text, msg.Attachments)
121		if cmd != nil {
122			return p, cmd
123		}
124	case commands.ToggleCompactModeMsg:
125		// Only allow toggling if window width is larger than compact breakpoint
126		if p.wWidth > CompactModeBreakpoint {
127			p.forceCompactMode = !p.forceCompactMode
128			// If force compact mode is enabled, switch to compact mode
129			// If force compact mode is disabled, switch based on window size
130			if p.forceCompactMode {
131				return p, p.setCompactMode(true)
132			} else {
133				// Return to auto mode based on window size
134				shouldBeCompact := p.wWidth <= CompactModeBreakpoint
135				return p, p.setCompactMode(shouldBeCompact)
136			}
137		}
138	case commands.CommandRunCustomMsg:
139		// Check if the agent is busy before executing custom commands
140		if p.app.CoderAgent.IsBusy() {
141			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
142		}
143
144		// Handle custom command execution
145		cmd := p.sendMessage(msg.Content, nil)
146		if cmd != nil {
147			return p, cmd
148		}
149	case chat.SessionSelectedMsg:
150		if p.session.ID == "" {
151			cmd := p.setMessages()
152			if cmd != nil {
153				cmds = append(cmds, cmd)
154			}
155		}
156		needsModeChange := p.session.ID == ""
157		p.session = msg
158		p.header.SetSession(msg)
159		if needsModeChange && (p.wWidth <= CompactModeBreakpoint || p.forceCompactMode) {
160			cmds = append(cmds, p.setCompactMode(true))
161		}
162	case tea.KeyPressMsg:
163		switch {
164		case key.Matches(msg, p.keyMap.NewSession):
165			p.session = session.Session{}
166			return p, tea.Batch(
167				p.clearMessages(),
168				util.CmdHandler(chat.SessionClearedMsg{}),
169				p.setCompactMode(false),
170				p.layout.FocusPanel(layout.BottomPanel),
171				util.CmdHandler(ChatFocusedMsg{Focused: 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				return p, tea.Batch()
217			}
218
219			return p, nil
220		}
221	}
222	u, cmd := p.layout.Update(msg)
223	cmds = append(cmds, cmd)
224	p.layout = u.(layout.SplitPaneLayout)
225	h, cmd := p.header.Update(msg)
226	p.header = h.(header.Header)
227	cmds = append(cmds, cmd)
228	s, cmd := p.compactSidebar.Update(msg)
229	p.compactSidebar = s.(layout.Container)
230	cmds = append(cmds, cmd)
231	return p, tea.Batch(cmds...)
232}
233
234func (p *chatPage) setMessages() tea.Cmd {
235	messagesContainer := layout.NewContainer(
236		chat.NewMessagesListCmp(p.app),
237		layout.WithPadding(1, 1, 0, 1),
238	)
239	return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
240}
241
242func (p *chatPage) setSidebar() tea.Cmd {
243	sidebarContainer := sidebarCmp(p.app, false, p.session)
244	sidebarContainer.Init()
245	return p.layout.SetRightPanel(sidebarContainer)
246}
247
248func (p *chatPage) clearMessages() tea.Cmd {
249	return p.layout.ClearLeftPanel()
250}
251
252func (p *chatPage) setCompactMode(compact bool) tea.Cmd {
253	p.compactMode = compact
254	var cmds []tea.Cmd
255	if compact {
256		// add offset for the header
257		p.layout.SetOffset(0, 1)
258		// make space for the header
259		cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight-1))
260		// remove the sidebar
261		cmds = append(cmds, p.layout.ClearRightPanel())
262		return tea.Batch(cmds...)
263	} else {
264		// remove the offset for the header
265		p.layout.SetOffset(0, 0)
266		// restore the original size
267		cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight))
268		// set the sidebar
269		cmds = append(cmds, p.setSidebar())
270		l, cmd := p.layout.Update(chat.SessionSelectedMsg(p.session))
271		p.layout = l.(layout.SplitPaneLayout)
272		cmds = append(cmds, cmd)
273
274		return tea.Batch(cmds...)
275	}
276}
277
278func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
279	var cmds []tea.Cmd
280	if p.session.ID == "" {
281		session, err := p.app.Sessions.Create(context.Background(), "New Session")
282		if err != nil {
283			return util.ReportError(err)
284		}
285
286		p.session = session
287		cmd := p.setMessages()
288		if cmd != nil {
289			cmds = append(cmds, cmd)
290		}
291		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
292	}
293
294	_, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
295	if err != nil {
296		return util.ReportError(err)
297	}
298	return tea.Batch(cmds...)
299}
300
301func (p *chatPage) SetSize(width, height int) tea.Cmd {
302	return p.layout.SetSize(width, height)
303}
304
305func (p *chatPage) GetSize() (int, int) {
306	return p.layout.GetSize()
307}
308
309func (p *chatPage) View() tea.View {
310	if !p.compactMode || p.session.ID == "" {
311		// If not in compact mode or there is no session, we don't show the header
312		return p.layout.View()
313	}
314	layoutView := p.layout.View()
315	chatView := strings.Join(
316		[]string{
317			p.header.View().String(),
318			layoutView.String(),
319		}, "\n",
320	)
321	layers := []*lipgloss.Layer{
322		lipgloss.NewLayer(chatView).X(0).Y(0),
323	}
324	if p.showDetails {
325		t := styles.CurrentTheme()
326		style := t.S().Base.
327			Border(lipgloss.RoundedBorder()).
328			BorderForeground(t.BorderFocus)
329		version := t.S().Subtle.Padding(0, 1).AlignHorizontal(lipgloss.Right).Width(p.wWidth - 4).Render(version.Version)
330		details := style.Render(
331			lipgloss.JoinVertical(
332				lipgloss.Left,
333				p.compactSidebar.View().String(),
334				version,
335			),
336		)
337		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
338	}
339	canvas := lipgloss.NewCanvas(
340		layers...,
341	)
342	view := tea.NewView(canvas.Render())
343	view.SetCursor(layoutView.Cursor())
344	return view
345}
346
347func (p *chatPage) Bindings() []key.Binding {
348	bindings := []key.Binding{
349		p.keyMap.NewSession,
350		p.keyMap.AddAttachment,
351	}
352	if p.app.CoderAgent.IsBusy() {
353		cancelBinding := p.keyMap.Cancel
354		if p.cancelPending {
355			cancelBinding = key.NewBinding(
356				key.WithKeys("esc"),
357				key.WithHelp("esc", "press again to cancel"),
358			)
359		}
360		bindings = append([]key.Binding{cancelBinding}, bindings...)
361	}
362
363	if p.chatFocused {
364		bindings = append([]key.Binding{
365			key.NewBinding(
366				key.WithKeys("tab"),
367				key.WithHelp("tab", "focus editor"),
368			),
369		}, bindings...)
370	} else {
371		bindings = append([]key.Binding{
372			key.NewBinding(
373				key.WithKeys("tab"),
374				key.WithHelp("tab", "focus chat"),
375			),
376		}, bindings...)
377	}
378
379	bindings = append(bindings, p.layout.Bindings()...)
380	return bindings
381}
382
383func sidebarCmp(app *app.App, compact bool, session session.Session) layout.Container {
384	padding := layout.WithPadding(1, 1, 1, 1)
385	if compact {
386		padding = layout.WithPadding(0, 1, 1, 1)
387	}
388	sidebar := sidebar.NewSidebarCmp(app.History, app.LSPClients, compact)
389	if session.ID != "" {
390		sidebar.SetSession(session)
391	}
392
393	return layout.NewContainer(
394		sidebar,
395		padding,
396	)
397}
398
399func NewChatPage(app *app.App) ChatPage {
400	editorContainer := layout.NewContainer(
401		editor.NewEditorCmp(app),
402	)
403	return &chatPage{
404		app: app,
405		layout: layout.NewSplitPane(
406			layout.WithRightPanel(sidebarCmp(app, false, session.Session{})),
407			layout.WithBottomPanel(editorContainer),
408			layout.WithFixedBottomHeight(5),
409			layout.WithFixedRightWidth(31),
410		),
411		compactSidebar: sidebarCmp(app, true, session.Session{}),
412		keyMap:         DefaultKeyMap(),
413		header:         header.New(app.LSPClients),
414	}
415}