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