package ui import ( "fmt" "image/color" "sort" "strings" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/mark3labs/kit/internal/clipboard" "github.com/mark3labs/kit/internal/ui/commands" "github.com/mark3labs/kit/internal/ui/core" "github.com/mark3labs/kit/internal/ui/imagepreview" "github.com/mark3labs/kit/internal/ui/style" ) // InputComponent is the interactive text input field for the parent AppModel. // It wraps the slash command autocomplete popup and delegates slash command // execution to the AppController. On submit it returns a submitMsg tea.Cmd // instead of tea.Quit — lifecycle is entirely managed by the parent. // // Slash commands handled locally (not forwarded to app layer): // - /quit, /q, /exit → tea.Quit // - /clear, /cls, /c → appCtrl.ClearMessages() then clear the textarea // // /clear-queue is forwarded to the parent via submitMsg so the parent can // update queueCount directly (calling ClearQueue from within Update would // require prog.Send which deadlocks). // // All other input is returned via submitMsg for the parent to forward to // app.Run(). type InputComponent struct { textarea textarea.Model commands []commands.SlashCommand showPopup bool filtered []FuzzyMatch selected int width int lastValue string popupHeight int submitNext bool // defer submit one tick so popup dismisses cleanly // popup is the shared PopupList used to render the / and @ autocomplete // dropdowns. State (items, cursor, visible search-driven filter) is // driven externally by InputComponent — we only use PopupList for the // rendering chrome so all popups in the app look identical. popup *PopupList // Argument completion state. When the user types "/cmd " followed by // a partial argument and the command has a Complete function, the popup // switches to argument-completion mode showing suggestions from Complete. argMode bool // true when showing arg completions argCommand string // command prefix for arg mode (e.g. "/bookmark") argSynthCmds []commands.SlashCommand // backing storage for synthetic arg entries // File completion state. When the user types @ followed by a partial // file path, the popup shows file/directory suggestions from the cwd. fileMode bool // true when showing @file completions filePrefix string // current text after @ being matched fileAtStartIdx int // byte offset of @ (or path start in /edit mode) in the textarea value fileSuggestions []FileSuggestion // backing storage for file entries fileSynthCmds []commands.SlashCommand // synthetic commands.SlashCommands wrapping file entries // fileEditMode is true when fileMode was activated by the /edit slash // command rather than an @ trigger. Selecting a file submits the line // (running $EDITOR on it); selecting a directory drills further like @ // does. MCP resources are excluded in this mode. fileEditMode bool // cwd is the working directory used for @file path resolution and // autocomplete suggestions. Set by the parent via SetCwd. cwd string // mcpResources is a callback that returns available MCP resources for // the @ autocomplete popup. Set by the parent via SetMCPResourceProvider. mcpResources func() []FileSuggestion // appCtrl is used for slash commands that mutate app state. // May be nil in tests; nil-safe. appCtrl AppController // hideHint suppresses the "enter submit · ctrl+j..." hint text. hideHint bool // agentBusy indicates the agent is currently working. When true, the // hint text shows steering shortcut (Ctrl+X s) instead of submit. agentBusy bool // pendingImages holds clipboard images attached to the next submission. // Images are added via Ctrl+V and cleared on submit or Ctrl+U. pendingImages []core.ImageAttachment // imageThumbs caches the rendered half-block thumbnail for each entry in // pendingImages (1:1 index correspondence). Thumbnails are rendered // asynchronously off the Bubble Tea event loop (decode + resample is too // slow to run inside Update), so an entry starts as the empty string // placeholder and is filled in when the matching thumbnailReadyMsg // arrives. An entry stays empty when the terminal cannot display a // half-block preview, in which case the text pill is shown alone. // See internal/ui/imagepreview. imageThumbs []string // imageGen is a monotonic generation counter incremented whenever the // pending image set is cleared. Async thumbnail results carry the // generation they were enqueued under and are discarded if it no longer // matches, preventing a stale thumbnail from landing on the wrong slot // after a clear + re-attach. imageGen int // history stores previously submitted prompts (most recent last). // Limited to maxHistory entries; duplicates of the previous entry are // skipped. Empty strings are never stored. history []string // historyIndex is the current position when browsing history. // When not browsing, historyIndex == len(history). historyIndex int // savedInput holds the user's in-progress text before they started // browsing history, so it can be restored when they press down past // the end of history. savedInput string // browsingHistory is true when the user is navigating history with // up/down arrows. Set to false when they type a character or submit. browsingHistory bool } // maxHistory is the maximum number of prompt entries kept in history. const maxHistory = 100 // clipboardImageMsg is the result of an async clipboard image read. type clipboardImageMsg struct { image *core.ImageAttachment err error } // thumbnailReadyMsg carries the result of an async thumbnail render back to // the Update loop. gen and index identify the pendingImages slot the // thumbnail belongs to; the result is dropped if the generation no longer // matches (the pending set was cleared) or the index is out of range. type thumbnailReadyMsg struct { gen int index int thumb string } // NewInputComponent creates a new InputComponent with the given width and // optional AppController. If appCtrl is nil the component still works but // /clear and /clear-queue are no-ops. func NewInputComponent(width int, appCtrl AppController) *InputComponent { ta := textarea.New() ta.Placeholder = "Type your message..." ta.ShowLineNumbers = false ta.Prompt = "" ta.CharLimit = 0 ta.SetWidth(width - 8) // Account for container padding, border and internal padding ta.SetHeight(4) // 4 lines for comfortable multi-line input ta.Focus() // Override InsertNewline so only ctrl+j and shift+enter insert newlines. // Enter always submits the input. ta.KeyMap.InsertNewline = key.NewBinding( key.WithKeys("ctrl+j", "shift+enter"), key.WithHelp("ctrl+j", "insert newline"), ) // Style the textarea using theme colors. theme := style.GetTheme() styles := ta.Styles() styles.Focused.Base = lipgloss.NewStyle() styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(theme.VeryMuted) styles.Focused.Text = lipgloss.NewStyle().Foreground(theme.Text) styles.Focused.Prompt = lipgloss.NewStyle() styles.Focused.CursorLine = lipgloss.NewStyle() ta.SetStyles(styles) ic := &InputComponent{ textarea: ta, commands: commands.SlashCommands, width: width, popupHeight: 7, appCtrl: appCtrl, hideHint: true, } ic.popup = NewPopupList("", nil, width, 0) ic.popup.ShowSearch = false ic.popup.HideCount = true ic.popup.MaxVisible = ic.popupHeight ic.popup.FooterHint = "↑↓ navigate • tab complete • ↵ select • esc dismiss" return ic } // SetCwd sets the working directory used for @file autocomplete suggestions // and path resolution. Should be called by the parent after construction. func (s *InputComponent) SetCwd(cwd string) { s.cwd = cwd } // SetMCPResourceProvider sets a callback that returns MCP resource suggestions // for the @ autocomplete popup. Called by the parent after construction. func (s *InputComponent) SetMCPResourceProvider(fn func() []FileSuggestion) { s.mcpResources = fn } // Init implements tea.Model. Starts the cursor blink animation. func (s *InputComponent) Init() tea.Cmd { return textarea.Blink } // Update implements tea.Model. Handles keyboard input, popup navigation, and // slash command execution. Returns submitMsg via a tea.Cmd when the user // submits text — it does NOT return tea.Quit (parent owns lifecycle). func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd // If submitNext is set, the previous update wanted to submit but needed one // more frame so the popup dismisses cleanly first. if s.submitNext { s.submitNext = false value := s.textarea.Value() s.pushHistory(value) s.textarea.SetValue("") s.textarea.CursorEnd() s.showPopup = false s.lastValue = "" return s, s.handleSubmit(value) } switch msg := msg.(type) { case tea.WindowSizeMsg: s.width = msg.Width s.textarea.SetWidth(msg.Width - 8) return s, nil case clipboardImageMsg: if msg.err != nil { // Silently ignore — no image on clipboard or tool unavailable. return s, nil } if msg.image != nil { img := *msg.image index := len(s.pendingImages) s.pendingImages = append(s.pendingImages, img) // Reserve a placeholder; the async render fills it in via // thumbnailReadyMsg so Update never blocks on decode/resample. s.imageThumbs = append(s.imageThumbs, "") cols := s.thumbCols() if cols < 1 { return s, nil } return s, renderThumbnailCmd(img, cols, thumbMaxRows, style.GetTheme().Background, s.imageGen, index) } return s, nil case thumbnailReadyMsg: if msg.gen == s.imageGen && msg.index >= 0 && msg.index < len(s.imageThumbs) { s.imageThumbs[msg.index] = msg.thumb } return s, nil case tea.KeyPressMsg: if !s.showPopup { switch msg.String() { case "enter": value := s.textarea.Value() s.pushHistory(value) s.textarea.SetValue("") s.textarea.CursorEnd() s.lastValue = "" return s, s.handleSubmit(value) case "up": // Navigate prompt history backward (older entries). if len(s.history) > 0 { if !s.browsingHistory { // Start browsing — save current input. s.savedInput = s.textarea.Value() s.browsingHistory = true s.historyIndex = len(s.history) } if s.historyIndex > 0 { s.historyIndex-- s.textarea.SetValue(s.history[s.historyIndex]) s.textarea.CursorEnd() s.lastValue = s.textarea.Value() } return s, nil } case "down": // Navigate prompt history forward (newer entries). if s.browsingHistory { if s.historyIndex < len(s.history)-1 { s.historyIndex++ s.textarea.SetValue(s.history[s.historyIndex]) s.textarea.CursorEnd() s.lastValue = s.textarea.Value() } else { // Past the end — restore saved input. s.historyIndex = len(s.history) s.browsingHistory = false s.textarea.SetValue(s.savedInput) s.textarea.CursorEnd() s.lastValue = s.textarea.Value() s.savedInput = "" } return s, nil } case "ctrl+v": // Try to read an image from the clipboard asynchronously. return s, readClipboardImageCmd() case "ctrl+u": // Clear all pending image attachments. if len(s.pendingImages) > 0 { s.pendingImages = nil s.imageThumbs = nil s.imageGen++ return s, nil } } } // Handle popup navigation if s.showPopup { switch { case key.Matches(msg, key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up"))): if s.selected > 0 { s.selected-- } return s, nil case key.Matches(msg, key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down"))): if s.selected < len(s.filtered)-1 { s.selected++ } return s, nil case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))): if s.selected < len(s.filtered) { if s.fileMode { s.applyFileCompletion(s.selected) } else if s.argMode { s.textarea.SetValue(s.argCommand + " " + s.filtered[s.selected].Command.Name) s.showPopup = false s.selected = 0 } else { s.textarea.SetValue(s.filtered[s.selected].Command.Name) s.showPopup = false s.selected = 0 } s.textarea.CursorEnd() } return s, nil case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): if s.selected < len(s.filtered) { if s.fileMode { // Apply file completion but don't submit. s.applyFileCompletion(s.selected) s.textarea.CursorEnd() return s, nil } selectedCmd := s.filtered[s.selected].Command // Populate textarea with selected item and submit on next tick. if s.argMode { s.textarea.SetValue(s.argCommand + " " + selectedCmd.Name) } else { s.textarea.SetValue(selectedCmd.Name) } s.textarea.CursorEnd() s.showPopup = false s.selected = 0 // If the selected command expects arguments, populate // the input with the command + trailing space so the // user can type args, instead of auto-submitting. if !s.argMode && selectedCmd.HasArgs { s.textarea.SetValue(selectedCmd.Name + " ") s.textarea.CursorEnd() } else { s.submitNext = true } return s, nil } return s, nil case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): s.showPopup = false s.selected = 0 return s, nil } } // Pass the key to the textarea. s.textarea, cmd = s.textarea.Update(msg) // Update autocomplete popup state. value := s.textarea.Value() if value != s.lastValue { s.lastValue = value // User typed something — exit history browsing mode. if s.browsingHistory { s.browsingHistory = false s.savedInput = "" } lines := strings.Split(value, "\n") line := lines[len(lines)-1] // current line (last line for multi-line) // Check for @file trigger first. cursorCol := len(line) // approximate: cursor is at end after typing if hasAt, prefix, atIdx := ExtractAtPrefix(line, cursorCol); hasAt { var suggestions []FileSuggestion // Local file suggestions (only if cwd is set). if s.cwd != "" { suggestions = GetFileSuggestions(prefix, s.cwd) } // MCP resource suggestions — merge with file suggestions. if s.mcpResources != nil { mcpSuggestions := s.mcpResources() if prefix != "" { // Fuzzy-filter MCP resources against the typed prefix. queryLower := strings.ToLower(prefix) var filtered []FileSuggestion for _, r := range mcpSuggestions { score := scoreFilePath(queryLower, r.RelPath) if score <= 0 { // Also try matching against the resource name without prefix. score = scoreFilePath(queryLower, r.MCPServerName+"/"+r.RelPath) } if score > 0 { r.Score = score filtered = append(filtered, r) } } mcpSuggestions = filtered } suggestions = append(suggestions, mcpSuggestions...) } if len(suggestions) > 0 { // Sort by score descending, cap at maxFileSuggestions. sort.Slice(suggestions, func(i, j int) bool { return suggestions[i].Score > suggestions[j].Score }) if len(suggestions) > maxFileSuggestions { suggestions = suggestions[:maxFileSuggestions] } s.showPopup = true s.fileMode = true s.argMode = false s.filePrefix = prefix s.fileAtStartIdx = atIdx s.fileSuggestions = suggestions s.fileSynthCmds = make([]commands.SlashCommand, len(suggestions)) s.filtered = make([]FuzzyMatch, len(suggestions)) for i, fs := range suggestions { name := fs.RelPath desc := "" if fs.IsDir { desc = "directory" } else if fs.IsMCPResource { desc = "mcp:" + fs.MCPServerName } s.fileSynthCmds[i] = commands.SlashCommand{Name: name, Description: desc} s.filtered[i] = FuzzyMatch{Command: &s.fileSynthCmds[i], Score: fs.Score} } s.selected = 0 } else { s.showPopup = false s.fileMode = false s.fileEditMode = false } } else if len(lines) == 1 && strings.HasPrefix(lines[0], "/") { s.fileMode = false s.fileEditMode = false if cmdLen, pathPrefix, isEdit := ExtractEditPrefix(lines[0]); isEdit { // /edit fuzzy-file picker. Behaves like @ except // MCP resources are excluded and selecting a file // submits the line (running $EDITOR). s.updateEditFilePopup(cmdLen, pathPrefix) } else if !strings.Contains(lines[0], " ") { // Command name completion. s.showPopup = true s.argMode = false s.filtered = FuzzyMatchCommands(lines[0], s.commands) s.selected = 0 } else if suggestions := s.completeArgs(lines[0]); len(suggestions) > 0 { // Argument completion for a command with a Complete function. s.showPopup = true // s.argMode, s.argCommand, s.argSynthCmds, s.filtered // are set by completeArgs. s.selected = 0 } else { s.showPopup = false s.argMode = false } } else { s.showPopup = false s.argMode = false s.fileMode = false s.fileEditMode = false } } return s, cmd default: s.textarea, cmd = s.textarea.Update(msg) return s, cmd } } // handleSubmit processes the submitted text. Slash commands that affect app // state are executed here; /quit returns tea.Quit; everything else returns a // submitMsg tea.Cmd for the parent to forward to app.Run(). // // Shell command prefixes (matching pi's behavior): // - !cmd → execute shell command, output INCLUDED in LLM context // - !!cmd → execute shell command, output EXCLUDED from LLM context func (s *InputComponent) handleSubmit(value string) tea.Cmd { trimmed := strings.TrimSpace(value) if trimmed == "" { return nil } // Check for shell command prefixes before slash commands. Test !! first // (more specific) to avoid matching the single-! case for double-bang. if strings.HasPrefix(trimmed, "!!") { cmd := strings.TrimSpace(trimmed[2:]) if cmd != "" { return func() tea.Msg { return core.ShellCommandMsg{Command: cmd, ExcludeFromContext: true} } } } else if strings.HasPrefix(trimmed, "!") { cmd := strings.TrimSpace(trimmed[1:]) if cmd != "" { return func() tea.Msg { return core.ShellCommandMsg{Command: cmd, ExcludeFromContext: false} } } } // Resolve via canonical command lookup so aliases are handled uniformly. // Only /quit is handled locally — all other slash commands (including // /clear and /clear-queue) are forwarded to the parent model via // submitMsg so the parent can update its own state (ScrollList, queue // counts, etc.) in one place. if sc := commands.GetCommandByName(trimmed); sc != nil { switch sc.Name { case "/quit": return tea.Quit } } // For all other input (including unrecognised slash commands and regular // prompts) hand off to the parent via submitMsg. Attach any pending // images and clear them. images := s.pendingImages s.pendingImages = nil s.imageThumbs = nil s.imageGen++ return func() tea.Msg { return core.SubmitMsg{Text: trimmed, Images: images} } } // pushHistory adds a prompt to the history ring buffer. Empty strings and // consecutive duplicates of the last entry are skipped. When the buffer // exceeds maxHistory, the oldest entry is dropped. func (s *InputComponent) pushHistory(value string) { trimmed := strings.TrimSpace(value) if trimmed == "" { return } // Skip consecutive duplicates. if len(s.history) > 0 && s.history[len(s.history)-1] == trimmed { s.resetHistoryBrowsing() return } s.history = append(s.history, trimmed) if len(s.history) > maxHistory { s.history = s.history[len(s.history)-maxHistory:] } s.resetHistoryBrowsing() } // resetHistoryBrowsing resets the history browsing state so the index // points past the end (ready for new input). func (s *InputComponent) resetHistoryBrowsing() { s.historyIndex = len(s.history) s.browsingHistory = false s.savedInput = "" } // thumbMaxCols and thumbMaxRows cap the size, in terminal cells, of pending // image previews. Kept small for the low-res look and to keep scrollback // light. const ( thumbMaxCols = 40 thumbMaxRows = 12 ) // thumbCols returns the thumbnail width in terminal cells given the current // input width, or 0 when there is no room to render a preview. func (s *InputComponent) thumbCols() int { if s.width <= 6 { return 0 } cols := min(thumbMaxCols, s.width-6) if cols < 1 { return 0 } return cols } // renderThumbnailCmd returns a tea.Cmd that renders a half-block ANSI preview // off the Bubble Tea event loop. The decode + resample work runs in the Cmd // goroutine, and the result is delivered as a thumbnailReadyMsg tagged with // the generation and slot index it was enqueued for. An empty thumbnail // (terminal unsupported or render error) leaves the text pill in place. func renderThumbnailCmd(img core.ImageAttachment, cols, rows int, bg color.Color, gen, index int) tea.Cmd { return func() tea.Msg { thumb, err := imagepreview.Render(img.Data, img.MediaType, cols, rows, bg) if err != nil { thumb = "" } return thumbnailReadyMsg{gen: gen, index: index, thumb: thumb} } } // View implements tea.Model. Renders the textarea, autocomplete popup // (if visible), and help text. func (s *InputComponent) View() tea.View { containerStyle := lipgloss.NewStyle() theme := style.GetTheme() inputBoxStyle := lipgloss.NewStyle(). Border(lipgloss.ThickBorder()). BorderLeft(true). BorderRight(false). BorderTop(false). BorderBottom(false). BorderForeground(theme.Primary). MarginTop(1). MarginBottom(1). PaddingLeft(2). // match message block paddingLeft Width(s.width - 1) // full width minus left border var view strings.Builder view.WriteString(inputBoxStyle.Render(s.textarea.View())) // Popup is now rendered as a centered overlay in AppModel.View() // instead of inline here to prevent bottom overflow // Show image attachment previews when images are pending. A cached // half-block thumbnail is rendered when the terminal supports it; // otherwise the text pill alone is shown. if len(s.pendingImages) > 0 { imgStyle := lipgloss.NewStyle(). Foreground(theme.Secondary). PaddingLeft(3) label := fmt.Sprintf("[%d image(s) attached] ctrl+u to clear", len(s.pendingImages)) view.WriteString("\n") view.WriteString(imgStyle.Render(label)) thumbStyle := lipgloss.NewStyle().PaddingLeft(3) for i := range s.pendingImages { if i < len(s.imageThumbs) && s.imageThumbs[i] != "" { view.WriteString("\n") view.WriteString(thumbStyle.Render(s.imageThumbs[i])) } } } if !s.hideHint { helpStyle := lipgloss.NewStyle(). Foreground(theme.VeryMuted). MarginTop(1). PaddingLeft(3) // Adapt hint text to available width (accounting for left padding of 3). var hint string availableHintWidth := s.width - 3 if s.agentBusy { // When the agent is working, show steering shortcut. if availableHintWidth >= 60 { hint = "enter queue • ctrl+x s steer • esc esc cancel" } else if availableHintWidth >= 40 { hint = "↵ queue • ^X s steer • esc×2 cancel" } else { hint = "^X s steer" } } else if availableHintWidth >= 80 { hint = "enter submit • ctrl+j / shift+enter new line • ctrl+x e editor • ctrl+v paste image" } else if availableHintWidth >= 67 { hint = "enter submit • ctrl+j new line • ctrl+x e editor • ctrl+v image" } else if availableHintWidth >= 40 { hint = "↵ submit • ctrl+j newline • ^X e editor" } else if availableHintWidth >= 20 { hint = "↵ submit • ^X e editor" } else { hint = "↵ submit" } view.WriteString("\n") view.WriteString(helpStyle.Render(hint)) } return tea.NewView(containerStyle.Render(view.String())) } // RenderPopupCentered renders the autocomplete popup for / or @ as a // centered overlay. Returns "" when the popup is not currently shown. // The actual filtering / selection state lives on InputComponent — this // method merely converts the filtered FuzzyMatch list into PopupItems // and asks the shared PopupList to draw it. As a result the / popup, the // @ popup, the model picker, the tree selector and the session selector // all share identical chrome. func (s *InputComponent) RenderPopupCentered(termWidth, termHeight int) string { if !s.showPopup || len(s.filtered) == 0 { return "" } items := make([]PopupItem, len(s.filtered)) for i, m := range s.filtered { desc := "" if m.Command != nil { desc = m.Command.Description } name := "" if m.Command != nil { name = m.Command.Name } items[i] = PopupItem{ Label: name, Description: desc, } } s.popup.SetSize(termWidth, termHeight) s.popup.SetItems(items) s.popup.SetCursor(s.selected) return s.popup.RenderCentered(termWidth, termHeight) } // completeArgs checks whether the input line matches a command with a Complete // function, calls it, and populates the arg-mode state on success. Returns the // list of suggestions (empty means no completions available). func (s *InputComponent) completeArgs(line string) []FuzzyMatch { parts := strings.SplitN(line, " ", 2) cmdName := parts[0] argPrefix := "" if len(parts) > 1 { argPrefix = parts[1] } cmd := s.findCommandWithComplete(cmdName) if cmd == nil { return nil } suggestions := cmd.Complete(argPrefix) if len(suggestions) == 0 { s.argMode = false return nil } s.argMode = true s.argCommand = cmdName s.argSynthCmds = make([]commands.SlashCommand, len(suggestions)) s.filtered = make([]FuzzyMatch, len(suggestions)) for i, sug := range suggestions { s.argSynthCmds[i] = commands.SlashCommand{Name: sug} s.filtered[i] = FuzzyMatch{Command: &s.argSynthCmds[i]} } return s.filtered } // findCommandWithComplete looks up a command by name that has a non-nil // Complete function. func (s *InputComponent) findCommandWithComplete(name string) *commands.SlashCommand { for i := range s.commands { if s.commands[i].Name == name && s.commands[i].Complete != nil { return &s.commands[i] } } return nil } // readClipboardImageCmd returns a tea.Cmd that reads an image from the system // clipboard. The result is delivered as a clipboardImageMsg. func readClipboardImageCmd() tea.Cmd { return func() tea.Msg { img, err := clipboard.ReadImage() if err != nil { return clipboardImageMsg{err: err} } return clipboardImageMsg{ image: &core.ImageAttachment{ Data: img.Data, MediaType: img.MediaType, }, } } } // ClearPendingImages removes all pending image attachments and returns them. // Used by the parent model when consuming images for submission. func (s *InputComponent) ClearPendingImages() []core.ImageAttachment { images := s.pendingImages s.pendingImages = nil s.imageThumbs = nil s.imageGen++ return images } // PendingImageCount returns the number of images currently attached. func (s *InputComponent) PendingImageCount() int { return len(s.pendingImages) } // Clear clears the textarea content and resets related state. Returns true if // there was content to clear, false if the input was already empty. func (s *InputComponent) Clear() bool { hadContent := s.textarea.Value() != "" s.textarea.SetValue("") s.textarea.CursorEnd() s.lastValue = "" s.showPopup = false s.argMode = false s.fileMode = false s.fileEditMode = false s.browsingHistory = false s.savedInput = "" return hadContent } // applyFileCompletion replaces the @prefix in the textarea with the selected // file or MCP resource suggestion. For directories, it keeps the popup open // for further drilling. For files and resources, it closes the popup and adds // a trailing space. // // When fileEditMode is active the same path-replacement happens against the // /edit (or alias) command prefix instead of an @ trigger. Selecting a file // also arms submitNext so the next tick runs $EDITOR on it; selecting a // directory keeps the popup open for drill-down. func (s *InputComponent) applyFileCompletion(idx int) { if idx >= len(s.fileSuggestions) { return } suggestion := s.fileSuggestions[idx] value := s.textarea.Value() // Build the replacement text. The @ and everything after it up to the // cursor should be replaced with @. // Find the current line's contribution. lines := strings.Split(value, "\n") lastLine := lines[len(lines)-1] // Reconstruct: everything before the @ on the last line + @ beforeAt := lastLine[:s.fileAtStartIdx] var replacement string switch { case s.fileEditMode: // /edit path mode — no @ prefix; the path is the bare argument. // MCP resources are excluded upstream, so only file/dir entries reach here. needsQuote := strings.Contains(suggestion.RelPath, " ") if needsQuote { replacement = `"` + suggestion.RelPath + `"` } else { replacement = suggestion.RelPath } case suggestion.IsMCPResource: // MCP resources use @mcp:server:uri format. // Quote if the URI contains spaces. ref := "mcp:" + suggestion.MCPServerName + ":" + suggestion.MCPResourceURI if strings.Contains(ref, " ") { replacement = `@"` + ref + `"` } else { replacement = "@" + ref } replacement += " " default: needsQuote := strings.Contains(suggestion.RelPath, " ") if needsQuote { replacement = `@"` + suggestion.RelPath + `"` } else { replacement = "@" + suggestion.RelPath } // For files, add a trailing space. For directories, don't — allow // continued drilling into the directory. if !suggestion.IsDir { replacement += " " } } newLastLine := beforeAt + replacement // Reconstruct the full value with the updated last line. lines[len(lines)-1] = newLastLine newValue := strings.Join(lines, "\n") s.textarea.SetValue(newValue) s.textarea.CursorEnd() if suggestion.IsDir && !suggestion.IsMCPResource { // Keep popup open — trigger a refresh for the new directory. s.lastValue = "" // force re-evaluation on next update tick return } s.showPopup = false s.fileMode = false s.selected = 0 if s.fileEditMode { // A file was selected via /edit — submit on the next tick so the // popup dismisses cleanly before $EDITOR takes the terminal. s.fileEditMode = false s.submitNext = true } } // updateEditFilePopup queries the file-suggestion engine for the /edit path // prefix and populates the popup state. cmdLen is the byte offset of the path // argument within the current line (i.e. length of "/edit " or "/ed "). // Directories are kept so the user can drill down; MCP resources are skipped. func (s *InputComponent) updateEditFilePopup(cmdLen int, pathPrefix string) { var suggestions []FileSuggestion if s.cwd != "" { suggestions = GetFileSuggestions(pathPrefix, s.cwd) } if len(suggestions) == 0 { s.showPopup = false s.fileMode = false s.fileEditMode = false return } sort.Slice(suggestions, func(i, j int) bool { return suggestions[i].Score > suggestions[j].Score }) if len(suggestions) > maxFileSuggestions { suggestions = suggestions[:maxFileSuggestions] } s.showPopup = true s.fileMode = true s.fileEditMode = true s.argMode = false s.filePrefix = pathPrefix s.fileAtStartIdx = cmdLen s.fileSuggestions = suggestions s.fileSynthCmds = make([]commands.SlashCommand, len(suggestions)) s.filtered = make([]FuzzyMatch, len(suggestions)) for i, fs := range suggestions { name := fs.RelPath desc := "" if fs.IsDir { desc = "directory" } s.fileSynthCmds[i] = commands.SlashCommand{Name: name, Description: desc} s.filtered[i] = FuzzyMatch{Command: &s.fileSynthCmds[i], Score: fs.Score} } s.selected = 0 }