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.SessionsID)
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.QuitID) {
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.SessionsID) {
374 // Bring to front
375 m.dialog.BringToFront(dialog.SessionsID)
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 m.dialog.Draw(scr, area)
539 }
540}
541
542// Cursor returns the cursor position and properties for the UI model. It
543// returns nil if the cursor should not be shown.
544func (m *UI) Cursor() *tea.Cursor {
545 if m.layout.editor.Dy() <= 0 {
546 // Don't show cursor if editor is not visible
547 return nil
548 }
549 if m.focus == uiFocusEditor && m.textarea.Focused() {
550 cur := m.textarea.Cursor()
551 cur.X++ // Adjust for app margins
552 cur.Y += m.layout.editor.Min.Y
553 return cur
554 }
555 return nil
556}
557
558// View renders the UI model's view.
559func (m *UI) View() tea.View {
560 var v tea.View
561 v.AltScreen = true
562 v.BackgroundColor = m.com.Styles.Background
563 v.Cursor = m.Cursor()
564 v.MouseMode = tea.MouseModeCellMotion
565
566 canvas := uv.NewScreenBuffer(m.width, m.height)
567 m.Draw(canvas, canvas.Bounds())
568
569 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
570 contentLines := strings.Split(content, "\n")
571 for i, line := range contentLines {
572 // Trim trailing spaces for concise rendering
573 contentLines[i] = strings.TrimRight(line, " ")
574 }
575
576 content = strings.Join(contentLines, "\n")
577
578 v.Content = content
579 if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
580 // HACK: use a random percentage to prevent ghostty from hiding it
581 // after a timeout.
582 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
583 }
584
585 return v
586}
587
588// ShortHelp implements [help.KeyMap].
589func (m *UI) ShortHelp() []key.Binding {
590 var binds []key.Binding
591 k := &m.keyMap
592
593 switch m.state {
594 case uiInitialize:
595 binds = append(binds, k.Quit)
596 default:
597 // TODO: other states
598 // if m.session == nil {
599 // no session selected
600 binds = append(binds,
601 k.Commands,
602 k.Models,
603 k.Editor.Newline,
604 k.Quit,
605 k.Help,
606 )
607 // }
608 // else {
609 // we have a session
610 // }
611
612 // switch m.state {
613 // case uiChat:
614 // case uiEdit:
615 // binds = append(binds,
616 // k.Editor.AddFile,
617 // k.Editor.SendMessage,
618 // k.Editor.OpenEditor,
619 // k.Editor.Newline,
620 // )
621 //
622 // if len(m.attachments) > 0 {
623 // binds = append(binds,
624 // k.Editor.AttachmentDeleteMode,
625 // k.Editor.DeleteAllAttachments,
626 // k.Editor.Escape,
627 // )
628 // }
629 // }
630 }
631
632 return binds
633}
634
635// FullHelp implements [help.KeyMap].
636func (m *UI) FullHelp() [][]key.Binding {
637 var binds [][]key.Binding
638 k := &m.keyMap
639 help := k.Help
640 help.SetHelp("ctrl+g", "less")
641
642 switch m.state {
643 case uiInitialize:
644 binds = append(binds,
645 []key.Binding{
646 k.Quit,
647 })
648 default:
649 if m.session == nil {
650 // no session selected
651 binds = append(binds,
652 []key.Binding{
653 k.Commands,
654 k.Models,
655 k.Sessions,
656 },
657 []key.Binding{
658 k.Editor.Newline,
659 k.Editor.AddImage,
660 k.Editor.MentionFile,
661 k.Editor.OpenEditor,
662 },
663 []key.Binding{
664 help,
665 },
666 )
667 }
668 // else {
669 // we have a session
670 // }
671 }
672
673 // switch m.state {
674 // case uiChat:
675 // case uiEdit:
676 // binds = append(binds, m.ShortHelp())
677 // }
678
679 return binds
680}
681
682// updateFocused updates the focused model (chat or editor) with the given message
683// and appends any resulting commands to the cmds slice.
684func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
685 switch m.state {
686 case uiConfigure:
687 return cmds
688 case uiInitialize:
689 return append(cmds, m.updateInitializeView(msg)...)
690 case uiChat, uiLanding, uiChatCompact:
691 switch m.focus {
692 case uiFocusMain:
693 case uiFocusEditor:
694 switch {
695 case key.Matches(msg, m.keyMap.Editor.Newline):
696 m.textarea.InsertRune('\n')
697 }
698
699 ta, cmd := m.textarea.Update(msg)
700 m.textarea = ta
701 cmds = append(cmds, cmd)
702 return cmds
703 }
704 }
705 return cmds
706}
707
708// updateLayoutAndSize updates the layout and sizes of UI components.
709func (m *UI) updateLayoutAndSize() {
710 m.layout = generateLayout(m, m.width, m.height)
711 m.updateSize()
712}
713
714// updateSize updates the sizes of UI components based on the current layout.
715func (m *UI) updateSize() {
716 // Set help width
717 m.help.SetWidth(m.layout.help.Dx())
718
719 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
720 m.textarea.SetWidth(m.layout.editor.Dx())
721 m.textarea.SetHeight(m.layout.editor.Dy())
722
723 // Handle different app states
724 switch m.state {
725 case uiConfigure, uiInitialize, uiLanding:
726 m.renderHeader(false, m.layout.header.Dx())
727
728 case uiChat:
729 m.renderSidebarLogo(m.layout.sidebar.Dx())
730
731 case uiChatCompact:
732 // TODO: set the width and heigh of the chat component
733 m.renderHeader(true, m.layout.header.Dx())
734 }
735}
736
737// generateLayout calculates the layout rectangles for all UI components based
738// on the current UI state and terminal dimensions.
739func generateLayout(m *UI, w, h int) layout {
740 // The screen area we're working with
741 area := image.Rect(0, 0, w, h)
742
743 // The help height
744 helpHeight := 1
745 // The editor height
746 editorHeight := 5
747 // The sidebar width
748 sidebarWidth := 30
749 // The header height
750 // TODO: handle compact
751 headerHeight := 4
752
753 var helpKeyMap help.KeyMap = m
754 if m.help.ShowAll {
755 for _, row := range helpKeyMap.FullHelp() {
756 helpHeight = max(helpHeight, len(row))
757 }
758 }
759
760 // Add app margins
761 appRect := area
762 appRect.Min.X += 1
763 appRect.Min.Y += 1
764 appRect.Max.X -= 1
765 appRect.Max.Y -= 1
766
767 if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
768 // extra padding on left and right for these states
769 appRect.Min.X += 1
770 appRect.Max.X -= 1
771 }
772
773 appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
774
775 layout := layout{
776 area: area,
777 help: helpRect,
778 }
779
780 // Handle different app states
781 switch m.state {
782 case uiConfigure, uiInitialize:
783 // Layout
784 //
785 // header
786 // ------
787 // main
788 // ------
789 // help
790
791 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
792 layout.header = headerRect
793 layout.main = mainRect
794
795 case uiLanding:
796 // Layout
797 //
798 // header
799 // ------
800 // main
801 // ------
802 // editor
803 // ------
804 // help
805 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
806 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
807 // Remove extra padding from editor (but keep it for header and main)
808 editorRect.Min.X -= 1
809 editorRect.Max.X += 1
810 layout.header = headerRect
811 layout.main = mainRect
812 layout.editor = editorRect
813
814 case uiChat:
815 // Layout
816 //
817 // ------|---
818 // main |
819 // ------| side
820 // editor|
821 // ----------
822 // help
823
824 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
825 // Add padding left
826 sideRect.Min.X += 1
827 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
828 mainRect.Max.X -= 1 // Add padding right
829 // Add bottom margin to main
830 mainRect.Max.Y -= 1
831 layout.sidebar = sideRect
832 layout.main = mainRect
833 layout.editor = editorRect
834
835 case uiChatCompact:
836 // Layout
837 //
838 // compact-header
839 // ------
840 // main
841 // ------
842 // editor
843 // ------
844 // help
845 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
846 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
847 layout.header = headerRect
848 layout.main = mainRect
849 layout.editor = editorRect
850 }
851
852 if !layout.editor.Empty() {
853 // Add editor margins 1 top and bottom
854 layout.editor.Min.Y += 1
855 layout.editor.Max.Y -= 1
856 }
857
858 return layout
859}
860
861// layout defines the positioning of UI elements.
862type layout struct {
863 // area is the overall available area.
864 area uv.Rectangle
865
866 // header is the header shown in special cases
867 // e.x when the sidebar is collapsed
868 // or when in the landing page
869 // or in init/config
870 header uv.Rectangle
871
872 // main is the area for the main pane. (e.x chat, configure, landing)
873 main uv.Rectangle
874
875 // editor is the area for the editor pane.
876 editor uv.Rectangle
877
878 // sidebar is the area for the sidebar.
879 sidebar uv.Rectangle
880
881 // help is the area for the help view.
882 help uv.Rectangle
883}
884
885// setEditorPrompt configures the textarea prompt function based on whether
886// yolo mode is enabled.
887func (m *UI) setEditorPrompt() {
888 if m.com.App.Permissions.SkipRequests() {
889 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
890 return
891 }
892 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
893}
894
895// normalPromptFunc returns the normal editor prompt style (" > " on first
896// line, "::: " on subsequent lines).
897func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
898 t := m.com.Styles
899 if info.LineNumber == 0 {
900 return " > "
901 }
902 if info.Focused {
903 return t.EditorPromptNormalFocused.Render()
904 }
905 return t.EditorPromptNormalBlurred.Render()
906}
907
908// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
909// and colored dots.
910func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
911 t := m.com.Styles
912 if info.LineNumber == 0 {
913 if info.Focused {
914 return t.EditorPromptYoloIconFocused.Render()
915 } else {
916 return t.EditorPromptYoloIconBlurred.Render()
917 }
918 }
919 if info.Focused {
920 return t.EditorPromptYoloDotsFocused.Render()
921 }
922 return t.EditorPromptYoloDotsBlurred.Render()
923}
924
925var readyPlaceholders = [...]string{
926 "Ready!",
927 "Ready...",
928 "Ready?",
929 "Ready for instructions",
930}
931
932var workingPlaceholders = [...]string{
933 "Working!",
934 "Working...",
935 "Brrrrr...",
936 "Prrrrrrrr...",
937 "Processing...",
938 "Thinking...",
939}
940
941// randomizePlaceholders selects random placeholder text for the textarea's
942// ready and working states.
943func (m *UI) randomizePlaceholders() {
944 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
945 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
946}
947
948// renderHeader renders and caches the header logo at the specified width.
949func (m *UI) renderHeader(compact bool, width int) {
950 // TODO: handle the compact case differently
951 m.header = renderLogo(m.com.Styles, compact, width)
952}
953
954// renderSidebarLogo renders and caches the sidebar logo at the specified
955// width.
956func (m *UI) renderSidebarLogo(width int) {
957 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
958}
959
960// loadSessionsCmd loads the list of sessions and returns a command that sends
961// a sessionFilesLoadedMsg when done.
962func (m *UI) loadSessionsCmd() tea.Msg {
963 allSessions, _ := m.com.App.Sessions.List(context.TODO())
964 return sessionsLoadedMsg{sessions: allSessions}
965}
966
967// renderLogo renders the Crush logo with the given styles and dimensions.
968func renderLogo(t *styles.Styles, compact bool, width int) string {
969 return logo.Render(version.Version, compact, logo.Opts{
970 FieldColor: t.LogoFieldColor,
971 TitleColorA: t.LogoTitleColorA,
972 TitleColorB: t.LogoTitleColorB,
973 CharmColor: t.LogoCharmColor,
974 VersionColor: t.LogoVersionColor,
975 Width: width,
976 })
977}