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