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}