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// UI represents the main user interface model.
68type UI struct {
69 com *common.Common
70 session *session.Session
71 sessionFiles []SessionFile
72
73 // The width and height of the terminal in cells.
74 width int
75 height int
76 layout layout
77
78 focus uiFocusState
79 state uiState
80
81 keyMap KeyMap
82 keyenh tea.KeyboardEnhancementsMsg
83
84 dialog *dialog.Overlay
85 help help.Model
86
87 // header is the last cached header logo
88 header string
89
90 // sendProgressBar instructs the TUI to send progress bar updates to the
91 // terminal.
92 sendProgressBar bool
93
94 // QueryVersion instructs the TUI to query for the terminal version when it
95 // starts.
96 QueryVersion bool
97
98 // Editor components
99 textarea textarea.Model
100
101 attachments []message.Attachment // TODO: Implement attachments
102
103 readyPlaceholder string
104 workingPlaceholder string
105
106 // Chat components
107 chat *Chat
108
109 // onboarding state
110 onboarding struct {
111 yesInitializeSelected bool
112 }
113
114 // lsp
115 lspStates map[string]app.LSPClientInfo
116
117 // mcp
118 mcpStates map[string]mcp.ClientInfo
119
120 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
121 sidebarLogo string
122}
123
124// New creates a new instance of the [UI] model.
125func New(com *common.Common) *UI {
126 // Editor components
127 ta := textarea.New()
128 ta.SetStyles(com.Styles.TextArea)
129 ta.ShowLineNumbers = false
130 ta.CharLimit = -1
131 ta.SetVirtualCursor(false)
132 ta.Focus()
133
134 ch := NewChat(com)
135
136 ui := &UI{
137 com: com,
138 dialog: dialog.NewOverlay(),
139 keyMap: DefaultKeyMap(),
140 help: help.New(),
141 focus: uiFocusNone,
142 state: uiConfigure,
143 textarea: ta,
144 chat: ch,
145 }
146
147 // set onboarding state defaults
148 ui.onboarding.yesInitializeSelected = true
149
150 // If no provider is configured show the user the provider list
151 if !com.Config().IsConfigured() {
152 ui.state = uiConfigure
153 // if the project needs initialization show the user the question
154 } else if n, _ := config.ProjectNeedsInitialization(); n {
155 ui.state = uiInitialize
156 // otherwise go to the landing UI
157 } else {
158 ui.state = uiLanding
159 ui.focus = uiFocusEditor
160 }
161
162 ui.setEditorPrompt(false)
163 ui.randomizePlaceholders()
164 ui.textarea.Placeholder = ui.readyPlaceholder
165 ui.help.Styles = com.Styles.Help
166
167 return ui
168}
169
170// Init initializes the UI model.
171func (m *UI) Init() tea.Cmd {
172 var cmds []tea.Cmd
173 if m.QueryVersion {
174 cmds = append(cmds, tea.RequestTerminalVersion)
175 }
176 return tea.Batch(cmds...)
177}
178
179// Update handles updates to the UI model.
180func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
181 var cmds []tea.Cmd
182 switch msg := msg.(type) {
183 case tea.EnvMsg:
184 // Is this Windows Terminal?
185 if !m.sendProgressBar {
186 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
187 }
188 case loadSessionMsg:
189 m.state = uiChat
190 m.session = msg.session
191 m.sessionFiles = msg.files
192 msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
193 if err != nil {
194 cmds = append(cmds, uiutil.ReportError(err))
195 break
196 }
197 if cmd := m.setSessionMessages(msgs); cmd != nil {
198 cmds = append(cmds, cmd)
199 }
200
201 case pubsub.Event[message.Message]:
202 // TODO: handle nested messages for agentic tools
203 if m.session == nil || msg.Payload.SessionID != m.session.ID {
204 break
205 }
206 switch msg.Type {
207 case pubsub.CreatedEvent:
208 cmds = append(cmds, m.appendSessionMessage(msg.Payload))
209 case pubsub.UpdatedEvent:
210 cmds = append(cmds, m.updateSessionMessage(msg.Payload))
211 }
212 case pubsub.Event[history.File]:
213 cmds = append(cmds, m.handleFileEvent(msg.Payload))
214 case pubsub.Event[app.LSPEvent]:
215 m.lspStates = app.GetLSPStates()
216 case pubsub.Event[mcp.Event]:
217 m.mcpStates = mcp.GetStates()
218 if msg.Type == pubsub.UpdatedEvent && m.dialog.ContainsDialog(dialog.CommandsID) {
219 dia := m.dialog.Dialog(dialog.CommandsID)
220 if dia == nil {
221 break
222 }
223
224 commands, ok := dia.(*dialog.Commands)
225 if ok {
226 if cmd := commands.ReloadMCPPrompts(); cmd != nil {
227 cmds = append(cmds, cmd)
228 }
229 }
230 }
231 case tea.TerminalVersionMsg:
232 termVersion := strings.ToLower(msg.Name)
233 // Only enable progress bar for the following terminals.
234 if !m.sendProgressBar {
235 m.sendProgressBar = strings.Contains(termVersion, "ghostty")
236 }
237 return m, nil
238 case tea.WindowSizeMsg:
239 m.width, m.height = msg.Width, msg.Height
240 m.updateLayoutAndSize()
241 case tea.KeyboardEnhancementsMsg:
242 m.keyenh = msg
243 if msg.SupportsKeyDisambiguation() {
244 m.keyMap.Models.SetHelp("ctrl+m", "models")
245 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
246 }
247 case tea.MouseClickMsg:
248 switch m.state {
249 case uiChat:
250 x, y := msg.X, msg.Y
251 // Adjust for chat area position
252 x -= m.layout.main.Min.X
253 y -= m.layout.main.Min.Y
254 m.chat.HandleMouseDown(x, y)
255 }
256
257 case tea.MouseMotionMsg:
258 switch m.state {
259 case uiChat:
260 if msg.Y <= 0 {
261 if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
262 cmds = append(cmds, cmd)
263 }
264 if !m.chat.SelectedItemInView() {
265 m.chat.SelectPrev()
266 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
267 cmds = append(cmds, cmd)
268 }
269 }
270 } else if msg.Y >= m.chat.Height()-1 {
271 if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
272 cmds = append(cmds, cmd)
273 }
274 if !m.chat.SelectedItemInView() {
275 m.chat.SelectNext()
276 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
277 cmds = append(cmds, cmd)
278 }
279 }
280 }
281
282 x, y := msg.X, msg.Y
283 // Adjust for chat area position
284 x -= m.layout.main.Min.X
285 y -= m.layout.main.Min.Y
286 m.chat.HandleMouseDrag(x, y)
287 }
288
289 case tea.MouseReleaseMsg:
290 switch m.state {
291 case uiChat:
292 x, y := msg.X, msg.Y
293 // Adjust for chat area position
294 x -= m.layout.main.Min.X
295 y -= m.layout.main.Min.Y
296 m.chat.HandleMouseUp(x, y)
297 }
298 case tea.MouseWheelMsg:
299 switch m.state {
300 case uiChat:
301 switch msg.Button {
302 case tea.MouseWheelUp:
303 if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil {
304 cmds = append(cmds, cmd)
305 }
306 if !m.chat.SelectedItemInView() {
307 m.chat.SelectPrev()
308 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
309 cmds = append(cmds, cmd)
310 }
311 }
312 case tea.MouseWheelDown:
313 if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil {
314 cmds = append(cmds, cmd)
315 }
316 if !m.chat.SelectedItemInView() {
317 m.chat.SelectNext()
318 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
319 cmds = append(cmds, cmd)
320 }
321 }
322 }
323 }
324 case anim.StepMsg:
325 if m.state == uiChat {
326 if cmd := m.chat.Animate(msg); cmd != nil {
327 cmds = append(cmds, cmd)
328 }
329 }
330 case tea.KeyPressMsg:
331 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
332 cmds = append(cmds, cmd)
333 }
334 case tea.PasteMsg:
335 if cmd := m.handlePasteMsg(msg); cmd != nil {
336 cmds = append(cmds, cmd)
337 }
338 case openEditorMsg:
339 m.textarea.SetValue(msg.Text)
340 m.textarea.MoveToEnd()
341 }
342
343 // This logic gets triggered on any message type, but should it?
344 switch m.focus {
345 case uiFocusMain:
346 case uiFocusEditor:
347 // Textarea placeholder logic
348 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
349 m.textarea.Placeholder = m.workingPlaceholder
350 } else {
351 m.textarea.Placeholder = m.readyPlaceholder
352 }
353 if m.com.App.Permissions.SkipRequests() {
354 m.textarea.Placeholder = "Yolo mode!"
355 }
356 }
357
358 return m, tea.Batch(cmds...)
359}
360
361// setSessionMessages sets the messages for the current session in the chat
362func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
363 var cmds []tea.Cmd
364 // Build tool result map to link tool calls with their results
365 msgPtrs := make([]*message.Message, len(msgs))
366 for i := range msgs {
367 msgPtrs[i] = &msgs[i]
368 }
369 toolResultMap := chat.BuildToolResultMap(msgPtrs)
370
371 // Add messages to chat with linked tool results
372 items := make([]chat.MessageItem, 0, len(msgs)*2)
373 for _, msg := range msgPtrs {
374 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
375 }
376
377 // If the user switches between sessions while the agent is working we want
378 // to make sure the animations are shown.
379 for _, item := range items {
380 if animatable, ok := item.(chat.Animatable); ok {
381 if cmd := animatable.StartAnimation(); cmd != nil {
382 cmds = append(cmds, cmd)
383 }
384 }
385 }
386
387 m.chat.SetMessages(items...)
388 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
389 cmds = append(cmds, cmd)
390 }
391 m.chat.SelectLast()
392 return tea.Batch(cmds...)
393}
394
395// appendSessionMessage appends a new message to the current session in the chat
396// if the message is a tool result it will update the corresponding tool call message
397func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
398 var cmds []tea.Cmd
399 switch msg.Role {
400 case message.User, message.Assistant:
401 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
402 for _, item := range items {
403 if animatable, ok := item.(chat.Animatable); ok {
404 if cmd := animatable.StartAnimation(); cmd != nil {
405 cmds = append(cmds, cmd)
406 }
407 }
408 }
409 m.chat.AppendMessages(items...)
410 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
411 cmds = append(cmds, cmd)
412 }
413 case message.Tool:
414 for _, tr := range msg.ToolResults() {
415 toolItem := m.chat.MessageItem(tr.ToolCallID)
416 if toolItem == nil {
417 // we should have an item!
418 continue
419 }
420 if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
421 toolMsgItem.SetResult(&tr)
422 }
423 }
424 }
425 return tea.Batch(cmds...)
426}
427
428// updateSessionMessage updates an existing message in the current session in the chat
429// when an assistant message is updated it may include updated tool calls as well
430// that is why we need to handle creating/updating each tool call message too
431func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
432 var cmds []tea.Cmd
433 existingItem := m.chat.MessageItem(msg.ID)
434 if existingItem == nil || msg.Role != message.Assistant {
435 return nil
436 }
437
438 if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
439 assistantItem.SetMessage(&msg)
440 }
441
442 var items []chat.MessageItem
443 for _, tc := range msg.ToolCalls() {
444 existingToolItem := m.chat.MessageItem(tc.ID)
445 if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
446 existingToolCall := toolItem.ToolCall()
447 // only update if finished state changed or input changed
448 // to avoid clearing the cache
449 if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
450 toolItem.SetToolCall(tc)
451 }
452 }
453 if existingToolItem == nil {
454 items = append(items, chat.NewToolMessageItem(m.com.Styles, tc, nil, false))
455 }
456 }
457
458 for _, item := range items {
459 if animatable, ok := item.(chat.Animatable); ok {
460 if cmd := animatable.StartAnimation(); cmd != nil {
461 cmds = append(cmds, cmd)
462 }
463 }
464 }
465 m.chat.AppendMessages(items...)
466 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
467 cmds = append(cmds, cmd)
468 }
469
470 return tea.Batch(cmds...)
471}
472
473func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
474 var cmds []tea.Cmd
475
476 handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
477 switch {
478 case key.Matches(msg, m.keyMap.Help):
479 m.help.ShowAll = !m.help.ShowAll
480 m.updateLayoutAndSize()
481 return true
482 case key.Matches(msg, m.keyMap.Commands):
483 if cmd := m.openCommandsDialog(); cmd != nil {
484 cmds = append(cmds, cmd)
485 }
486 return true
487 case key.Matches(msg, m.keyMap.Models):
488 if cmd := m.openModelsDialog(); cmd != nil {
489 cmds = append(cmds, cmd)
490 }
491 return true
492 case key.Matches(msg, m.keyMap.Sessions):
493 if cmd := m.openSessionsDialog(); cmd != nil {
494 cmds = append(cmds, cmd)
495 }
496 return true
497 }
498 return false
499 }
500
501 if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
502 // Always handle quit keys first
503 if cmd := m.openQuitDialog(); cmd != nil {
504 cmds = append(cmds, cmd)
505 }
506
507 return tea.Batch(cmds...)
508 }
509
510 // Route all messages to dialog if one is open.
511 if m.dialog.HasDialogs() {
512 msg := m.dialog.Update(msg)
513 if msg == nil {
514 return tea.Batch(cmds...)
515 }
516
517 switch msg := msg.(type) {
518 // Generic dialog messages
519 case dialog.CloseMsg:
520 m.dialog.CloseFrontDialog()
521
522 // Session dialog messages
523 case dialog.SessionSelectedMsg:
524 m.dialog.CloseDialog(dialog.SessionsID)
525 cmds = append(cmds, m.loadSession(msg.Session.ID))
526
527 // Open dialog message
528 case dialog.OpenDialogMsg:
529 switch msg.DialogID {
530 case dialog.SessionsID:
531 if cmd := m.openSessionsDialog(); cmd != nil {
532 cmds = append(cmds, cmd)
533 }
534 case dialog.ModelsID:
535 if cmd := m.openModelsDialog(); cmd != nil {
536 cmds = append(cmds, cmd)
537 }
538 default:
539 // Unknown dialog
540 break
541 }
542
543 m.dialog.CloseDialog(msg.DialogID)
544
545 // Command dialog messages
546 case dialog.ToggleYoloModeMsg:
547 yolo := !m.com.App.Permissions.SkipRequests()
548 m.com.App.Permissions.SetSkipRequests(yolo)
549 m.setEditorPrompt(yolo)
550 m.dialog.CloseDialog(dialog.CommandsID)
551 case dialog.NewSessionsMsg:
552 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
553 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
554 break
555 }
556 m.newSession()
557 m.dialog.CloseDialog(dialog.CommandsID)
558 case dialog.CompactMsg:
559 err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
560 if err != nil {
561 cmds = append(cmds, uiutil.ReportError(err))
562 }
563 case dialog.ToggleHelpMsg:
564 m.help.ShowAll = !m.help.ShowAll
565 m.dialog.CloseDialog(dialog.CommandsID)
566 case dialog.QuitMsg:
567 cmds = append(cmds, tea.Quit)
568 case dialog.ModelSelectedMsg:
569 // TODO: Handle model switching
570 }
571
572 return tea.Batch(cmds...)
573 }
574
575 switch m.state {
576 case uiConfigure:
577 return tea.Batch(cmds...)
578 case uiInitialize:
579 cmds = append(cmds, m.updateInitializeView(msg)...)
580 return tea.Batch(cmds...)
581 case uiChat, uiLanding, uiChatCompact:
582 switch m.focus {
583 case uiFocusEditor:
584 switch {
585 case key.Matches(msg, m.keyMap.Editor.SendMessage):
586 value := m.textarea.Value()
587 if strings.HasSuffix(value, "\\") {
588 // If the last character is a backslash, remove it and add a newline.
589 m.textarea.SetValue(strings.TrimSuffix(value, "\\"))
590 break
591 }
592
593 // Otherwise, send the message
594 m.textarea.Reset()
595
596 value = strings.TrimSpace(value)
597 if value == "exit" || value == "quit" {
598 return m.openQuitDialog()
599 }
600
601 attachments := m.attachments
602 m.attachments = nil
603 if len(value) == 0 {
604 return nil
605 }
606
607 m.randomizePlaceholders()
608
609 return m.sendMessage(value, attachments)
610 case key.Matches(msg, m.keyMap.Chat.NewSession):
611 if m.session == nil || m.session.ID == "" {
612 break
613 }
614 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
615 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
616 break
617 }
618 m.newSession()
619 case key.Matches(msg, m.keyMap.Tab):
620 m.focus = uiFocusMain
621 m.textarea.Blur()
622 m.chat.Focus()
623 m.chat.SetSelected(m.chat.Len() - 1)
624 case key.Matches(msg, m.keyMap.Editor.OpenEditor):
625 if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
626 cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
627 break
628 }
629 cmds = append(cmds, m.openEditor(m.textarea.Value()))
630 case key.Matches(msg, m.keyMap.Editor.Newline):
631 m.textarea.InsertRune('\n')
632 default:
633 if handleGlobalKeys(msg) {
634 // Handle global keys first before passing to textarea.
635 break
636 }
637
638 ta, cmd := m.textarea.Update(msg)
639 m.textarea = ta
640 cmds = append(cmds, cmd)
641 }
642 case uiFocusMain:
643 switch {
644 case key.Matches(msg, m.keyMap.Tab):
645 m.focus = uiFocusEditor
646 cmds = append(cmds, m.textarea.Focus())
647 m.chat.Blur()
648 case key.Matches(msg, m.keyMap.Chat.Expand):
649 m.chat.ToggleExpandedSelectedItem()
650 case key.Matches(msg, m.keyMap.Chat.Up):
651 if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
652 cmds = append(cmds, cmd)
653 }
654 if !m.chat.SelectedItemInView() {
655 m.chat.SelectPrev()
656 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
657 cmds = append(cmds, cmd)
658 }
659 }
660 case key.Matches(msg, m.keyMap.Chat.Down):
661 if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
662 cmds = append(cmds, cmd)
663 }
664 if !m.chat.SelectedItemInView() {
665 m.chat.SelectNext()
666 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
667 cmds = append(cmds, cmd)
668 }
669 }
670 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
671 m.chat.SelectPrev()
672 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
673 cmds = append(cmds, cmd)
674 }
675 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
676 m.chat.SelectNext()
677 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
678 cmds = append(cmds, cmd)
679 }
680 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
681 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
682 cmds = append(cmds, cmd)
683 }
684 m.chat.SelectFirstInView()
685 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
686 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
687 cmds = append(cmds, cmd)
688 }
689 m.chat.SelectLastInView()
690 case key.Matches(msg, m.keyMap.Chat.PageUp):
691 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
692 cmds = append(cmds, cmd)
693 }
694 m.chat.SelectFirstInView()
695 case key.Matches(msg, m.keyMap.Chat.PageDown):
696 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
697 cmds = append(cmds, cmd)
698 }
699 m.chat.SelectLastInView()
700 case key.Matches(msg, m.keyMap.Chat.Home):
701 if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
702 cmds = append(cmds, cmd)
703 }
704 m.chat.SelectFirst()
705 case key.Matches(msg, m.keyMap.Chat.End):
706 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
707 cmds = append(cmds, cmd)
708 }
709 m.chat.SelectLast()
710 default:
711 handleGlobalKeys(msg)
712 }
713 default:
714 handleGlobalKeys(msg)
715 }
716 default:
717 handleGlobalKeys(msg)
718 }
719
720 return tea.Batch(cmds...)
721}
722
723// Draw implements [tea.Layer] and draws the UI model.
724func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
725 layout := m.generateLayout(area.Dx(), area.Dy())
726
727 if m.layout != layout {
728 m.layout = layout
729 m.updateSize()
730 }
731
732 // Clear the screen first
733 screen.Clear(scr)
734
735 switch m.state {
736 case uiConfigure:
737 header := uv.NewStyledString(m.header)
738 header.Draw(scr, layout.header)
739
740 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
741 Height(layout.main.Dy()).
742 Background(lipgloss.ANSIColor(rand.Intn(256))).
743 Render(" Configure ")
744 main := uv.NewStyledString(mainView)
745 main.Draw(scr, layout.main)
746
747 case uiInitialize:
748 header := uv.NewStyledString(m.header)
749 header.Draw(scr, layout.header)
750
751 main := uv.NewStyledString(m.initializeView())
752 main.Draw(scr, layout.main)
753
754 case uiLanding:
755 header := uv.NewStyledString(m.header)
756 header.Draw(scr, layout.header)
757 main := uv.NewStyledString(m.landingView())
758 main.Draw(scr, layout.main)
759
760 editor := uv.NewStyledString(m.textarea.View())
761 editor.Draw(scr, layout.editor)
762
763 case uiChat:
764 m.chat.Draw(scr, layout.main)
765
766 header := uv.NewStyledString(m.header)
767 header.Draw(scr, layout.header)
768 m.drawSidebar(scr, layout.sidebar)
769
770 editor := uv.NewStyledString(m.textarea.View())
771 editor.Draw(scr, layout.editor)
772
773 case uiChatCompact:
774 header := uv.NewStyledString(m.header)
775 header.Draw(scr, layout.header)
776
777 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
778 Height(layout.main.Dy()).
779 Background(lipgloss.ANSIColor(rand.Intn(256))).
780 Render(" Compact Chat Messages ")
781 main := uv.NewStyledString(mainView)
782 main.Draw(scr, layout.main)
783
784 editor := uv.NewStyledString(m.textarea.View())
785 editor.Draw(scr, layout.editor)
786 }
787
788 // Add help layer
789 help := uv.NewStyledString(m.help.View(m))
790 help.Draw(scr, layout.help)
791
792 // Debugging rendering (visually see when the tui rerenders)
793 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
794 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
795 debug := uv.NewStyledString(debugView.String())
796 debug.Draw(scr, image.Rectangle{
797 Min: image.Pt(4, 1),
798 Max: image.Pt(8, 3),
799 })
800 }
801
802 // This needs to come last to overlay on top of everything
803 if m.dialog.HasDialogs() {
804 m.dialog.Draw(scr, area)
805 }
806}
807
808// Cursor returns the cursor position and properties for the UI model. It
809// returns nil if the cursor should not be shown.
810func (m *UI) Cursor() *tea.Cursor {
811 if m.layout.editor.Dy() <= 0 {
812 // Don't show cursor if editor is not visible
813 return nil
814 }
815 if m.dialog.HasDialogs() {
816 if front := m.dialog.DialogLast(); front != nil {
817 c, ok := front.(uiutil.Cursor)
818 if ok {
819 cur := c.Cursor()
820 if cur != nil {
821 pos := m.dialog.CenterPosition(m.layout.area, front.ID())
822 cur.X += pos.Min.X
823 cur.Y += pos.Min.Y
824 return cur
825 }
826 }
827 }
828 return nil
829 }
830 switch m.focus {
831 case uiFocusEditor:
832 if m.textarea.Focused() {
833 cur := m.textarea.Cursor()
834 cur.X++ // Adjust for app margins
835 cur.Y += m.layout.editor.Min.Y
836 return cur
837 }
838 }
839 return nil
840}
841
842// View renders the UI model's view.
843func (m *UI) View() tea.View {
844 var v tea.View
845 v.AltScreen = true
846 v.BackgroundColor = m.com.Styles.Background
847 v.Cursor = m.Cursor()
848 v.MouseMode = tea.MouseModeCellMotion
849
850 canvas := uv.NewScreenBuffer(m.width, m.height)
851 m.Draw(canvas, canvas.Bounds())
852
853 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
854 contentLines := strings.Split(content, "\n")
855 for i, line := range contentLines {
856 // Trim trailing spaces for concise rendering
857 contentLines[i] = strings.TrimRight(line, " ")
858 }
859
860 content = strings.Join(contentLines, "\n")
861
862 v.Content = content
863 if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
864 // HACK: use a random percentage to prevent ghostty from hiding it
865 // after a timeout.
866 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
867 }
868
869 return v
870}
871
872// ShortHelp implements [help.KeyMap].
873func (m *UI) ShortHelp() []key.Binding {
874 var binds []key.Binding
875 k := &m.keyMap
876 tab := k.Tab
877 commands := k.Commands
878 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
879 commands.SetHelp("/ or ctrl+p", "commands")
880 }
881
882 switch m.state {
883 case uiInitialize:
884 binds = append(binds, k.Quit)
885 case uiChat:
886 if m.focus == uiFocusEditor {
887 tab.SetHelp("tab", "focus chat")
888 } else {
889 tab.SetHelp("tab", "focus editor")
890 }
891
892 binds = append(binds,
893 tab,
894 commands,
895 k.Models,
896 )
897
898 switch m.focus {
899 case uiFocusEditor:
900 binds = append(binds,
901 k.Editor.Newline,
902 )
903 case uiFocusMain:
904 binds = append(binds,
905 k.Chat.UpDown,
906 k.Chat.UpDownOneItem,
907 k.Chat.PageUp,
908 k.Chat.PageDown,
909 k.Chat.Copy,
910 )
911 }
912 default:
913 // TODO: other states
914 // if m.session == nil {
915 // no session selected
916 binds = append(binds,
917 commands,
918 k.Models,
919 k.Editor.Newline,
920 )
921 }
922
923 binds = append(binds,
924 k.Quit,
925 k.Help,
926 )
927
928 return binds
929}
930
931// FullHelp implements [help.KeyMap].
932func (m *UI) FullHelp() [][]key.Binding {
933 var binds [][]key.Binding
934 k := &m.keyMap
935 help := k.Help
936 help.SetHelp("ctrl+g", "less")
937 hasAttachments := false // TODO: implement attachments
938 hasSession := m.session != nil && m.session.ID != ""
939 commands := k.Commands
940 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
941 commands.SetHelp("/ or ctrl+p", "commands")
942 }
943
944 switch m.state {
945 case uiInitialize:
946 binds = append(binds,
947 []key.Binding{
948 k.Quit,
949 })
950 case uiChat:
951 mainBinds := []key.Binding{}
952 tab := k.Tab
953 if m.focus == uiFocusEditor {
954 tab.SetHelp("tab", "focus chat")
955 } else {
956 tab.SetHelp("tab", "focus editor")
957 }
958
959 mainBinds = append(mainBinds,
960 tab,
961 commands,
962 k.Models,
963 k.Sessions,
964 )
965 if hasSession {
966 mainBinds = append(mainBinds, k.Chat.NewSession)
967 }
968
969 binds = append(binds, mainBinds)
970
971 switch m.focus {
972 case uiFocusEditor:
973 binds = append(binds,
974 []key.Binding{
975 k.Editor.Newline,
976 k.Editor.AddImage,
977 k.Editor.MentionFile,
978 k.Editor.OpenEditor,
979 },
980 )
981 if hasAttachments {
982 binds = append(binds,
983 []key.Binding{
984 k.Editor.AttachmentDeleteMode,
985 k.Editor.DeleteAllAttachments,
986 k.Editor.Escape,
987 },
988 )
989 }
990 case uiFocusMain:
991 binds = append(binds,
992 []key.Binding{
993 k.Chat.UpDown,
994 k.Chat.UpDownOneItem,
995 k.Chat.PageUp,
996 k.Chat.PageDown,
997 },
998 []key.Binding{
999 k.Chat.HalfPageUp,
1000 k.Chat.HalfPageDown,
1001 k.Chat.Home,
1002 k.Chat.End,
1003 },
1004 []key.Binding{
1005 k.Chat.Copy,
1006 k.Chat.ClearHighlight,
1007 },
1008 )
1009 }
1010 default:
1011 if m.session == nil {
1012 // no session selected
1013 binds = append(binds,
1014 []key.Binding{
1015 commands,
1016 k.Models,
1017 k.Sessions,
1018 },
1019 []key.Binding{
1020 k.Editor.Newline,
1021 k.Editor.AddImage,
1022 k.Editor.MentionFile,
1023 k.Editor.OpenEditor,
1024 },
1025 []key.Binding{
1026 help,
1027 },
1028 )
1029 }
1030 }
1031
1032 binds = append(binds,
1033 []key.Binding{
1034 help,
1035 k.Quit,
1036 },
1037 )
1038
1039 return binds
1040}
1041
1042// updateLayoutAndSize updates the layout and sizes of UI components.
1043func (m *UI) updateLayoutAndSize() {
1044 m.layout = m.generateLayout(m.width, m.height)
1045 m.updateSize()
1046}
1047
1048// updateSize updates the sizes of UI components based on the current layout.
1049func (m *UI) updateSize() {
1050 // Set help width
1051 m.help.SetWidth(m.layout.help.Dx())
1052
1053 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
1054 m.textarea.SetWidth(m.layout.editor.Dx())
1055 m.textarea.SetHeight(m.layout.editor.Dy())
1056
1057 // Handle different app states
1058 switch m.state {
1059 case uiConfigure, uiInitialize, uiLanding:
1060 m.renderHeader(false, m.layout.header.Dx())
1061
1062 case uiChat:
1063 m.renderSidebarLogo(m.layout.sidebar.Dx())
1064
1065 case uiChatCompact:
1066 // TODO: set the width and heigh of the chat component
1067 m.renderHeader(true, m.layout.header.Dx())
1068 }
1069}
1070
1071// generateLayout calculates the layout rectangles for all UI components based
1072// on the current UI state and terminal dimensions.
1073func (m *UI) generateLayout(w, h int) layout {
1074 // The screen area we're working with
1075 area := image.Rect(0, 0, w, h)
1076
1077 // The help height
1078 helpHeight := 1
1079 // The editor height
1080 editorHeight := 5
1081 // The sidebar width
1082 sidebarWidth := 30
1083 // The header height
1084 // TODO: handle compact
1085 headerHeight := 4
1086
1087 var helpKeyMap help.KeyMap = m
1088 if m.help.ShowAll {
1089 for _, row := range helpKeyMap.FullHelp() {
1090 helpHeight = max(helpHeight, len(row))
1091 }
1092 }
1093
1094 // Add app margins
1095 appRect := area
1096 appRect.Min.X += 1
1097 appRect.Min.Y += 1
1098 appRect.Max.X -= 1
1099 appRect.Max.Y -= 1
1100
1101 if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
1102 // extra padding on left and right for these states
1103 appRect.Min.X += 1
1104 appRect.Max.X -= 1
1105 }
1106
1107 appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
1108
1109 layout := layout{
1110 area: area,
1111 help: helpRect,
1112 }
1113
1114 // Handle different app states
1115 switch m.state {
1116 case uiConfigure, uiInitialize:
1117 // Layout
1118 //
1119 // header
1120 // ------
1121 // main
1122 // ------
1123 // help
1124
1125 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1126 layout.header = headerRect
1127 layout.main = mainRect
1128
1129 case uiLanding:
1130 // Layout
1131 //
1132 // header
1133 // ------
1134 // main
1135 // ------
1136 // editor
1137 // ------
1138 // help
1139 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1140 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1141 // Remove extra padding from editor (but keep it for header and main)
1142 editorRect.Min.X -= 1
1143 editorRect.Max.X += 1
1144 layout.header = headerRect
1145 layout.main = mainRect
1146 layout.editor = editorRect
1147
1148 case uiChat:
1149 // Layout
1150 //
1151 // ------|---
1152 // main |
1153 // ------| side
1154 // editor|
1155 // ----------
1156 // help
1157
1158 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
1159 // Add padding left
1160 sideRect.Min.X += 1
1161 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1162 mainRect.Max.X -= 1 // Add padding right
1163 // Add bottom margin to main
1164 mainRect.Max.Y -= 1
1165 layout.sidebar = sideRect
1166 layout.main = mainRect
1167 layout.editor = editorRect
1168
1169 case uiChatCompact:
1170 // Layout
1171 //
1172 // compact-header
1173 // ------
1174 // main
1175 // ------
1176 // editor
1177 // ------
1178 // help
1179 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
1180 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1181 layout.header = headerRect
1182 layout.main = mainRect
1183 layout.editor = editorRect
1184 }
1185
1186 if !layout.editor.Empty() {
1187 // Add editor margins 1 top and bottom
1188 layout.editor.Min.Y += 1
1189 layout.editor.Max.Y -= 1
1190 }
1191
1192 return layout
1193}
1194
1195// layout defines the positioning of UI elements.
1196type layout struct {
1197 // area is the overall available area.
1198 area uv.Rectangle
1199
1200 // header is the header shown in special cases
1201 // e.x when the sidebar is collapsed
1202 // or when in the landing page
1203 // or in init/config
1204 header uv.Rectangle
1205
1206 // main is the area for the main pane. (e.x chat, configure, landing)
1207 main uv.Rectangle
1208
1209 // editor is the area for the editor pane.
1210 editor uv.Rectangle
1211
1212 // sidebar is the area for the sidebar.
1213 sidebar uv.Rectangle
1214
1215 // help is the area for the help view.
1216 help uv.Rectangle
1217}
1218
1219func (m *UI) openEditor(value string) tea.Cmd {
1220 editor := os.Getenv("EDITOR")
1221 if editor == "" {
1222 // Use platform-appropriate default editor
1223 if runtime.GOOS == "windows" {
1224 editor = "notepad"
1225 } else {
1226 editor = "nvim"
1227 }
1228 }
1229
1230 tmpfile, err := os.CreateTemp("", "msg_*.md")
1231 if err != nil {
1232 return uiutil.ReportError(err)
1233 }
1234 defer tmpfile.Close() //nolint:errcheck
1235 if _, err := tmpfile.WriteString(value); err != nil {
1236 return uiutil.ReportError(err)
1237 }
1238 cmdStr := editor + " " + tmpfile.Name()
1239 return uiutil.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
1240 if err != nil {
1241 return uiutil.ReportError(err)
1242 }
1243 content, err := os.ReadFile(tmpfile.Name())
1244 if err != nil {
1245 return uiutil.ReportError(err)
1246 }
1247 if len(content) == 0 {
1248 return uiutil.ReportWarn("Message is empty")
1249 }
1250 os.Remove(tmpfile.Name())
1251 return openEditorMsg{
1252 Text: strings.TrimSpace(string(content)),
1253 }
1254 })
1255}
1256
1257// setEditorPrompt configures the textarea prompt function based on whether
1258// yolo mode is enabled.
1259func (m *UI) setEditorPrompt(yolo bool) {
1260 if yolo {
1261 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
1262 return
1263 }
1264 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
1265}
1266
1267// normalPromptFunc returns the normal editor prompt style (" > " on first
1268// line, "::: " on subsequent lines).
1269func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
1270 t := m.com.Styles
1271 if info.LineNumber == 0 {
1272 if info.Focused {
1273 return " > "
1274 }
1275 return "::: "
1276 }
1277 if info.Focused {
1278 return t.EditorPromptNormalFocused.Render()
1279 }
1280 return t.EditorPromptNormalBlurred.Render()
1281}
1282
1283// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
1284// and colored dots.
1285func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
1286 t := m.com.Styles
1287 if info.LineNumber == 0 {
1288 if info.Focused {
1289 return t.EditorPromptYoloIconFocused.Render()
1290 } else {
1291 return t.EditorPromptYoloIconBlurred.Render()
1292 }
1293 }
1294 if info.Focused {
1295 return t.EditorPromptYoloDotsFocused.Render()
1296 }
1297 return t.EditorPromptYoloDotsBlurred.Render()
1298}
1299
1300var readyPlaceholders = [...]string{
1301 "Ready!",
1302 "Ready...",
1303 "Ready?",
1304 "Ready for instructions",
1305}
1306
1307var workingPlaceholders = [...]string{
1308 "Working!",
1309 "Working...",
1310 "Brrrrr...",
1311 "Prrrrrrrr...",
1312 "Processing...",
1313 "Thinking...",
1314}
1315
1316// randomizePlaceholders selects random placeholder text for the textarea's
1317// ready and working states.
1318func (m *UI) randomizePlaceholders() {
1319 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
1320 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
1321}
1322
1323// renderHeader renders and caches the header logo at the specified width.
1324func (m *UI) renderHeader(compact bool, width int) {
1325 // TODO: handle the compact case differently
1326 m.header = renderLogo(m.com.Styles, compact, width)
1327}
1328
1329// renderSidebarLogo renders and caches the sidebar logo at the specified
1330// width.
1331func (m *UI) renderSidebarLogo(width int) {
1332 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
1333}
1334
1335// sendMessage sends a message with the given content and attachments.
1336func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd {
1337 if m.com.App.AgentCoordinator == nil {
1338 return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
1339 }
1340
1341 var cmds []tea.Cmd
1342 if m.session == nil || m.session.ID == "" {
1343 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
1344 if err != nil {
1345 return uiutil.ReportError(err)
1346 }
1347 m.state = uiChat
1348 m.session = &newSession
1349 cmds = append(cmds, m.loadSession(newSession.ID))
1350 }
1351
1352 // Capture session ID to avoid race with main goroutine updating m.session.
1353 sessionID := m.session.ID
1354 cmds = append(cmds, func() tea.Msg {
1355 _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
1356 if err != nil {
1357 isCancelErr := errors.Is(err, context.Canceled)
1358 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
1359 if isCancelErr || isPermissionErr {
1360 return nil
1361 }
1362 return uiutil.InfoMsg{
1363 Type: uiutil.InfoTypeError,
1364 Msg: err.Error(),
1365 }
1366 }
1367 return nil
1368 })
1369 return tea.Batch(cmds...)
1370}
1371
1372// openQuitDialog opens the quit confirmation dialog.
1373func (m *UI) openQuitDialog() tea.Cmd {
1374 if m.dialog.ContainsDialog(dialog.QuitID) {
1375 // Bring to front
1376 m.dialog.BringToFront(dialog.QuitID)
1377 return nil
1378 }
1379
1380 quitDialog := dialog.NewQuit(m.com)
1381 m.dialog.OpenDialog(quitDialog)
1382 return nil
1383}
1384
1385// openModelsDialog opens the models dialog.
1386func (m *UI) openModelsDialog() tea.Cmd {
1387 if m.dialog.ContainsDialog(dialog.ModelsID) {
1388 // Bring to front
1389 m.dialog.BringToFront(dialog.ModelsID)
1390 return nil
1391 }
1392
1393 modelsDialog, err := dialog.NewModels(m.com)
1394 if err != nil {
1395 return uiutil.ReportError(err)
1396 }
1397
1398 modelsDialog.SetSize(min(60, m.width-8), 30)
1399 m.dialog.OpenDialog(modelsDialog)
1400
1401 return nil
1402}
1403
1404// openCommandsDialog opens the commands dialog.
1405func (m *UI) openCommandsDialog() tea.Cmd {
1406 if m.dialog.ContainsDialog(dialog.CommandsID) {
1407 // Bring to front
1408 m.dialog.BringToFront(dialog.CommandsID)
1409 return nil
1410 }
1411
1412 sessionID := ""
1413 if m.session != nil {
1414 sessionID = m.session.ID
1415 }
1416
1417 commands, err := dialog.NewCommands(m.com, sessionID)
1418 if err != nil {
1419 return uiutil.ReportError(err)
1420 }
1421
1422 // TODO: Get. Rid. Of. Magic numbers!
1423 commands.SetSize(min(120, m.width-8), 30)
1424 m.dialog.OpenDialog(commands)
1425
1426 return nil
1427}
1428
1429// openSessionsDialog opens the sessions dialog. If the dialog is already open,
1430// it brings it to the front. Otherwise, it will list all the sessions and open
1431// the dialog.
1432func (m *UI) openSessionsDialog() tea.Cmd {
1433 if m.dialog.ContainsDialog(dialog.SessionsID) {
1434 // Bring to front
1435 m.dialog.BringToFront(dialog.SessionsID)
1436 return nil
1437 }
1438
1439 sessions, err := m.com.App.Sessions.List(context.TODO())
1440 if err != nil {
1441 return uiutil.ReportError(err)
1442 }
1443
1444 dialog := dialog.NewSessions(m.com, sessions...)
1445 // TODO: Get. Rid. Of. Magic numbers!
1446 dialog.SetSize(min(120, m.width-8), 30)
1447 m.dialog.OpenDialog(dialog)
1448
1449 return nil
1450}
1451
1452// newSession clears the current session state and prepares for a new session.
1453// The actual session creation happens when the user sends their first message.
1454func (m *UI) newSession() {
1455 if m.session == nil || m.session.ID == "" {
1456 return
1457 }
1458
1459 m.session = nil
1460 m.sessionFiles = nil
1461 m.state = uiLanding
1462 m.focus = uiFocusEditor
1463 m.textarea.Focus()
1464 m.chat.Blur()
1465 m.chat.ClearMessages()
1466}
1467
1468// handlePasteMsg handles a paste message.
1469func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
1470 if m.focus != uiFocusEditor {
1471 return nil
1472 }
1473
1474 var cmd tea.Cmd
1475 path := strings.ReplaceAll(msg.Content, "\\ ", " ")
1476 // try to get an image
1477 path, err := filepath.Abs(strings.TrimSpace(path))
1478 if err != nil {
1479 m.textarea, cmd = m.textarea.Update(msg)
1480 return cmd
1481 }
1482 isAllowedType := false
1483 for _, ext := range filepicker.AllowedTypes {
1484 if strings.HasSuffix(path, ext) {
1485 isAllowedType = true
1486 break
1487 }
1488 }
1489 if !isAllowedType {
1490 m.textarea, cmd = m.textarea.Update(msg)
1491 return cmd
1492 }
1493 tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
1494 if tooBig {
1495 m.textarea, cmd = m.textarea.Update(msg)
1496 return cmd
1497 }
1498
1499 content, err := os.ReadFile(path)
1500 if err != nil {
1501 m.textarea, cmd = m.textarea.Update(msg)
1502 return cmd
1503 }
1504 mimeBufferSize := min(512, len(content))
1505 mimeType := http.DetectContentType(content[:mimeBufferSize])
1506 fileName := filepath.Base(path)
1507 attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
1508 return uiutil.CmdHandler(filepicker.FilePickedMsg{
1509 Attachment: attachment,
1510 })
1511}
1512
1513// renderLogo renders the Crush logo with the given styles and dimensions.
1514func renderLogo(t *styles.Styles, compact bool, width int) string {
1515 return logo.Render(version.Version, compact, logo.Opts{
1516 FieldColor: t.LogoFieldColor,
1517 TitleColorA: t.LogoTitleColorA,
1518 TitleColorB: t.LogoTitleColorB,
1519 CharmColor: t.LogoCharmColor,
1520 VersionColor: t.LogoVersionColor,
1521 Width: width,
1522 })
1523}