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