on 94de6bc help sign in
929 lines 23 kB history raw
1package model
3import (
4 "context"
5 "image"
6 "math/rand"
7 "os"
8 "slices"
9 "strings"
10 "time"
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"
33// uiFocusState represents the current focus state of the UI.
34type uiFocusState uint8
36// Possible uiFocusState values.
37const (
38 uiFocusNone uiFocusState = iota
39 uiFocusEditor
40 uiFocusMain
43type uiState uint8
45// Possible uiState values.
46const (
47 uiConfigure uiState = iota
48 uiInitialize
49 uiLanding
50 uiChat
51 uiChatCompact
54type sessionLoadedMsg struct {
55 sess session.Session
58type sessionFilesLoadedMsg struct {
59 files []SessionFile
62// UI represents the main user interface model.
63type UI struct {
64 com *common.Common
65 session *session.Session
66 sessionFiles []SessionFile
68 // The width and height of the terminal in cells.
69 width int
70 height int
71 layout layout
73 focus uiFocusState
74 state uiState
76 keyMap KeyMap
77 keyenh tea.KeyboardEnhancementsMsg
79 dialog *dialog.Overlay
80 help help.Model
82 // header is the last cached header logo
83 header string
85 // sendProgressBar instructs the TUI to send progress bar updates to the
86 // terminal.
87 sendProgressBar bool
89 // QueryVersion instructs the TUI to query for the terminal version when it
90 // starts.
91 QueryVersion bool
93 // Editor components
94 textarea textarea.Model
96 attachments []any // TODO: Implement attachments
98 readyPlaceholder string
99 workingPlaceholder string
101 // Chat components
102 chat *Chat
104 // onboarding state
105 onboarding struct {
106 yesInitializeSelected bool
107 }
109 // lsp
110 lspStates map[string]app.LSPClientInfo
112 // mcp
113 mcpStates map[string]mcp.ClientInfo
115 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
116 sidebarLogo string
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()
129 ch := NewChat(com)
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 }
142 // set onboarding state defaults
143 ui.onboarding.yesInitializeSelected = true
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 }
157 ui.setEditorPrompt()
158 ui.randomizePlaceholders()
159 ui.textarea.Placeholder = ui.readyPlaceholder
160 ui.help.Styles = com.Styles.Help
162 return ui
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...)
181// sessionLoadedDoneMsg indicates that session loading and message appending is
182// done.
183type sessionLoadedDoneMsg struct{}
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)
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)
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...)
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 }
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 }
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 }
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 }
306 return m, tea.Batch(cmds...)
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 }
317func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
318 if m.dialog.HasDialogs() {
319 return m.updateDialogs(msg)
320 }
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 }
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 }
399 cmds = append(cmds, m.updateFocused(msg)...)
400 return cmds
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())
407 if m.layout != layout {
408 m.layout = layout
409 m.updateSize()
410 }
412 // Clear the screen first
413 screen.Clear(scr)
415 switch m.state {
416 case uiConfigure:
417 header := uv.NewStyledString(m.header)
418 header.Draw(scr, layout.header)
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)
427 case uiInitialize:
428 header := uv.NewStyledString(m.header)
429 header.Draw(scr, layout.header)
431 main := uv.NewStyledString(m.initializeView())
432 main.Draw(scr, layout.main)
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)
440 editor := uv.NewStyledString(m.textarea.View())
441 editor.Draw(scr, layout.editor)
443 case uiChat:
444 m.chat.Draw(scr, layout.main)
446 header := uv.NewStyledString(m.header)
447 header.Draw(scr, layout.header)
448 m.drawSidebar(scr, layout.sidebar)
450 editor := uv.NewStyledString(m.textarea.View())
451 editor.Draw(scr, layout.editor)
453 case uiChatCompact:
454 header := uv.NewStyledString(m.header)
455 header.Draw(scr, layout.header)
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)
464 editor := uv.NewStyledString(m.textarea.View())
465 editor.Draw(scr, layout.editor)
466 }
468 // Add help layer
469 help := uv.NewStyledString(m.help.View(m))
470 help.Draw(scr, layout.help)
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 }
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 }
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
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
517 canvas := uv.NewScreenBuffer(m.width, m.height)
518 m.Draw(canvas, canvas.Bounds())
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 }
527 content = strings.Join(contentLines, "\n")
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 }
536 return v
539// ShortHelp implements [help.KeyMap].
540func (m *UI) ShortHelp() []key.Binding {
541 var binds []key.Binding
542 k := &m.keyMap
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 // }
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 }
583 return binds
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")
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 }
624 // switch m.state {
625 // case uiChat:
626 // case uiEdit:
627 // binds = append(binds, m.ShortHelp())
628 // }
630 return binds
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
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 }
658 ta, cmd := m.textarea.Update(msg)
659 m.textarea = ta
660 cmds = append(cmds, cmd)
661 return cmds
662 }
663 }
664 return cmds
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()
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())
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())
682 // Handle different app states
683 switch m.state {
684 case uiConfigure, uiInitialize, uiLanding:
685 m.renderHeader(false, m.layout.header.Dx())
687 case uiChat:
688 m.renderSidebarLogo(m.layout.sidebar.Dx())
690 case uiChatCompact:
691 // TODO: set the width and heigh of the chat component
692 m.renderHeader(true, m.layout.header.Dx())
693 }
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)
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
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 }
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
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 }
732 appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
734 layout := layout{
735 area: area,
736 help: helpRect,
737 }
739 // Handle different app states
740 switch m.state {
741 case uiConfigure, uiInitialize:
742 // Layout
743 //
744 // header
745 // ------
746 // main
747 // ------
748 // help
750 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
751 layout.header = headerRect
752 layout.main = mainRect
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
773 case uiChat:
774 // Layout
775 //
776 // ------|---
777 // main |
778 // ------| side
779 // editor|
780 // ----------
781 // help
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
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 }
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 }
817 return layout
820// layout defines the positioning of UI elements.
821type layout struct {
822 // area is the overall available area.
823 area uv.Rectangle
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
831 // main is the area for the main pane. (e.x chat, configure, landing)
832 main uv.Rectangle
834 // editor is the area for the editor pane.
835 editor uv.Rectangle
837 // sidebar is the area for the sidebar.
838 sidebar uv.Rectangle
840 // help is the area for the help view.
841 help uv.Rectangle
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)
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()
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()
884var readyPlaceholders = [...]string{
885 "Ready!",
886 "Ready...",
887 "Ready?",
888 "Ready for instructions",
891var workingPlaceholders = [...]string{
892 "Working!",
893 "Working...",
894 "Brrrrr...",
895 "Prrrrrrrr...",
896 "Processing...",
897 "Thinking...",
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))]
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)
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)
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 })