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