1package model
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "image"
8 "math/rand"
9 "net/http"
10 "os"
11 "path/filepath"
12 "runtime"
13 "slices"
14 "strings"
15
16 "charm.land/bubbles/v2/help"
17 "charm.land/bubbles/v2/key"
18 "charm.land/bubbles/v2/textarea"
19 tea "charm.land/bubbletea/v2"
20 "charm.land/lipgloss/v2"
21 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
22 "github.com/charmbracelet/crush/internal/app"
23 "github.com/charmbracelet/crush/internal/config"
24 "github.com/charmbracelet/crush/internal/history"
25 "github.com/charmbracelet/crush/internal/message"
26 "github.com/charmbracelet/crush/internal/permission"
27 "github.com/charmbracelet/crush/internal/pubsub"
28 "github.com/charmbracelet/crush/internal/session"
29 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
30 "github.com/charmbracelet/crush/internal/ui/anim"
31 "github.com/charmbracelet/crush/internal/ui/chat"
32 "github.com/charmbracelet/crush/internal/ui/common"
33 "github.com/charmbracelet/crush/internal/ui/dialog"
34 "github.com/charmbracelet/crush/internal/ui/logo"
35 "github.com/charmbracelet/crush/internal/ui/styles"
36 "github.com/charmbracelet/crush/internal/uiutil"
37 "github.com/charmbracelet/crush/internal/version"
38 uv "github.com/charmbracelet/ultraviolet"
39 "github.com/charmbracelet/ultraviolet/screen"
40)
41
42// uiFocusState represents the current focus state of the UI.
43type uiFocusState uint8
44
45// Possible uiFocusState values.
46const (
47 uiFocusNone uiFocusState = iota
48 uiFocusEditor
49 uiFocusMain
50)
51
52type uiState uint8
53
54// Possible uiState values.
55const (
56 uiConfigure uiState = iota
57 uiInitialize
58 uiLanding
59 uiChat
60 uiChatCompact
61)
62
63type openEditorMsg struct {
64 Text string
65}
66
67// listSessionsMsg is a message to list available sessions.
68type listSessionsMsg struct {
69 sessions []session.Session
70}
71
72// UI represents the main user interface model.
73type UI struct {
74 com *common.Common
75 session *session.Session
76 sessionFiles []SessionFile
77
78 // The width and height of the terminal in cells.
79 width int
80 height int
81 layout layout
82
83 focus uiFocusState
84 state uiState
85
86 keyMap KeyMap
87 keyenh tea.KeyboardEnhancementsMsg
88
89 dialog *dialog.Overlay
90 help help.Model
91
92 // header is the last cached header logo
93 header string
94
95 // sendProgressBar instructs the TUI to send progress bar updates to the
96 // terminal.
97 sendProgressBar bool
98
99 // QueryVersion instructs the TUI to query for the terminal version when it
100 // starts.
101 QueryVersion bool
102
103 // Editor components
104 textarea textarea.Model
105
106 attachments []message.Attachment // TODO: Implement attachments
107
108 readyPlaceholder string
109 workingPlaceholder string
110
111 // Chat components
112 chat *Chat
113
114 // onboarding state
115 onboarding struct {
116 yesInitializeSelected bool
117 }
118
119 // lsp
120 lspStates map[string]app.LSPClientInfo
121
122 // mcp
123 mcpStates map[string]mcp.ClientInfo
124
125 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
126 sidebarLogo string
127}
128
129// New creates a new instance of the [UI] model.
130func New(com *common.Common) *UI {
131 // Editor components
132 ta := textarea.New()
133 ta.SetStyles(com.Styles.TextArea)
134 ta.ShowLineNumbers = false
135 ta.CharLimit = -1
136 ta.SetVirtualCursor(false)
137 ta.Focus()
138
139 ch := NewChat(com)
140
141 ui := &UI{
142 com: com,
143 dialog: dialog.NewOverlay(),
144 keyMap: DefaultKeyMap(),
145 help: help.New(),
146 focus: uiFocusNone,
147 state: uiConfigure,
148 textarea: ta,
149 chat: ch,
150 }
151
152 // set onboarding state defaults
153 ui.onboarding.yesInitializeSelected = true
154
155 // If no provider is configured show the user the provider list
156 if !com.Config().IsConfigured() {
157 ui.state = uiConfigure
158 // if the project needs initialization show the user the question
159 } else if n, _ := config.ProjectNeedsInitialization(); n {
160 ui.state = uiInitialize
161 // otherwise go to the landing UI
162 } else {
163 ui.state = uiLanding
164 ui.focus = uiFocusEditor
165 }
166
167 ui.setEditorPrompt(false)
168 ui.randomizePlaceholders()
169 ui.textarea.Placeholder = ui.readyPlaceholder
170 ui.help.Styles = com.Styles.Help
171
172 return ui
173}
174
175// Init initializes the UI model.
176func (m *UI) Init() tea.Cmd {
177 var cmds []tea.Cmd
178 if m.QueryVersion {
179 cmds = append(cmds, tea.RequestTerminalVersion)
180 }
181 return tea.Batch(cmds...)
182}
183
184// Update handles updates to the UI model.
185func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
186 var cmds []tea.Cmd
187 switch msg := msg.(type) {
188 case tea.EnvMsg:
189 // Is this Windows Terminal?
190 if !m.sendProgressBar {
191 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
192 }
193 case listSessionsMsg:
194 if cmd := m.openSessionsDialog(msg.sessions); cmd != nil {
195 cmds = append(cmds, cmd)
196 }
197 case loadSessionMsg:
198 m.state = uiChat
199 m.session = msg.session
200 m.sessionFiles = msg.files
201 msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
202 if err != nil {
203 cmds = append(cmds, uiutil.ReportError(err))
204 break
205 }
206 if cmd := m.setSessionMessages(msgs); cmd != nil {
207 cmds = append(cmds, cmd)
208 }
209
210 case pubsub.Event[message.Message]:
211 if m.session == nil || msg.Payload.SessionID != m.session.ID {
212 break
213 }
214 switch msg.Type {
215 case pubsub.CreatedEvent:
216 cmds = append(cmds, m.appendSessionMessage(msg.Payload))
217 case pubsub.UpdatedEvent:
218 cmds = append(cmds, m.updateSessionMessage(msg.Payload))
219 }
220 // TODO: Finish implementing me
221 // cmds = append(cmds, m.setMessageEvents(msg.Payload))
222 case pubsub.Event[history.File]:
223 cmds = append(cmds, m.handleFileEvent(msg.Payload))
224 case pubsub.Event[app.LSPEvent]:
225 m.lspStates = app.GetLSPStates()
226 case pubsub.Event[mcp.Event]:
227 m.mcpStates = mcp.GetStates()
228 if msg.Type == pubsub.UpdatedEvent && m.dialog.ContainsDialog(dialog.CommandsID) {
229 dia := m.dialog.Dialog(dialog.CommandsID)
230 if dia == nil {
231 break
232 }
233
234 commands, ok := dia.(*dialog.Commands)
235 if ok {
236 if cmd := commands.ReloadMCPPrompts(); cmd != nil {
237 cmds = append(cmds, cmd)
238 }
239 }
240 }
241 case tea.TerminalVersionMsg:
242 termVersion := strings.ToLower(msg.Name)
243 // Only enable progress bar for the following terminals.
244 if !m.sendProgressBar {
245 m.sendProgressBar = strings.Contains(termVersion, "ghostty")
246 }
247 return m, nil
248 case tea.WindowSizeMsg:
249 m.width, m.height = msg.Width, msg.Height
250 m.updateLayoutAndSize()
251 case tea.KeyboardEnhancementsMsg:
252 m.keyenh = msg
253 if msg.SupportsKeyDisambiguation() {
254 m.keyMap.Models.SetHelp("ctrl+m", "models")
255 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
256 }
257 case tea.MouseClickMsg:
258 switch m.state {
259 case uiChat:
260 x, y := msg.X, msg.Y
261 // Adjust for chat area position
262 x -= m.layout.main.Min.X
263 y -= m.layout.main.Min.Y
264 m.chat.HandleMouseDown(x, y)
265 }
266
267 case tea.MouseMotionMsg:
268 switch m.state {
269 case uiChat:
270 if msg.Y <= 0 {
271 if cmd := m.chat.ScrollBy(-1); cmd != nil {
272 cmds = append(cmds, cmd)
273 }
274 if !m.chat.SelectedItemInView() {
275 m.chat.SelectPrev()
276 if cmd := m.chat.ScrollToSelected(); cmd != nil {
277 cmds = append(cmds, cmd)
278 }
279 }
280 } else if msg.Y >= m.chat.Height()-1 {
281 if cmd := m.chat.ScrollBy(1); cmd != nil {
282 cmds = append(cmds, cmd)
283 }
284 if !m.chat.SelectedItemInView() {
285 m.chat.SelectNext()
286 if cmd := m.chat.ScrollToSelected(); cmd != nil {
287 cmds = append(cmds, cmd)
288 }
289 }
290 }
291
292 x, y := msg.X, msg.Y
293 // Adjust for chat area position
294 x -= m.layout.main.Min.X
295 y -= m.layout.main.Min.Y
296 m.chat.HandleMouseDrag(x, y)
297 }
298
299 case tea.MouseReleaseMsg:
300 switch m.state {
301 case uiChat:
302 x, y := msg.X, msg.Y
303 // Adjust for chat area position
304 x -= m.layout.main.Min.X
305 y -= m.layout.main.Min.Y
306 m.chat.HandleMouseUp(x, y)
307 }
308 case tea.MouseWheelMsg:
309 switch m.state {
310 case uiChat:
311 switch msg.Button {
312 case tea.MouseWheelUp:
313 if cmd := m.chat.ScrollBy(-5); cmd != nil {
314 cmds = append(cmds, cmd)
315 }
316 if !m.chat.SelectedItemInView() {
317 m.chat.SelectPrev()
318 if cmd := m.chat.ScrollToSelected(); cmd != nil {
319 cmds = append(cmds, cmd)
320 }
321 }
322 case tea.MouseWheelDown:
323 if cmd := m.chat.ScrollBy(5); cmd != nil {
324 cmds = append(cmds, cmd)
325 }
326 if !m.chat.SelectedItemInView() {
327 m.chat.SelectNext()
328 if cmd := m.chat.ScrollToSelected(); cmd != nil {
329 cmds = append(cmds, cmd)
330 }
331 }
332 }
333 }
334 case anim.StepMsg:
335 if m.state == uiChat {
336 if cmd := m.chat.Animate(msg); cmd != nil {
337 cmds = append(cmds, cmd)
338 }
339 }
340 case tea.KeyPressMsg:
341 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
342 cmds = append(cmds, cmd)
343 }
344 case tea.PasteMsg:
345 if cmd := m.handlePasteMsg(msg); cmd != nil {
346 cmds = append(cmds, cmd)
347 }
348 case openEditorMsg:
349 m.textarea.SetValue(msg.Text)
350 m.textarea.MoveToEnd()
351 }
352
353 // This logic gets triggered on any message type, but should it?
354 switch m.focus {
355 case uiFocusMain:
356 case uiFocusEditor:
357 // Textarea placeholder logic
358 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
359 m.textarea.Placeholder = m.workingPlaceholder
360 } else {
361 m.textarea.Placeholder = m.readyPlaceholder
362 }
363 if m.com.App.Permissions.SkipRequests() {
364 m.textarea.Placeholder = "Yolo mode!"
365 }
366 }
367
368 return m, tea.Batch(cmds...)
369}
370
371// setSessionMessages sets the messages for the current session in the chat
372func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
373 var cmds []tea.Cmd
374 // Build tool result map to link tool calls with their results
375 msgPtrs := make([]*message.Message, len(msgs))
376 for i := range msgs {
377 msgPtrs[i] = &msgs[i]
378 }
379 toolResultMap := chat.BuildToolResultMap(msgPtrs)
380
381 // Add messages to chat with linked tool results
382 items := make([]chat.MessageItem, 0, len(msgs)*2)
383 for _, msg := range msgPtrs {
384 items = append(items, chat.GetMessageItems(m.com.Styles, msg, toolResultMap)...)
385 }
386
387 // If the user switches between sessions while the agent is working we want
388 // to make sure the animations are shown.
389 for _, item := range items {
390 if animatable, ok := item.(chat.Animatable); ok {
391 if cmd := animatable.StartAnimation(); cmd != nil {
392 cmds = append(cmds, cmd)
393 }
394 }
395 }
396
397 m.chat.SetMessages(items...)
398 if cmd := m.chat.ScrollToBottom(); cmd != nil {
399 cmds = append(cmds, cmd)
400 }
401 m.chat.SelectLast()
402 return tea.Batch(cmds...)
403}
404
405// appendSessionMessage appends a new message to the current session in the chat
406func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
407 items := chat.GetMessageItems(m.com.Styles, &msg, nil)
408 var cmds []tea.Cmd
409 for _, item := range items {
410 if animatable, ok := item.(chat.Animatable); ok {
411 if cmd := animatable.StartAnimation(); cmd != nil {
412 cmds = append(cmds, cmd)
413 }
414 }
415 }
416 m.chat.AppendMessages(items...)
417 if cmd := m.chat.ScrollToBottom(); cmd != nil {
418 cmds = append(cmds, cmd)
419 }
420 return tea.Batch(cmds...)
421}
422
423// updateSessionMessage updates an existing message in the current session in the chat
424// INFO: currently only updates the assistant when I add tools this will get a bit more complex
425func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
426 existingItem := m.chat.GetMessageItem(msg.ID)
427 switch msg.Role {
428 case message.Assistant:
429 if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
430 assistantItem.SetMessage(&msg)
431 }
432 }
433
434 return nil
435}
436
437func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
438 var cmds []tea.Cmd
439
440 handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
441 switch {
442 case key.Matches(msg, m.keyMap.Help):
443 m.help.ShowAll = !m.help.ShowAll
444 m.updateLayoutAndSize()
445 return true
446 case key.Matches(msg, m.keyMap.Commands):
447 if cmd := m.openCommandsDialog(); cmd != nil {
448 cmds = append(cmds, cmd)
449 }
450 return true
451 case key.Matches(msg, m.keyMap.Models):
452 // TODO: Implement me
453 return true
454 case key.Matches(msg, m.keyMap.Sessions):
455 if m.dialog.ContainsDialog(dialog.SessionsID) {
456 // Bring to front
457 m.dialog.BringToFront(dialog.SessionsID)
458 } else {
459 cmds = append(cmds, m.listSessions)
460 }
461 return true
462 }
463 return false
464 }
465
466 if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
467 // Always handle quit keys first
468 if cmd := m.openQuitDialog(); cmd != nil {
469 cmds = append(cmds, cmd)
470 }
471
472 return tea.Batch(cmds...)
473 }
474
475 // Route all messages to dialog if one is open.
476 if m.dialog.HasDialogs() {
477 msg := m.dialog.Update(msg)
478 if msg == nil {
479 return tea.Batch(cmds...)
480 }
481
482 switch msg := msg.(type) {
483 // Generic dialog messages
484 case dialog.CloseMsg:
485 m.dialog.CloseFrontDialog()
486
487 // Session dialog messages
488 case dialog.SessionSelectedMsg:
489 m.dialog.CloseDialog(dialog.SessionsID)
490 cmds = append(cmds, m.loadSession(msg.Session.ID))
491
492 // Command dialog messages
493 case dialog.ToggleYoloModeMsg:
494 yolo := !m.com.App.Permissions.SkipRequests()
495 m.com.App.Permissions.SetSkipRequests(yolo)
496 m.setEditorPrompt(yolo)
497 m.dialog.CloseDialog(dialog.CommandsID)
498 case dialog.SwitchSessionsMsg:
499 cmds = append(cmds, m.listSessions)
500 m.dialog.CloseDialog(dialog.CommandsID)
501 case dialog.CompactMsg:
502 err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
503 if err != nil {
504 cmds = append(cmds, uiutil.ReportError(err))
505 }
506 case dialog.ToggleHelpMsg:
507 m.help.ShowAll = !m.help.ShowAll
508 m.dialog.CloseDialog(dialog.CommandsID)
509 case dialog.QuitMsg:
510 cmds = append(cmds, tea.Quit)
511 }
512
513 return tea.Batch(cmds...)
514 }
515
516 switch m.state {
517 case uiConfigure:
518 return tea.Batch(cmds...)
519 case uiInitialize:
520 cmds = append(cmds, m.updateInitializeView(msg)...)
521 return tea.Batch(cmds...)
522 case uiChat, uiLanding, uiChatCompact:
523 switch m.focus {
524 case uiFocusEditor:
525 switch {
526 case key.Matches(msg, m.keyMap.Editor.SendMessage):
527 value := m.textarea.Value()
528 if strings.HasSuffix(value, "\\") {
529 // If the last character is a backslash, remove it and add a newline.
530 m.textarea.SetValue(strings.TrimSuffix(value, "\\"))
531 break
532 }
533
534 // Otherwise, send the message
535 m.textarea.Reset()
536
537 value = strings.TrimSpace(value)
538 if value == "exit" || value == "quit" {
539 return m.openQuitDialog()
540 }
541
542 attachments := m.attachments
543 m.attachments = nil
544 if len(value) == 0 {
545 return nil
546 }
547
548 m.randomizePlaceholders()
549
550 return m.sendMessage(value, attachments)
551 case key.Matches(msg, m.keyMap.Tab):
552 m.focus = uiFocusMain
553 m.textarea.Blur()
554 m.chat.Focus()
555 m.chat.SetSelected(m.chat.Len() - 1)
556 case key.Matches(msg, m.keyMap.Editor.OpenEditor):
557 if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
558 cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
559 break
560 }
561 cmds = append(cmds, m.openEditor(m.textarea.Value()))
562 case key.Matches(msg, m.keyMap.Editor.Newline):
563 m.textarea.InsertRune('\n')
564 default:
565 if handleGlobalKeys(msg) {
566 // Handle global keys first before passing to textarea.
567 break
568 }
569
570 ta, cmd := m.textarea.Update(msg)
571 m.textarea = ta
572 cmds = append(cmds, cmd)
573 }
574 case uiFocusMain:
575 switch {
576 case key.Matches(msg, m.keyMap.Tab):
577 m.focus = uiFocusEditor
578 cmds = append(cmds, m.textarea.Focus())
579 m.chat.Blur()
580 case key.Matches(msg, m.keyMap.Chat.Up):
581 if cmd := m.chat.ScrollBy(-1); cmd != nil {
582 cmds = append(cmds, cmd)
583 }
584 if !m.chat.SelectedItemInView() {
585 m.chat.SelectPrev()
586 if cmd := m.chat.ScrollToSelected(); cmd != nil {
587 cmds = append(cmds, cmd)
588 }
589 }
590 case key.Matches(msg, m.keyMap.Chat.Down):
591 if cmd := m.chat.ScrollBy(1); cmd != nil {
592 cmds = append(cmds, cmd)
593 }
594 if !m.chat.SelectedItemInView() {
595 m.chat.SelectNext()
596 if cmd := m.chat.ScrollToSelected(); cmd != nil {
597 cmds = append(cmds, cmd)
598 }
599 }
600 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
601 m.chat.SelectPrev()
602 if cmd := m.chat.ScrollToSelected(); cmd != nil {
603 cmds = append(cmds, cmd)
604 }
605 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
606 m.chat.SelectNext()
607 if cmd := m.chat.ScrollToSelected(); cmd != nil {
608 cmds = append(cmds, cmd)
609 }
610 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
611 if cmd := m.chat.ScrollBy(-m.chat.Height() / 2); cmd != nil {
612 cmds = append(cmds, cmd)
613 }
614 m.chat.SelectFirstInView()
615 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
616 if cmd := m.chat.ScrollBy(m.chat.Height() / 2); cmd != nil {
617 cmds = append(cmds, cmd)
618 }
619 m.chat.SelectLastInView()
620 case key.Matches(msg, m.keyMap.Chat.PageUp):
621 if cmd := m.chat.ScrollBy(-m.chat.Height()); cmd != nil {
622 cmds = append(cmds, cmd)
623 }
624 m.chat.SelectFirstInView()
625 case key.Matches(msg, m.keyMap.Chat.PageDown):
626 if cmd := m.chat.ScrollBy(m.chat.Height()); cmd != nil {
627 cmds = append(cmds, cmd)
628 }
629 m.chat.SelectLastInView()
630 case key.Matches(msg, m.keyMap.Chat.Home):
631 if cmd := m.chat.ScrollToTop(); cmd != nil {
632 cmds = append(cmds, cmd)
633 }
634 m.chat.SelectFirst()
635 case key.Matches(msg, m.keyMap.Chat.End):
636 if cmd := m.chat.ScrollToBottom(); cmd != nil {
637 cmds = append(cmds, cmd)
638 }
639 m.chat.SelectLast()
640 default:
641 handleGlobalKeys(msg)
642 }
643 default:
644 handleGlobalKeys(msg)
645 }
646 default:
647 handleGlobalKeys(msg)
648 }
649
650 return tea.Batch(cmds...)
651}
652
653// Draw implements [tea.Layer] and draws the UI model.
654func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
655 layout := m.generateLayout(area.Dx(), area.Dy())
656
657 if m.layout != layout {
658 m.layout = layout
659 m.updateSize()
660 }
661
662 // Clear the screen first
663 screen.Clear(scr)
664
665 switch m.state {
666 case uiConfigure:
667 header := uv.NewStyledString(m.header)
668 header.Draw(scr, layout.header)
669
670 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
671 Height(layout.main.Dy()).
672 Background(lipgloss.ANSIColor(rand.Intn(256))).
673 Render(" Configure ")
674 main := uv.NewStyledString(mainView)
675 main.Draw(scr, layout.main)
676
677 case uiInitialize:
678 header := uv.NewStyledString(m.header)
679 header.Draw(scr, layout.header)
680
681 main := uv.NewStyledString(m.initializeView())
682 main.Draw(scr, layout.main)
683
684 case uiLanding:
685 header := uv.NewStyledString(m.header)
686 header.Draw(scr, layout.header)
687 main := uv.NewStyledString(m.landingView())
688 main.Draw(scr, layout.main)
689
690 editor := uv.NewStyledString(m.textarea.View())
691 editor.Draw(scr, layout.editor)
692
693 case uiChat:
694 m.chat.Draw(scr, layout.main)
695
696 header := uv.NewStyledString(m.header)
697 header.Draw(scr, layout.header)
698 m.drawSidebar(scr, layout.sidebar)
699
700 editor := uv.NewStyledString(m.textarea.View())
701 editor.Draw(scr, layout.editor)
702
703 case uiChatCompact:
704 header := uv.NewStyledString(m.header)
705 header.Draw(scr, layout.header)
706
707 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
708 Height(layout.main.Dy()).
709 Background(lipgloss.ANSIColor(rand.Intn(256))).
710 Render(" Compact Chat Messages ")
711 main := uv.NewStyledString(mainView)
712 main.Draw(scr, layout.main)
713
714 editor := uv.NewStyledString(m.textarea.View())
715 editor.Draw(scr, layout.editor)
716 }
717
718 // Add help layer
719 help := uv.NewStyledString(m.help.View(m))
720 help.Draw(scr, layout.help)
721
722 // Debugging rendering (visually see when the tui rerenders)
723 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
724 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
725 debug := uv.NewStyledString(debugView.String())
726 debug.Draw(scr, image.Rectangle{
727 Min: image.Pt(4, 1),
728 Max: image.Pt(8, 3),
729 })
730 }
731
732 // This needs to come last to overlay on top of everything
733 if m.dialog.HasDialogs() {
734 m.dialog.Draw(scr, area)
735 }
736}
737
738// Cursor returns the cursor position and properties for the UI model. It
739// returns nil if the cursor should not be shown.
740func (m *UI) Cursor() *tea.Cursor {
741 if m.layout.editor.Dy() <= 0 {
742 // Don't show cursor if editor is not visible
743 return nil
744 }
745 if m.dialog.HasDialogs() {
746 if front := m.dialog.DialogLast(); front != nil {
747 c, ok := front.(uiutil.Cursor)
748 if ok {
749 cur := c.Cursor()
750 if cur != nil {
751 pos := m.dialog.CenterPosition(m.layout.area, front.ID())
752 cur.X += pos.Min.X
753 cur.Y += pos.Min.Y
754 return cur
755 }
756 }
757 }
758 return nil
759 }
760 switch m.focus {
761 case uiFocusEditor:
762 if m.textarea.Focused() {
763 cur := m.textarea.Cursor()
764 cur.X++ // Adjust for app margins
765 cur.Y += m.layout.editor.Min.Y
766 return cur
767 }
768 }
769 return nil
770}
771
772// View renders the UI model's view.
773func (m *UI) View() tea.View {
774 var v tea.View
775 v.AltScreen = true
776 v.BackgroundColor = m.com.Styles.Background
777 v.Cursor = m.Cursor()
778 v.MouseMode = tea.MouseModeCellMotion
779
780 canvas := uv.NewScreenBuffer(m.width, m.height)
781 m.Draw(canvas, canvas.Bounds())
782
783 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
784 contentLines := strings.Split(content, "\n")
785 for i, line := range contentLines {
786 // Trim trailing spaces for concise rendering
787 contentLines[i] = strings.TrimRight(line, " ")
788 }
789
790 content = strings.Join(contentLines, "\n")
791
792 v.Content = content
793 if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
794 // HACK: use a random percentage to prevent ghostty from hiding it
795 // after a timeout.
796 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
797 }
798
799 return v
800}
801
802// ShortHelp implements [help.KeyMap].
803func (m *UI) ShortHelp() []key.Binding {
804 var binds []key.Binding
805 k := &m.keyMap
806 tab := k.Tab
807 commands := k.Commands
808 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
809 commands.SetHelp("/ or ctrl+p", "commands")
810 }
811
812 switch m.state {
813 case uiInitialize:
814 binds = append(binds, k.Quit)
815 case uiChat:
816 if m.focus == uiFocusEditor {
817 tab.SetHelp("tab", "focus chat")
818 } else {
819 tab.SetHelp("tab", "focus editor")
820 }
821
822 binds = append(binds,
823 tab,
824 commands,
825 k.Models,
826 )
827
828 switch m.focus {
829 case uiFocusEditor:
830 binds = append(binds,
831 k.Editor.Newline,
832 )
833 case uiFocusMain:
834 binds = append(binds,
835 k.Chat.UpDown,
836 k.Chat.UpDownOneItem,
837 k.Chat.PageUp,
838 k.Chat.PageDown,
839 k.Chat.Copy,
840 )
841 }
842 default:
843 // TODO: other states
844 // if m.session == nil {
845 // no session selected
846 binds = append(binds,
847 commands,
848 k.Models,
849 k.Editor.Newline,
850 )
851 }
852
853 binds = append(binds,
854 k.Quit,
855 k.Help,
856 )
857
858 return binds
859}
860
861// FullHelp implements [help.KeyMap].
862func (m *UI) FullHelp() [][]key.Binding {
863 var binds [][]key.Binding
864 k := &m.keyMap
865 help := k.Help
866 help.SetHelp("ctrl+g", "less")
867 hasAttachments := false // TODO: implement attachments
868 hasSession := m.session != nil && m.session.ID != ""
869 commands := k.Commands
870 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
871 commands.SetHelp("/ or ctrl+p", "commands")
872 }
873
874 switch m.state {
875 case uiInitialize:
876 binds = append(binds,
877 []key.Binding{
878 k.Quit,
879 })
880 case uiChat:
881 mainBinds := []key.Binding{}
882 tab := k.Tab
883 if m.focus == uiFocusEditor {
884 tab.SetHelp("tab", "focus chat")
885 } else {
886 tab.SetHelp("tab", "focus editor")
887 }
888
889 mainBinds = append(mainBinds,
890 tab,
891 commands,
892 k.Models,
893 k.Sessions,
894 )
895 if hasSession {
896 mainBinds = append(mainBinds, k.Chat.NewSession)
897 }
898
899 binds = append(binds, mainBinds)
900
901 switch m.focus {
902 case uiFocusEditor:
903 binds = append(binds,
904 []key.Binding{
905 k.Editor.Newline,
906 k.Editor.AddImage,
907 k.Editor.MentionFile,
908 k.Editor.OpenEditor,
909 },
910 )
911 if hasAttachments {
912 binds = append(binds,
913 []key.Binding{
914 k.Editor.AttachmentDeleteMode,
915 k.Editor.DeleteAllAttachments,
916 k.Editor.Escape,
917 },
918 )
919 }
920 case uiFocusMain:
921 binds = append(binds,
922 []key.Binding{
923 k.Chat.UpDown,
924 k.Chat.UpDownOneItem,
925 k.Chat.PageUp,
926 k.Chat.PageDown,
927 },
928 []key.Binding{
929 k.Chat.HalfPageUp,
930 k.Chat.HalfPageDown,
931 k.Chat.Home,
932 k.Chat.End,
933 },
934 []key.Binding{
935 k.Chat.Copy,
936 k.Chat.ClearHighlight,
937 },
938 )
939 }
940 default:
941 if m.session == nil {
942 // no session selected
943 binds = append(binds,
944 []key.Binding{
945 commands,
946 k.Models,
947 k.Sessions,
948 },
949 []key.Binding{
950 k.Editor.Newline,
951 k.Editor.AddImage,
952 k.Editor.MentionFile,
953 k.Editor.OpenEditor,
954 },
955 []key.Binding{
956 help,
957 },
958 )
959 }
960 }
961
962 binds = append(binds,
963 []key.Binding{
964 help,
965 k.Quit,
966 },
967 )
968
969 return binds
970}
971
972// updateLayoutAndSize updates the layout and sizes of UI components.
973func (m *UI) updateLayoutAndSize() {
974 m.layout = m.generateLayout(m.width, m.height)
975 m.updateSize()
976}
977
978// updateSize updates the sizes of UI components based on the current layout.
979func (m *UI) updateSize() {
980 // Set help width
981 m.help.SetWidth(m.layout.help.Dx())
982
983 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
984 m.textarea.SetWidth(m.layout.editor.Dx())
985 m.textarea.SetHeight(m.layout.editor.Dy())
986
987 // Handle different app states
988 switch m.state {
989 case uiConfigure, uiInitialize, uiLanding:
990 m.renderHeader(false, m.layout.header.Dx())
991
992 case uiChat:
993 m.renderSidebarLogo(m.layout.sidebar.Dx())
994
995 case uiChatCompact:
996 // TODO: set the width and heigh of the chat component
997 m.renderHeader(true, m.layout.header.Dx())
998 }
999}
1000
1001// generateLayout calculates the layout rectangles for all UI components based
1002// on the current UI state and terminal dimensions.
1003func (m *UI) generateLayout(w, h int) layout {
1004 // The screen area we're working with
1005 area := image.Rect(0, 0, w, h)
1006
1007 // The help height
1008 helpHeight := 1
1009 // The editor height
1010 editorHeight := 5
1011 // The sidebar width
1012 sidebarWidth := 30
1013 // The header height
1014 // TODO: handle compact
1015 headerHeight := 4
1016
1017 var helpKeyMap help.KeyMap = m
1018 if m.help.ShowAll {
1019 for _, row := range helpKeyMap.FullHelp() {
1020 helpHeight = max(helpHeight, len(row))
1021 }
1022 }
1023
1024 // Add app margins
1025 appRect := area
1026 appRect.Min.X += 1
1027 appRect.Min.Y += 1
1028 appRect.Max.X -= 1
1029 appRect.Max.Y -= 1
1030
1031 if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
1032 // extra padding on left and right for these states
1033 appRect.Min.X += 1
1034 appRect.Max.X -= 1
1035 }
1036
1037 appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
1038
1039 layout := layout{
1040 area: area,
1041 help: helpRect,
1042 }
1043
1044 // Handle different app states
1045 switch m.state {
1046 case uiConfigure, uiInitialize:
1047 // Layout
1048 //
1049 // header
1050 // ------
1051 // main
1052 // ------
1053 // help
1054
1055 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1056 layout.header = headerRect
1057 layout.main = mainRect
1058
1059 case uiLanding:
1060 // Layout
1061 //
1062 // header
1063 // ------
1064 // main
1065 // ------
1066 // editor
1067 // ------
1068 // help
1069 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1070 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1071 // Remove extra padding from editor (but keep it for header and main)
1072 editorRect.Min.X -= 1
1073 editorRect.Max.X += 1
1074 layout.header = headerRect
1075 layout.main = mainRect
1076 layout.editor = editorRect
1077
1078 case uiChat:
1079 // Layout
1080 //
1081 // ------|---
1082 // main |
1083 // ------| side
1084 // editor|
1085 // ----------
1086 // help
1087
1088 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
1089 // Add padding left
1090 sideRect.Min.X += 1
1091 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1092 mainRect.Max.X -= 1 // Add padding right
1093 // Add bottom margin to main
1094 mainRect.Max.Y -= 1
1095 layout.sidebar = sideRect
1096 layout.main = mainRect
1097 layout.editor = editorRect
1098
1099 case uiChatCompact:
1100 // Layout
1101 //
1102 // compact-header
1103 // ------
1104 // main
1105 // ------
1106 // editor
1107 // ------
1108 // help
1109 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
1110 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1111 layout.header = headerRect
1112 layout.main = mainRect
1113 layout.editor = editorRect
1114 }
1115
1116 if !layout.editor.Empty() {
1117 // Add editor margins 1 top and bottom
1118 layout.editor.Min.Y += 1
1119 layout.editor.Max.Y -= 1
1120 }
1121
1122 return layout
1123}
1124
1125// layout defines the positioning of UI elements.
1126type layout struct {
1127 // area is the overall available area.
1128 area uv.Rectangle
1129
1130 // header is the header shown in special cases
1131 // e.x when the sidebar is collapsed
1132 // or when in the landing page
1133 // or in init/config
1134 header uv.Rectangle
1135
1136 // main is the area for the main pane. (e.x chat, configure, landing)
1137 main uv.Rectangle
1138
1139 // editor is the area for the editor pane.
1140 editor uv.Rectangle
1141
1142 // sidebar is the area for the sidebar.
1143 sidebar uv.Rectangle
1144
1145 // help is the area for the help view.
1146 help uv.Rectangle
1147}
1148
1149func (m *UI) openEditor(value string) tea.Cmd {
1150 editor := os.Getenv("EDITOR")
1151 if editor == "" {
1152 // Use platform-appropriate default editor
1153 if runtime.GOOS == "windows" {
1154 editor = "notepad"
1155 } else {
1156 editor = "nvim"
1157 }
1158 }
1159
1160 tmpfile, err := os.CreateTemp("", "msg_*.md")
1161 if err != nil {
1162 return uiutil.ReportError(err)
1163 }
1164 defer tmpfile.Close() //nolint:errcheck
1165 if _, err := tmpfile.WriteString(value); err != nil {
1166 return uiutil.ReportError(err)
1167 }
1168 cmdStr := editor + " " + tmpfile.Name()
1169 return uiutil.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
1170 if err != nil {
1171 return uiutil.ReportError(err)
1172 }
1173 content, err := os.ReadFile(tmpfile.Name())
1174 if err != nil {
1175 return uiutil.ReportError(err)
1176 }
1177 if len(content) == 0 {
1178 return uiutil.ReportWarn("Message is empty")
1179 }
1180 os.Remove(tmpfile.Name())
1181 return openEditorMsg{
1182 Text: strings.TrimSpace(string(content)),
1183 }
1184 })
1185}
1186
1187// setEditorPrompt configures the textarea prompt function based on whether
1188// yolo mode is enabled.
1189func (m *UI) setEditorPrompt(yolo bool) {
1190 if yolo {
1191 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
1192 return
1193 }
1194 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
1195}
1196
1197// normalPromptFunc returns the normal editor prompt style (" > " on first
1198// line, "::: " on subsequent lines).
1199func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
1200 t := m.com.Styles
1201 if info.LineNumber == 0 {
1202 if info.Focused {
1203 return " > "
1204 }
1205 return "::: "
1206 }
1207 if info.Focused {
1208 return t.EditorPromptNormalFocused.Render()
1209 }
1210 return t.EditorPromptNormalBlurred.Render()
1211}
1212
1213// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
1214// and colored dots.
1215func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
1216 t := m.com.Styles
1217 if info.LineNumber == 0 {
1218 if info.Focused {
1219 return t.EditorPromptYoloIconFocused.Render()
1220 } else {
1221 return t.EditorPromptYoloIconBlurred.Render()
1222 }
1223 }
1224 if info.Focused {
1225 return t.EditorPromptYoloDotsFocused.Render()
1226 }
1227 return t.EditorPromptYoloDotsBlurred.Render()
1228}
1229
1230var readyPlaceholders = [...]string{
1231 "Ready!",
1232 "Ready...",
1233 "Ready?",
1234 "Ready for instructions",
1235}
1236
1237var workingPlaceholders = [...]string{
1238 "Working!",
1239 "Working...",
1240 "Brrrrr...",
1241 "Prrrrrrrr...",
1242 "Processing...",
1243 "Thinking...",
1244}
1245
1246// randomizePlaceholders selects random placeholder text for the textarea's
1247// ready and working states.
1248func (m *UI) randomizePlaceholders() {
1249 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
1250 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
1251}
1252
1253// renderHeader renders and caches the header logo at the specified width.
1254func (m *UI) renderHeader(compact bool, width int) {
1255 // TODO: handle the compact case differently
1256 m.header = renderLogo(m.com.Styles, compact, width)
1257}
1258
1259// renderSidebarLogo renders and caches the sidebar logo at the specified
1260// width.
1261func (m *UI) renderSidebarLogo(width int) {
1262 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
1263}
1264
1265// sendMessage sends a message with the given content and attachments.
1266func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd {
1267 if m.com.App.AgentCoordinator == nil {
1268 return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
1269 }
1270
1271 var cmds []tea.Cmd
1272 if m.session == nil || m.session.ID == "" {
1273 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
1274 if err != nil {
1275 return uiutil.ReportError(err)
1276 }
1277 m.state = uiChat
1278 m.session = &newSession
1279 cmds = append(cmds, m.loadSession(newSession.ID))
1280 }
1281
1282 // Capture session ID to avoid race with main goroutine updating m.session.
1283 sessionID := m.session.ID
1284 cmds = append(cmds, func() tea.Msg {
1285 _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
1286 if err != nil {
1287 isCancelErr := errors.Is(err, context.Canceled)
1288 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
1289 if isCancelErr || isPermissionErr {
1290 return nil
1291 }
1292 return uiutil.InfoMsg{
1293 Type: uiutil.InfoTypeError,
1294 Msg: err.Error(),
1295 }
1296 }
1297 return nil
1298 })
1299 return tea.Batch(cmds...)
1300}
1301
1302// openQuitDialog opens the quit confirmation dialog.
1303func (m *UI) openQuitDialog() tea.Cmd {
1304 if m.dialog.ContainsDialog(dialog.QuitID) {
1305 // Bring to front
1306 m.dialog.BringToFront(dialog.QuitID)
1307 return nil
1308 }
1309
1310 quitDialog := dialog.NewQuit(m.com)
1311 m.dialog.OpenDialog(quitDialog)
1312 return nil
1313}
1314
1315// openCommandsDialog opens the commands dialog.
1316func (m *UI) openCommandsDialog() tea.Cmd {
1317 if m.dialog.ContainsDialog(dialog.CommandsID) {
1318 // Bring to front
1319 m.dialog.BringToFront(dialog.CommandsID)
1320 return nil
1321 }
1322
1323 sessionID := ""
1324 if m.session != nil {
1325 sessionID = m.session.ID
1326 }
1327
1328 commands, err := dialog.NewCommands(m.com, sessionID)
1329 if err != nil {
1330 return uiutil.ReportError(err)
1331 }
1332
1333 // TODO: Get. Rid. Of. Magic numbers!
1334 commands.SetSize(min(120, m.width-8), 30)
1335 m.dialog.OpenDialog(commands)
1336
1337 return nil
1338}
1339
1340// openSessionsDialog opens the sessions dialog with the given sessions.
1341func (m *UI) openSessionsDialog(sessions []session.Session) tea.Cmd {
1342 if m.dialog.ContainsDialog(dialog.SessionsID) {
1343 // Bring to front
1344 m.dialog.BringToFront(dialog.SessionsID)
1345 return nil
1346 }
1347
1348 dialog := dialog.NewSessions(m.com, sessions...)
1349 // TODO: Get. Rid. Of. Magic numbers!
1350 dialog.SetSize(min(120, m.width-8), 30)
1351 m.dialog.OpenDialog(dialog)
1352
1353 return nil
1354}
1355
1356// listSessions is a [tea.Cmd] that lists all sessions and returns them in a
1357// [listSessionsMsg].
1358func (m *UI) listSessions() tea.Msg {
1359 allSessions, _ := m.com.App.Sessions.List(context.TODO())
1360 return listSessionsMsg{sessions: allSessions}
1361}
1362
1363// handlePasteMsg handles a paste message.
1364func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
1365 if m.focus != uiFocusEditor {
1366 return nil
1367 }
1368
1369 var cmd tea.Cmd
1370 path := strings.ReplaceAll(msg.Content, "\\ ", " ")
1371 // try to get an image
1372 path, err := filepath.Abs(strings.TrimSpace(path))
1373 if err != nil {
1374 m.textarea, cmd = m.textarea.Update(msg)
1375 return cmd
1376 }
1377 isAllowedType := false
1378 for _, ext := range filepicker.AllowedTypes {
1379 if strings.HasSuffix(path, ext) {
1380 isAllowedType = true
1381 break
1382 }
1383 }
1384 if !isAllowedType {
1385 m.textarea, cmd = m.textarea.Update(msg)
1386 return cmd
1387 }
1388 tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
1389 if tooBig {
1390 m.textarea, cmd = m.textarea.Update(msg)
1391 return cmd
1392 }
1393
1394 content, err := os.ReadFile(path)
1395 if err != nil {
1396 m.textarea, cmd = m.textarea.Update(msg)
1397 return cmd
1398 }
1399 mimeBufferSize := min(512, len(content))
1400 mimeType := http.DetectContentType(content[:mimeBufferSize])
1401 fileName := filepath.Base(path)
1402 attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
1403 return uiutil.CmdHandler(filepicker.FilePickedMsg{
1404 Attachment: attachment,
1405 })
1406}
1407
1408// renderLogo renders the Crush logo with the given styles and dimensions.
1409func renderLogo(t *styles.Styles, compact bool, width int) string {
1410 return logo.Render(version.Version, compact, logo.Opts{
1411 FieldColor: t.LogoFieldColor,
1412 TitleColorA: t.LogoTitleColorA,
1413 TitleColorB: t.LogoTitleColorB,
1414 CharmColor: t.LogoCharmColor,
1415 VersionColor: t.LogoVersionColor,
1416 Width: width,
1417 })
1418}