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 = 90 // 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		return tea.Batch(cmds...)
257	}
258}
259
260func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
261	var cmds []tea.Cmd
262	if p.session.ID == "" {
263		session, err := p.app.Sessions.Create(context.Background(), "New Session")
264		if err != nil {
265			return util.ReportError(err)
266		}
267
268		p.session = session
269		cmd := p.setMessages()
270		if cmd != nil {
271			cmds = append(cmds, cmd)
272		}
273		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
274	}
275
276	_, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
277	if err != nil {
278		return util.ReportError(err)
279	}
280	return tea.Batch(cmds...)
281}
282
283func (p *chatPage) SetSize(width, height int) tea.Cmd {
284	return p.layout.SetSize(width, height)
285}
286
287func (p *chatPage) GetSize() (int, int) {
288	return p.layout.GetSize()
289}
290
291func (p *chatPage) View() tea.View {
292	if !p.compactMode || p.session.ID == "" {
293		// If not in compact mode or there is no session, we don't show the header
294		return p.layout.View()
295	}
296	layoutView := p.layout.View()
297	chatView := strings.Join(
298		[]string{
299			p.header.View().String(),
300			layoutView.String(),
301		}, "\n",
302	)
303	layers := []*lipgloss.Layer{
304		lipgloss.NewLayer(chatView).X(0).Y(0),
305	}
306	if p.showDetails {
307		t := styles.CurrentTheme()
308		style := t.S().Base.
309			Border(lipgloss.RoundedBorder()).
310			BorderForeground(t.BorderFocus)
311		version := t.S().Subtle.Padding(0, 1).AlignHorizontal(lipgloss.Right).Width(p.wWidth - 4).Render(version.Version)
312		details := style.Render(
313			lipgloss.JoinVertical(
314				lipgloss.Left,
315				p.compactSidebar.View().String(),
316				version,
317			),
318		)
319		layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
320	}
321	canvas := lipgloss.NewCanvas(
322		layers...,
323	)
324	view := tea.NewView(canvas.Render())
325	view.SetCursor(layoutView.Cursor())
326	return view
327}
328
329func (p *chatPage) Bindings() []key.Binding {
330	bindings := []key.Binding{
331		p.keyMap.NewSession,
332		p.keyMap.AddAttachment,
333	}
334	if p.app.CoderAgent.IsBusy() {
335		bindings = append([]key.Binding{p.keyMap.Cancel}, bindings...)
336	}
337
338	if p.chatFocused {
339		bindings = append([]key.Binding{
340			key.NewBinding(
341				key.WithKeys("tab"),
342				key.WithHelp("tab", "focus editor"),
343			),
344		}, bindings...)
345	} else {
346		bindings = append([]key.Binding{
347			key.NewBinding(
348				key.WithKeys("tab"),
349				key.WithHelp("tab", "focus chat"),
350			),
351		}, bindings...)
352	}
353
354	bindings = append(bindings, p.layout.Bindings()...)
355	return bindings
356}
357
358func sidebarCmp(app *app.App, compact bool) layout.Container {
359	padding := layout.WithPadding(1, 1, 1, 1)
360	if compact {
361		padding = layout.WithPadding(0, 1, 1, 1)
362	}
363	return layout.NewContainer(
364		sidebar.NewSidebarCmp(app.History, app.LSPClients, compact),
365		padding,
366	)
367}
368
369func NewChatPage(app *app.App) ChatPage {
370	editorContainer := layout.NewContainer(
371		editor.NewEditorCmp(app),
372	)
373	return &chatPage{
374		app: app,
375		layout: layout.NewSplitPane(
376			layout.WithRightPanel(sidebarCmp(app, false)),
377			layout.WithBottomPanel(editorContainer),
378			layout.WithFixedBottomHeight(5),
379			layout.WithFixedRightWidth(31),
380		),
381		keyMap: DefaultKeyMap(),
382		header: header.New(app.LSPClients),
383	}
384}