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