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