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