1package model
2
3import (
4 "image"
5 "math/rand"
6 "slices"
7 "strings"
8
9 "charm.land/bubbles/v2/help"
10 "charm.land/bubbles/v2/key"
11 "charm.land/bubbles/v2/textarea"
12 tea "charm.land/bubbletea/v2"
13 "charm.land/lipgloss/v2"
14 "github.com/charmbracelet/crush/internal/session"
15 "github.com/charmbracelet/crush/internal/ui/common"
16 "github.com/charmbracelet/crush/internal/ui/dialog"
17 uv "github.com/charmbracelet/ultraviolet"
18)
19
20// uiState represents the current focus state of the UI.
21type uiState uint8
22
23// Possible uiState values.
24const (
25 uiEdit uiState = iota
26 uiChat
27)
28
29// UI represents the main user interface model.
30type UI struct {
31 com *common.Common
32 sess *session.Session
33
34 state uiState
35
36 keyMap KeyMap
37 keyenh tea.KeyboardEnhancementsMsg
38
39 chat *ChatModel
40 side *SidebarModel
41 dialog *dialog.Overlay
42 help help.Model
43
44 layout layout
45
46 // sendProgressBar instructs the TUI to send progress bar updates to the
47 // terminal.
48 sendProgressBar bool
49
50 // QueryVersion instructs the TUI to query for the terminal version when it
51 // starts.
52 QueryVersion bool
53
54 // Editor components
55 textarea textarea.Model
56
57 attachments []any // TODO: Implement attachments
58
59 readyPlaceholder string
60 workingPlaceholder string
61}
62
63// New creates a new instance of the [UI] model.
64func New(com *common.Common) *UI {
65 // Editor components
66 ta := textarea.New()
67 ta.SetStyles(com.Styles.TextArea)
68 ta.ShowLineNumbers = false
69 ta.CharLimit = -1
70 ta.SetVirtualCursor(false)
71 ta.Focus()
72
73 ui := &UI{
74 com: com,
75 dialog: dialog.NewOverlay(),
76 keyMap: DefaultKeyMap(),
77 side: NewSidebarModel(com),
78 help: help.New(),
79 textarea: ta,
80 }
81
82 ui.setEditorPrompt()
83 ui.randomizePlaceholders()
84 ui.textarea.Placeholder = ui.readyPlaceholder
85
86 return ui
87}
88
89// Init initializes the UI model.
90func (m *UI) Init() tea.Cmd {
91 if m.QueryVersion {
92 return tea.RequestTerminalVersion
93 }
94
95 return nil
96}
97
98// Update handles updates to the UI model.
99func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
100 var cmds []tea.Cmd
101 hasDialogs := m.dialog.HasDialogs()
102 switch msg := msg.(type) {
103 case tea.EnvMsg:
104 // Is this Windows Terminal?
105 if !m.sendProgressBar {
106 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
107 }
108 case tea.TerminalVersionMsg:
109 termVersion := strings.ToLower(msg.Name)
110 // Only enable progress bar for the following terminals.
111 if !m.sendProgressBar {
112 m.sendProgressBar = strings.Contains(termVersion, "ghostty")
113 }
114 return m, nil
115 case tea.WindowSizeMsg:
116 m.updateLayoutAndSize(msg.Width, msg.Height)
117 case tea.KeyboardEnhancementsMsg:
118 m.keyenh = msg
119 if msg.SupportsKeyDisambiguation() {
120 m.keyMap.Models.SetHelp("ctrl+m", "models")
121 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
122 }
123 case tea.KeyPressMsg:
124 if hasDialogs {
125 m.updateDialogs(msg, &cmds)
126 }
127 }
128
129 if !hasDialogs {
130 // This branch only handles UI elements when there's no dialog shown.
131 switch msg := msg.(type) {
132 case tea.KeyPressMsg:
133 switch {
134 case key.Matches(msg, m.keyMap.Tab):
135 if m.state == uiChat {
136 m.state = uiEdit
137 cmds = append(cmds, m.textarea.Focus())
138 } else {
139 m.state = uiChat
140 m.textarea.Blur()
141 }
142 case key.Matches(msg, m.keyMap.Help):
143 m.help.ShowAll = !m.help.ShowAll
144 m.updateLayoutAndSize(m.layout.area.Dx(), m.layout.area.Dy())
145 case key.Matches(msg, m.keyMap.Quit):
146 if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
147 m.dialog.AddDialog(dialog.NewQuit(m.com))
148 return m, nil
149 }
150 case key.Matches(msg, m.keyMap.Commands):
151 // TODO: Implement me
152 case key.Matches(msg, m.keyMap.Models):
153 // TODO: Implement me
154 case key.Matches(msg, m.keyMap.Sessions):
155 // TODO: Implement me
156 default:
157 m.updateFocused(msg, &cmds)
158 }
159 }
160
161 // This logic gets triggered on any message type, but should it?
162 switch m.state {
163 case uiChat:
164 case uiEdit:
165 // Textarea placeholder logic
166 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
167 m.textarea.Placeholder = m.workingPlaceholder
168 } else {
169 m.textarea.Placeholder = m.readyPlaceholder
170 }
171 if m.com.App.Permissions.SkipRequests() {
172 m.textarea.Placeholder = "Yolo mode!"
173 }
174 }
175 }
176
177 return m, tea.Batch(cmds...)
178}
179
180// View renders the UI model's view.
181func (m *UI) View() tea.View {
182 var v tea.View
183 v.AltScreen = true
184
185 layers := []*lipgloss.Layer{}
186
187 // Determine the help key map based on focus
188 var helpKeyMap help.KeyMap = m
189
190 // The screen areas we're working with
191 area := m.layout.area
192 chatRect := m.layout.chat
193 sideRect := m.layout.sidebar
194 editRect := m.layout.editor
195 helpRect := m.layout.help
196
197 if m.dialog.HasDialogs() {
198 if dialogView := m.dialog.View(); dialogView != "" {
199 dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
200 dialogArea := common.CenterRect(area, dialogWidth, dialogHeight)
201 layers = append(layers,
202 lipgloss.NewLayer(dialogView).
203 X(dialogArea.Min.X).
204 Y(dialogArea.Min.Y).
205 Z(99),
206 )
207 }
208 }
209
210 if m.state == uiEdit && m.textarea.Focused() {
211 cur := m.textarea.Cursor()
212 cur.X++ // Adjust for app margins
213 cur.Y += editRect.Min.Y
214 v.Cursor = cur
215 }
216
217 mainLayer := lipgloss.NewLayer("").X(area.Min.X).Y(area.Min.Y).
218 Width(area.Dx()).Height(area.Dy()).
219 AddLayers(
220 lipgloss.NewLayer(
221 lipgloss.NewStyle().Width(chatRect.Dx()).
222 Height(chatRect.Dy()).
223 Background(lipgloss.ANSIColor(rand.Intn(256))).
224 Render(" Main View "),
225 ).X(chatRect.Min.X).Y(chatRect.Min.Y),
226 lipgloss.NewLayer(m.side.View()).
227 X(sideRect.Min.X).Y(sideRect.Min.Y),
228 lipgloss.NewLayer(m.textarea.View()).
229 X(editRect.Min.X).Y(editRect.Min.Y),
230 lipgloss.NewLayer(m.help.View(helpKeyMap)).
231 X(helpRect.Min.X).Y(helpRect.Min.Y),
232 )
233
234 layers = append(layers, mainLayer)
235
236 v.Content = lipgloss.NewCanvas(layers...)
237 if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
238 // HACK: use a random percentage to prevent ghostty from hiding it
239 // after a timeout.
240 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
241 }
242
243 return v
244}
245
246// ShortHelp implements [help.KeyMap].
247func (m *UI) ShortHelp() []key.Binding {
248 var binds []key.Binding
249 k := &m.keyMap
250
251 if m.sess == nil {
252 // no session selected
253 binds = append(binds,
254 k.Commands,
255 k.Models,
256 k.Editor.Newline,
257 k.Quit,
258 k.Help,
259 )
260 } else {
261 // we have a session
262 }
263
264 // switch m.state {
265 // case uiChat:
266 // case uiEdit:
267 // binds = append(binds,
268 // k.Editor.AddFile,
269 // k.Editor.SendMessage,
270 // k.Editor.OpenEditor,
271 // k.Editor.Newline,
272 // )
273 //
274 // if len(m.attachments) > 0 {
275 // binds = append(binds,
276 // k.Editor.AttachmentDeleteMode,
277 // k.Editor.DeleteAllAttachments,
278 // k.Editor.Escape,
279 // )
280 // }
281 // }
282
283 return binds
284}
285
286// FullHelp implements [help.KeyMap].
287func (m *UI) FullHelp() [][]key.Binding {
288 var binds [][]key.Binding
289 k := &m.keyMap
290 help := k.Help
291 help.SetHelp("ctrl+g", "less")
292
293 if m.sess == nil {
294 // no session selected
295 binds = append(binds,
296 []key.Binding{
297 k.Commands,
298 k.Models,
299 k.Sessions,
300 },
301 []key.Binding{
302 k.Editor.Newline,
303 k.Editor.AddImage,
304 k.Editor.MentionFile,
305 k.Editor.OpenEditor,
306 },
307 []key.Binding{
308 help,
309 },
310 )
311 } else {
312 // we have a session
313 }
314
315 // switch m.state {
316 // case uiChat:
317 // case uiEdit:
318 // binds = append(binds, m.ShortHelp())
319 // }
320
321 return binds
322}
323
324// updateDialogs updates the dialog overlay with the given message and appends
325// any resulting commands to the cmds slice.
326func (m *UI) updateDialogs(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
327 updatedDialog, cmd := m.dialog.Update(msg)
328 m.dialog = updatedDialog
329 if cmd != nil {
330 *cmds = append(*cmds, cmd)
331 }
332}
333
334// updateFocused updates the focused model (chat or editor) with the given message
335// and appends any resulting commands to the cmds slice.
336func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
337 switch m.state {
338 case uiChat:
339 m.updateChat(msg, cmds)
340 case uiEdit:
341 switch {
342 case key.Matches(msg, m.keyMap.Editor.Newline):
343 m.textarea.InsertRune('\n')
344 }
345
346 ta, cmd := m.textarea.Update(msg)
347 m.textarea = ta
348 if cmd != nil {
349 *cmds = append(*cmds, cmd)
350 }
351 }
352}
353
354// updateChat updates the chat model with the given message and appends any
355// resulting commands to the cmds slice.
356func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
357 updatedChat, cmd := m.chat.Update(msg)
358 m.chat = updatedChat
359 if cmd != nil {
360 *cmds = append(*cmds, cmd)
361 }
362}
363
364// updateLayoutAndSize updates the layout and sub-models sizes based on the
365// given terminal width and height given in cells.
366func (m *UI) updateLayoutAndSize(w, h int) {
367 // The screen area we're working with
368 area := image.Rect(0, 0, w, h)
369 var helpKeyMap help.KeyMap = m
370 helpHeight := 1
371 if m.help.ShowAll {
372 for _, row := range helpKeyMap.FullHelp() {
373 helpHeight = max(helpHeight, len(row))
374 }
375 }
376
377 // Add app margins
378 mainRect := area
379 mainRect.Min.X += 1
380 mainRect.Min.Y += 1
381 mainRect.Max.X -= 1
382 mainRect.Max.Y -= 1
383
384 mainRect, helpRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-helpHeight))
385 chatRect, sideRect := uv.SplitHorizontal(mainRect, uv.Fixed(mainRect.Dx()-40))
386 chatRect, editRect := uv.SplitVertical(chatRect, uv.Fixed(mainRect.Dy()-5))
387
388 // Add 1 line margin bottom of chatRect
389 chatRect, _ = uv.SplitVertical(chatRect, uv.Fixed(chatRect.Dy()-1))
390 // Add 1 line margin bottom of editRect
391 editRect, _ = uv.SplitVertical(editRect, uv.Fixed(editRect.Dy()-1))
392
393 m.layout = layout{
394 area: area,
395 main: mainRect,
396 chat: chatRect,
397 editor: editRect,
398 sidebar: sideRect,
399 help: helpRect,
400 }
401
402 // Update sub-model sizes
403 m.side.SetWidth(m.layout.sidebar.Dx())
404 m.textarea.SetWidth(m.layout.editor.Dx())
405 m.textarea.SetHeight(m.layout.editor.Dy())
406 m.help.SetWidth(m.layout.help.Dx())
407}
408
409// layout defines the positioning of UI elements.
410type layout struct {
411 // area is the overall available area.
412 area uv.Rectangle
413
414 // main is the main area excluding help.
415 main uv.Rectangle
416
417 // chat is the area for the chat pane.
418 chat uv.Rectangle
419
420 // editor is the area for the editor pane.
421 editor uv.Rectangle
422
423 // sidebar is the area for the sidebar.
424 sidebar uv.Rectangle
425
426 // help is the area for the help view.
427 help uv.Rectangle
428}
429
430func (m *UI) setEditorPrompt() {
431 if m.com.App.Permissions.SkipRequests() {
432 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
433 return
434 }
435 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
436}
437
438func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
439 t := m.com.Styles
440 if info.LineNumber == 0 {
441 return " > "
442 }
443 if info.Focused {
444 return t.EditorPromptNormalFocused.Render()
445 }
446 return t.EditorPromptNormalBlurred.Render()
447}
448
449func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
450 t := m.com.Styles
451 if info.LineNumber == 0 {
452 if info.Focused {
453 return t.EditorPromptYoloIconFocused.Render()
454 } else {
455 return t.EditorPromptYoloIconBlurred.Render()
456 }
457 }
458 if info.Focused {
459 return t.EditorPromptYoloDotsFocused.Render()
460 }
461 return t.EditorPromptYoloDotsBlurred.Render()
462}
463
464var readyPlaceholders = [...]string{
465 "Ready!",
466 "Ready...",
467 "Ready?",
468 "Ready for instructions",
469}
470
471var workingPlaceholders = [...]string{
472 "Working!",
473 "Working...",
474 "Brrrrr...",
475 "Prrrrrrrr...",
476 "Processing...",
477 "Thinking...",
478}
479
480func (m *UI) randomizePlaceholders() {
481 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
482 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
483}