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