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