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