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