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