diff --git a/cmd/root.go b/cmd/root.go index 411106e4..a1c3f4f6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1065,11 +1065,13 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN termHeight = 24 } + cwd, _ := os.Getwd() appModel := ui.NewAppModel(appInstance, ui.AppModelOptions{ CompactMode: viper.GetBool("compact"), ModelName: modelName, ProviderName: providerName, LoadingMessage: loadingMessage, + Cwd: cwd, Width: termWidth, Height: termHeight, ServerNames: serverNames, diff --git a/internal/ui/file_processor.go b/internal/ui/file_processor.go new file mode 100644 index 00000000..5181142f --- /dev/null +++ b/internal/ui/file_processor.go @@ -0,0 +1,129 @@ +package ui + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// fileTokenPattern matches @file references in user text. Supports: +// - @"path with spaces.txt" (quoted) +// - @path/to/file.txt (unquoted, no spaces) +var fileTokenPattern = regexp.MustCompile(`@"[^"]+"|@[^\s]+`) + +// ProcessFileAttachments scans the user's input text for @file references, +// reads each referenced file, and returns the text with @tokens replaced by +// XML-wrapped file content. Non-file @ tokens (like email addresses) are left +// unchanged. +// +// Returns the original text unchanged if no valid @file references are found. +func ProcessFileAttachments(text string, cwd string) string { + tokens := fileTokenPattern.FindAllString(text, -1) + if len(tokens) == 0 { + return text + } + + result := text + for _, token := range tokens { + path := tokenToPath(token) + if path == "" { + continue + } + + absPath, err := resolvePath(path, cwd) + if err != nil { + // Not a valid file reference — leave the token as-is. + // This handles cases like email addresses (@user) gracefully. + continue + } + + info, err := os.Stat(absPath) + if err != nil { + continue + } + + // Skip directories — we only attach file content. + if info.IsDir() { + continue + } + + // Skip empty files. + if info.Size() == 0 { + continue + } + + content, err := os.ReadFile(absPath) + if err != nil { + continue + } + + // Build the XML-wrapped replacement. + wrapped := wrapFileContent(absPath, content) + result = strings.Replace(result, token, wrapped, 1) + } + + return result +} + +// tokenToPath strips the @ prefix and optional quotes from a token, +// returning the raw file path. Returns "" for invalid tokens. +func tokenToPath(token string) string { + if !strings.HasPrefix(token, "@") { + return "" + } + path := token[1:] + + // Strip quotes. + if strings.HasPrefix(path, `"`) && strings.HasSuffix(path, `"`) { + path = path[1 : len(path)-1] + } + + // Reject obviously non-file tokens (e.g. bare @ or @-flags). + if path == "" || strings.HasPrefix(path, "-") { + return "" + } + + return path +} + +// resolvePath resolves a potentially relative file path to an absolute path. +// Supports ~/ expansion and relative paths. No CWD restriction — the user +// can reference any file they have read access to. +func resolvePath(path string, cwd string) (string, error) { + // Expand ~/ + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot expand ~: %w", err) + } + path = filepath.Join(home, path[2:]) + } + + // Resolve relative to cwd. + if !filepath.IsAbs(path) { + path = filepath.Join(cwd, path) + } + + // Clean and resolve symlinks for consistent paths. + absPath, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("invalid path: %w", err) + } + + // Resolve symlinks so the displayed path is canonical. + resolved, err := filepath.EvalSymlinks(absPath) + if err != nil { + // EvalSymlinks fails if the file doesn't exist — fall back to + // the cleaned absolute path and let the caller's Stat handle it. + return absPath, nil + } + + return resolved, nil +} + +// wrapFileContent wraps file content in XML tags for LLM consumption. +func wrapFileContent(absPath string, content []byte) string { + return fmt.Sprintf("\n%s\n", absPath, string(content)) +} diff --git a/internal/ui/file_suggestions.go b/internal/ui/file_suggestions.go new file mode 100644 index 00000000..69c2d5a4 --- /dev/null +++ b/internal/ui/file_suggestions.go @@ -0,0 +1,389 @@ +package ui + +import ( + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "unicode/utf8" +) + +// FileSuggestion represents a single file or directory suggestion for the @ +// autocomplete popup. +type FileSuggestion struct { + // RelPath is the path relative to the search base (e.g. "cmd/kit/main.go"). + RelPath string + // IsDir is true when the entry is a directory. + IsDir bool + // Score is the fuzzy match score (higher is better). + Score int +} + +// maxFileSuggestions is the maximum number of file suggestions returned. +const maxFileSuggestions = 20 + +// ExtractAtPrefix checks the current line for an @-file trigger at cursorCol. +// It returns: +// - hasAt: true if a valid @ trigger was found +// - prefix: the text after @ (possibly empty) that the user has typed so far +// - startIdx: byte offset of the @ character in the line +// +// The @ must appear at the start of the line or after whitespace. Quoted paths +// are supported: @"path with spaces" — the returned prefix strips quotes. +func ExtractAtPrefix(line string, cursorCol int) (hasAt bool, prefix string, startIdx int) { + if cursorCol > len(line) { + cursorCol = len(line) + } + + // Walk backwards from cursorCol to find the @ character. + text := line[:cursorCol] + + // Find the last @ that is preceded by whitespace or is at position 0. + atIdx := -1 + for i := len(text) - 1; i >= 0; i-- { + if text[i] == '@' { + // Must be at start of line or preceded by whitespace. + if i == 0 || text[i-1] == ' ' || text[i-1] == '\t' { + atIdx = i + break + } + } + // Stop scanning if we hit a space — the @ we want must be in the + // current "word". + if text[i] == ' ' || text[i] == '\t' { + break + } + } + + if atIdx < 0 { + return false, "", 0 + } + + raw := text[atIdx+1:] + + // Handle quoted paths: @"some path" — strip leading quote. + if strings.HasPrefix(raw, `"`) { + raw = strings.TrimPrefix(raw, `"`) + raw = strings.TrimSuffix(raw, `"`) + } + + return true, raw, atIdx +} + +// GetFileSuggestions returns file/directory suggestions matching the given +// prefix. It tries `git ls-files` first (fast, respects .gitignore), then +// falls back to a simple directory walk. +// +// If prefix contains a path separator the search is scoped to that +// subdirectory. For example, prefix "cmd/k" searches inside "cmd/" for +// entries matching "k". +func GetFileSuggestions(prefix string, cwd string) []FileSuggestion { + // Resolve the base directory and filter query from the prefix. + baseDir, query := splitPrefixPath(prefix) + + searchDir := cwd + if baseDir != "" { + candidate := resolveSearchDir(baseDir, cwd) + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + searchDir = candidate + } else { + return nil // invalid base directory + } + } + + files := listFiles(searchDir, cwd) + if len(files) == 0 { + return nil + } + + // Prepend baseDir so results display as "cmd/main.go" not just "main.go". + if baseDir != "" { + for i := range files { + files[i].RelPath = baseDir + files[i].RelPath + } + } + + return fuzzyFilterFiles(files, prefix, query) +} + +// splitPrefixPath separates a prefix like "cmd/kit/m" into +// baseDir="cmd/kit/" and query="m". If there is no separator the +// baseDir is empty and query is the full prefix. +func splitPrefixPath(prefix string) (baseDir, query string) { + // Handle ~ expansion display (we keep it in the prefix for display + // but resolve it when actually searching). + idx := strings.LastIndex(prefix, "/") + if idx < 0 { + return "", prefix + } + return prefix[:idx+1], prefix[idx+1:] +} + +// resolveSearchDir converts a baseDir from the prefix into an absolute path. +// Supports ~/, ../, and absolute paths. +func resolveSearchDir(baseDir, cwd string) string { + // Expand ~/ + if strings.HasPrefix(baseDir, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, baseDir[2:]) + } + } + + // Absolute paths + if filepath.IsAbs(baseDir) { + return filepath.Clean(baseDir) + } + + // Relative to cwd + return filepath.Join(cwd, baseDir) +} + +// listFiles returns files and directories within searchDir, relative to that +// directory. Uses `git ls-files` when inside a git repo for speed and +// .gitignore awareness, otherwise falls back to os.ReadDir. +func listFiles(searchDir, cwd string) []FileSuggestion { + // Try git ls-files first (fast, respects .gitignore). + if files := listFilesGit(searchDir, cwd); files != nil { + return files + } + return listFilesReadDir(searchDir) +} + +// listFilesGit uses `git ls-files` and `git ls-files --others --exclude-standard` +// to list tracked and untracked-but-not-ignored files. +func listFilesGit(searchDir, cwd string) []FileSuggestion { + // Check if we're in a git repo. + check := exec.Command("git", "rev-parse", "--show-toplevel") + check.Dir = cwd + if err := check.Run(); err != nil { + return nil + } + + seen := make(map[string]bool) + var results []FileSuggestion + + // Tracked files. + cmd := exec.Command("git", "ls-files") + cmd.Dir = searchDir + out, err := cmd.Output() + if err == nil { + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + // Normalize separators. + line = filepath.ToSlash(line) + addFileEntries(&results, seen, line, searchDir) + } + } + + // Untracked, non-ignored files. + cmd2 := exec.Command("git", "ls-files", "--others", "--exclude-standard") + cmd2.Dir = searchDir + out2, err := cmd2.Output() + if err == nil { + for _, line := range strings.Split(strings.TrimSpace(string(out2)), "\n") { + if line == "" { + continue + } + line = filepath.ToSlash(line) + addFileEntries(&results, seen, line, searchDir) + } + } + + if len(results) == 0 { + return nil + } + return results +} + +// addFileEntries adds the file and any intermediate directory entries to +// results if not already seen. Paths are stored with forward slashes. +func addFileEntries(results *[]FileSuggestion, seen map[string]bool, relPath string, searchDir string) { + // Add intermediate directories as suggestions (first component only). + parts := strings.SplitN(relPath, "/", 2) + if len(parts) > 1 { + dir := parts[0] + "/" + if !seen[dir] { + seen[dir] = true + *results = append(*results, FileSuggestion{RelPath: dir, IsDir: true}) + } + } + + // Add the file itself. + if !seen[relPath] { + seen[relPath] = true + *results = append(*results, FileSuggestion{RelPath: relPath, IsDir: false}) + } +} + +// listFilesReadDir is the fallback when git is not available. Lists immediate +// children of dir via os.ReadDir, skipping hidden dirs and common noise. +func listFilesReadDir(dir string) []FileSuggestion { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + + skip := map[string]bool{ + ".git": true, "node_modules": true, ".kit": true, + "__pycache__": true, ".venv": true, "vendor": true, + } + + var results []FileSuggestion + for _, e := range entries { + name := e.Name() + if skip[name] { + continue + } + // Skip hidden files/dirs (except common config files). + if strings.HasPrefix(name, ".") && name != ".env" && name != ".gitignore" { + continue + } + if e.IsDir() { + results = append(results, FileSuggestion{RelPath: name + "/", IsDir: true}) + } else { + results = append(results, FileSuggestion{RelPath: name, IsDir: false}) + } + } + return results +} + +// fuzzyFilterFiles scores and filters file suggestions against the query, +// returning the top maxFileSuggestions results sorted by score descending. +// Directories are boosted slightly so they appear near the top. +func fuzzyFilterFiles(files []FileSuggestion, fullPrefix, query string) []FileSuggestion { + if query == "" && fullPrefix == "" { + // No filter — return all (capped). + if len(files) > maxFileSuggestions { + files = files[:maxFileSuggestions] + } + return files + } + + // When there's a base dir but no query (e.g. "cmd/"), show everything + // in that directory. + if query == "" { + var filtered []FileSuggestion + for i := range files { + if strings.HasPrefix(files[i].RelPath, fullPrefix) { + // Only show direct children of the base directory. + rest := files[i].RelPath[len(fullPrefix):] + if rest == "" { + continue + } + filtered = append(filtered, files[i]) + } + } + if len(filtered) > maxFileSuggestions { + filtered = filtered[:maxFileSuggestions] + } + return filtered + } + + var scored []FileSuggestion + queryLower := strings.ToLower(query) + + for i := range files { + path := files[i].RelPath + // When we have a fullPrefix with a dir component, only consider + // files under that directory. + if fullPrefix != query && !strings.HasPrefix(path, fullPrefix[:len(fullPrefix)-len(query)]) { + continue + } + + score := scoreFilePath(queryLower, path) + if score <= 0 { + continue + } + + // Boost directories so they appear near the top for navigation. + if files[i].IsDir { + score += 10 + } + + files[i].Score = score + scored = append(scored, files[i]) + } + + // Sort by score descending. + sort.Slice(scored, func(i, j int) bool { + return scored[i].Score > scored[j].Score + }) + + if len(scored) > maxFileSuggestions { + scored = scored[:maxFileSuggestions] + } + return scored +} + +// scoreFilePath scores a file path against a fuzzy query. Higher is better. +// Returns 0 if there is no match. +func scoreFilePath(query, path string) int { + pathLower := strings.ToLower(path) + baseName := filepath.Base(strings.TrimSuffix(path, "/")) + baseNameLower := strings.ToLower(baseName) + + // Exact basename match. + if baseNameLower == query { + return 1000 + } + + // Basename starts with query. + if strings.HasPrefix(baseNameLower, query) { + return 800 - len(baseName) + len(query) + } + + // Basename contains query as substring. + if strings.Contains(baseNameLower, query) { + return 500 - len(baseName) + len(query) + } + + // Full path contains query as substring. + if strings.Contains(pathLower, query) { + return 300 - len(path) + len(query) + } + + // Fuzzy character match on basename. + if score := fuzzyCharMatch(query, baseNameLower); score > 0 { + return score + } + + // Fuzzy character match on full path. + if score := fuzzyCharMatch(query, pathLower); score > 0 { + return score - 50 + } + + return 0 +} + +// fuzzyCharMatch performs character-by-character fuzzy matching. Returns a +// positive score if all query characters appear in order in the target. +func fuzzyCharMatch(query, target string) int { + if utf8.RuneCountInString(query) > utf8.RuneCountInString(target) { + return 0 + } + + qRunes := []rune(query) + tRunes := []rune(target) + qi := 0 + score := 100 + consecutive := 0 + + for ti := 0; ti < len(tRunes) && qi < len(qRunes); ti++ { + if tRunes[ti] == qRunes[qi] { + qi++ + consecutive++ + score += consecutive * 5 + } else { + consecutive = 0 + score -= 2 + } + } + + if qi < len(qRunes) { + return 0 + } + return score +} diff --git a/internal/ui/input.go b/internal/ui/input.go index f108e51f..c2aa3b4a 100644 --- a/internal/ui/input.go +++ b/internal/ui/input.go @@ -43,6 +43,18 @@ type InputComponent struct { argCommand string // command prefix for arg mode (e.g. "/bookmark") argSynthCmds []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 @ in the textarea value + fileSuggestions []FileSuggestion // backing storage for file entries + fileSynthCmds []SlashCommand // synthetic SlashCommands wrapping file entries + + // cwd is the working directory used for @file path resolution and + // autocomplete suggestions. Set by the parent via SetCwd. + cwd string + // appCtrl is used for slash commands that mutate app state. // May be nil in tests; nil-safe. appCtrl AppController @@ -90,6 +102,12 @@ func NewInputComponent(width int, title string, appCtrl AppController) *InputCom } } +// 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 +} + // Init implements tea.Model. Starts the cursor blink animation. func (s *InputComponent) Init() tea.Cmd { return textarea.Blink @@ -148,19 +166,29 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))): if s.selected < len(s.filtered) { - if s.argMode { + 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.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 + } // Populate textarea with selected item and submit on next tick. if s.argMode { s.textarea.SetValue(s.argCommand + " " + s.filtered[s.selected].Command.Name) @@ -190,7 +218,37 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if value != s.lastValue { s.lastValue = value lines := strings.Split(value, "\n") - if len(lines) == 1 && strings.HasPrefix(lines[0], "/") { + 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 && s.cwd != "" { + suggestions := GetFileSuggestions(prefix, s.cwd) + if len(suggestions) > 0 { + s.showPopup = true + s.fileMode = true + s.argMode = false + s.filePrefix = prefix + s.fileAtStartIdx = atIdx + s.fileSuggestions = suggestions + s.fileSynthCmds = make([]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] = 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 + } + } else if len(lines) == 1 && strings.HasPrefix(lines[0], "/") { + s.fileMode = false if !strings.Contains(lines[0], " ") { // Command name completion. s.showPopup = true @@ -210,6 +268,7 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { s.showPopup = false s.argMode = false + s.fileMode = false } } return s, cmd @@ -335,16 +394,32 @@ func (s *InputComponent) renderPopup() string { descStyle = descStyle.Foreground(lipgloss.Color("250")) } - nameWidth := 15 - name := nameStyle.Width(nameWidth - 2).Render(sc.Name) + if s.fileMode { + // File mode: use full width for the path, show description + // (e.g. "directory") inline after a gap. + maxNameLen := s.width - 24 + displayName := sc.Name + if len(displayName) > maxNameLen && maxNameLen > 3 { + displayName = displayName[:maxNameLen-3] + "..." + } + name := nameStyle.Render(displayName) + if sc.Description != "" { + items = append(items, indicator+name+" "+descStyle.Render(sc.Description)) + } else { + items = append(items, indicator+name) + } + } else { + nameWidth := 15 + name := nameStyle.Width(nameWidth - 2).Render(sc.Name) - desc := sc.Description - maxDescLen := s.width - nameWidth - 14 - if len(desc) > maxDescLen && maxDescLen > 3 { - desc = desc[:maxDescLen-3] + "..." + desc := sc.Description + maxDescLen := s.width - nameWidth - 14 + if len(desc) > maxDescLen && maxDescLen > 3 { + desc = desc[:maxDescLen-3] + "..." + } + + items = append(items, indicator+name+descStyle.Render(desc)) } - - items = append(items, indicator+name+descStyle.Render(desc)) } if startIdx > 0 { @@ -404,3 +479,56 @@ func (s *InputComponent) findCommandWithComplete(name string) *SlashCommand { } return nil } + +// applyFileCompletion replaces the @prefix in the textarea with the selected +// file suggestion. For directories, it keeps the popup open for further +// drilling. For files, it closes the popup and adds a trailing space. +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] + needsQuote := strings.Contains(suggestion.RelPath, " ") + + var replacement string + 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 { + // Keep popup open — trigger a refresh for the new directory. + s.lastValue = "" // force re-evaluation on next update tick + } else { + s.showPopup = false + s.fileMode = false + s.selected = 0 + } +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 281da467..f047c4fe 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -201,6 +201,10 @@ type AppModelOptions struct { // (e.g. GPU fallback info). Displayed at startup when non-empty. LoadingMessage string + // Cwd is the working directory for @file autocomplete and path resolution. + // If empty, @file features are disabled. + Cwd string + // Width is the initial terminal width in columns. Width int @@ -449,6 +453,9 @@ type AppModel struct { // so the model can return to it when the overlay completes. preOverlayState appState + // cwd is the working directory for @file path resolution. + cwd string + // width and height track the terminal dimensions. width int height int @@ -526,6 +533,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel { serverNames: opts.ServerNames, toolNames: opts.ToolNames, usageTracker: opts.UsageTracker, + cwd: opts.Cwd, width: width, height: height, } @@ -552,6 +560,11 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel { // Wire up child components now that we have the concrete implementations. m.input = NewInputComponent(width, "Enter your prompt (Type /help for commands, Ctrl+C to quit)", appCtrl) + // Wire up cwd for @file autocomplete. + if ic, ok := m.input.(*InputComponent); ok && opts.Cwd != "" { + ic.SetCwd(opts.Cwd) + } + // Merge extension commands into the InputComponent's autocomplete source. if ic, ok := m.input.(*InputComponent); ok && len(opts.ExtensionCommands) > 0 { for _, ec := range opts.ExtensionCommands { @@ -898,12 +911,20 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Regular prompt — forward to the app layer. + // Preprocess @file references: expand them into XML-wrapped file + // content before sending to the agent. The display text (shown in + // scrollback) uses the original user text so the UI stays clean. + processedText := msg.Text + if m.cwd != "" { + processedText = ProcessFileAttachments(msg.Text, m.cwd) + } + if m.appCtrl != nil { // Run returns the queue depth: >0 means the prompt was queued // (agent is busy). We update queuedMessages directly here // instead of relying on an event from prog.Send(), which would // deadlock when called synchronously from within Update(). - if qLen := m.appCtrl.Run(msg.Text); qLen > 0 { + if qLen := m.appCtrl.Run(processedText); qLen > 0 { // Queued: anchor the message text above the input with a // "queued" badge. It will be printed to scrollback when // the agent picks it up (on QueueUpdatedEvent).