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/completions"
34 "github.com/charmbracelet/crush/internal/ui/dialog"
35 "github.com/charmbracelet/crush/internal/ui/logo"
36 "github.com/charmbracelet/crush/internal/ui/styles"
37 "github.com/charmbracelet/crush/internal/uiutil"
38 "github.com/charmbracelet/crush/internal/version"
39 uv "github.com/charmbracelet/ultraviolet"
40 "github.com/charmbracelet/ultraviolet/screen"
41)
42
43// uiFocusState represents the current focus state of the UI.
44type uiFocusState uint8
45
46// Possible uiFocusState values.
47const (
48 uiFocusNone uiFocusState = iota
49 uiFocusEditor
50 uiFocusMain
51)
52
53type uiState uint8
54
55// Possible uiState values.
56const (
57 uiConfigure uiState = iota
58 uiInitialize
59 uiLanding
60 uiChat
61 uiChatCompact
62)
63
64type openEditorMsg struct {
65 Text string
66}
67
68// UI represents the main user interface model.
69type UI struct {
70 com *common.Common
71 session *session.Session
72 sessionFiles []SessionFile
73
74 // The width and height of the terminal in cells.
75 width int
76 height int
77 layout layout
78
79 focus uiFocusState
80 state uiState
81
82 keyMap KeyMap
83 keyenh tea.KeyboardEnhancementsMsg
84
85 dialog *dialog.Overlay
86 status *Status
87
88 // header is the last cached header logo
89 header string
90
91 // sendProgressBar instructs the TUI to send progress bar updates to the
92 // terminal.
93 sendProgressBar bool
94
95 // QueryVersion instructs the TUI to query for the terminal version when it
96 // starts.
97 QueryVersion bool
98
99 // Editor components
100 textarea textarea.Model
101
102 attachments []message.Attachment // TODO: Implement attachments
103
104 readyPlaceholder string
105 workingPlaceholder string
106
107 // Completions state
108 completions *completions.Completions
109 completionsOpen bool
110 completionsStartIndex int
111 completionsQuery string
112 completionsPositionStart image.Point // x,y where user typed '@'
113
114 // Chat components
115 chat *Chat
116
117 // onboarding state
118 onboarding struct {
119 yesInitializeSelected bool
120 }
121
122 // lsp
123 lspStates map[string]app.LSPClientInfo
124
125 // mcp
126 mcpStates map[string]mcp.ClientInfo
127
128 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
129 sidebarLogo string
130}
131
132// New creates a new instance of the [UI] model.
133func New(com *common.Common) *UI {
134 // Editor components
135 ta := textarea.New()
136 ta.SetStyles(com.Styles.TextArea)
137 ta.ShowLineNumbers = false
138 ta.CharLimit = -1
139 ta.SetVirtualCursor(false)
140 ta.Focus()
141
142 ch := NewChat(com)
143
144 // Completions component
145 comp := completions.New(
146 com.Styles.Completions.Normal,
147 com.Styles.Completions.Focused,
148 com.Styles.Completions.Match,
149 )
150
151 ui := &UI{
152 com: com,
153 dialog: dialog.NewOverlay(),
154 keyMap: DefaultKeyMap(),
155 focus: uiFocusNone,
156 state: uiConfigure,
157 textarea: ta,
158 chat: ch,
159 completions: comp,
160 }
161
162 status := NewStatus(com, ui)
163
164 // set onboarding state defaults
165 ui.onboarding.yesInitializeSelected = true
166
167 // If no provider is configured show the user the provider list
168 if !com.Config().IsConfigured() {
169 ui.state = uiConfigure
170 // if the project needs initialization show the user the question
171 } else if n, _ := config.ProjectNeedsInitialization(); n {
172 ui.state = uiInitialize
173 // otherwise go to the landing UI
174 } else {
175 ui.state = uiLanding
176 ui.focus = uiFocusEditor
177 }
178
179 ui.setEditorPrompt(false)
180 ui.randomizePlaceholders()
181 ui.textarea.Placeholder = ui.readyPlaceholder
182 ui.status = status
183
184 return ui
185}
186
187// Init initializes the UI model.
188func (m *UI) Init() tea.Cmd {
189 var cmds []tea.Cmd
190 if m.QueryVersion {
191 cmds = append(cmds, tea.RequestTerminalVersion)
192 }
193 return tea.Batch(cmds...)
194}
195
196// Update handles updates to the UI model.
197func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
198 var cmds []tea.Cmd
199 switch msg := msg.(type) {
200 case tea.EnvMsg:
201 // Is this Windows Terminal?
202 if !m.sendProgressBar {
203 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
204 }
205 case loadSessionMsg:
206 m.state = uiChat
207 m.session = msg.session
208 m.sessionFiles = msg.files
209 msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
210 if err != nil {
211 cmds = append(cmds, uiutil.ReportError(err))
212 break
213 }
214 if cmd := m.setSessionMessages(msgs); cmd != nil {
215 cmds = append(cmds, cmd)
216 }
217
218 case pubsub.Event[message.Message]:
219 // Check if this is a child session message for an agent tool.
220 if m.session == nil {
221 break
222 }
223 if msg.Payload.SessionID != m.session.ID {
224 // This might be a child session message from an agent tool.
225 if cmd := m.handleChildSessionMessage(msg); cmd != nil {
226 cmds = append(cmds, cmd)
227 }
228 break
229 }
230 switch msg.Type {
231 case pubsub.CreatedEvent:
232 cmds = append(cmds, m.appendSessionMessage(msg.Payload))
233 case pubsub.UpdatedEvent:
234 cmds = append(cmds, m.updateSessionMessage(msg.Payload))
235 }
236 case pubsub.Event[history.File]:
237 cmds = append(cmds, m.handleFileEvent(msg.Payload))
238 case pubsub.Event[app.LSPEvent]:
239 m.lspStates = app.GetLSPStates()
240 case pubsub.Event[mcp.Event]:
241 m.mcpStates = mcp.GetStates()
242 if msg.Type == pubsub.UpdatedEvent && m.dialog.ContainsDialog(dialog.CommandsID) {
243 dia := m.dialog.Dialog(dialog.CommandsID)
244 if dia == nil {
245 break
246 }
247
248 commands, ok := dia.(*dialog.Commands)
249 if ok {
250 if cmd := commands.ReloadMCPPrompts(); cmd != nil {
251 cmds = append(cmds, cmd)
252 }
253 }
254 }
255 case tea.TerminalVersionMsg:
256 termVersion := strings.ToLower(msg.Name)
257 // Only enable progress bar for the following terminals.
258 if !m.sendProgressBar {
259 m.sendProgressBar = strings.Contains(termVersion, "ghostty")
260 }
261 return m, nil
262 case tea.WindowSizeMsg:
263 m.width, m.height = msg.Width, msg.Height
264 m.updateLayoutAndSize()
265 case tea.KeyboardEnhancementsMsg:
266 m.keyenh = msg
267 if msg.SupportsKeyDisambiguation() {
268 m.keyMap.Models.SetHelp("ctrl+m", "models")
269 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
270 }
271 case tea.MouseClickMsg:
272 switch m.state {
273 case uiChat:
274 x, y := msg.X, msg.Y
275 // Adjust for chat area position
276 x -= m.layout.main.Min.X
277 y -= m.layout.main.Min.Y
278 m.chat.HandleMouseDown(x, y)
279 }
280
281 case tea.MouseMotionMsg:
282 switch m.state {
283 case uiChat:
284 if msg.Y <= 0 {
285 if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
286 cmds = append(cmds, cmd)
287 }
288 if !m.chat.SelectedItemInView() {
289 m.chat.SelectPrev()
290 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
291 cmds = append(cmds, cmd)
292 }
293 }
294 } else if msg.Y >= m.chat.Height()-1 {
295 if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
296 cmds = append(cmds, cmd)
297 }
298 if !m.chat.SelectedItemInView() {
299 m.chat.SelectNext()
300 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
301 cmds = append(cmds, cmd)
302 }
303 }
304 }
305
306 x, y := msg.X, msg.Y
307 // Adjust for chat area position
308 x -= m.layout.main.Min.X
309 y -= m.layout.main.Min.Y
310 m.chat.HandleMouseDrag(x, y)
311 }
312
313 case tea.MouseReleaseMsg:
314 switch m.state {
315 case uiChat:
316 x, y := msg.X, msg.Y
317 // Adjust for chat area position
318 x -= m.layout.main.Min.X
319 y -= m.layout.main.Min.Y
320 m.chat.HandleMouseUp(x, y)
321 }
322 case tea.MouseWheelMsg:
323 switch m.state {
324 case uiChat:
325 switch msg.Button {
326 case tea.MouseWheelUp:
327 if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil {
328 cmds = append(cmds, cmd)
329 }
330 if !m.chat.SelectedItemInView() {
331 m.chat.SelectPrev()
332 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
333 cmds = append(cmds, cmd)
334 }
335 }
336 case tea.MouseWheelDown:
337 if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil {
338 cmds = append(cmds, cmd)
339 }
340 if !m.chat.SelectedItemInView() {
341 m.chat.SelectNext()
342 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
343 cmds = append(cmds, cmd)
344 }
345 }
346 }
347 }
348 case anim.StepMsg:
349 if m.state == uiChat {
350 if cmd := m.chat.Animate(msg); cmd != nil {
351 cmds = append(cmds, cmd)
352 }
353 }
354 case tea.KeyPressMsg:
355 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
356 cmds = append(cmds, cmd)
357 }
358 case tea.PasteMsg:
359 if cmd := m.handlePasteMsg(msg); cmd != nil {
360 cmds = append(cmds, cmd)
361 }
362 case openEditorMsg:
363 m.textarea.SetValue(msg.Text)
364 m.textarea.MoveToEnd()
365 case uiutil.InfoMsg:
366 m.status.SetInfoMsg(msg)
367 ttl := msg.TTL
368 if ttl <= 0 {
369 ttl = DefaultStatusTTL
370 }
371 cmds = append(cmds, clearInfoMsgCmd(ttl))
372 case uiutil.ClearStatusMsg:
373 m.status.ClearInfoMsg()
374 }
375
376 // This logic gets triggered on any message type, but should it?
377 switch m.focus {
378 case uiFocusMain:
379 case uiFocusEditor:
380 // Textarea placeholder logic
381 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
382 m.textarea.Placeholder = m.workingPlaceholder
383 } else {
384 m.textarea.Placeholder = m.readyPlaceholder
385 }
386 if m.com.App.Permissions.SkipRequests() {
387 m.textarea.Placeholder = "Yolo mode!"
388 }
389 }
390
391 return m, tea.Batch(cmds...)
392}
393
394// setSessionMessages sets the messages for the current session in the chat
395func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
396 var cmds []tea.Cmd
397 // Build tool result map to link tool calls with their results
398 msgPtrs := make([]*message.Message, len(msgs))
399 for i := range msgs {
400 msgPtrs[i] = &msgs[i]
401 }
402 toolResultMap := chat.BuildToolResultMap(msgPtrs)
403
404 // Add messages to chat with linked tool results
405 items := make([]chat.MessageItem, 0, len(msgs)*2)
406 for _, msg := range msgPtrs {
407 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
408 }
409
410 // Load nested tool calls for agent/agentic_fetch tools.
411 m.loadNestedToolCalls(items)
412
413 // If the user switches between sessions while the agent is working we want
414 // to make sure the animations are shown.
415 for _, item := range items {
416 if animatable, ok := item.(chat.Animatable); ok {
417 if cmd := animatable.StartAnimation(); cmd != nil {
418 cmds = append(cmds, cmd)
419 }
420 }
421 }
422
423 m.chat.SetMessages(items...)
424 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
425 cmds = append(cmds, cmd)
426 }
427 m.chat.SelectLast()
428 return tea.Batch(cmds...)
429}
430
431// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
432func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
433 for _, item := range items {
434 nestedContainer, ok := item.(chat.NestedToolContainer)
435 if !ok {
436 continue
437 }
438 toolItem, ok := item.(chat.ToolMessageItem)
439 if !ok {
440 continue
441 }
442
443 tc := toolItem.ToolCall()
444 messageID := toolItem.MessageID()
445
446 // Get the agent tool session ID.
447 agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
448
449 // Fetch nested messages.
450 nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
451 if err != nil || len(nestedMsgs) == 0 {
452 continue
453 }
454
455 // Build tool result map for nested messages.
456 nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
457 for i := range nestedMsgs {
458 nestedMsgPtrs[i] = &nestedMsgs[i]
459 }
460 nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
461
462 // Extract nested tool items.
463 var nestedTools []chat.ToolMessageItem
464 for _, nestedMsg := range nestedMsgPtrs {
465 nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
466 for _, nestedItem := range nestedItems {
467 if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
468 // Mark nested tools as simple (compact) rendering.
469 if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
470 simplifiable.SetCompact(true)
471 }
472 nestedTools = append(nestedTools, nestedToolItem)
473 }
474 }
475 }
476
477 // Recursively load nested tool calls for any agent tools within.
478 nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
479 for i, nt := range nestedTools {
480 nestedMessageItems[i] = nt
481 }
482 m.loadNestedToolCalls(nestedMessageItems)
483
484 // Set nested tools on the parent.
485 nestedContainer.SetNestedTools(nestedTools)
486 }
487}
488
489// appendSessionMessage appends a new message to the current session in the chat
490// if the message is a tool result it will update the corresponding tool call message
491func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
492 var cmds []tea.Cmd
493 switch msg.Role {
494 case message.User, message.Assistant:
495 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
496 for _, item := range items {
497 if animatable, ok := item.(chat.Animatable); ok {
498 if cmd := animatable.StartAnimation(); cmd != nil {
499 cmds = append(cmds, cmd)
500 }
501 }
502 }
503 m.chat.AppendMessages(items...)
504 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
505 cmds = append(cmds, cmd)
506 }
507 case message.Tool:
508 for _, tr := range msg.ToolResults() {
509 toolItem := m.chat.MessageItem(tr.ToolCallID)
510 if toolItem == nil {
511 // we should have an item!
512 continue
513 }
514 if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
515 toolMsgItem.SetResult(&tr)
516 }
517 }
518 }
519 return tea.Batch(cmds...)
520}
521
522// updateSessionMessage updates an existing message in the current session in the chat
523// when an assistant message is updated it may include updated tool calls as well
524// that is why we need to handle creating/updating each tool call message too
525func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
526 var cmds []tea.Cmd
527 existingItem := m.chat.MessageItem(msg.ID)
528
529 if existingItem != nil {
530 if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
531 assistantItem.SetMessage(&msg)
532 }
533 }
534
535 // if the message of the assistant does not have any response just tool calls we need to remove it
536 if !chat.ShouldRenderAssistantMessage(&msg) && len(msg.ToolCalls()) > 0 && existingItem != nil {
537 m.chat.RemoveMessage(msg.ID)
538 }
539
540 var items []chat.MessageItem
541 for _, tc := range msg.ToolCalls() {
542 existingToolItem := m.chat.MessageItem(tc.ID)
543 if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
544 existingToolCall := toolItem.ToolCall()
545 // only update if finished state changed or input changed
546 // to avoid clearing the cache
547 if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
548 toolItem.SetToolCall(tc)
549 }
550 }
551 if existingToolItem == nil {
552 items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
553 }
554 }
555
556 for _, item := range items {
557 if animatable, ok := item.(chat.Animatable); ok {
558 if cmd := animatable.StartAnimation(); cmd != nil {
559 cmds = append(cmds, cmd)
560 }
561 }
562 }
563 m.chat.AppendMessages(items...)
564 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
565 cmds = append(cmds, cmd)
566 }
567
568 return tea.Batch(cmds...)
569}
570
571// handleChildSessionMessage handles messages from child sessions (agent tools).
572func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
573 var cmds []tea.Cmd
574
575 // Only process messages with tool calls or results.
576 if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
577 return nil
578 }
579
580 // Check if this is an agent tool session and parse it.
581 childSessionID := event.Payload.SessionID
582 _, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
583 if !ok {
584 return nil
585 }
586
587 // Find the parent agent tool item.
588 var agentItem chat.NestedToolContainer
589 for i := 0; i < m.chat.Len(); i++ {
590 item := m.chat.MessageItem(toolCallID)
591 if item == nil {
592 continue
593 }
594 if agent, ok := item.(chat.NestedToolContainer); ok {
595 if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
596 if toolMessageItem.ToolCall().ID == toolCallID {
597 // Verify this agent belongs to the correct parent message.
598 // We can't directly check parentMessageID on the item, so we trust the session parsing.
599 agentItem = agent
600 break
601 }
602 }
603 }
604 }
605
606 if agentItem == nil {
607 return nil
608 }
609
610 // Get existing nested tools.
611 nestedTools := agentItem.NestedTools()
612
613 // Update or create nested tool calls.
614 for _, tc := range event.Payload.ToolCalls() {
615 found := false
616 for _, existingTool := range nestedTools {
617 if existingTool.ToolCall().ID == tc.ID {
618 existingTool.SetToolCall(tc)
619 found = true
620 break
621 }
622 }
623 if !found {
624 // Create a new nested tool item.
625 nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
626 if simplifiable, ok := nestedItem.(chat.Compactable); ok {
627 simplifiable.SetCompact(true)
628 }
629 if animatable, ok := nestedItem.(chat.Animatable); ok {
630 if cmd := animatable.StartAnimation(); cmd != nil {
631 cmds = append(cmds, cmd)
632 }
633 }
634 nestedTools = append(nestedTools, nestedItem)
635 }
636 }
637
638 // Update nested tool results.
639 for _, tr := range event.Payload.ToolResults() {
640 for _, nestedTool := range nestedTools {
641 if nestedTool.ToolCall().ID == tr.ToolCallID {
642 nestedTool.SetResult(&tr)
643 break
644 }
645 }
646 }
647
648 // Update the agent item with the new nested tools.
649 agentItem.SetNestedTools(nestedTools)
650
651 // Update the chat so it updates the index map for animations to work as expected
652 m.chat.UpdateNestedToolIDs(toolCallID)
653
654 return tea.Batch(cmds...)
655}
656
657func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
658 var cmds []tea.Cmd
659
660 handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
661 switch {
662 case key.Matches(msg, m.keyMap.Help):
663 m.status.ToggleHelp()
664 m.updateLayoutAndSize()
665 return true
666 case key.Matches(msg, m.keyMap.Commands):
667 if cmd := m.openCommandsDialog(); cmd != nil {
668 cmds = append(cmds, cmd)
669 }
670 return true
671 case key.Matches(msg, m.keyMap.Models):
672 if cmd := m.openModelsDialog(); cmd != nil {
673 cmds = append(cmds, cmd)
674 }
675 return true
676 case key.Matches(msg, m.keyMap.Sessions):
677 if cmd := m.openSessionsDialog(); cmd != nil {
678 cmds = append(cmds, cmd)
679 }
680 return true
681 }
682 return false
683 }
684
685 if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
686 // Always handle quit keys first
687 if cmd := m.openQuitDialog(); cmd != nil {
688 cmds = append(cmds, cmd)
689 }
690
691 return tea.Batch(cmds...)
692 }
693
694 // Route all messages to dialog if one is open.
695 if m.dialog.HasDialogs() {
696 msg := m.dialog.Update(msg)
697 if msg == nil {
698 return tea.Batch(cmds...)
699 }
700
701 switch msg := msg.(type) {
702 // Generic dialog messages
703 case dialog.CloseMsg:
704 m.dialog.CloseFrontDialog()
705
706 // Session dialog messages
707 case dialog.SessionSelectedMsg:
708 m.dialog.CloseDialog(dialog.SessionsID)
709 cmds = append(cmds, m.loadSession(msg.Session.ID))
710
711 // Open dialog message
712 case dialog.OpenDialogMsg:
713 switch msg.DialogID {
714 case dialog.SessionsID:
715 if cmd := m.openSessionsDialog(); cmd != nil {
716 cmds = append(cmds, cmd)
717 }
718 case dialog.ModelsID:
719 if cmd := m.openModelsDialog(); cmd != nil {
720 cmds = append(cmds, cmd)
721 }
722 default:
723 // Unknown dialog
724 break
725 }
726
727 m.dialog.CloseDialog(dialog.CommandsID)
728
729 // Command dialog messages
730 case dialog.ToggleYoloModeMsg:
731 yolo := !m.com.App.Permissions.SkipRequests()
732 m.com.App.Permissions.SetSkipRequests(yolo)
733 m.setEditorPrompt(yolo)
734 m.dialog.CloseDialog(dialog.CommandsID)
735 case dialog.NewSessionsMsg:
736 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
737 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
738 break
739 }
740 m.newSession()
741 m.dialog.CloseDialog(dialog.CommandsID)
742 case dialog.CompactMsg:
743 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
744 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
745 break
746 }
747 err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
748 if err != nil {
749 cmds = append(cmds, uiutil.ReportError(err))
750 }
751 case dialog.ToggleHelpMsg:
752 m.status.ToggleHelp()
753 m.dialog.CloseDialog(dialog.CommandsID)
754 case dialog.QuitMsg:
755 cmds = append(cmds, tea.Quit)
756 case dialog.ModelSelectedMsg:
757 if m.com.App.AgentCoordinator.IsBusy() {
758 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
759 break
760 }
761
762 // TODO: Validate model API and authentication here?
763
764 cfg := m.com.Config()
765 if cfg == nil {
766 cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
767 break
768 }
769
770 if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
771 cmds = append(cmds, uiutil.ReportError(err))
772 }
773
774 // XXX: Should this be in a separate goroutine?
775 go m.com.App.UpdateAgentModel(context.TODO())
776
777 modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
778 cmds = append(cmds, uiutil.ReportInfo(modelMsg))
779 m.dialog.CloseDialog(dialog.ModelsID)
780 }
781
782 return tea.Batch(cmds...)
783 }
784
785 switch m.state {
786 case uiConfigure:
787 return tea.Batch(cmds...)
788 case uiInitialize:
789 cmds = append(cmds, m.updateInitializeView(msg)...)
790 return tea.Batch(cmds...)
791 case uiChat, uiLanding, uiChatCompact:
792 switch m.focus {
793 case uiFocusEditor:
794 // Handle completions if open.
795 if m.completionsOpen {
796 if msg, ok := m.completions.Update(msg); ok {
797 switch msg := msg.(type) {
798 case completions.SelectionMsg:
799 // Handle file completion selection.
800 if item, ok := msg.Value.(completions.FileCompletionValue); ok {
801 m.insertFileCompletion(item.Path)
802 }
803 if !msg.Insert {
804 m.closeCompletions()
805 }
806 case completions.ClosedMsg:
807 m.completionsOpen = false
808 }
809 return tea.Batch(cmds...)
810 }
811 }
812
813 switch {
814 case key.Matches(msg, m.keyMap.Editor.SendMessage):
815 value := m.textarea.Value()
816 if before, ok := strings.CutSuffix(value, "\\"); ok {
817 // If the last character is a backslash, remove it and add a newline.
818 m.textarea.SetValue(before)
819 break
820 }
821
822 // Otherwise, send the message
823 m.textarea.Reset()
824
825 value = strings.TrimSpace(value)
826 if value == "exit" || value == "quit" {
827 return m.openQuitDialog()
828 }
829
830 attachments := m.attachments
831 m.attachments = nil
832 if len(value) == 0 {
833 return nil
834 }
835
836 m.randomizePlaceholders()
837
838 return m.sendMessage(value, attachments)
839 case key.Matches(msg, m.keyMap.Chat.NewSession):
840 if m.session == nil || m.session.ID == "" {
841 break
842 }
843 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
844 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
845 break
846 }
847 m.newSession()
848 case key.Matches(msg, m.keyMap.Tab):
849 m.focus = uiFocusMain
850 m.textarea.Blur()
851 m.chat.Focus()
852 m.chat.SetSelected(m.chat.Len() - 1)
853 case key.Matches(msg, m.keyMap.Editor.OpenEditor):
854 if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) {
855 cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
856 break
857 }
858 cmds = append(cmds, m.openEditor(m.textarea.Value()))
859 case key.Matches(msg, m.keyMap.Editor.Newline):
860 m.textarea.InsertRune('\n')
861 m.closeCompletions()
862 default:
863 if handleGlobalKeys(msg) {
864 // Handle global keys first before passing to textarea.
865 break
866 }
867
868 // Check for @ trigger before passing to textarea.
869 curValue := m.textarea.Value()
870 curIdx := len(curValue)
871
872 // Trigger completions on @.
873 if msg.String() == "@" && !m.completionsOpen {
874 // Only show if beginning of prompt or after whitespace.
875 if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
876 m.completionsOpen = true
877 m.completionsQuery = ""
878 m.completionsStartIndex = curIdx
879 m.completionsPositionStart = m.completionsPosition()
880 depth, limit := m.com.Config().Options.TUI.Completions.Limits()
881 m.completions.OpenWithFiles(depth, limit)
882 }
883 }
884
885 ta, cmd := m.textarea.Update(msg)
886 m.textarea = ta
887 cmds = append(cmds, cmd)
888
889 // After updating textarea, check if we need to filter completions.
890 // Skip filtering on the initial @ keystroke since items are loading async.
891 if m.completionsOpen && msg.String() != "@" {
892 newValue := m.textarea.Value()
893 newIdx := len(newValue)
894
895 // Close completions if cursor moved before start.
896 if newIdx <= m.completionsStartIndex {
897 m.closeCompletions()
898 } else if msg.String() == "space" {
899 // Close on space.
900 m.closeCompletions()
901 } else {
902 // Extract current word and filter.
903 word := m.textareaWord()
904 if strings.HasPrefix(word, "@") {
905 m.completionsQuery = word[1:]
906 m.completions.Filter(m.completionsQuery)
907 } else if m.completionsOpen {
908 m.closeCompletions()
909 }
910 }
911 }
912 }
913 case uiFocusMain:
914 switch {
915 case key.Matches(msg, m.keyMap.Tab):
916 m.focus = uiFocusEditor
917 cmds = append(cmds, m.textarea.Focus())
918 m.chat.Blur()
919 case key.Matches(msg, m.keyMap.Chat.Expand):
920 m.chat.ToggleExpandedSelectedItem()
921 case key.Matches(msg, m.keyMap.Chat.Up):
922 if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
923 cmds = append(cmds, cmd)
924 }
925 if !m.chat.SelectedItemInView() {
926 m.chat.SelectPrev()
927 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
928 cmds = append(cmds, cmd)
929 }
930 }
931 case key.Matches(msg, m.keyMap.Chat.Down):
932 if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
933 cmds = append(cmds, cmd)
934 }
935 if !m.chat.SelectedItemInView() {
936 m.chat.SelectNext()
937 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
938 cmds = append(cmds, cmd)
939 }
940 }
941 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
942 m.chat.SelectPrev()
943 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
944 cmds = append(cmds, cmd)
945 }
946 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
947 m.chat.SelectNext()
948 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
949 cmds = append(cmds, cmd)
950 }
951 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
952 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
953 cmds = append(cmds, cmd)
954 }
955 m.chat.SelectFirstInView()
956 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
957 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
958 cmds = append(cmds, cmd)
959 }
960 m.chat.SelectLastInView()
961 case key.Matches(msg, m.keyMap.Chat.PageUp):
962 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
963 cmds = append(cmds, cmd)
964 }
965 m.chat.SelectFirstInView()
966 case key.Matches(msg, m.keyMap.Chat.PageDown):
967 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
968 cmds = append(cmds, cmd)
969 }
970 m.chat.SelectLastInView()
971 case key.Matches(msg, m.keyMap.Chat.Home):
972 if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
973 cmds = append(cmds, cmd)
974 }
975 m.chat.SelectFirst()
976 case key.Matches(msg, m.keyMap.Chat.End):
977 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
978 cmds = append(cmds, cmd)
979 }
980 m.chat.SelectLast()
981 default:
982 handleGlobalKeys(msg)
983 }
984 default:
985 handleGlobalKeys(msg)
986 }
987 default:
988 handleGlobalKeys(msg)
989 }
990
991 return tea.Batch(cmds...)
992}
993
994// Draw implements [uv.Drawable] and draws the UI model.
995func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
996 layout := m.generateLayout(area.Dx(), area.Dy())
997
998 if m.layout != layout {
999 m.layout = layout
1000 m.updateSize()
1001 }
1002
1003 // Clear the screen first
1004 screen.Clear(scr)
1005
1006 switch m.state {
1007 case uiConfigure:
1008 header := uv.NewStyledString(m.header)
1009 header.Draw(scr, layout.header)
1010
1011 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
1012 Height(layout.main.Dy()).
1013 Background(lipgloss.ANSIColor(rand.Intn(256))).
1014 Render(" Configure ")
1015 main := uv.NewStyledString(mainView)
1016 main.Draw(scr, layout.main)
1017
1018 case uiInitialize:
1019 header := uv.NewStyledString(m.header)
1020 header.Draw(scr, layout.header)
1021
1022 main := uv.NewStyledString(m.initializeView())
1023 main.Draw(scr, layout.main)
1024
1025 case uiLanding:
1026 header := uv.NewStyledString(m.header)
1027 header.Draw(scr, layout.header)
1028 main := uv.NewStyledString(m.landingView())
1029 main.Draw(scr, layout.main)
1030
1031 editor := uv.NewStyledString(m.textarea.View())
1032 editor.Draw(scr, layout.editor)
1033
1034 case uiChat:
1035 m.chat.Draw(scr, layout.main)
1036
1037 header := uv.NewStyledString(m.header)
1038 header.Draw(scr, layout.header)
1039 m.drawSidebar(scr, layout.sidebar)
1040
1041 editor := uv.NewStyledString(m.textarea.View())
1042 editor.Draw(scr, layout.editor)
1043
1044 case uiChatCompact:
1045 header := uv.NewStyledString(m.header)
1046 header.Draw(scr, layout.header)
1047
1048 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
1049 Height(layout.main.Dy()).
1050 Background(lipgloss.ANSIColor(rand.Intn(256))).
1051 Render(" Compact Chat Messages ")
1052 main := uv.NewStyledString(mainView)
1053 main.Draw(scr, layout.main)
1054
1055 editor := uv.NewStyledString(m.textarea.View())
1056 editor.Draw(scr, layout.editor)
1057 }
1058
1059 // Add status and help layer
1060 m.status.Draw(scr, layout.status)
1061
1062 // Draw completions popup if open
1063 if m.completionsOpen && m.completions.HasItems() {
1064 w, h := m.completions.Size()
1065 x := m.completionsPositionStart.X
1066 y := m.completionsPositionStart.Y - h
1067
1068 screenW := area.Dx()
1069 if x+w > screenW {
1070 x = screenW - w
1071 }
1072 x = max(0, x)
1073 y = max(0, y)
1074
1075 completionsView := uv.NewStyledString(m.completions.Render())
1076 completionsView.Draw(scr, image.Rectangle{
1077 Min: image.Pt(x, y),
1078 Max: image.Pt(x+w, y+h),
1079 })
1080 }
1081
1082 // Debugging rendering (visually see when the tui rerenders)
1083 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
1084 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
1085 debug := uv.NewStyledString(debugView.String())
1086 debug.Draw(scr, image.Rectangle{
1087 Min: image.Pt(4, 1),
1088 Max: image.Pt(8, 3),
1089 })
1090 }
1091
1092 // This needs to come last to overlay on top of everything
1093 if m.dialog.HasDialogs() {
1094 m.dialog.Draw(scr, area)
1095 }
1096}
1097
1098// Cursor returns the cursor position and properties for the UI model. It
1099// returns nil if the cursor should not be shown.
1100func (m *UI) Cursor() *tea.Cursor {
1101 if m.layout.editor.Dy() <= 0 {
1102 // Don't show cursor if editor is not visible
1103 return nil
1104 }
1105 if m.dialog.HasDialogs() {
1106 if front := m.dialog.DialogLast(); front != nil {
1107 c, ok := front.(uiutil.Cursor)
1108 if ok {
1109 cur := c.Cursor()
1110 if cur != nil {
1111 pos := m.dialog.CenterPosition(m.layout.area, front.ID())
1112 cur.X += pos.Min.X
1113 cur.Y += pos.Min.Y
1114 return cur
1115 }
1116 }
1117 }
1118 return nil
1119 }
1120 switch m.focus {
1121 case uiFocusEditor:
1122 if m.textarea.Focused() {
1123 cur := m.textarea.Cursor()
1124 cur.X++ // Adjust for app margins
1125 cur.Y += m.layout.editor.Min.Y
1126 return cur
1127 }
1128 }
1129 return nil
1130}
1131
1132// View renders the UI model's view.
1133func (m *UI) View() tea.View {
1134 var v tea.View
1135 v.AltScreen = true
1136 v.BackgroundColor = m.com.Styles.Background
1137 v.Cursor = m.Cursor()
1138 v.MouseMode = tea.MouseModeCellMotion
1139
1140 canvas := uv.NewScreenBuffer(m.width, m.height)
1141 m.Draw(canvas, canvas.Bounds())
1142
1143 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
1144 contentLines := strings.Split(content, "\n")
1145 for i, line := range contentLines {
1146 // Trim trailing spaces for concise rendering
1147 contentLines[i] = strings.TrimRight(line, " ")
1148 }
1149
1150 content = strings.Join(contentLines, "\n")
1151
1152 v.Content = content
1153 if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
1154 // HACK: use a random percentage to prevent ghostty from hiding it
1155 // after a timeout.
1156 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
1157 }
1158
1159 return v
1160}
1161
1162// ShortHelp implements [help.KeyMap].
1163func (m *UI) ShortHelp() []key.Binding {
1164 var binds []key.Binding
1165 k := &m.keyMap
1166 tab := k.Tab
1167 commands := k.Commands
1168 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1169 commands.SetHelp("/ or ctrl+p", "commands")
1170 }
1171
1172 switch m.state {
1173 case uiInitialize:
1174 binds = append(binds, k.Quit)
1175 case uiChat:
1176 if m.focus == uiFocusEditor {
1177 tab.SetHelp("tab", "focus chat")
1178 } else {
1179 tab.SetHelp("tab", "focus editor")
1180 }
1181
1182 binds = append(binds,
1183 tab,
1184 commands,
1185 k.Models,
1186 )
1187
1188 switch m.focus {
1189 case uiFocusEditor:
1190 binds = append(binds,
1191 k.Editor.Newline,
1192 )
1193 case uiFocusMain:
1194 binds = append(binds,
1195 k.Chat.UpDown,
1196 k.Chat.UpDownOneItem,
1197 k.Chat.PageUp,
1198 k.Chat.PageDown,
1199 k.Chat.Copy,
1200 )
1201 }
1202 default:
1203 // TODO: other states
1204 // if m.session == nil {
1205 // no session selected
1206 binds = append(binds,
1207 commands,
1208 k.Models,
1209 k.Editor.Newline,
1210 )
1211 }
1212
1213 binds = append(binds,
1214 k.Quit,
1215 k.Help,
1216 )
1217
1218 return binds
1219}
1220
1221// FullHelp implements [help.KeyMap].
1222func (m *UI) FullHelp() [][]key.Binding {
1223 var binds [][]key.Binding
1224 k := &m.keyMap
1225 help := k.Help
1226 help.SetHelp("ctrl+g", "less")
1227 hasAttachments := false // TODO: implement attachments
1228 hasSession := m.session != nil && m.session.ID != ""
1229 commands := k.Commands
1230 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1231 commands.SetHelp("/ or ctrl+p", "commands")
1232 }
1233
1234 switch m.state {
1235 case uiInitialize:
1236 binds = append(binds,
1237 []key.Binding{
1238 k.Quit,
1239 })
1240 case uiChat:
1241 mainBinds := []key.Binding{}
1242 tab := k.Tab
1243 if m.focus == uiFocusEditor {
1244 tab.SetHelp("tab", "focus chat")
1245 } else {
1246 tab.SetHelp("tab", "focus editor")
1247 }
1248
1249 mainBinds = append(mainBinds,
1250 tab,
1251 commands,
1252 k.Models,
1253 k.Sessions,
1254 )
1255 if hasSession {
1256 mainBinds = append(mainBinds, k.Chat.NewSession)
1257 }
1258
1259 binds = append(binds, mainBinds)
1260
1261 switch m.focus {
1262 case uiFocusEditor:
1263 binds = append(binds,
1264 []key.Binding{
1265 k.Editor.Newline,
1266 k.Editor.AddImage,
1267 k.Editor.MentionFile,
1268 k.Editor.OpenEditor,
1269 },
1270 )
1271 if hasAttachments {
1272 binds = append(binds,
1273 []key.Binding{
1274 k.Editor.AttachmentDeleteMode,
1275 k.Editor.DeleteAllAttachments,
1276 k.Editor.Escape,
1277 },
1278 )
1279 }
1280 case uiFocusMain:
1281 binds = append(binds,
1282 []key.Binding{
1283 k.Chat.UpDown,
1284 k.Chat.UpDownOneItem,
1285 k.Chat.PageUp,
1286 k.Chat.PageDown,
1287 },
1288 []key.Binding{
1289 k.Chat.HalfPageUp,
1290 k.Chat.HalfPageDown,
1291 k.Chat.Home,
1292 k.Chat.End,
1293 },
1294 []key.Binding{
1295 k.Chat.Copy,
1296 k.Chat.ClearHighlight,
1297 },
1298 )
1299 }
1300 default:
1301 if m.session == nil {
1302 // no session selected
1303 binds = append(binds,
1304 []key.Binding{
1305 commands,
1306 k.Models,
1307 k.Sessions,
1308 },
1309 []key.Binding{
1310 k.Editor.Newline,
1311 k.Editor.AddImage,
1312 k.Editor.MentionFile,
1313 k.Editor.OpenEditor,
1314 },
1315 []key.Binding{
1316 help,
1317 },
1318 )
1319 }
1320 }
1321
1322 binds = append(binds,
1323 []key.Binding{
1324 help,
1325 k.Quit,
1326 },
1327 )
1328
1329 return binds
1330}
1331
1332// updateLayoutAndSize updates the layout and sizes of UI components.
1333func (m *UI) updateLayoutAndSize() {
1334 m.layout = m.generateLayout(m.width, m.height)
1335 m.updateSize()
1336}
1337
1338// updateSize updates the sizes of UI components based on the current layout.
1339func (m *UI) updateSize() {
1340 // Set status width
1341 m.status.SetWidth(m.layout.status.Dx())
1342
1343 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
1344 m.textarea.SetWidth(m.layout.editor.Dx())
1345 m.textarea.SetHeight(m.layout.editor.Dy())
1346
1347 // Handle different app states
1348 switch m.state {
1349 case uiConfigure, uiInitialize, uiLanding:
1350 m.renderHeader(false, m.layout.header.Dx())
1351
1352 case uiChat:
1353 m.renderSidebarLogo(m.layout.sidebar.Dx())
1354
1355 case uiChatCompact:
1356 // TODO: set the width and heigh of the chat component
1357 m.renderHeader(true, m.layout.header.Dx())
1358 }
1359}
1360
1361// generateLayout calculates the layout rectangles for all UI components based
1362// on the current UI state and terminal dimensions.
1363func (m *UI) generateLayout(w, h int) layout {
1364 // The screen area we're working with
1365 area := image.Rect(0, 0, w, h)
1366
1367 // The help height
1368 helpHeight := 1
1369 // The editor height
1370 editorHeight := 5
1371 // The sidebar width
1372 sidebarWidth := 30
1373 // The header height
1374 // TODO: handle compact
1375 headerHeight := 4
1376
1377 var helpKeyMap help.KeyMap = m
1378 if m.status.ShowingAll() {
1379 for _, row := range helpKeyMap.FullHelp() {
1380 helpHeight = max(helpHeight, len(row))
1381 }
1382 }
1383
1384 // Add app margins
1385 appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight))
1386 appRect.Min.Y += 1
1387 appRect.Max.Y -= 1
1388 helpRect.Min.Y -= 1
1389 appRect.Min.X += 1
1390 appRect.Max.X -= 1
1391
1392 if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
1393 // extra padding on left and right for these states
1394 appRect.Min.X += 1
1395 appRect.Max.X -= 1
1396 }
1397
1398 layout := layout{
1399 area: area,
1400 status: helpRect,
1401 }
1402
1403 // Handle different app states
1404 switch m.state {
1405 case uiConfigure, uiInitialize:
1406 // Layout
1407 //
1408 // header
1409 // ------
1410 // main
1411 // ------
1412 // help
1413
1414 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1415 layout.header = headerRect
1416 layout.main = mainRect
1417
1418 case uiLanding:
1419 // Layout
1420 //
1421 // header
1422 // ------
1423 // main
1424 // ------
1425 // editor
1426 // ------
1427 // help
1428 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1429 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1430 // Remove extra padding from editor (but keep it for header and main)
1431 editorRect.Min.X -= 1
1432 editorRect.Max.X += 1
1433 layout.header = headerRect
1434 layout.main = mainRect
1435 layout.editor = editorRect
1436
1437 case uiChat:
1438 // Layout
1439 //
1440 // ------|---
1441 // main |
1442 // ------| side
1443 // editor|
1444 // ----------
1445 // help
1446
1447 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
1448 // Add padding left
1449 sideRect.Min.X += 1
1450 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1451 mainRect.Max.X -= 1 // Add padding right
1452 // Add bottom margin to main
1453 mainRect.Max.Y -= 1
1454 layout.sidebar = sideRect
1455 layout.main = mainRect
1456 layout.editor = editorRect
1457
1458 case uiChatCompact:
1459 // Layout
1460 //
1461 // compact-header
1462 // ------
1463 // main
1464 // ------
1465 // editor
1466 // ------
1467 // help
1468 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
1469 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1470 layout.header = headerRect
1471 layout.main = mainRect
1472 layout.editor = editorRect
1473 }
1474
1475 if !layout.editor.Empty() {
1476 // Add editor margins 1 top and bottom
1477 layout.editor.Min.Y += 1
1478 layout.editor.Max.Y -= 1
1479 }
1480
1481 return layout
1482}
1483
1484// layout defines the positioning of UI elements.
1485type layout struct {
1486 // area is the overall available area.
1487 area uv.Rectangle
1488
1489 // header is the header shown in special cases
1490 // e.x when the sidebar is collapsed
1491 // or when in the landing page
1492 // or in init/config
1493 header uv.Rectangle
1494
1495 // main is the area for the main pane. (e.x chat, configure, landing)
1496 main uv.Rectangle
1497
1498 // editor is the area for the editor pane.
1499 editor uv.Rectangle
1500
1501 // sidebar is the area for the sidebar.
1502 sidebar uv.Rectangle
1503
1504 // status is the area for the status view.
1505 status uv.Rectangle
1506}
1507
1508func (m *UI) openEditor(value string) tea.Cmd {
1509 editor := os.Getenv("EDITOR")
1510 if editor == "" {
1511 // Use platform-appropriate default editor
1512 if runtime.GOOS == "windows" {
1513 editor = "notepad"
1514 } else {
1515 editor = "nvim"
1516 }
1517 }
1518
1519 tmpfile, err := os.CreateTemp("", "msg_*.md")
1520 if err != nil {
1521 return uiutil.ReportError(err)
1522 }
1523 defer tmpfile.Close() //nolint:errcheck
1524 if _, err := tmpfile.WriteString(value); err != nil {
1525 return uiutil.ReportError(err)
1526 }
1527 cmdStr := editor + " " + tmpfile.Name()
1528 return uiutil.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
1529 if err != nil {
1530 return uiutil.ReportError(err)
1531 }
1532 content, err := os.ReadFile(tmpfile.Name())
1533 if err != nil {
1534 return uiutil.ReportError(err)
1535 }
1536 if len(content) == 0 {
1537 return uiutil.ReportWarn("Message is empty")
1538 }
1539 os.Remove(tmpfile.Name())
1540 return openEditorMsg{
1541 Text: strings.TrimSpace(string(content)),
1542 }
1543 })
1544}
1545
1546// setEditorPrompt configures the textarea prompt function based on whether
1547// yolo mode is enabled.
1548func (m *UI) setEditorPrompt(yolo bool) {
1549 if yolo {
1550 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
1551 return
1552 }
1553 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
1554}
1555
1556// normalPromptFunc returns the normal editor prompt style (" > " on first
1557// line, "::: " on subsequent lines).
1558func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
1559 t := m.com.Styles
1560 if info.LineNumber == 0 {
1561 if info.Focused {
1562 return " > "
1563 }
1564 return "::: "
1565 }
1566 if info.Focused {
1567 return t.EditorPromptNormalFocused.Render()
1568 }
1569 return t.EditorPromptNormalBlurred.Render()
1570}
1571
1572// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
1573// and colored dots.
1574func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
1575 t := m.com.Styles
1576 if info.LineNumber == 0 {
1577 if info.Focused {
1578 return t.EditorPromptYoloIconFocused.Render()
1579 } else {
1580 return t.EditorPromptYoloIconBlurred.Render()
1581 }
1582 }
1583 if info.Focused {
1584 return t.EditorPromptYoloDotsFocused.Render()
1585 }
1586 return t.EditorPromptYoloDotsBlurred.Render()
1587}
1588
1589// closeCompletions closes the completions popup and resets state.
1590func (m *UI) closeCompletions() {
1591 m.completionsOpen = false
1592 m.completionsQuery = ""
1593 m.completionsStartIndex = 0
1594 m.completions.Close()
1595}
1596
1597// insertFileCompletion inserts the selected file path into the textarea,
1598// replacing the @query, and adds the file as an attachment.
1599func (m *UI) insertFileCompletion(path string) {
1600 value := m.textarea.Value()
1601 word := m.textareaWord()
1602
1603 // Find the @ and query to replace.
1604 if m.completionsStartIndex > len(value) {
1605 return
1606 }
1607
1608 // Build the new value: everything before @, the path, everything after query.
1609 endIdx := m.completionsStartIndex + len(word)
1610 if endIdx > len(value) {
1611 endIdx = len(value)
1612 }
1613
1614 newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
1615 m.textarea.SetValue(newValue)
1616 // XXX: This will always move the cursor to the end of the textarea.
1617 m.textarea.MoveToEnd()
1618
1619 // Add file as attachment.
1620 content, err := os.ReadFile(path)
1621 if err != nil {
1622 // If it fails, let the LLM handle it later.
1623 return
1624 }
1625
1626 m.attachments = append(m.attachments, message.Attachment{
1627 FilePath: path,
1628 FileName: filepath.Base(path),
1629 MimeType: mimeOf(content),
1630 Content: content,
1631 })
1632}
1633
1634// completionsPosition returns the X and Y position for the completions popup.
1635func (m *UI) completionsPosition() image.Point {
1636 cur := m.textarea.Cursor()
1637 if cur == nil {
1638 return image.Point{
1639 X: m.layout.editor.Min.X,
1640 Y: m.layout.editor.Min.Y,
1641 }
1642 }
1643 return image.Point{
1644 X: cur.X + m.layout.editor.Min.X,
1645 Y: m.layout.editor.Min.Y + cur.Y,
1646 }
1647}
1648
1649// textareaWord returns the current word at the cursor position.
1650func (m *UI) textareaWord() string {
1651 return m.textarea.Word()
1652}
1653
1654// isWhitespace returns true if the byte is a whitespace character.
1655func isWhitespace(b byte) bool {
1656 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
1657}
1658
1659// mimeOf detects the MIME type of the given content.
1660func mimeOf(content []byte) string {
1661 mimeBufferSize := min(512, len(content))
1662 return http.DetectContentType(content[:mimeBufferSize])
1663}
1664
1665var readyPlaceholders = [...]string{
1666 "Ready!",
1667 "Ready...",
1668 "Ready?",
1669 "Ready for instructions",
1670}
1671
1672var workingPlaceholders = [...]string{
1673 "Working!",
1674 "Working...",
1675 "Brrrrr...",
1676 "Prrrrrrrr...",
1677 "Processing...",
1678 "Thinking...",
1679}
1680
1681// randomizePlaceholders selects random placeholder text for the textarea's
1682// ready and working states.
1683func (m *UI) randomizePlaceholders() {
1684 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
1685 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
1686}
1687
1688// renderHeader renders and caches the header logo at the specified width.
1689func (m *UI) renderHeader(compact bool, width int) {
1690 // TODO: handle the compact case differently
1691 m.header = renderLogo(m.com.Styles, compact, width)
1692}
1693
1694// renderSidebarLogo renders and caches the sidebar logo at the specified
1695// width.
1696func (m *UI) renderSidebarLogo(width int) {
1697 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
1698}
1699
1700// sendMessage sends a message with the given content and attachments.
1701func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd {
1702 if m.com.App.AgentCoordinator == nil {
1703 return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
1704 }
1705
1706 var cmds []tea.Cmd
1707 if m.session == nil || m.session.ID == "" {
1708 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
1709 if err != nil {
1710 return uiutil.ReportError(err)
1711 }
1712 m.state = uiChat
1713 m.session = &newSession
1714 cmds = append(cmds, m.loadSession(newSession.ID))
1715 }
1716
1717 // Capture session ID to avoid race with main goroutine updating m.session.
1718 sessionID := m.session.ID
1719 cmds = append(cmds, func() tea.Msg {
1720 _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
1721 if err != nil {
1722 isCancelErr := errors.Is(err, context.Canceled)
1723 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
1724 if isCancelErr || isPermissionErr {
1725 return nil
1726 }
1727 return uiutil.InfoMsg{
1728 Type: uiutil.InfoTypeError,
1729 Msg: err.Error(),
1730 }
1731 }
1732 return nil
1733 })
1734 return tea.Batch(cmds...)
1735}
1736
1737// openQuitDialog opens the quit confirmation dialog.
1738func (m *UI) openQuitDialog() tea.Cmd {
1739 if m.dialog.ContainsDialog(dialog.QuitID) {
1740 // Bring to front
1741 m.dialog.BringToFront(dialog.QuitID)
1742 return nil
1743 }
1744
1745 quitDialog := dialog.NewQuit(m.com)
1746 m.dialog.OpenDialog(quitDialog)
1747 return nil
1748}
1749
1750// openModelsDialog opens the models dialog.
1751func (m *UI) openModelsDialog() tea.Cmd {
1752 if m.dialog.ContainsDialog(dialog.ModelsID) {
1753 // Bring to front
1754 m.dialog.BringToFront(dialog.ModelsID)
1755 return nil
1756 }
1757
1758 modelsDialog, err := dialog.NewModels(m.com)
1759 if err != nil {
1760 return uiutil.ReportError(err)
1761 }
1762
1763 modelsDialog.SetSize(min(60, m.width-8), 30)
1764 m.dialog.OpenDialog(modelsDialog)
1765
1766 return nil
1767}
1768
1769// openCommandsDialog opens the commands dialog.
1770func (m *UI) openCommandsDialog() tea.Cmd {
1771 if m.dialog.ContainsDialog(dialog.CommandsID) {
1772 // Bring to front
1773 m.dialog.BringToFront(dialog.CommandsID)
1774 return nil
1775 }
1776
1777 sessionID := ""
1778 if m.session != nil {
1779 sessionID = m.session.ID
1780 }
1781
1782 commands, err := dialog.NewCommands(m.com, sessionID)
1783 if err != nil {
1784 return uiutil.ReportError(err)
1785 }
1786
1787 // TODO: Get. Rid. Of. Magic numbers!
1788 commands.SetSize(min(120, m.width-8), 30)
1789 m.dialog.OpenDialog(commands)
1790
1791 return nil
1792}
1793
1794// openSessionsDialog opens the sessions dialog. If the dialog is already open,
1795// it brings it to the front. Otherwise, it will list all the sessions and open
1796// the dialog.
1797func (m *UI) openSessionsDialog() tea.Cmd {
1798 if m.dialog.ContainsDialog(dialog.SessionsID) {
1799 // Bring to front
1800 m.dialog.BringToFront(dialog.SessionsID)
1801 return nil
1802 }
1803
1804 selectedSessionID := ""
1805 if m.session != nil {
1806 selectedSessionID = m.session.ID
1807 }
1808
1809 dialog, err := dialog.NewSessions(m.com, selectedSessionID)
1810 if err != nil {
1811 return uiutil.ReportError(err)
1812 }
1813
1814 // TODO: Get. Rid. Of. Magic numbers!
1815 dialog.SetSize(min(120, m.width-8), 30)
1816 m.dialog.OpenDialog(dialog)
1817
1818 return nil
1819}
1820
1821// newSession clears the current session state and prepares for a new session.
1822// The actual session creation happens when the user sends their first message.
1823func (m *UI) newSession() {
1824 if m.session == nil || m.session.ID == "" {
1825 return
1826 }
1827
1828 m.session = nil
1829 m.sessionFiles = nil
1830 m.state = uiLanding
1831 m.focus = uiFocusEditor
1832 m.textarea.Focus()
1833 m.chat.Blur()
1834 m.chat.ClearMessages()
1835}
1836
1837// handlePasteMsg handles a paste message.
1838func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
1839 if m.focus != uiFocusEditor {
1840 return nil
1841 }
1842
1843 var cmd tea.Cmd
1844 path := strings.ReplaceAll(msg.Content, "\\ ", " ")
1845 // try to get an image
1846 path, err := filepath.Abs(strings.TrimSpace(path))
1847 if err != nil {
1848 m.textarea, cmd = m.textarea.Update(msg)
1849 return cmd
1850 }
1851 isAllowedType := false
1852 for _, ext := range filepicker.AllowedTypes {
1853 if strings.HasSuffix(path, ext) {
1854 isAllowedType = true
1855 break
1856 }
1857 }
1858 if !isAllowedType {
1859 m.textarea, cmd = m.textarea.Update(msg)
1860 return cmd
1861 }
1862 tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
1863 if tooBig {
1864 m.textarea, cmd = m.textarea.Update(msg)
1865 return cmd
1866 }
1867
1868 content, err := os.ReadFile(path)
1869 if err != nil {
1870 m.textarea, cmd = m.textarea.Update(msg)
1871 return cmd
1872 }
1873 mimeBufferSize := min(512, len(content))
1874 mimeType := http.DetectContentType(content[:mimeBufferSize])
1875 fileName := filepath.Base(path)
1876 attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
1877 return uiutil.CmdHandler(filepicker.FilePickedMsg{
1878 Attachment: attachment,
1879 })
1880}
1881
1882// renderLogo renders the Crush logo with the given styles and dimensions.
1883func renderLogo(t *styles.Styles, compact bool, width int) string {
1884 return logo.Render(version.Version, compact, logo.Opts{
1885 FieldColor: t.LogoFieldColor,
1886 TitleColorA: t.LogoTitleColorA,
1887 TitleColorB: t.LogoTitleColorB,
1888 CharmColor: t.LogoCharmColor,
1889 VersionColor: t.LogoVersionColor,
1890 Width: width,
1891 })
1892}