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}