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.Expand):
581 m.chat.ToggleExpandedSelectedItem()
582 case key.Matches(msg, m.keyMap.Chat.Up):
583 if cmd := m.chat.ScrollBy(-1); cmd != nil {
584 cmds = append(cmds, cmd)
585 }
586 if !m.chat.SelectedItemInView() {
587 m.chat.SelectPrev()
588 if cmd := m.chat.ScrollToSelected(); cmd != nil {
589 cmds = append(cmds, cmd)
590 }
591 }
592 case key.Matches(msg, m.keyMap.Chat.Down):
593 if cmd := m.chat.ScrollBy(1); cmd != nil {
594 cmds = append(cmds, cmd)
595 }
596 if !m.chat.SelectedItemInView() {
597 m.chat.SelectNext()
598 if cmd := m.chat.ScrollToSelected(); cmd != nil {
599 cmds = append(cmds, cmd)
600 }
601 }
602 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
603 m.chat.SelectPrev()
604 if cmd := m.chat.ScrollToSelected(); cmd != nil {
605 cmds = append(cmds, cmd)
606 }
607 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
608 m.chat.SelectNext()
609 if cmd := m.chat.ScrollToSelected(); cmd != nil {
610 cmds = append(cmds, cmd)
611 }
612 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
613 if cmd := m.chat.ScrollBy(-m.chat.Height() / 2); cmd != nil {
614 cmds = append(cmds, cmd)
615 }
616 m.chat.SelectFirstInView()
617 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
618 if cmd := m.chat.ScrollBy(m.chat.Height() / 2); cmd != nil {
619 cmds = append(cmds, cmd)
620 }
621 m.chat.SelectLastInView()
622 case key.Matches(msg, m.keyMap.Chat.PageUp):
623 if cmd := m.chat.ScrollBy(-m.chat.Height()); cmd != nil {
624 cmds = append(cmds, cmd)
625 }
626 m.chat.SelectFirstInView()
627 case key.Matches(msg, m.keyMap.Chat.PageDown):
628 if cmd := m.chat.ScrollBy(m.chat.Height()); cmd != nil {
629 cmds = append(cmds, cmd)
630 }
631 m.chat.SelectLastInView()
632 case key.Matches(msg, m.keyMap.Chat.Home):
633 if cmd := m.chat.ScrollToTop(); cmd != nil {
634 cmds = append(cmds, cmd)
635 }
636 m.chat.SelectFirst()
637 case key.Matches(msg, m.keyMap.Chat.End):
638 if cmd := m.chat.ScrollToBottom(); cmd != nil {
639 cmds = append(cmds, cmd)
640 }
641 m.chat.SelectLast()
642 default:
643 handleGlobalKeys(msg)
644 }
645 default:
646 handleGlobalKeys(msg)
647 }
648 default:
649 handleGlobalKeys(msg)
650 }
651
652 return tea.Batch(cmds...)
653}
654
655// Draw implements [tea.Layer] and draws the UI model.
656func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
657 layout := m.generateLayout(area.Dx(), area.Dy())
658
659 if m.layout != layout {
660 m.layout = layout
661 m.updateSize()
662 }
663
664 // Clear the screen first
665 screen.Clear(scr)
666
667 switch m.state {
668 case uiConfigure:
669 header := uv.NewStyledString(m.header)
670 header.Draw(scr, layout.header)
671
672 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
673 Height(layout.main.Dy()).
674 Background(lipgloss.ANSIColor(rand.Intn(256))).
675 Render(" Configure ")
676 main := uv.NewStyledString(mainView)
677 main.Draw(scr, layout.main)
678
679 case uiInitialize:
680 header := uv.NewStyledString(m.header)
681 header.Draw(scr, layout.header)
682
683 main := uv.NewStyledString(m.initializeView())
684 main.Draw(scr, layout.main)
685
686 case uiLanding:
687 header := uv.NewStyledString(m.header)
688 header.Draw(scr, layout.header)
689 main := uv.NewStyledString(m.landingView())
690 main.Draw(scr, layout.main)
691
692 editor := uv.NewStyledString(m.textarea.View())
693 editor.Draw(scr, layout.editor)
694
695 case uiChat:
696 m.chat.Draw(scr, layout.main)
697
698 header := uv.NewStyledString(m.header)
699 header.Draw(scr, layout.header)
700 m.drawSidebar(scr, layout.sidebar)
701
702 editor := uv.NewStyledString(m.textarea.View())
703 editor.Draw(scr, layout.editor)
704
705 case uiChatCompact:
706 header := uv.NewStyledString(m.header)
707 header.Draw(scr, layout.header)
708
709 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
710 Height(layout.main.Dy()).
711 Background(lipgloss.ANSIColor(rand.Intn(256))).
712 Render(" Compact Chat Messages ")
713 main := uv.NewStyledString(mainView)
714 main.Draw(scr, layout.main)
715
716 editor := uv.NewStyledString(m.textarea.View())
717 editor.Draw(scr, layout.editor)
718 }
719
720 // Add help layer
721 help := uv.NewStyledString(m.help.View(m))
722 help.Draw(scr, layout.help)
723
724 // Debugging rendering (visually see when the tui rerenders)
725 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
726 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
727 debug := uv.NewStyledString(debugView.String())
728 debug.Draw(scr, image.Rectangle{
729 Min: image.Pt(4, 1),
730 Max: image.Pt(8, 3),
731 })
732 }
733
734 // This needs to come last to overlay on top of everything
735 if m.dialog.HasDialogs() {
736 m.dialog.Draw(scr, area)
737 }
738}
739
740// Cursor returns the cursor position and properties for the UI model. It
741// returns nil if the cursor should not be shown.
742func (m *UI) Cursor() *tea.Cursor {
743 if m.layout.editor.Dy() <= 0 {
744 // Don't show cursor if editor is not visible
745 return nil
746 }
747 if m.dialog.HasDialogs() {
748 if front := m.dialog.DialogLast(); front != nil {
749 c, ok := front.(uiutil.Cursor)
750 if ok {
751 cur := c.Cursor()
752 if cur != nil {
753 pos := m.dialog.CenterPosition(m.layout.area, front.ID())
754 cur.X += pos.Min.X
755 cur.Y += pos.Min.Y
756 return cur
757 }
758 }
759 }
760 return nil
761 }
762 switch m.focus {
763 case uiFocusEditor:
764 if m.textarea.Focused() {
765 cur := m.textarea.Cursor()
766 cur.X++ // Adjust for app margins
767 cur.Y += m.layout.editor.Min.Y
768 return cur
769 }
770 }
771 return nil
772}
773
774// View renders the UI model's view.
775func (m *UI) View() tea.View {
776 var v tea.View
777 v.AltScreen = true
778 v.BackgroundColor = m.com.Styles.Background
779 v.Cursor = m.Cursor()
780 v.MouseMode = tea.MouseModeCellMotion
781
782 canvas := uv.NewScreenBuffer(m.width, m.height)
783 m.Draw(canvas, canvas.Bounds())
784
785 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
786 contentLines := strings.Split(content, "\n")
787 for i, line := range contentLines {
788 // Trim trailing spaces for concise rendering
789 contentLines[i] = strings.TrimRight(line, " ")
790 }
791
792 content = strings.Join(contentLines, "\n")
793
794 v.Content = content
795 if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
796 // HACK: use a random percentage to prevent ghostty from hiding it
797 // after a timeout.
798 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
799 }
800
801 return v
802}
803
804// ShortHelp implements [help.KeyMap].
805func (m *UI) ShortHelp() []key.Binding {
806 var binds []key.Binding
807 k := &m.keyMap
808 tab := k.Tab
809 commands := k.Commands
810 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
811 commands.SetHelp("/ or ctrl+p", "commands")
812 }
813
814 switch m.state {
815 case uiInitialize:
816 binds = append(binds, k.Quit)
817 case uiChat:
818 if m.focus == uiFocusEditor {
819 tab.SetHelp("tab", "focus chat")
820 } else {
821 tab.SetHelp("tab", "focus editor")
822 }
823
824 binds = append(binds,
825 tab,
826 commands,
827 k.Models,
828 )
829
830 switch m.focus {
831 case uiFocusEditor:
832 binds = append(binds,
833 k.Editor.Newline,
834 )
835 case uiFocusMain:
836 binds = append(binds,
837 k.Chat.UpDown,
838 k.Chat.UpDownOneItem,
839 k.Chat.PageUp,
840 k.Chat.PageDown,
841 k.Chat.Copy,
842 )
843 }
844 default:
845 // TODO: other states
846 // if m.session == nil {
847 // no session selected
848 binds = append(binds,
849 commands,
850 k.Models,
851 k.Editor.Newline,
852 )
853 }
854
855 binds = append(binds,
856 k.Quit,
857 k.Help,
858 )
859
860 return binds
861}
862
863// FullHelp implements [help.KeyMap].
864func (m *UI) FullHelp() [][]key.Binding {
865 var binds [][]key.Binding
866 k := &m.keyMap
867 help := k.Help
868 help.SetHelp("ctrl+g", "less")
869 hasAttachments := false // TODO: implement attachments
870 hasSession := m.session != nil && m.session.ID != ""
871 commands := k.Commands
872 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
873 commands.SetHelp("/ or ctrl+p", "commands")
874 }
875
876 switch m.state {
877 case uiInitialize:
878 binds = append(binds,
879 []key.Binding{
880 k.Quit,
881 })
882 case uiChat:
883 mainBinds := []key.Binding{}
884 tab := k.Tab
885 if m.focus == uiFocusEditor {
886 tab.SetHelp("tab", "focus chat")
887 } else {
888 tab.SetHelp("tab", "focus editor")
889 }
890
891 mainBinds = append(mainBinds,
892 tab,
893 commands,
894 k.Models,
895 k.Sessions,
896 )
897 if hasSession {
898 mainBinds = append(mainBinds, k.Chat.NewSession)
899 }
900
901 binds = append(binds, mainBinds)
902
903 switch m.focus {
904 case uiFocusEditor:
905 binds = append(binds,
906 []key.Binding{
907 k.Editor.Newline,
908 k.Editor.AddImage,
909 k.Editor.MentionFile,
910 k.Editor.OpenEditor,
911 },
912 )
913 if hasAttachments {
914 binds = append(binds,
915 []key.Binding{
916 k.Editor.AttachmentDeleteMode,
917 k.Editor.DeleteAllAttachments,
918 k.Editor.Escape,
919 },
920 )
921 }
922 case uiFocusMain:
923 binds = append(binds,
924 []key.Binding{
925 k.Chat.UpDown,
926 k.Chat.UpDownOneItem,
927 k.Chat.PageUp,
928 k.Chat.PageDown,
929 },
930 []key.Binding{
931 k.Chat.HalfPageUp,
932 k.Chat.HalfPageDown,
933 k.Chat.Home,
934 k.Chat.End,
935 },
936 []key.Binding{
937 k.Chat.Copy,
938 k.Chat.ClearHighlight,
939 },
940 )
941 }
942 default:
943 if m.session == nil {
944 // no session selected
945 binds = append(binds,
946 []key.Binding{
947 commands,
948 k.Models,
949 k.Sessions,
950 },
951 []key.Binding{
952 k.Editor.Newline,
953 k.Editor.AddImage,
954 k.Editor.MentionFile,
955 k.Editor.OpenEditor,
956 },
957 []key.Binding{
958 help,
959 },
960 )
961 }
962 }
963
964 binds = append(binds,
965 []key.Binding{
966 help,
967 k.Quit,
968 },
969 )
970
971 return binds
972}
973
974// updateLayoutAndSize updates the layout and sizes of UI components.
975func (m *UI) updateLayoutAndSize() {
976 m.layout = m.generateLayout(m.width, m.height)
977 m.updateSize()
978}
979
980// updateSize updates the sizes of UI components based on the current layout.
981func (m *UI) updateSize() {
982 // Set help width
983 m.help.SetWidth(m.layout.help.Dx())
984
985 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
986 m.textarea.SetWidth(m.layout.editor.Dx())
987 m.textarea.SetHeight(m.layout.editor.Dy())
988
989 // Handle different app states
990 switch m.state {
991 case uiConfigure, uiInitialize, uiLanding:
992 m.renderHeader(false, m.layout.header.Dx())
993
994 case uiChat:
995 m.renderSidebarLogo(m.layout.sidebar.Dx())
996
997 case uiChatCompact:
998 // TODO: set the width and heigh of the chat component
999 m.renderHeader(true, m.layout.header.Dx())
1000 }
1001}
1002
1003// generateLayout calculates the layout rectangles for all UI components based
1004// on the current UI state and terminal dimensions.
1005func (m *UI) generateLayout(w, h int) layout {
1006 // The screen area we're working with
1007 area := image.Rect(0, 0, w, h)
1008
1009 // The help height
1010 helpHeight := 1
1011 // The editor height
1012 editorHeight := 5
1013 // The sidebar width
1014 sidebarWidth := 30
1015 // The header height
1016 // TODO: handle compact
1017 headerHeight := 4
1018
1019 var helpKeyMap help.KeyMap = m
1020 if m.help.ShowAll {
1021 for _, row := range helpKeyMap.FullHelp() {
1022 helpHeight = max(helpHeight, len(row))
1023 }
1024 }
1025
1026 // Add app margins
1027 appRect := area
1028 appRect.Min.X += 1
1029 appRect.Min.Y += 1
1030 appRect.Max.X -= 1
1031 appRect.Max.Y -= 1
1032
1033 if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
1034 // extra padding on left and right for these states
1035 appRect.Min.X += 1
1036 appRect.Max.X -= 1
1037 }
1038
1039 appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
1040
1041 layout := layout{
1042 area: area,
1043 help: helpRect,
1044 }
1045
1046 // Handle different app states
1047 switch m.state {
1048 case uiConfigure, uiInitialize:
1049 // Layout
1050 //
1051 // header
1052 // ------
1053 // main
1054 // ------
1055 // help
1056
1057 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1058 layout.header = headerRect
1059 layout.main = mainRect
1060
1061 case uiLanding:
1062 // Layout
1063 //
1064 // header
1065 // ------
1066 // main
1067 // ------
1068 // editor
1069 // ------
1070 // help
1071 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1072 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1073 // Remove extra padding from editor (but keep it for header and main)
1074 editorRect.Min.X -= 1
1075 editorRect.Max.X += 1
1076 layout.header = headerRect
1077 layout.main = mainRect
1078 layout.editor = editorRect
1079
1080 case uiChat:
1081 // Layout
1082 //
1083 // ------|---
1084 // main |
1085 // ------| side
1086 // editor|
1087 // ----------
1088 // help
1089
1090 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
1091 // Add padding left
1092 sideRect.Min.X += 1
1093 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1094 mainRect.Max.X -= 1 // Add padding right
1095 // Add bottom margin to main
1096 mainRect.Max.Y -= 1
1097 layout.sidebar = sideRect
1098 layout.main = mainRect
1099 layout.editor = editorRect
1100
1101 case uiChatCompact:
1102 // Layout
1103 //
1104 // compact-header
1105 // ------
1106 // main
1107 // ------
1108 // editor
1109 // ------
1110 // help
1111 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
1112 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1113 layout.header = headerRect
1114 layout.main = mainRect
1115 layout.editor = editorRect
1116 }
1117
1118 if !layout.editor.Empty() {
1119 // Add editor margins 1 top and bottom
1120 layout.editor.Min.Y += 1
1121 layout.editor.Max.Y -= 1
1122 }
1123
1124 return layout
1125}
1126
1127// layout defines the positioning of UI elements.
1128type layout struct {
1129 // area is the overall available area.
1130 area uv.Rectangle
1131
1132 // header is the header shown in special cases
1133 // e.x when the sidebar is collapsed
1134 // or when in the landing page
1135 // or in init/config
1136 header uv.Rectangle
1137
1138 // main is the area for the main pane. (e.x chat, configure, landing)
1139 main uv.Rectangle
1140
1141 // editor is the area for the editor pane.
1142 editor uv.Rectangle
1143
1144 // sidebar is the area for the sidebar.
1145 sidebar uv.Rectangle
1146
1147 // help is the area for the help view.
1148 help uv.Rectangle
1149}
1150
1151func (m *UI) openEditor(value string) tea.Cmd {
1152 editor := os.Getenv("EDITOR")
1153 if editor == "" {
1154 // Use platform-appropriate default editor
1155 if runtime.GOOS == "windows" {
1156 editor = "notepad"
1157 } else {
1158 editor = "nvim"
1159 }
1160 }
1161
1162 tmpfile, err := os.CreateTemp("", "msg_*.md")
1163 if err != nil {
1164 return uiutil.ReportError(err)
1165 }
1166 defer tmpfile.Close() //nolint:errcheck
1167 if _, err := tmpfile.WriteString(value); err != nil {
1168 return uiutil.ReportError(err)
1169 }
1170 cmdStr := editor + " " + tmpfile.Name()
1171 return uiutil.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
1172 if err != nil {
1173 return uiutil.ReportError(err)
1174 }
1175 content, err := os.ReadFile(tmpfile.Name())
1176 if err != nil {
1177 return uiutil.ReportError(err)
1178 }
1179 if len(content) == 0 {
1180 return uiutil.ReportWarn("Message is empty")
1181 }
1182 os.Remove(tmpfile.Name())
1183 return openEditorMsg{
1184 Text: strings.TrimSpace(string(content)),
1185 }
1186 })
1187}
1188
1189// setEditorPrompt configures the textarea prompt function based on whether
1190// yolo mode is enabled.
1191func (m *UI) setEditorPrompt(yolo bool) {
1192 if yolo {
1193 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
1194 return
1195 }
1196 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
1197}
1198
1199// normalPromptFunc returns the normal editor prompt style (" > " on first
1200// line, "::: " on subsequent lines).
1201func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
1202 t := m.com.Styles
1203 if info.LineNumber == 0 {
1204 if info.Focused {
1205 return " > "
1206 }
1207 return "::: "
1208 }
1209 if info.Focused {
1210 return t.EditorPromptNormalFocused.Render()
1211 }
1212 return t.EditorPromptNormalBlurred.Render()
1213}
1214
1215// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
1216// and colored dots.
1217func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
1218 t := m.com.Styles
1219 if info.LineNumber == 0 {
1220 if info.Focused {
1221 return t.EditorPromptYoloIconFocused.Render()
1222 } else {
1223 return t.EditorPromptYoloIconBlurred.Render()
1224 }
1225 }
1226 if info.Focused {
1227 return t.EditorPromptYoloDotsFocused.Render()
1228 }
1229 return t.EditorPromptYoloDotsBlurred.Render()
1230}
1231
1232var readyPlaceholders = [...]string{
1233 "Ready!",
1234 "Ready...",
1235 "Ready?",
1236 "Ready for instructions",
1237}
1238
1239var workingPlaceholders = [...]string{
1240 "Working!",
1241 "Working...",
1242 "Brrrrr...",
1243 "Prrrrrrrr...",
1244 "Processing...",
1245 "Thinking...",
1246}
1247
1248// randomizePlaceholders selects random placeholder text for the textarea's
1249// ready and working states.
1250func (m *UI) randomizePlaceholders() {
1251 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
1252 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
1253}
1254
1255// renderHeader renders and caches the header logo at the specified width.
1256func (m *UI) renderHeader(compact bool, width int) {
1257 // TODO: handle the compact case differently
1258 m.header = renderLogo(m.com.Styles, compact, width)
1259}
1260
1261// renderSidebarLogo renders and caches the sidebar logo at the specified
1262// width.
1263func (m *UI) renderSidebarLogo(width int) {
1264 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
1265}
1266
1267// sendMessage sends a message with the given content and attachments.
1268func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd {
1269 if m.com.App.AgentCoordinator == nil {
1270 return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
1271 }
1272
1273 var cmds []tea.Cmd
1274 if m.session == nil || m.session.ID == "" {
1275 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
1276 if err != nil {
1277 return uiutil.ReportError(err)
1278 }
1279 m.state = uiChat
1280 m.session = &newSession
1281 cmds = append(cmds, m.loadSession(newSession.ID))
1282 }
1283
1284 // Capture session ID to avoid race with main goroutine updating m.session.
1285 sessionID := m.session.ID
1286 cmds = append(cmds, func() tea.Msg {
1287 _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
1288 if err != nil {
1289 isCancelErr := errors.Is(err, context.Canceled)
1290 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
1291 if isCancelErr || isPermissionErr {
1292 return nil
1293 }
1294 return uiutil.InfoMsg{
1295 Type: uiutil.InfoTypeError,
1296 Msg: err.Error(),
1297 }
1298 }
1299 return nil
1300 })
1301 return tea.Batch(cmds...)
1302}
1303
1304// openQuitDialog opens the quit confirmation dialog.
1305func (m *UI) openQuitDialog() tea.Cmd {
1306 if m.dialog.ContainsDialog(dialog.QuitID) {
1307 // Bring to front
1308 m.dialog.BringToFront(dialog.QuitID)
1309 return nil
1310 }
1311
1312 quitDialog := dialog.NewQuit(m.com)
1313 m.dialog.OpenDialog(quitDialog)
1314 return nil
1315}
1316
1317// openCommandsDialog opens the commands dialog.
1318func (m *UI) openCommandsDialog() tea.Cmd {
1319 if m.dialog.ContainsDialog(dialog.CommandsID) {
1320 // Bring to front
1321 m.dialog.BringToFront(dialog.CommandsID)
1322 return nil
1323 }
1324
1325 sessionID := ""
1326 if m.session != nil {
1327 sessionID = m.session.ID
1328 }
1329
1330 commands, err := dialog.NewCommands(m.com, sessionID)
1331 if err != nil {
1332 return uiutil.ReportError(err)
1333 }
1334
1335 // TODO: Get. Rid. Of. Magic numbers!
1336 commands.SetSize(min(120, m.width-8), 30)
1337 m.dialog.OpenDialog(commands)
1338
1339 return nil
1340}
1341
1342// openSessionsDialog opens the sessions dialog with the given sessions.
1343func (m *UI) openSessionsDialog(sessions []session.Session) tea.Cmd {
1344 if m.dialog.ContainsDialog(dialog.SessionsID) {
1345 // Bring to front
1346 m.dialog.BringToFront(dialog.SessionsID)
1347 return nil
1348 }
1349
1350 dialog := dialog.NewSessions(m.com, sessions...)
1351 // TODO: Get. Rid. Of. Magic numbers!
1352 dialog.SetSize(min(120, m.width-8), 30)
1353 m.dialog.OpenDialog(dialog)
1354
1355 return nil
1356}
1357
1358// listSessions is a [tea.Cmd] that lists all sessions and returns them in a
1359// [listSessionsMsg].
1360func (m *UI) listSessions() tea.Msg {
1361 allSessions, _ := m.com.App.Sessions.List(context.TODO())
1362 return listSessionsMsg{sessions: allSessions}
1363}
1364
1365// handlePasteMsg handles a paste message.
1366func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
1367 if m.focus != uiFocusEditor {
1368 return nil
1369 }
1370
1371 var cmd tea.Cmd
1372 path := strings.ReplaceAll(msg.Content, "\\ ", " ")
1373 // try to get an image
1374 path, err := filepath.Abs(strings.TrimSpace(path))
1375 if err != nil {
1376 m.textarea, cmd = m.textarea.Update(msg)
1377 return cmd
1378 }
1379 isAllowedType := false
1380 for _, ext := range filepicker.AllowedTypes {
1381 if strings.HasSuffix(path, ext) {
1382 isAllowedType = true
1383 break
1384 }
1385 }
1386 if !isAllowedType {
1387 m.textarea, cmd = m.textarea.Update(msg)
1388 return cmd
1389 }
1390 tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
1391 if tooBig {
1392 m.textarea, cmd = m.textarea.Update(msg)
1393 return cmd
1394 }
1395
1396 content, err := os.ReadFile(path)
1397 if err != nil {
1398 m.textarea, cmd = m.textarea.Update(msg)
1399 return cmd
1400 }
1401 mimeBufferSize := min(512, len(content))
1402 mimeType := http.DetectContentType(content[:mimeBufferSize])
1403 fileName := filepath.Base(path)
1404 attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
1405 return uiutil.CmdHandler(filepicker.FilePickedMsg{
1406 Attachment: attachment,
1407 })
1408}
1409
1410// renderLogo renders the Crush logo with the given styles and dimensions.
1411func renderLogo(t *styles.Styles, compact bool, width int) string {
1412 return logo.Render(version.Version, compact, logo.Opts{
1413 FieldColor: t.LogoFieldColor,
1414 TitleColorA: t.LogoTitleColorA,
1415 TitleColorB: t.LogoTitleColorB,
1416 CharmColor: t.LogoCharmColor,
1417 VersionColor: t.LogoVersionColor,
1418 Width: width,
1419 })
1420}