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}