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