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