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