1package chat
2
3import (
4 "context"
5 "time"
6
7 "github.com/charmbracelet/bubbles/v2/key"
8 "github.com/charmbracelet/bubbles/v2/spinner"
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/history"
13 "github.com/charmbracelet/crush/internal/message"
14 "github.com/charmbracelet/crush/internal/pubsub"
15 "github.com/charmbracelet/crush/internal/session"
16 "github.com/charmbracelet/crush/internal/tui/components/anim"
17 "github.com/charmbracelet/crush/internal/tui/components/chat"
18 "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
19 "github.com/charmbracelet/crush/internal/tui/components/chat/header"
20 "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
21 "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
22 "github.com/charmbracelet/crush/internal/tui/components/completions"
23 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
25 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
26 "github.com/charmbracelet/crush/internal/tui/page"
27 "github.com/charmbracelet/crush/internal/tui/styles"
28 "github.com/charmbracelet/crush/internal/tui/util"
29 "github.com/charmbracelet/crush/internal/version"
30 "github.com/charmbracelet/lipgloss/v2"
31)
32
33var ChatPageID page.PageID = "chat"
34
35type (
36 OpenFilePickerMsg struct{}
37 ChatFocusedMsg struct {
38 Focused bool // True if the chat input is focused, false otherwise
39 }
40 CancelTimerExpiredMsg struct{}
41)
42
43type ChatState string
44
45const (
46 ChatStateOnboarding ChatState = "onboarding"
47 ChatStateInitProject ChatState = "init_project"
48 ChatStateNewMessage ChatState = "new_message"
49 ChatStateInSession ChatState = "in_session"
50)
51
52type PanelType string
53
54const (
55 PanelTypeChat PanelType = "chat"
56 PanelTypeEditor PanelType = "editor"
57 PanelTypeSplash PanelType = "splash"
58)
59
60const (
61 CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
62 EditorHeight = 5 // Height of the editor input area including padding
63 SideBarWidth = 31 // Width of the sidebar
64 SideBarDetailsPadding = 1 // Padding for the sidebar details section
65 HeaderHeight = 1 // Height of the header
66)
67
68type ChatPage interface {
69 util.Model
70 layout.Help
71}
72
73// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds
74func cancelTimerCmd() tea.Cmd {
75 return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
76 return CancelTimerExpiredMsg{}
77 })
78}
79
80type chatPage struct {
81 width, height int
82 detailsWidth, detailsHeight int
83 app *app.App
84 state ChatState
85 session session.Session
86 keyMap KeyMap
87 focusedPane PanelType
88 // Compact mode
89 compact bool
90 header header.Header
91 showingDetails bool
92
93 sidebar sidebar.Sidebar
94 chat chat.MessageListCmp
95 editor editor.Editor
96 splash splash.Splash
97 canceling bool
98
99 // This will force the compact mode even in big screens
100 // usually triggered by the user command
101 // this will also be set when the user config is set to compact mode
102 forceCompact bool
103}
104
105func New(app *app.App) ChatPage {
106 return &chatPage{
107 app: app,
108 state: ChatStateOnboarding,
109
110 keyMap: DefaultKeyMap(),
111
112 header: header.New(app.LSPClients),
113 sidebar: sidebar.New(app.History, app.LSPClients, false),
114 chat: chat.New(app),
115 editor: editor.New(app),
116 splash: splash.New(),
117 focusedPane: PanelTypeSplash,
118 }
119}
120
121func (p *chatPage) Init() tea.Cmd {
122 cfg := config.Get()
123 if config.HasInitialDataConfig() {
124 if b, _ := config.ProjectNeedsInitialization(); b {
125 p.state = ChatStateInitProject
126 } else {
127 p.state = ChatStateNewMessage
128 p.focusedPane = PanelTypeEditor
129 }
130 }
131
132 compact := cfg.Options.TUI.CompactMode
133 p.compact = compact
134 p.forceCompact = compact
135 p.sidebar.SetCompactMode(p.compact)
136 return tea.Batch(
137 p.header.Init(),
138 p.sidebar.Init(),
139 p.chat.Init(),
140 p.editor.Init(),
141 p.splash.Init(),
142 )
143}
144
145func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
146 var cmds []tea.Cmd
147 switch msg := msg.(type) {
148 case tea.WindowSizeMsg:
149 return p, p.SetSize(msg.Width, msg.Height)
150 case CancelTimerExpiredMsg:
151 p.canceling = false
152 return p, nil
153 case chat.SendMsg:
154 return p, p.sendMessage(msg.Text, msg.Attachments)
155 case chat.SessionSelectedMsg:
156 return p, p.setSession(msg)
157 case commands.ToggleCompactModeMsg:
158 p.forceCompact = !p.forceCompact
159 var cmd tea.Cmd
160 if p.forceCompact {
161 p.setCompactMode(true)
162 cmd = p.updateCompactConfig(true)
163 } else if p.width >= CompactModeBreakpoint {
164 p.setCompactMode(false)
165 cmd = p.updateCompactConfig(false)
166 }
167 return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
168 case pubsub.Event[session.Session]:
169 // this needs to go to header/sidebar
170 u, cmd := p.header.Update(msg)
171 p.header = u.(header.Header)
172 cmds = append(cmds, cmd)
173 u, cmd = p.sidebar.Update(msg)
174 p.sidebar = u.(sidebar.Sidebar)
175 cmds = append(cmds, cmd)
176 return p, tea.Batch(cmds...)
177 case chat.SessionClearedMsg:
178 u, cmd := p.header.Update(msg)
179 p.header = u.(header.Header)
180 cmds = append(cmds, cmd)
181 u, cmd = p.sidebar.Update(msg)
182 p.sidebar = u.(sidebar.Sidebar)
183 cmds = append(cmds, cmd)
184 u, cmd = p.chat.Update(msg)
185 p.chat = u.(chat.MessageListCmp)
186 cmds = append(cmds, cmd)
187 return p, tea.Batch(cmds...)
188 case filepicker.FilePickedMsg,
189 completions.CompletionsClosedMsg,
190 completions.SelectCompletionMsg:
191 u, cmd := p.editor.Update(msg)
192 p.editor = u.(editor.Editor)
193 cmds = append(cmds, cmd)
194 return p, tea.Batch(cmds...)
195
196 case pubsub.Event[message.Message],
197 anim.StepMsg,
198 spinner.TickMsg:
199 // this needs to go to chat
200 u, cmd := p.chat.Update(msg)
201 p.chat = u.(chat.MessageListCmp)
202 cmds = append(cmds, cmd)
203 return p, tea.Batch(cmds...)
204
205 case pubsub.Event[history.File], sidebar.SessionFilesMsg:
206 // this needs to go to sidebar
207 u, cmd := p.sidebar.Update(msg)
208 p.sidebar = u.(sidebar.Sidebar)
209 cmds = append(cmds, cmd)
210 return p, tea.Batch(cmds...)
211
212 case commands.CommandRunCustomMsg:
213 // Check if the agent is busy before executing custom commands
214 if p.app.CoderAgent.IsBusy() {
215 return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
216 }
217
218 // Handle custom command execution
219 cmd := p.sendMessage(msg.Content, nil)
220 if cmd != nil {
221 return p, cmd
222 }
223 case tea.KeyPressMsg:
224 switch {
225 case key.Matches(msg, p.keyMap.NewSession):
226 return p, p.newSession()
227 case key.Matches(msg, p.keyMap.AddAttachment):
228 agentCfg := config.Get().Agents["coder"]
229 model := config.Get().GetModelByType(agentCfg.Model)
230 if model.SupportsImages {
231 return p, util.CmdHandler(OpenFilePickerMsg{})
232 } else {
233 return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
234 }
235 case key.Matches(msg, p.keyMap.Tab):
236 p.changeFocus()
237 return p, nil
238 case key.Matches(msg, p.keyMap.Cancel):
239 return p, p.cancel()
240 case key.Matches(msg, p.keyMap.Details):
241 p.showDetails()
242 return p, nil
243 }
244
245 // Send the key press to the focused pane
246 switch p.focusedPane {
247 case PanelTypeChat:
248 u, cmd := p.chat.Update(msg)
249 p.chat = u.(chat.MessageListCmp)
250 cmds = append(cmds, cmd)
251 case PanelTypeEditor:
252 u, cmd := p.editor.Update(msg)
253 p.editor = u.(editor.Editor)
254 cmds = append(cmds, cmd)
255 case PanelTypeSplash:
256 u, cmd := p.splash.Update(msg)
257 p.splash = u.(splash.Splash)
258 cmds = append(cmds, cmd)
259 }
260 }
261 return p, tea.Batch(cmds...)
262}
263
264func (p *chatPage) View() tea.View {
265 var chatView tea.View
266 t := styles.CurrentTheme()
267 switch p.state {
268 case ChatStateOnboarding, ChatStateInitProject:
269 chatView = p.splash.View()
270 case ChatStateNewMessage:
271 editorView := p.editor.View()
272 chatView = tea.NewView(
273 lipgloss.JoinVertical(
274 lipgloss.Left,
275 t.S().Base.Render(
276 p.splash.View().String(),
277 ),
278 editorView.String(),
279 ),
280 )
281 chatView.SetCursor(editorView.Cursor())
282 case ChatStateInSession:
283 messagesView := p.chat.View()
284 editorView := p.editor.View()
285 if p.compact {
286 headerView := p.header.View()
287 chatView = tea.NewView(
288 lipgloss.JoinVertical(
289 lipgloss.Left,
290 headerView.String(),
291 messagesView.String(),
292 editorView.String(),
293 ),
294 )
295 chatView.SetCursor(editorView.Cursor())
296 } else {
297 sidebarView := p.sidebar.View()
298 messages := lipgloss.JoinHorizontal(
299 lipgloss.Left,
300 messagesView.String(),
301 sidebarView.String(),
302 )
303 chatView = tea.NewView(
304 lipgloss.JoinVertical(
305 lipgloss.Left,
306 messages,
307 p.editor.View().String(),
308 ),
309 )
310 chatView.SetCursor(editorView.Cursor())
311 }
312 default:
313 chatView = tea.NewView("Unknown chat state")
314 }
315
316 layers := []*lipgloss.Layer{
317 lipgloss.NewLayer(chatView.String()).X(0).Y(0),
318 }
319
320 if p.showingDetails {
321 style := t.S().Base.
322 Width(p.detailsWidth).
323 Border(lipgloss.RoundedBorder()).
324 BorderForeground(t.BorderFocus)
325 version := t.S().Subtle.Width(p.detailsWidth - 2).AlignHorizontal(lipgloss.Right).Render(version.Version)
326 details := style.Render(
327 lipgloss.JoinVertical(
328 lipgloss.Left,
329 p.sidebar.View().String(),
330 version,
331 ),
332 )
333 layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
334 }
335 canvas := lipgloss.NewCanvas(
336 layers...,
337 )
338 view := tea.NewView(canvas.Render())
339 view.SetCursor(chatView.Cursor())
340 return view
341}
342
343func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
344 return func() tea.Msg {
345 err := config.Get().SetCompactMode(compact)
346 if err != nil {
347 return util.InfoMsg{
348 Type: util.InfoTypeError,
349 Msg: "Failed to update compact mode configuration: " + err.Error(),
350 }
351 }
352 return nil
353 }
354}
355
356func (p *chatPage) setCompactMode(compact bool) {
357 if p.compact == compact {
358 return
359 }
360 p.compact = compact
361 if compact {
362 p.compact = true
363 p.sidebar.SetCompactMode(true)
364 } else {
365 p.compact = false
366 p.showingDetails = false
367 p.sidebar.SetCompactMode(false)
368 }
369}
370
371func (p *chatPage) handleCompactMode(newWidth int) {
372 if p.forceCompact {
373 return
374 }
375 if newWidth < CompactModeBreakpoint && !p.compact {
376 p.setCompactMode(true)
377 }
378 if newWidth >= CompactModeBreakpoint && p.compact {
379 p.setCompactMode(false)
380 }
381}
382
383func (p *chatPage) SetSize(width, height int) tea.Cmd {
384 p.handleCompactMode(width)
385 p.width = width
386 p.height = height
387 var cmds []tea.Cmd
388 switch p.state {
389 case ChatStateOnboarding, ChatStateInitProject:
390 // here we should just have the splash screen
391 cmds = append(cmds, p.splash.SetSize(width, height))
392 case ChatStateNewMessage:
393 cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
394 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
395 cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
396 case ChatStateInSession:
397 if p.compact {
398 cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
399 // In compact mode, the sidebar is shown in the details section, the width needs to be adjusted for the padding and border
400 p.detailsWidth = width - 2 // because of position
401 cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-2, p.detailsHeight-2)) // adjust for border
402 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
403 cmds = append(cmds, p.header.SetWidth(width-1))
404 } else {
405 cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
406 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
407 cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
408 }
409 cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
410 }
411 return tea.Batch(cmds...)
412}
413
414func (p *chatPage) newSession() tea.Cmd {
415 if p.state != ChatStateInSession {
416 // Cannot start a new session if we are not in the session state
417 return nil
418 }
419 // blank session
420 p.session = session.Session{}
421 p.state = ChatStateNewMessage
422 p.focusedPane = PanelTypeEditor
423 p.canceling = false
424 // Reset the chat and editor components
425 return tea.Batch(
426 util.CmdHandler(chat.SessionClearedMsg{}),
427 p.SetSize(p.width, p.height),
428 )
429}
430
431func (p *chatPage) setSession(session session.Session) tea.Cmd {
432 if p.session.ID == session.ID {
433 return nil
434 }
435
436 var cmds []tea.Cmd
437 p.session = session
438 // We want to first resize the components
439 if p.state != ChatStateInSession {
440 p.state = ChatStateInSession
441 cmds = append(cmds, p.SetSize(p.width, p.height))
442 }
443 cmds = append(cmds, p.chat.SetSession(session))
444 cmds = append(cmds, p.sidebar.SetSession(session))
445 cmds = append(cmds, p.header.SetSession(session))
446 cmds = append(cmds, p.editor.SetSession(session))
447
448 return tea.Sequence(cmds...)
449}
450
451func (p *chatPage) changeFocus() {
452 if p.state != ChatStateInSession {
453 // Cannot change focus if we are not in the session state
454 return
455 }
456 switch p.focusedPane {
457 case PanelTypeChat:
458 p.focusedPane = PanelTypeEditor
459 case PanelTypeEditor:
460 p.focusedPane = PanelTypeChat
461 }
462}
463
464func (p *chatPage) cancel() tea.Cmd {
465 if p.state != ChatStateInSession || !p.app.CoderAgent.IsBusy() {
466 // Cannot cancel if we are not in the session state
467 return nil
468 }
469
470 // second press of cancel key will actually cancel the session
471 if p.canceling {
472 p.canceling = false
473 p.app.CoderAgent.Cancel(p.session.ID)
474 return nil
475 }
476
477 p.canceling = true
478 return cancelTimerCmd()
479}
480
481func (p *chatPage) showDetails() {
482 if p.state != ChatStateInSession || !p.compact {
483 // Cannot show details if we are not in the session state or if we are not in compact mode
484 return
485 }
486 p.showingDetails = !p.showingDetails
487 p.header.SetDetailsOpen(p.showingDetails)
488}
489
490func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
491 session := p.session
492 var cmds []tea.Cmd
493 if p.state != ChatStateInSession {
494 // branch new session
495 newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
496 if err != nil {
497 return util.ReportError(err)
498 }
499 session = newSession
500 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
501 }
502 _, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
503 if err != nil {
504 return util.ReportError(err)
505 }
506 return tea.Batch(cmds...)
507}
508
509func (p *chatPage) Bindings() []key.Binding {
510 bindings := []key.Binding{
511 p.keyMap.NewSession,
512 p.keyMap.AddAttachment,
513 }
514 if p.app.CoderAgent.IsBusy() {
515 cancelBinding := p.keyMap.Cancel
516 if p.canceling {
517 cancelBinding = key.NewBinding(
518 key.WithKeys("esc"),
519 key.WithHelp("esc", "press again to cancel"),
520 )
521 }
522 bindings = append([]key.Binding{cancelBinding}, bindings...)
523 }
524
525 switch p.focusedPane {
526 case PanelTypeChat:
527 bindings = append([]key.Binding{
528 key.NewBinding(
529 key.WithKeys("tab"),
530 key.WithHelp("tab", "focus editor"),
531 ),
532 }, bindings...)
533 bindings = append(bindings, p.chat.Bindings()...)
534 case PanelTypeEditor:
535 bindings = append([]key.Binding{
536 key.NewBinding(
537 key.WithKeys("tab"),
538 key.WithHelp("tab", "focus chat"),
539 ),
540 }, bindings...)
541 bindings = append(bindings, p.editor.Bindings()...)
542 case PanelTypeSplash:
543 bindings = append(bindings, p.splash.Bindings()...)
544 }
545
546 return bindings
547}