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 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			agentCfg := config.Get().Agents["coder"]
175			model := config.Get().GetModelByType(agentCfg.Model)
176			if model.SupportsImages {
177				return p, util.CmdHandler(OpenFilePickerMsg{})
178			} else {
179				return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Model)
180			}
181		case key.Matches(msg, p.keyMap.Tab):
182			if p.session.ID == "" {
183				return p, nil
184			}
185			p.chatFocused = !p.chatFocused
186			if p.chatFocused {
187				cmds = append(cmds, p.layout.FocusPanel(layout.LeftPanel))
188				cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: true}))
189			} else {
190				cmds = append(cmds, p.layout.FocusPanel(layout.BottomPanel))
191				cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: false}))
192			}
193			return p, tea.Batch(cmds...)
194		case key.Matches(msg, p.keyMap.Cancel):
195			if p.session.ID != "" {
196				if p.cancelPending {
197					// Second ESC press - actually cancel the session
198					p.cancelPending = false
199					p.app.CoderAgent.Cancel(p.session.ID)
200					return p, nil
201				} else {
202					// First ESC press - start the timer
203					p.cancelPending = true
204					return p, p.cancelTimerCmd()
205				}
206			}
207		case key.Matches(msg, p.keyMap.Details):
208			if p.session.ID == "" || !p.compactMode {
209				return p, nil // No session to show details for
210			}
211			p.showDetails = !p.showDetails
212			p.header.SetDetailsOpen(p.showDetails)
213			if p.showDetails {
214				return p, tea.Batch()
215			}
216
217			return p, nil
218		}
219	}
220	u, cmd := p.layout.Update(msg)
221	cmds = append(cmds, cmd)
222	p.layout = u.(layout.SplitPaneLayout)
223	h, cmd := p.header.Update(msg)
224	p.header = h.(header.Header)
225	cmds = append(cmds, cmd)
226	s, cmd := p.compactSidebar.Update(msg)
227	p.compactSidebar = s.(layout.Container)
228	cmds = append(cmds, cmd)
229	return p, tea.Batch(cmds...)
230}
231
232func (p *chatPage) setMessages() tea.Cmd {
233	messagesContainer := layout.NewContainer(
234		chat.NewMessagesListCmp(p.app),
235		layout.WithPadding(1, 1, 0, 1),
236	)
237	return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
238}
239
240func (p *chatPage) setSidebar() tea.Cmd {
241	sidebarContainer := sidebarCmp(p.app, false, p.session)
242	sidebarContainer.Init()
243	return p.layout.SetRightPanel(sidebarContainer)
244}
245
246func (p *chatPage) clearMessages() tea.Cmd {
247	return p.layout.ClearLeftPanel()
248}
249
250func (p *chatPage) setCompactMode(compact bool) tea.Cmd {
251	p.compactMode = compact
252	var cmds []tea.Cmd
253	if compact {
254		// add offset for the header
255		p.layout.SetOffset(0, 1)
256		// make space for the header
257		cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight-1))
258		// remove the sidebar
259		cmds = append(cmds, p.layout.ClearRightPanel())
260		return tea.Batch(cmds...)
261	} else {
262		// remove the offset for the header
263		p.layout.SetOffset(0, 0)
264		// restore the original size
265		cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight))
266		// set the sidebar
267		cmds = append(cmds, p.setSidebar())
268		l, cmd := p.layout.Update(chat.SessionSelectedMsg(p.session))
269		p.layout = l.(layout.SplitPaneLayout)
270		cmds = append(cmds, cmd)
271
272		return tea.Batch(cmds...)
273	}
274}
275
276func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
277	var cmds []tea.Cmd
278	if p.session.ID == "" {
279		session, err := p.app.Sessions.Create(context.Background(), "New Session")
280		if err != nil {
281			return util.ReportError(err)
282		}
283
284		p.session = session
285		cmd := p.setMessages()
286		if cmd != nil {
287			cmds = append(cmds, cmd)
288		}
289		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
290	}
291
292	_, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
293	if err != nil {
294		return util.ReportError(err)
295	}
296	return tea.Batch(cmds...)
297}
298
299func (p *chatPage) SetSize(width, height int) tea.Cmd {
300	return p.layout.SetSize(width, height)
301}
302
303func (p *chatPage) GetSize() (int, int) {
304	return p.layout.GetSize()
305}
306
307func (p *chatPage) View() string {
308	if !p.compactMode || p.session.ID == "" {
309		// If not in compact mode or there is no session, we don't show the header
310		return p.layout.View()
311	}
312	layoutView := p.layout.View()
313	chatView := strings.Join(
314		[]string{
315			p.header.View(),
316			layoutView,
317		}, "\n",
318	)
319	layers := []*lipgloss.Layer{
320		lipgloss.NewLayer(chatView).X(0).Y(0),
321	}
322	if p.showDetails {
323		t := styles.CurrentTheme()
324		style := t.S().Base.
325			Border(lipgloss.RoundedBorder()).
326			BorderForeground(t.BorderFocus)
327		version := t.S().Subtle.Padding(0, 1).AlignHorizontal(lipgloss.Right).Width(p.wWidth - 4).Render(version.Version)
328		details := style.Render(
329			lipgloss.JoinVertical(
330				lipgloss.Left,
331				p.compactSidebar.View(),
332				version,
333			),
334		)
335		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
336	}
337	canvas := lipgloss.NewCanvas(
338		layers...,
339	)
340	return canvas.Render()
341}
342
343func (p *chatPage) Cursor() *tea.Cursor {
344	if v, ok := p.layout.(util.Cursor); ok {
345		return v.Cursor()
346	}
347	return nil
348}
349
350func (p *chatPage) Bindings() []key.Binding {
351	bindings := []key.Binding{
352		p.keyMap.NewSession,
353		p.keyMap.AddAttachment,
354	}
355	if p.app.CoderAgent.IsBusy() {
356		cancelBinding := p.keyMap.Cancel
357		if p.cancelPending {
358			cancelBinding = key.NewBinding(
359				key.WithKeys("esc"),
360				key.WithHelp("esc", "press again to cancel"),
361			)
362		}
363		bindings = append([]key.Binding{cancelBinding}, bindings...)
364	}
365
366	if p.chatFocused {
367		bindings = append([]key.Binding{
368			key.NewBinding(
369				key.WithKeys("tab"),
370				key.WithHelp("tab", "focus editor"),
371			),
372		}, bindings...)
373	} else {
374		bindings = append([]key.Binding{
375			key.NewBinding(
376				key.WithKeys("tab"),
377				key.WithHelp("tab", "focus chat"),
378			),
379		}, bindings...)
380	}
381
382	bindings = append(bindings, p.layout.Bindings()...)
383	return bindings
384}
385
386func sidebarCmp(app *app.App, compact bool, session session.Session) layout.Container {
387	padding := layout.WithPadding(1, 1, 1, 1)
388	if compact {
389		padding = layout.WithPadding(0, 1, 1, 1)
390	}
391	sidebar := sidebar.NewSidebarCmp(app.History, app.LSPClients, compact)
392	if session.ID != "" {
393		sidebar.SetSession(session)
394	}
395
396	return layout.NewContainer(
397		sidebar,
398		padding,
399	)
400}
401
402func NewChatPage(app *app.App) ChatPage {
403	editorContainer := layout.NewContainer(
404		editor.NewEditorCmp(app),
405	)
406	return &chatPage{
407		app: app,
408		layout: layout.NewSplitPane(
409			layout.WithRightPanel(sidebarCmp(app, false, session.Session{})),
410			layout.WithBottomPanel(editorContainer),
411			layout.WithFixedBottomHeight(5),
412			layout.WithFixedRightWidth(31),
413		),
414		compactSidebar: sidebarCmp(app, true, session.Session{}),
415		keyMap:         DefaultKeyMap(),
416		header:         header.New(app.LSPClients),
417	}
418}
419
420// IsChatFocused returns whether the chat messages are focused (true) or editor is focused (false)
421func (p *chatPage) IsChatFocused() bool {
422	return p.chatFocused
423}