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 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
317 cmds = append(cmds, cmd)
318 }
319 case tea.PasteMsg:
320 if cmd := m.handlePasteMsg(msg); cmd != nil {
321 cmds = append(cmds, cmd)
322 }
323 case openEditorMsg:
324 m.textarea.SetValue(msg.Text)
325 m.textarea.MoveToEnd()
326 }
327
328 // This logic gets triggered on any message type, but should it?
329 switch m.focus {
330 case uiFocusMain:
331 case uiFocusEditor:
332 // Textarea placeholder logic
333 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
334 m.textarea.Placeholder = m.workingPlaceholder
335 } else {
336 m.textarea.Placeholder = m.readyPlaceholder
337 }
338 if m.com.App.Permissions.SkipRequests() {
339 m.textarea.Placeholder = "Yolo mode!"
340 }
341 }
342
343 return m, tea.Batch(cmds...)
344}
345
346func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
347 var cmds []tea.Cmd
348
349 handleQuitKeys := func(msg tea.KeyPressMsg) bool {
350 switch {
351 case key.Matches(msg, m.keyMap.Quit):
352 if !m.dialog.ContainsDialog(dialog.QuitID) {
353 m.dialog.OpenDialog(dialog.NewQuit(m.com))
354 return true
355 }
356 }
357 return false
358 }
359
360 handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
361 if handleQuitKeys(msg) {
362 return true
363 }
364 switch {
365 case key.Matches(msg, m.keyMap.Help):
366 m.help.ShowAll = !m.help.ShowAll
367 m.updateLayoutAndSize()
368 return true
369 case key.Matches(msg, m.keyMap.Commands):
370 if cmd := m.openCommandsDialog(); cmd != nil {
371 cmds = append(cmds, cmd)
372 }
373 return true
374 case key.Matches(msg, m.keyMap.Models):
375 // TODO: Implement me
376 return true
377 case key.Matches(msg, m.keyMap.Sessions):
378 if m.dialog.ContainsDialog(dialog.SessionsID) {
379 // Bring to front
380 m.dialog.BringToFront(dialog.SessionsID)
381 } else {
382 cmds = append(cmds, m.listSessions)
383 }
384 return true
385 }
386 return false
387 }
388
389 // Route all messages to dialog if one is open.
390 if m.dialog.HasDialogs() {
391 // Always handle quit keys first
392 if handleQuitKeys(msg) {
393 return tea.Batch(cmds...)
394 }
395
396 msg := m.dialog.Update(msg)
397 if msg == nil {
398 return tea.Batch(cmds...)
399 }
400
401 switch msg := msg.(type) {
402 // Generic dialog messages
403 case dialog.CloseMsg:
404 m.dialog.CloseFrontDialog()
405
406 // Session dialog messages
407 case dialog.SessionSelectedMsg:
408 m.dialog.CloseDialog(dialog.SessionsID)
409 cmds = append(cmds, m.loadSession(msg.Session.ID))
410
411 // Command dialog messages
412 case dialog.ToggleYoloModeMsg:
413 yolo := !m.com.App.Permissions.SkipRequests()
414 m.com.App.Permissions.SetSkipRequests(yolo)
415 m.setEditorPrompt(yolo)
416 m.dialog.CloseDialog(dialog.CommandsID)
417 case dialog.SwitchSessionsMsg:
418 cmds = append(cmds, m.listSessions)
419 m.dialog.CloseDialog(dialog.CommandsID)
420 case dialog.CompactMsg:
421 err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
422 if err != nil {
423 cmds = append(cmds, uiutil.ReportError(err))
424 }
425 case dialog.ToggleHelpMsg:
426 m.help.ShowAll = !m.help.ShowAll
427 m.dialog.CloseDialog(dialog.CommandsID)
428 case dialog.QuitMsg:
429 cmds = append(cmds, tea.Quit)
430 }
431
432 return tea.Batch(cmds...)
433 }
434
435 switch m.state {
436 case uiConfigure:
437 return tea.Batch(cmds...)
438 case uiInitialize:
439 cmds = append(cmds, m.updateInitializeView(msg)...)
440 return tea.Batch(cmds...)
441 case uiChat, uiLanding, uiChatCompact:
442 switch m.focus {
443 case uiFocusEditor:
444 switch {
445 case key.Matches(msg, m.keyMap.Editor.SendMessage):
446 // TODO: Implement me
447 case key.Matches(msg, m.keyMap.Tab):
448 m.focus = uiFocusMain
449 m.textarea.Blur()
450 m.chat.Focus()
451 m.chat.SetSelected(m.chat.Len() - 1)
452 case key.Matches(msg, m.keyMap.Editor.OpenEditor):
453 if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
454 cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
455 break
456 }
457 cmds = append(cmds, m.openEditor(m.textarea.Value()))
458 case key.Matches(msg, m.keyMap.Editor.Newline):
459 m.textarea.InsertRune('\n')
460 default:
461 if handleGlobalKeys(msg) {
462 // Handle global keys first before passing to textarea.
463 break
464 }
465
466 ta, cmd := m.textarea.Update(msg)
467 m.textarea = ta
468 cmds = append(cmds, cmd)
469 }
470 case uiFocusMain:
471 switch {
472 case key.Matches(msg, m.keyMap.Tab):
473 m.focus = uiFocusEditor
474 cmds = append(cmds, m.textarea.Focus())
475 m.chat.Blur()
476 case key.Matches(msg, m.keyMap.Chat.Up):
477 m.chat.ScrollBy(-1)
478 if !m.chat.SelectedItemInView() {
479 m.chat.SelectPrev()
480 m.chat.ScrollToSelected()
481 }
482 case key.Matches(msg, m.keyMap.Chat.Down):
483 m.chat.ScrollBy(1)
484 if !m.chat.SelectedItemInView() {
485 m.chat.SelectNext()
486 m.chat.ScrollToSelected()
487 }
488 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
489 m.chat.SelectPrev()
490 m.chat.ScrollToSelected()
491 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
492 m.chat.SelectNext()
493 m.chat.ScrollToSelected()
494 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
495 m.chat.ScrollBy(-m.chat.Height() / 2)
496 m.chat.SelectFirstInView()
497 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
498 m.chat.ScrollBy(m.chat.Height() / 2)
499 m.chat.SelectLastInView()
500 case key.Matches(msg, m.keyMap.Chat.PageUp):
501 m.chat.ScrollBy(-m.chat.Height())
502 m.chat.SelectFirstInView()
503 case key.Matches(msg, m.keyMap.Chat.PageDown):
504 m.chat.ScrollBy(m.chat.Height())
505 m.chat.SelectLastInView()
506 case key.Matches(msg, m.keyMap.Chat.Home):
507 m.chat.ScrollToTop()
508 m.chat.SelectFirst()
509 case key.Matches(msg, m.keyMap.Chat.End):
510 m.chat.ScrollToBottom()
511 m.chat.SelectLast()
512 default:
513 handleGlobalKeys(msg)
514 }
515 default:
516 handleGlobalKeys(msg)
517 }
518 default:
519 handleGlobalKeys(msg)
520 }
521
522 return tea.Batch(cmds...)
523}
524
525// Draw implements [tea.Layer] and draws the UI model.
526func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
527 layout := m.generateLayout(area.Dx(), area.Dy())
528
529 if m.layout != layout {
530 m.layout = layout
531 m.updateSize()
532 }
533
534 // Clear the screen first
535 screen.Clear(scr)
536
537 switch m.state {
538 case uiConfigure:
539 header := uv.NewStyledString(m.header)
540 header.Draw(scr, layout.header)
541
542 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
543 Height(layout.main.Dy()).
544 Background(lipgloss.ANSIColor(rand.Intn(256))).
545 Render(" Configure ")
546 main := uv.NewStyledString(mainView)
547 main.Draw(scr, layout.main)
548
549 case uiInitialize:
550 header := uv.NewStyledString(m.header)
551 header.Draw(scr, layout.header)
552
553 main := uv.NewStyledString(m.initializeView())
554 main.Draw(scr, layout.main)
555
556 case uiLanding:
557 header := uv.NewStyledString(m.header)
558 header.Draw(scr, layout.header)
559 main := uv.NewStyledString(m.landingView())
560 main.Draw(scr, layout.main)
561
562 editor := uv.NewStyledString(m.textarea.View())
563 editor.Draw(scr, layout.editor)
564
565 case uiChat:
566 m.chat.Draw(scr, layout.main)
567
568 header := uv.NewStyledString(m.header)
569 header.Draw(scr, layout.header)
570 m.drawSidebar(scr, layout.sidebar)
571
572 editor := uv.NewStyledString(m.textarea.View())
573 editor.Draw(scr, layout.editor)
574
575 case uiChatCompact:
576 header := uv.NewStyledString(m.header)
577 header.Draw(scr, layout.header)
578
579 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
580 Height(layout.main.Dy()).
581 Background(lipgloss.ANSIColor(rand.Intn(256))).
582 Render(" Compact Chat Messages ")
583 main := uv.NewStyledString(mainView)
584 main.Draw(scr, layout.main)
585
586 editor := uv.NewStyledString(m.textarea.View())
587 editor.Draw(scr, layout.editor)
588 }
589
590 // Add help layer
591 help := uv.NewStyledString(m.help.View(m))
592 help.Draw(scr, layout.help)
593
594 // Debugging rendering (visually see when the tui rerenders)
595 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
596 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
597 debug := uv.NewStyledString(debugView.String())
598 debug.Draw(scr, image.Rectangle{
599 Min: image.Pt(4, 1),
600 Max: image.Pt(8, 3),
601 })
602 }
603
604 // This needs to come last to overlay on top of everything
605 if m.dialog.HasDialogs() {
606 m.dialog.Draw(scr, area)
607 }
608}
609
610// Cursor returns the cursor position and properties for the UI model. It
611// returns nil if the cursor should not be shown.
612func (m *UI) Cursor() *tea.Cursor {
613 if m.layout.editor.Dy() <= 0 {
614 // Don't show cursor if editor is not visible
615 return nil
616 }
617 if m.dialog.HasDialogs() {
618 if front := m.dialog.DialogLast(); front != nil {
619 c, ok := front.(uiutil.Cursor)
620 if ok {
621 cur := c.Cursor()
622 if cur != nil {
623 pos := m.dialog.CenterPosition(m.layout.area, front.ID())
624 cur.X += pos.Min.X
625 cur.Y += pos.Min.Y
626 return cur
627 }
628 }
629 }
630 return nil
631 }
632 switch m.focus {
633 case uiFocusEditor:
634 if m.textarea.Focused() {
635 cur := m.textarea.Cursor()
636 cur.X++ // Adjust for app margins
637 cur.Y += m.layout.editor.Min.Y
638 return cur
639 }
640 }
641 return nil
642}
643
644// View renders the UI model's view.
645func (m *UI) View() tea.View {
646 var v tea.View
647 v.AltScreen = true
648 v.BackgroundColor = m.com.Styles.Background
649 v.Cursor = m.Cursor()
650 v.MouseMode = tea.MouseModeCellMotion
651
652 canvas := uv.NewScreenBuffer(m.width, m.height)
653 m.Draw(canvas, canvas.Bounds())
654
655 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
656 contentLines := strings.Split(content, "\n")
657 for i, line := range contentLines {
658 // Trim trailing spaces for concise rendering
659 contentLines[i] = strings.TrimRight(line, " ")
660 }
661
662 content = strings.Join(contentLines, "\n")
663
664 v.Content = content
665 if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
666 // HACK: use a random percentage to prevent ghostty from hiding it
667 // after a timeout.
668 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
669 }
670
671 return v
672}
673
674// ShortHelp implements [help.KeyMap].
675func (m *UI) ShortHelp() []key.Binding {
676 var binds []key.Binding
677 k := &m.keyMap
678 tab := k.Tab
679 commands := k.Commands
680 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
681 commands.SetHelp("/ or ctrl+p", "commands")
682 }
683
684 switch m.state {
685 case uiInitialize:
686 binds = append(binds, k.Quit)
687 case uiChat:
688 if m.focus == uiFocusEditor {
689 tab.SetHelp("tab", "focus chat")
690 } else {
691 tab.SetHelp("tab", "focus editor")
692 }
693
694 binds = append(binds,
695 tab,
696 commands,
697 k.Models,
698 )
699
700 switch m.focus {
701 case uiFocusEditor:
702 binds = append(binds,
703 k.Editor.Newline,
704 )
705 case uiFocusMain:
706 binds = append(binds,
707 k.Chat.UpDown,
708 k.Chat.UpDownOneItem,
709 k.Chat.PageUp,
710 k.Chat.PageDown,
711 k.Chat.Copy,
712 )
713 }
714 default:
715 // TODO: other states
716 // if m.session == nil {
717 // no session selected
718 binds = append(binds,
719 commands,
720 k.Models,
721 k.Editor.Newline,
722 )
723 }
724
725 binds = append(binds,
726 k.Quit,
727 k.Help,
728 )
729
730 return binds
731}
732
733// FullHelp implements [help.KeyMap].
734func (m *UI) FullHelp() [][]key.Binding {
735 var binds [][]key.Binding
736 k := &m.keyMap
737 help := k.Help
738 help.SetHelp("ctrl+g", "less")
739 hasAttachments := false // TODO: implement attachments
740 hasSession := m.session != nil && m.session.ID != ""
741 commands := k.Commands
742 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
743 commands.SetHelp("/ or ctrl+p", "commands")
744 }
745
746 switch m.state {
747 case uiInitialize:
748 binds = append(binds,
749 []key.Binding{
750 k.Quit,
751 })
752 case uiChat:
753 mainBinds := []key.Binding{}
754 tab := k.Tab
755 if m.focus == uiFocusEditor {
756 tab.SetHelp("tab", "focus chat")
757 } else {
758 tab.SetHelp("tab", "focus editor")
759 }
760
761 mainBinds = append(mainBinds,
762 tab,
763 commands,
764 k.Models,
765 k.Sessions,
766 )
767 if hasSession {
768 mainBinds = append(mainBinds, k.Chat.NewSession)
769 }
770
771 binds = append(binds, mainBinds)
772
773 switch m.focus {
774 case uiFocusEditor:
775 binds = append(binds,
776 []key.Binding{
777 k.Editor.Newline,
778 k.Editor.AddImage,
779 k.Editor.MentionFile,
780 k.Editor.OpenEditor,
781 },
782 )
783 if hasAttachments {
784 binds = append(binds,
785 []key.Binding{
786 k.Editor.AttachmentDeleteMode,
787 k.Editor.DeleteAllAttachments,
788 k.Editor.Escape,
789 },
790 )
791 }
792 case uiFocusMain:
793 binds = append(binds,
794 []key.Binding{
795 k.Chat.UpDown,
796 k.Chat.UpDownOneItem,
797 k.Chat.PageUp,
798 k.Chat.PageDown,
799 },
800 []key.Binding{
801 k.Chat.HalfPageUp,
802 k.Chat.HalfPageDown,
803 k.Chat.Home,
804 k.Chat.End,
805 },
806 []key.Binding{
807 k.Chat.Copy,
808 k.Chat.ClearHighlight,
809 },
810 )
811 }
812 default:
813 if m.session == nil {
814 // no session selected
815 binds = append(binds,
816 []key.Binding{
817 commands,
818 k.Models,
819 k.Sessions,
820 },
821 []key.Binding{
822 k.Editor.Newline,
823 k.Editor.AddImage,
824 k.Editor.MentionFile,
825 k.Editor.OpenEditor,
826 },
827 []key.Binding{
828 help,
829 },
830 )
831 }
832 }
833
834 binds = append(binds,
835 []key.Binding{
836 help,
837 k.Quit,
838 },
839 )
840
841 return binds
842}
843
844// updateLayoutAndSize updates the layout and sizes of UI components.
845func (m *UI) updateLayoutAndSize() {
846 m.layout = m.generateLayout(m.width, m.height)
847 m.updateSize()
848}
849
850// updateSize updates the sizes of UI components based on the current layout.
851func (m *UI) updateSize() {
852 // Set help width
853 m.help.SetWidth(m.layout.help.Dx())
854
855 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
856 m.textarea.SetWidth(m.layout.editor.Dx())
857 m.textarea.SetHeight(m.layout.editor.Dy())
858
859 // Handle different app states
860 switch m.state {
861 case uiConfigure, uiInitialize, uiLanding:
862 m.renderHeader(false, m.layout.header.Dx())
863
864 case uiChat:
865 m.renderSidebarLogo(m.layout.sidebar.Dx())
866
867 case uiChatCompact:
868 // TODO: set the width and heigh of the chat component
869 m.renderHeader(true, m.layout.header.Dx())
870 }
871}
872
873// generateLayout calculates the layout rectangles for all UI components based
874// on the current UI state and terminal dimensions.
875func (m *UI) generateLayout(w, h int) layout {
876 // The screen area we're working with
877 area := image.Rect(0, 0, w, h)
878
879 // The help height
880 helpHeight := 1
881 // The editor height
882 editorHeight := 5
883 // The sidebar width
884 sidebarWidth := 30
885 // The header height
886 // TODO: handle compact
887 headerHeight := 4
888
889 var helpKeyMap help.KeyMap = m
890 if m.help.ShowAll {
891 for _, row := range helpKeyMap.FullHelp() {
892 helpHeight = max(helpHeight, len(row))
893 }
894 }
895
896 // Add app margins
897 appRect := area
898 appRect.Min.X += 1
899 appRect.Min.Y += 1
900 appRect.Max.X -= 1
901 appRect.Max.Y -= 1
902
903 if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
904 // extra padding on left and right for these states
905 appRect.Min.X += 1
906 appRect.Max.X -= 1
907 }
908
909 appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
910
911 layout := layout{
912 area: area,
913 help: helpRect,
914 }
915
916 // Handle different app states
917 switch m.state {
918 case uiConfigure, uiInitialize:
919 // Layout
920 //
921 // header
922 // ------
923 // main
924 // ------
925 // help
926
927 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
928 layout.header = headerRect
929 layout.main = mainRect
930
931 case uiLanding:
932 // Layout
933 //
934 // header
935 // ------
936 // main
937 // ------
938 // editor
939 // ------
940 // help
941 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
942 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
943 // Remove extra padding from editor (but keep it for header and main)
944 editorRect.Min.X -= 1
945 editorRect.Max.X += 1
946 layout.header = headerRect
947 layout.main = mainRect
948 layout.editor = editorRect
949
950 case uiChat:
951 // Layout
952 //
953 // ------|---
954 // main |
955 // ------| side
956 // editor|
957 // ----------
958 // help
959
960 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
961 // Add padding left
962 sideRect.Min.X += 1
963 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
964 mainRect.Max.X -= 1 // Add padding right
965 // Add bottom margin to main
966 mainRect.Max.Y -= 1
967 layout.sidebar = sideRect
968 layout.main = mainRect
969 layout.editor = editorRect
970
971 case uiChatCompact:
972 // Layout
973 //
974 // compact-header
975 // ------
976 // main
977 // ------
978 // editor
979 // ------
980 // help
981 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
982 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
983 layout.header = headerRect
984 layout.main = mainRect
985 layout.editor = editorRect
986 }
987
988 if !layout.editor.Empty() {
989 // Add editor margins 1 top and bottom
990 layout.editor.Min.Y += 1
991 layout.editor.Max.Y -= 1
992 }
993
994 return layout
995}
996
997// layout defines the positioning of UI elements.
998type layout struct {
999 // area is the overall available area.
1000 area uv.Rectangle
1001
1002 // header is the header shown in special cases
1003 // e.x when the sidebar is collapsed
1004 // or when in the landing page
1005 // or in init/config
1006 header uv.Rectangle
1007
1008 // main is the area for the main pane. (e.x chat, configure, landing)
1009 main uv.Rectangle
1010
1011 // editor is the area for the editor pane.
1012 editor uv.Rectangle
1013
1014 // sidebar is the area for the sidebar.
1015 sidebar uv.Rectangle
1016
1017 // help is the area for the help view.
1018 help uv.Rectangle
1019}
1020
1021func (m *UI) openEditor(value string) tea.Cmd {
1022 editor := os.Getenv("EDITOR")
1023 if editor == "" {
1024 // Use platform-appropriate default editor
1025 if runtime.GOOS == "windows" {
1026 editor = "notepad"
1027 } else {
1028 editor = "nvim"
1029 }
1030 }
1031
1032 tmpfile, err := os.CreateTemp("", "msg_*.md")
1033 if err != nil {
1034 return uiutil.ReportError(err)
1035 }
1036 defer tmpfile.Close() //nolint:errcheck
1037 if _, err := tmpfile.WriteString(value); err != nil {
1038 return uiutil.ReportError(err)
1039 }
1040 cmdStr := editor + " " + tmpfile.Name()
1041 return uiutil.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
1042 if err != nil {
1043 return uiutil.ReportError(err)
1044 }
1045 content, err := os.ReadFile(tmpfile.Name())
1046 if err != nil {
1047 return uiutil.ReportError(err)
1048 }
1049 if len(content) == 0 {
1050 return uiutil.ReportWarn("Message is empty")
1051 }
1052 os.Remove(tmpfile.Name())
1053 return openEditorMsg{
1054 Text: strings.TrimSpace(string(content)),
1055 }
1056 })
1057}
1058
1059// setEditorPrompt configures the textarea prompt function based on whether
1060// yolo mode is enabled.
1061func (m *UI) setEditorPrompt(yolo bool) {
1062 if yolo {
1063 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
1064 return
1065 }
1066 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
1067}
1068
1069// normalPromptFunc returns the normal editor prompt style (" > " on first
1070// line, "::: " on subsequent lines).
1071func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
1072 t := m.com.Styles
1073 if info.LineNumber == 0 {
1074 if info.Focused {
1075 return " > "
1076 }
1077 return "::: "
1078 }
1079 if info.Focused {
1080 return t.EditorPromptNormalFocused.Render()
1081 }
1082 return t.EditorPromptNormalBlurred.Render()
1083}
1084
1085// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
1086// and colored dots.
1087func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
1088 t := m.com.Styles
1089 if info.LineNumber == 0 {
1090 if info.Focused {
1091 return t.EditorPromptYoloIconFocused.Render()
1092 } else {
1093 return t.EditorPromptYoloIconBlurred.Render()
1094 }
1095 }
1096 if info.Focused {
1097 return t.EditorPromptYoloDotsFocused.Render()
1098 }
1099 return t.EditorPromptYoloDotsBlurred.Render()
1100}
1101
1102var readyPlaceholders = [...]string{
1103 "Ready!",
1104 "Ready...",
1105 "Ready?",
1106 "Ready for instructions",
1107}
1108
1109var workingPlaceholders = [...]string{
1110 "Working!",
1111 "Working...",
1112 "Brrrrr...",
1113 "Prrrrrrrr...",
1114 "Processing...",
1115 "Thinking...",
1116}
1117
1118// randomizePlaceholders selects random placeholder text for the textarea's
1119// ready and working states.
1120func (m *UI) randomizePlaceholders() {
1121 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
1122 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
1123}
1124
1125// renderHeader renders and caches the header logo at the specified width.
1126func (m *UI) renderHeader(compact bool, width int) {
1127 // TODO: handle the compact case differently
1128 m.header = renderLogo(m.com.Styles, compact, width)
1129}
1130
1131// renderSidebarLogo renders and caches the sidebar logo at the specified
1132// width.
1133func (m *UI) renderSidebarLogo(width int) {
1134 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
1135}
1136
1137// openCommandsDialog opens the commands dialog.
1138func (m *UI) openCommandsDialog() tea.Cmd {
1139 if m.dialog.ContainsDialog(dialog.CommandsID) {
1140 // Bring to front
1141 m.dialog.BringToFront(dialog.CommandsID)
1142 return nil
1143 }
1144
1145 sessionID := ""
1146 if m.session != nil {
1147 sessionID = m.session.ID
1148 }
1149
1150 commands, err := dialog.NewCommands(m.com, sessionID)
1151 if err != nil {
1152 return uiutil.ReportError(err)
1153 }
1154
1155 // TODO: Get. Rid. Of. Magic numbers!
1156 commands.SetSize(min(120, m.width-8), 30)
1157 m.dialog.OpenDialog(commands)
1158
1159 return nil
1160}
1161
1162// openSessionsDialog opens the sessions dialog with the given sessions.
1163func (m *UI) openSessionsDialog(sessions []session.Session) tea.Cmd {
1164 if m.dialog.ContainsDialog(dialog.SessionsID) {
1165 // Bring to front
1166 m.dialog.BringToFront(dialog.SessionsID)
1167 return nil
1168 }
1169
1170 dialog := dialog.NewSessions(m.com, sessions...)
1171 // TODO: Get. Rid. Of. Magic numbers!
1172 dialog.SetSize(min(120, m.width-8), 30)
1173 m.dialog.OpenDialog(dialog)
1174
1175 return nil
1176}
1177
1178// listSessions is a [tea.Cmd] that lists all sessions and returns them in a
1179// [listSessionsMsg].
1180func (m *UI) listSessions() tea.Msg {
1181 allSessions, _ := m.com.App.Sessions.List(context.TODO())
1182 return listSessionsMsg{sessions: allSessions}
1183}
1184
1185// handlePasteMsg handles a paste message.
1186func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
1187 if m.focus != uiFocusEditor {
1188 return nil
1189 }
1190
1191 var cmd tea.Cmd
1192 path := strings.ReplaceAll(msg.Content, "\\ ", " ")
1193 // try to get an image
1194 path, err := filepath.Abs(strings.TrimSpace(path))
1195 if err != nil {
1196 m.textarea, cmd = m.textarea.Update(msg)
1197 return cmd
1198 }
1199 isAllowedType := false
1200 for _, ext := range filepicker.AllowedTypes {
1201 if strings.HasSuffix(path, ext) {
1202 isAllowedType = true
1203 break
1204 }
1205 }
1206 if !isAllowedType {
1207 m.textarea, cmd = m.textarea.Update(msg)
1208 return cmd
1209 }
1210 tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
1211 if tooBig {
1212 m.textarea, cmd = m.textarea.Update(msg)
1213 return cmd
1214 }
1215
1216 content, err := os.ReadFile(path)
1217 if err != nil {
1218 m.textarea, cmd = m.textarea.Update(msg)
1219 return cmd
1220 }
1221 mimeBufferSize := min(512, len(content))
1222 mimeType := http.DetectContentType(content[:mimeBufferSize])
1223 fileName := filepath.Base(path)
1224 attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
1225 return uiutil.CmdHandler(filepicker.FilePickedMsg{
1226 Attachment: attachment,
1227 })
1228}
1229
1230// renderLogo renders the Crush logo with the given styles and dimensions.
1231func renderLogo(t *styles.Styles, compact bool, width int) string {
1232 return logo.Render(version.Version, compact, logo.Opts{
1233 FieldColor: t.LogoFieldColor,
1234 TitleColorA: t.LogoTitleColorA,
1235 TitleColorB: t.LogoTitleColorB,
1236 CharmColor: t.LogoCharmColor,
1237 VersionColor: t.LogoVersionColor,
1238 Width: width,
1239 })
1240}