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