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