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