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