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