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