1package model
2
3import (
4 "image"
5 "math/rand"
6 "os"
7 "slices"
8 "strings"
9
10 "charm.land/bubbles/v2/help"
11 "charm.land/bubbles/v2/key"
12 "charm.land/bubbles/v2/textarea"
13 tea "charm.land/bubbletea/v2"
14 "charm.land/lipgloss/v2"
15 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
16 "github.com/charmbracelet/crush/internal/app"
17 "github.com/charmbracelet/crush/internal/config"
18 "github.com/charmbracelet/crush/internal/pubsub"
19 "github.com/charmbracelet/crush/internal/session"
20 "github.com/charmbracelet/crush/internal/ui/common"
21 "github.com/charmbracelet/crush/internal/ui/dialog"
22 "github.com/charmbracelet/crush/internal/ui/logo"
23 "github.com/charmbracelet/crush/internal/ui/styles"
24 "github.com/charmbracelet/crush/internal/version"
25 uv "github.com/charmbracelet/ultraviolet"
26 "github.com/charmbracelet/ultraviolet/screen"
27 "github.com/charmbracelet/x/ansi"
28)
29
30// uiFocusState represents the current focus state of the UI.
31type uiFocusState uint8
32
33// Possible uiFocusState values.
34const (
35 uiFocusNone uiFocusState = iota
36 uiFocusEditor
37 uiFocusMain
38)
39
40type uiState uint8
41
42// Possible uiState values.
43const (
44 uiConfigure uiState = iota
45 uiInitialize
46 uiLanding
47 uiChat
48 uiChatCompact
49)
50
51// UI represents the main user interface model.
52type UI struct {
53 com *common.Common
54 sess *session.Session
55
56 // The width and height of the terminal in cells.
57 width int
58 height int
59 layout layout
60
61 focus uiFocusState
62 state uiState
63
64 keyMap KeyMap
65 keyenh tea.KeyboardEnhancementsMsg
66
67 chat *ChatModel
68 side *SidebarModel
69 dialog *dialog.Overlay
70 help help.Model
71
72 // header is the last cached header logo
73 header string
74
75 // sendProgressBar instructs the TUI to send progress bar updates to the
76 // terminal.
77 sendProgressBar bool
78
79 // QueryVersion instructs the TUI to query for the terminal version when it
80 // starts.
81 QueryVersion bool
82
83 // Editor components
84 textarea textarea.Model
85
86 attachments []any // TODO: Implement attachments
87
88 readyPlaceholder string
89 workingPlaceholder string
90
91 // onboarding state
92 onboarding struct {
93 yesInitializeSelected bool
94 }
95
96 // lsp
97 lspStates map[string]app.LSPClientInfo
98
99 // mcp
100 mcpStates map[string]mcp.ClientInfo
101}
102
103// New creates a new instance of the [UI] model.
104func New(com *common.Common) *UI {
105 // Editor components
106 ta := textarea.New()
107 ta.SetStyles(com.Styles.TextArea)
108 ta.ShowLineNumbers = false
109 ta.CharLimit = -1
110 ta.SetVirtualCursor(false)
111 ta.Focus()
112
113 ui := &UI{
114 com: com,
115 dialog: dialog.NewOverlay(),
116 keyMap: DefaultKeyMap(),
117 side: NewSidebarModel(com),
118 help: help.New(),
119 focus: uiFocusNone,
120 state: uiConfigure,
121 textarea: ta,
122 }
123
124 // set onboarding state defaults
125 ui.onboarding.yesInitializeSelected = true
126
127 // If no provider is configured show the user the provider list
128 if !com.Config().IsConfigured() {
129 ui.state = uiConfigure
130 // if the project needs initialization show the user the question
131 } else if n, _ := config.ProjectNeedsInitialization(); n {
132 ui.state = uiInitialize
133 // otherwise go to the landing UI
134 } else {
135 ui.state = uiLanding
136 ui.focus = uiFocusEditor
137 }
138
139 ui.setEditorPrompt()
140 ui.randomizePlaceholders()
141 ui.textarea.Placeholder = ui.readyPlaceholder
142 ui.help.Styles = com.Styles.Help
143
144 return ui
145}
146
147// Init initializes the UI model.
148func (m *UI) Init() tea.Cmd {
149 if m.QueryVersion {
150 return tea.RequestTerminalVersion
151 }
152 return nil
153}
154
155// Update handles updates to the UI model.
156func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
157 var cmds []tea.Cmd
158 switch msg := msg.(type) {
159 case tea.EnvMsg:
160 // Is this Windows Terminal?
161 if !m.sendProgressBar {
162 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
163 }
164 case pubsub.Event[app.LSPEvent]:
165 m.lspStates = app.GetLSPStates()
166 case pubsub.Event[mcp.Event]:
167 m.mcpStates = mcp.GetStates()
168 case tea.TerminalVersionMsg:
169 termVersion := strings.ToLower(msg.Name)
170 // Only enable progress bar for the following terminals.
171 if !m.sendProgressBar {
172 m.sendProgressBar = strings.Contains(termVersion, "ghostty")
173 }
174 return m, nil
175 case tea.WindowSizeMsg:
176 m.width, m.height = msg.Width, msg.Height
177 m.updateLayoutAndSize()
178 case tea.KeyboardEnhancementsMsg:
179 m.keyenh = msg
180 if msg.SupportsKeyDisambiguation() {
181 m.keyMap.Models.SetHelp("ctrl+m", "models")
182 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
183 }
184 case tea.KeyPressMsg:
185 cmds = append(cmds, m.handleKeyPressMsg(msg)...)
186 }
187
188 // This logic gets triggered on any message type, but should it?
189 switch m.focus {
190 case uiFocusMain:
191 case uiFocusEditor:
192 // Textarea placeholder logic
193 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
194 m.textarea.Placeholder = m.workingPlaceholder
195 } else {
196 m.textarea.Placeholder = m.readyPlaceholder
197 }
198 if m.com.App.Permissions.SkipRequests() {
199 m.textarea.Placeholder = "Yolo mode!"
200 }
201 }
202
203 return m, tea.Batch(cmds...)
204}
205
206func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
207 if m.dialog.HasDialogs() {
208 return m.updateDialogs(msg)
209 }
210
211 switch {
212 case key.Matches(msg, m.keyMap.Tab):
213 switch m.state {
214 case uiChat:
215 if m.focus == uiFocusMain {
216 m.focus = uiFocusEditor
217 cmds = append(cmds, m.textarea.Focus())
218 } else {
219 m.focus = uiFocusMain
220 m.textarea.Blur()
221 }
222 }
223 case key.Matches(msg, m.keyMap.Help):
224 m.help.ShowAll = !m.help.ShowAll
225 m.updateLayoutAndSize()
226 return cmds
227 case key.Matches(msg, m.keyMap.Quit):
228 if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
229 m.dialog.AddDialog(dialog.NewQuit(m.com))
230 return
231 }
232 return cmds
233 case key.Matches(msg, m.keyMap.Commands):
234 // TODO: Implement me
235 return cmds
236 case key.Matches(msg, m.keyMap.Models):
237 // TODO: Implement me
238 return cmds
239 case key.Matches(msg, m.keyMap.Sessions):
240 // TODO: Implement me
241 return cmds
242 }
243
244 cmds = append(cmds, m.updateFocused(msg)...)
245 return cmds
246}
247
248// Draw implements [tea.Layer] and draws the UI model.
249func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
250 layout := generateLayout(m, area.Dx(), area.Dy())
251
252 // Clear the screen first
253 screen.Clear(scr)
254
255 switch m.state {
256 case uiConfigure:
257 header := uv.NewStyledString(m.header)
258 header.Draw(scr, layout.header)
259
260 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
261 Height(layout.main.Dy()).
262 Background(lipgloss.ANSIColor(rand.Intn(256))).
263 Render(" Configure ")
264 main := uv.NewStyledString(mainView)
265 main.Draw(scr, layout.main)
266
267 case uiInitialize:
268 header := uv.NewStyledString(m.header)
269 header.Draw(scr, layout.header)
270
271 main := uv.NewStyledString(m.initializeView())
272 main.Draw(scr, layout.main)
273
274 case uiLanding:
275 header := uv.NewStyledString(m.header)
276 header.Draw(scr, layout.header)
277 main := uv.NewStyledString(m.landingView())
278 main.Draw(scr, layout.main)
279
280 editor := uv.NewStyledString(m.textarea.View())
281 editor.Draw(scr, layout.editor)
282
283 case uiChat:
284 header := uv.NewStyledString(m.header)
285 header.Draw(scr, layout.header)
286
287 side := uv.NewStyledString(m.side.View())
288 side.Draw(scr, layout.sidebar)
289
290 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
291 Height(layout.main.Dy()).
292 Background(lipgloss.ANSIColor(rand.Intn(256))).
293 Render(" Chat Messages ")
294 main := uv.NewStyledString(mainView)
295 main.Draw(scr, layout.main)
296
297 editor := uv.NewStyledString(m.textarea.View())
298 editor.Draw(scr, layout.editor)
299
300 case uiChatCompact:
301 header := uv.NewStyledString(m.header)
302 header.Draw(scr, layout.header)
303
304 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
305 Height(layout.main.Dy()).
306 Background(lipgloss.ANSIColor(rand.Intn(256))).
307 Render(" Compact Chat Messages ")
308 main := uv.NewStyledString(mainView)
309 main.Draw(scr, layout.main)
310
311 editor := uv.NewStyledString(m.textarea.View())
312 editor.Draw(scr, layout.editor)
313 }
314
315 // Add help layer
316 help := uv.NewStyledString(m.help.View(m))
317 help.Draw(scr, layout.help)
318
319 // Debugging rendering (visually see when the tui rerenders)
320 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
321 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
322 debug := uv.NewStyledString(debugView.String())
323 debug.Draw(scr, image.Rectangle{
324 Min: image.Pt(4, 1),
325 Max: image.Pt(8, 3),
326 })
327 }
328
329 // This needs to come last to overlay on top of everything
330 if m.dialog.HasDialogs() {
331 if dialogView := m.dialog.View(); dialogView != "" {
332 dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
333 dialogArea := common.CenterRect(area, dialogWidth, dialogHeight)
334 dialog := uv.NewStyledString(dialogView)
335 dialog.Draw(scr, dialogArea)
336 }
337 }
338}
339
340// View renders the UI model's view.
341func (m *UI) View() tea.View {
342 var v tea.View
343 v.AltScreen = true
344 v.BackgroundColor = m.com.Styles.Background
345
346 layout := generateLayout(m, m.width, m.height)
347 if m.focus == uiFocusEditor && m.textarea.Focused() {
348 cur := m.textarea.Cursor()
349 cur.X++ // Adjust for app margins
350 cur.Y += layout.editor.Min.Y
351 v.Cursor = cur
352 }
353
354 // TODO: Switch to lipgloss.Canvas when available
355 canvas := uv.NewScreenBuffer(m.width, m.height)
356 canvas.Method = ansi.GraphemeWidth
357
358 m.Draw(canvas, canvas.Bounds())
359
360 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
361 contentLines := strings.Split(content, "\n")
362 for i, line := range contentLines {
363 // Trim trailing spaces for concise rendering
364 contentLines[i] = strings.TrimRight(line, " ")
365 }
366
367 content = strings.Join(contentLines, "\n")
368 v.Content = content
369 if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
370 // HACK: use a random percentage to prevent ghostty from hiding it
371 // after a timeout.
372 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
373 }
374
375 return v
376}
377
378// ShortHelp implements [help.KeyMap].
379func (m *UI) ShortHelp() []key.Binding {
380 var binds []key.Binding
381 k := &m.keyMap
382
383 switch m.state {
384 case uiInitialize:
385 binds = append(binds, k.Quit)
386 default:
387 // TODO: other states
388 if m.sess == nil {
389 // no session selected
390 binds = append(binds,
391 k.Commands,
392 k.Models,
393 k.Editor.Newline,
394 k.Quit,
395 k.Help,
396 )
397 }
398 // else {
399 // we have a session
400 // }
401
402 // switch m.state {
403 // case uiChat:
404 // case uiEdit:
405 // binds = append(binds,
406 // k.Editor.AddFile,
407 // k.Editor.SendMessage,
408 // k.Editor.OpenEditor,
409 // k.Editor.Newline,
410 // )
411 //
412 // if len(m.attachments) > 0 {
413 // binds = append(binds,
414 // k.Editor.AttachmentDeleteMode,
415 // k.Editor.DeleteAllAttachments,
416 // k.Editor.Escape,
417 // )
418 // }
419 // }
420 }
421
422 return binds
423}
424
425// FullHelp implements [help.KeyMap].
426func (m *UI) FullHelp() [][]key.Binding {
427 var binds [][]key.Binding
428 k := &m.keyMap
429 help := k.Help
430 help.SetHelp("ctrl+g", "less")
431
432 switch m.state {
433 case uiInitialize:
434 binds = append(binds,
435 []key.Binding{
436 k.Quit,
437 })
438 default:
439 if m.sess == nil {
440 // no session selected
441 binds = append(binds,
442 []key.Binding{
443 k.Commands,
444 k.Models,
445 k.Sessions,
446 },
447 []key.Binding{
448 k.Editor.Newline,
449 k.Editor.AddImage,
450 k.Editor.MentionFile,
451 k.Editor.OpenEditor,
452 },
453 []key.Binding{
454 help,
455 },
456 )
457 }
458 // else {
459 // we have a session
460 // }
461 }
462
463 // switch m.state {
464 // case uiChat:
465 // case uiEdit:
466 // binds = append(binds, m.ShortHelp())
467 // }
468
469 return binds
470}
471
472// updateDialogs updates the dialog overlay with the given message and returns cmds
473func (m *UI) updateDialogs(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
474 updatedDialog, cmd := m.dialog.Update(msg)
475 m.dialog = updatedDialog
476 cmds = append(cmds, cmd)
477 return cmds
478}
479
480// updateFocused updates the focused model (chat or editor) with the given message
481// and appends any resulting commands to the cmds slice.
482func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
483 switch m.state {
484 case uiConfigure:
485 return cmds
486 case uiInitialize:
487 return append(cmds, m.updateInitializeView(msg)...)
488 case uiChat, uiLanding, uiChatCompact:
489 switch m.focus {
490 case uiFocusMain:
491 cmds = append(cmds, m.updateChat(msg)...)
492 case uiFocusEditor:
493 switch {
494 case key.Matches(msg, m.keyMap.Editor.Newline):
495 m.textarea.InsertRune('\n')
496 }
497
498 ta, cmd := m.textarea.Update(msg)
499 m.textarea = ta
500 cmds = append(cmds, cmd)
501 return cmds
502 }
503 }
504 return cmds
505}
506
507// updateChat updates the chat model with the given message and appends any
508// resulting commands to the cmds slice.
509func (m *UI) updateChat(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
510 updatedChat, cmd := m.chat.Update(msg)
511 m.chat = updatedChat
512 cmds = append(cmds, cmd)
513 return cmds
514}
515
516// updateLayoutAndSize updates the layout and sizes of UI components.
517func (m *UI) updateLayoutAndSize() {
518 m.layout = generateLayout(m, m.width, m.height)
519 m.updateSize()
520}
521
522// updateSize updates the sizes of UI components based on the current layout.
523func (m *UI) updateSize() {
524 // Set help width
525 m.help.SetWidth(m.layout.help.Dx())
526
527 // Handle different app states
528 switch m.state {
529 case uiConfigure, uiInitialize:
530 m.renderHeader(false, m.layout.header.Dx())
531
532 case uiLanding:
533 m.renderHeader(false, m.layout.header.Dx())
534 m.textarea.SetWidth(m.layout.editor.Dx())
535 m.textarea.SetHeight(m.layout.editor.Dy())
536
537 case uiChat:
538 // TODO: set the width and heigh of the chat component
539 m.side.SetWidth(m.layout.sidebar.Dx())
540 m.textarea.SetWidth(m.layout.editor.Dx())
541 m.textarea.SetHeight(m.layout.editor.Dy())
542
543 case uiChatCompact:
544 // TODO: set the width and heigh of the chat component
545 m.renderHeader(true, m.layout.header.Dx())
546 m.textarea.SetWidth(m.layout.editor.Dx())
547 m.textarea.SetHeight(m.layout.editor.Dy())
548 }
549}
550
551// generateLayout generates a [layout] for the given rectangle.
552func generateLayout(m *UI, w, h int) layout {
553 // The screen area we're working with
554 area := image.Rect(0, 0, w, h)
555
556 // The help height
557 helpHeight := 1
558 // The editor height
559 editorHeight := 5
560 // The sidebar width
561 sidebarWidth := 40
562 // The header height
563 // TODO: handle compact
564 headerHeight := 4
565
566 var helpKeyMap help.KeyMap = m
567 if m.help.ShowAll {
568 for _, row := range helpKeyMap.FullHelp() {
569 helpHeight = max(helpHeight, len(row))
570 }
571 }
572
573 // Add app margins
574 appRect := area
575 appRect.Min.X += 1
576 appRect.Min.Y += 1
577 appRect.Max.X -= 1
578 appRect.Max.Y -= 1
579
580 if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
581 // extra padding on left and right for these states
582 appRect.Min.X += 1
583 appRect.Max.X -= 1
584 }
585
586 appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
587
588 layout := layout{
589 area: area,
590 help: helpRect,
591 }
592
593 // Handle different app states
594 switch m.state {
595 case uiConfigure, uiInitialize:
596 // Layout
597 //
598 // header
599 // ------
600 // main
601 // ------
602 // help
603
604 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
605 layout.header = headerRect
606 layout.main = mainRect
607
608 case uiLanding:
609 // Layout
610 //
611 // header
612 // ------
613 // main
614 // ------
615 // editor
616 // ------
617 // help
618 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
619 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
620 // Remove extra padding from editor (but keep it for header and main)
621 editorRect.Min.X -= 1
622 editorRect.Max.X += 1
623 layout.header = headerRect
624 layout.main = mainRect
625 layout.editor = editorRect
626
627 case uiChat:
628 // Layout
629 //
630 // ------|---
631 // main |
632 // ------| side
633 // editor|
634 // ----------
635 // help
636
637 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
638 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
639 layout.sidebar = sideRect
640 layout.main = mainRect
641 layout.editor = editorRect
642
643 case uiChatCompact:
644 // Layout
645 //
646 // compact-header
647 // ------
648 // main
649 // ------
650 // editor
651 // ------
652 // help
653 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
654 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
655 layout.header = headerRect
656 layout.main = mainRect
657 layout.editor = editorRect
658 }
659
660 if !layout.editor.Empty() {
661 // Add editor margins 1 top and bottom
662 layout.editor.Min.Y += 1
663 layout.editor.Max.Y -= 1
664 }
665
666 return layout
667}
668
669// layout defines the positioning of UI elements.
670type layout struct {
671 // area is the overall available area.
672 area uv.Rectangle
673
674 // header is the header shown in special cases
675 // e.x when the sidebar is collapsed
676 // or when in the landing page
677 // or in init/config
678 header uv.Rectangle
679
680 // main is the area for the main pane. (e.x chat, configure, landing)
681 main uv.Rectangle
682
683 // editor is the area for the editor pane.
684 editor uv.Rectangle
685
686 // sidebar is the area for the sidebar.
687 sidebar uv.Rectangle
688
689 // help is the area for the help view.
690 help uv.Rectangle
691}
692
693func (m *UI) setEditorPrompt() {
694 if m.com.App.Permissions.SkipRequests() {
695 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
696 return
697 }
698 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
699}
700
701func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
702 t := m.com.Styles
703 if info.LineNumber == 0 {
704 return " > "
705 }
706 if info.Focused {
707 return t.EditorPromptNormalFocused.Render()
708 }
709 return t.EditorPromptNormalBlurred.Render()
710}
711
712func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
713 t := m.com.Styles
714 if info.LineNumber == 0 {
715 if info.Focused {
716 return t.EditorPromptYoloIconFocused.Render()
717 } else {
718 return t.EditorPromptYoloIconBlurred.Render()
719 }
720 }
721 if info.Focused {
722 return t.EditorPromptYoloDotsFocused.Render()
723 }
724 return t.EditorPromptYoloDotsBlurred.Render()
725}
726
727var readyPlaceholders = [...]string{
728 "Ready!",
729 "Ready...",
730 "Ready?",
731 "Ready for instructions",
732}
733
734var workingPlaceholders = [...]string{
735 "Working!",
736 "Working...",
737 "Brrrrr...",
738 "Prrrrrrrr...",
739 "Processing...",
740 "Thinking...",
741}
742
743func (m *UI) randomizePlaceholders() {
744 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
745 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
746}
747
748func (m *UI) renderHeader(compact bool, width int) {
749 // TODO: handle the compact case differently
750 m.header = renderLogo(m.com.Styles, compact, width)
751}
752
753func renderLogo(t *styles.Styles, compact bool, width int) string {
754 return logo.Render(version.Version, compact, logo.Opts{
755 FieldColor: t.LogoFieldColor,
756 TitleColorA: t.LogoTitleColorA,
757 TitleColorB: t.LogoTitleColorB,
758 CharmColor: t.LogoCharmColor,
759 VersionColor: t.LogoVersionColor,
760 Width: max(0, width-2),
761 })
762}