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