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