chat.go

  1package chat
  2
  3import (
  4	"context"
  5	"strings"
  6
  7	"github.com/charmbracelet/bubbles/v2/key"
  8	tea "github.com/charmbracelet/bubbletea/v2"
  9	"github.com/charmbracelet/crush/internal/app"
 10	"github.com/charmbracelet/crush/internal/config"
 11	"github.com/charmbracelet/crush/internal/llm/models"
 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)
 37
 38type ChatPage interface {
 39	util.Model
 40	layout.Help
 41}
 42
 43type chatPage struct {
 44	wWidth, wHeight int // Window dimensions
 45	app             *app.App
 46
 47	layout layout.SplitPaneLayout
 48
 49	session session.Session
 50
 51	keyMap KeyMap
 52
 53	chatFocused bool
 54
 55	compactMode      bool
 56	forceCompactMode bool // Force compact mode regardless of window size
 57	showDetails      bool // Show details in the header
 58	header           header.Header
 59	compactSidebar   layout.Container
 60}
 61
 62func (p *chatPage) Init() tea.Cmd {
 63	cmd := p.layout.Init()
 64	return tea.Batch(
 65		cmd,
 66		p.layout.FocusPanel(layout.BottomPanel), // Focus on the bottom panel (editor)
 67	)
 68}
 69
 70func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 71	var cmds []tea.Cmd
 72	switch msg := msg.(type) {
 73	case tea.WindowSizeMsg:
 74		h, cmd := p.header.Update(msg)
 75		cmds = append(cmds, cmd)
 76		p.header = h.(header.Header)
 77		if p.compactMode && p.showDetails {
 78			cmds = append(cmds, p.compactSidebar.SetSize(msg.Width-4, 0))
 79		}
 80		// the mode is only relevant when there is a  session
 81		if p.session.ID != "" {
 82			// Only auto-switch to compact mode if not forced
 83			if !p.forceCompactMode {
 84				if msg.Width <= CompactModeBreakpoint && p.wWidth > CompactModeBreakpoint {
 85					p.wWidth = msg.Width
 86					p.wHeight = msg.Height
 87					cmds = append(cmds, p.setCompactMode(true))
 88					return p, tea.Batch(cmds...)
 89				} else if msg.Width > CompactModeBreakpoint && p.wWidth <= CompactModeBreakpoint {
 90					p.wWidth = msg.Width
 91					p.wHeight = msg.Height
 92					return p, p.setCompactMode(false)
 93				}
 94			}
 95		}
 96		p.wWidth = msg.Width
 97		p.wHeight = msg.Height
 98		layoutHeight := msg.Height
 99		if p.compactMode {
100			// make space for the header
101			layoutHeight -= 1
102		}
103		cmd = p.layout.SetSize(msg.Width, layoutHeight)
104		cmds = append(cmds, cmd)
105		return p, tea.Batch(cmds...)
106
107	case chat.SendMsg:
108		cmd := p.sendMessage(msg.Text, msg.Attachments)
109		if cmd != nil {
110			return p, cmd
111		}
112	case commands.ToggleCompactModeMsg:
113		// Only allow toggling if window width is larger than compact breakpoint
114		if p.wWidth > CompactModeBreakpoint {
115			p.forceCompactMode = !p.forceCompactMode
116			// If force compact mode is enabled, switch to compact mode
117			// If force compact mode is disabled, switch based on window size
118			if p.forceCompactMode {
119				return p, p.setCompactMode(true)
120			} else {
121				// Return to auto mode based on window size
122				shouldBeCompact := p.wWidth <= CompactModeBreakpoint
123				return p, p.setCompactMode(shouldBeCompact)
124			}
125		}
126	case commands.CommandRunCustomMsg:
127		// Check if the agent is busy before executing custom commands
128		if p.app.CoderAgent.IsBusy() {
129			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
130		}
131
132		// Handle custom command execution
133		cmd := p.sendMessage(msg.Content, nil)
134		if cmd != nil {
135			return p, cmd
136		}
137	case chat.SessionSelectedMsg:
138		if p.session.ID == "" {
139			cmd := p.setMessages()
140			if cmd != nil {
141				cmds = append(cmds, cmd)
142			}
143		}
144		needsModeChange := p.session.ID == ""
145		p.session = msg
146		p.header.SetSession(msg)
147		if needsModeChange && (p.wWidth <= CompactModeBreakpoint || p.forceCompactMode) {
148			cmds = append(cmds, p.setCompactMode(true))
149		}
150	case tea.KeyPressMsg:
151		switch {
152		case key.Matches(msg, p.keyMap.NewSession):
153			p.session = session.Session{}
154			return p, tea.Batch(
155				p.clearMessages(),
156				util.CmdHandler(chat.SessionClearedMsg{}),
157				p.setCompactMode(false),
158			)
159		case key.Matches(msg, p.keyMap.AddAttachment):
160			cfg := config.Get()
161			agentCfg := cfg.Agents[config.AgentCoder]
162			selectedModelID := agentCfg.Model
163			model := models.SupportedModels[selectedModelID]
164			if model.SupportsAttachments {
165				return p, util.CmdHandler(OpenFilePickerMsg{})
166			} else {
167				return p, util.ReportWarn("File attachments are not supported by the current model: " + string(selectedModelID))
168			}
169		case key.Matches(msg, p.keyMap.Tab):
170			if p.session.ID == "" {
171				return p, nil
172			}
173			p.chatFocused = !p.chatFocused
174			if p.chatFocused {
175				cmds = append(cmds, p.layout.FocusPanel(layout.LeftPanel))
176				cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: true}))
177			} else {
178				cmds = append(cmds, p.layout.FocusPanel(layout.BottomPanel))
179				cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: false}))
180			}
181			return p, tea.Batch(cmds...)
182		case key.Matches(msg, p.keyMap.Cancel):
183			if p.session.ID != "" {
184				// Cancel the current session's generation process
185				// This allows users to interrupt long-running operations
186				p.app.CoderAgent.Cancel(p.session.ID)
187				return p, nil
188			}
189		case key.Matches(msg, p.keyMap.Details):
190			if p.session.ID == "" || !p.compactMode {
191				return p, nil // No session to show details for
192			}
193			p.showDetails = !p.showDetails
194			p.header.SetDetailsOpen(p.showDetails)
195			if p.showDetails {
196				p.compactSidebar = sidebarCmp(p.app, true)
197				c, cmd := p.compactSidebar.Update(chat.SessionSelectedMsg(p.session))
198				p.compactSidebar = c.(layout.Container)
199				return p, tea.Batch(
200					cmd,
201					p.compactSidebar.SetSize(p.wWidth-4, 0),
202				)
203			}
204
205			return p, nil
206		}
207	}
208	u, cmd := p.layout.Update(msg)
209	cmds = append(cmds, cmd)
210	p.layout = u.(layout.SplitPaneLayout)
211
212	if p.compactMode && p.showDetails {
213		s, cmd := p.compactSidebar.Update(msg)
214		p.compactSidebar = s.(layout.Container)
215		cmds = append(cmds, cmd)
216	}
217	return p, tea.Batch(cmds...)
218}
219
220func (p *chatPage) setMessages() tea.Cmd {
221	messagesContainer := layout.NewContainer(
222		chat.NewMessagesListCmp(p.app),
223		layout.WithPadding(1, 1, 0, 1),
224	)
225	return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
226}
227
228func (p *chatPage) setSidebar() tea.Cmd {
229	sidebarContainer := sidebarCmp(p.app, false)
230	sidebarContainer.Init()
231	return p.layout.SetRightPanel(sidebarContainer)
232}
233
234func (p *chatPage) clearMessages() tea.Cmd {
235	return p.layout.ClearLeftPanel()
236}
237
238func (p *chatPage) setCompactMode(compact bool) tea.Cmd {
239	p.compactMode = compact
240	var cmds []tea.Cmd
241	if compact {
242		// add offset for the header
243		p.layout.SetOffset(0, 1)
244		// make space for the header
245		cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight-1))
246		// remove the sidebar
247		cmds = append(cmds, p.layout.ClearRightPanel())
248		return tea.Batch(cmds...)
249	} else {
250		// remove the offset for the header
251		p.layout.SetOffset(0, 0)
252		// restore the original size
253		cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight))
254		// set the sidebar
255		cmds = append(cmds, p.setSidebar())
256		l, cmd := p.layout.Update(chat.SessionSelectedMsg(p.session))
257		p.layout = l.(layout.SplitPaneLayout)
258		cmds = append(cmds, cmd)
259
260		return tea.Batch(cmds...)
261	}
262}
263
264func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
265	var cmds []tea.Cmd
266	if p.session.ID == "" {
267		session, err := p.app.Sessions.Create(context.Background(), "New Session")
268		if err != nil {
269			return util.ReportError(err)
270		}
271
272		p.session = session
273		cmd := p.setMessages()
274		if cmd != nil {
275			cmds = append(cmds, cmd)
276		}
277		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
278	}
279
280	_, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
281	if err != nil {
282		return util.ReportError(err)
283	}
284	return tea.Batch(cmds...)
285}
286
287func (p *chatPage) SetSize(width, height int) tea.Cmd {
288	return p.layout.SetSize(width, height)
289}
290
291func (p *chatPage) GetSize() (int, int) {
292	return p.layout.GetSize()
293}
294
295func (p *chatPage) View() tea.View {
296	if !p.compactMode || p.session.ID == "" {
297		// If not in compact mode or there is no session, we don't show the header
298		return p.layout.View()
299	}
300	layoutView := p.layout.View()
301	chatView := strings.Join(
302		[]string{
303			p.header.View().String(),
304			layoutView.String(),
305		}, "\n",
306	)
307	layers := []*lipgloss.Layer{
308		lipgloss.NewLayer(chatView).X(0).Y(0),
309	}
310	if p.showDetails {
311		t := styles.CurrentTheme()
312		style := t.S().Base.
313			Border(lipgloss.RoundedBorder()).
314			BorderForeground(t.BorderFocus)
315		version := t.S().Subtle.Padding(0, 1).AlignHorizontal(lipgloss.Right).Width(p.wWidth - 4).Render(version.Version)
316		details := style.Render(
317			lipgloss.JoinVertical(
318				lipgloss.Left,
319				p.compactSidebar.View().String(),
320				version,
321			),
322		)
323		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
324	}
325	canvas := lipgloss.NewCanvas(
326		layers...,
327	)
328	view := tea.NewView(canvas.Render())
329	view.SetCursor(layoutView.Cursor())
330	return view
331}
332
333func (p *chatPage) Bindings() []key.Binding {
334	bindings := []key.Binding{
335		p.keyMap.NewSession,
336		p.keyMap.AddAttachment,
337	}
338	if p.app.CoderAgent.IsBusy() {
339		bindings = append([]key.Binding{p.keyMap.Cancel}, bindings...)
340	}
341
342	if p.chatFocused {
343		bindings = append([]key.Binding{
344			key.NewBinding(
345				key.WithKeys("tab"),
346				key.WithHelp("tab", "focus editor"),
347			),
348		}, bindings...)
349	} else {
350		bindings = append([]key.Binding{
351			key.NewBinding(
352				key.WithKeys("tab"),
353				key.WithHelp("tab", "focus chat"),
354			),
355		}, bindings...)
356	}
357
358	bindings = append(bindings, p.layout.Bindings()...)
359	return bindings
360}
361
362func sidebarCmp(app *app.App, compact bool) layout.Container {
363	padding := layout.WithPadding(1, 1, 1, 1)
364	if compact {
365		padding = layout.WithPadding(0, 1, 1, 1)
366	}
367	return layout.NewContainer(
368		sidebar.NewSidebarCmp(app.History, app.LSPClients, compact),
369		padding,
370	)
371}
372
373func NewChatPage(app *app.App) ChatPage {
374	editorContainer := layout.NewContainer(
375		editor.NewEditorCmp(app),
376	)
377	return &chatPage{
378		app: app,
379		layout: layout.NewSplitPane(
380			layout.WithRightPanel(sidebarCmp(app, false)),
381			layout.WithBottomPanel(editorContainer),
382			layout.WithFixedBottomHeight(5),
383			layout.WithFixedRightWidth(31),
384		),
385		keyMap: DefaultKeyMap(),
386		header: header.New(app.LSPClients),
387	}
388}