How the `skills find` Interactive Search Command Works with fzf-Style Selection

The skills find command in the Skills CLI provides an interactive fuzzy search interface using raw terminal mode, debounced API calls, and ANSI escape codes to replicate the fzf experience without external dependencies.

The skills find command is the discovery entry point for the Skills CLI (vercel-labs/skills). When invoked without arguments in an interactive terminal, it launches a fzf-style fuzzy finder that lets users search, filter, and select skills from a remote API. This interactive mode is implemented entirely within the Node.js standard library—no external TUI libraries required.


Architecture Overview

The interactive search flow centers on two main functions in src/find.ts:

Function Purpose
runFind() Command entry point; decides between interactive and non-interactive modes
runSearchPrompt() Spins up the fzf-style terminal UI and returns the selected skill

When process.stdin.isTTY is true and no query argument is provided, runFind delegates to runSearchPrompt, which takes over the terminal for the duration of the search session.


Terminal Setup: Raw Mode and Keypress Events

The foundation of the fzf-style interface is raw terminal mode, configured in src/find.ts (lines 70-78):

// src/find.ts
async function runSearchPrompt(initialQuery = ''): Promise<SearchSkill | null> {
  if (process.stdin.isTTY) {
    process.stdin.setRawMode(true);            // capture every keystroke
  }
  readline.emitKeypressEvents(process.stdin);  // parse bytes into key objects
  process.stdin.resume();
  process.stdout.write(HIDE_CURSOR);           // prevent cursor flicker during redraw

Key technical details:

  • setRawMode(true) disables canonical terminal processing. Normally, the terminal buffers input until Enter is pressed; raw mode delivers each byte immediately.
  • readline.emitKeypressEvents transforms raw byte sequences into structured keypress events with properties like key.name, key.ctrl, and key.sequence.
  • HIDE_CURSOR is an ANSI escape sequence (\u001B[?25l) that suppresses the terminal cursor—essential since the UI redraws constantly.

The Rendering Engine: ANSI Escape Codes for Live Updates

The render() function in src/find.ts (lines 92-141) implements the visual update loop. It uses ANSI escape codes to erase and redraw the entire interface in place, creating the illusion of a smooth, animated list.

// src/find.ts
function render(): void {
  // Erase previous frame
  if (lastRenderedLines > 0) {
    process.stdout.write(MOVE_UP(lastRenderedLines) + MOVE_TO_COL(1));
  }
  process.stdout.write(CLEAR_DOWN);

  const lines: string[] = [];
  
  // Query input line with simulated cursor
  lines.push(`${TEXT}Search skills:${RESET} ${query}${cursor}`);
  
  // Results list (max 8 visible items)
  for (let i = 0; i < visible.length; i++) {
    const skill = visible[i]!;
    const isSelected = i === selectedIndex;
    const arrow = isSelected ? `${BOLD}>${RESET}` : ' ';
    const name = isSelected ? `${BOLD}${skill.name}${RESET}` : `${TEXT}${skill.name}${RESET}`;
    
    lines.push(`  ${arrow} ${name}${source}${installsBadge}${loadingIndicator}`);
  }
  
  // Write the new frame
  for (const line of lines) {
    process.stdout.write(line + '\n');
  }
  lastRenderedLines = lines.length;
}

Critical rendering techniques:

Technique Escape Code Purpose
Move cursor up N lines \u001B[{n}A Return to top of previous frame
Move to column 1 \u001B[1G Left-align for clean overwrite
Clear below cursor \u001B[0J Erase old content without full clear
Bold formatting \u001B[1m Highlight selected item
Text color \u001B[38;5;250m Dim unselected items

The UI displays 8 results maximum with a > arrow and bold formatting on the selected index—visual conventions borrowed directly from fzf.


Debounced API Search with Adaptive Timing

The search implementation balances responsiveness against API load using a dynamic debounce strategy in src/find.ts (lines 44-80):

// src/find.ts
function triggerSearch(q: string): void {
  // Cancel pending search
  if (debounceTimer) {
    clearTimeout(debounceTimer);
    debounceTimer = null;
  }
  
  // Require minimum query length
  if (!q || q.length < 2) {
    results = [];
    render();
    return;
  }
  
  loading = true;
  render();
  
  // Adaptive debounce: shorter queries wait longer
  const debounceMs = Math.max(150, 350 - q.length * 50);
  
  debounceTimer = setTimeout(async () => {
    try {
      results = await searchSkillsAPI(q);
      selectedIndex = 0;
    } catch {
      results = [];
    } finally {
      loading = false;
      debounceTimer = null;
      render();
    }
  }, debounceMs);
}

Adaptive debounce logic:

Query Length Debounce Delay Rationale
2 characters 250 ms Early typing, more ambiguity
4 characters 150 ms Narrower results, faster feedback
5+ characters 150 ms Maximum responsiveness

The searchSkillsAPI function (lines 32-58) performs the actual HTTP GET to /api/search?q=${encodeURIComponent(q)}, returning typed SearchSkill objects.


Keyboard Handling: Navigation and Selection

The handleKeypress function in src/find.ts (lines 200-246) maps terminal input to UI actions:

// src/find.ts
function handleKeypress(_ch: string, key: Key): void {
  // Exit handlers
  if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
    cleanup();
    resolve(null);
    return;
  }
  
  // Selection
  if (key.name === 'return') {
    cleanup();
    resolve(results[selectedIndex] || null);
    return;
  }
  
  // Navigation
  if (key.name === 'up') {
    selectedIndex = Math.max(0, selectedIndex - 1);
    render();
    return;
  }
  if (key.name === 'down') {
    selectedIndex = Math.min(results.length - 1, selectedIndex + 1);
    render();
    return;
  }
  
  // Editing
  if (key.name === 'backspace') {
    query = query.slice(0, -1);
    triggerSearch(query);
    return;
  }
  
  // Printable characters
  if (key.sequence && !key.ctrl && !key.meta && key.sequence.length === 1) {
    const char = key.sequence;
    if (char >= ' ' && char <= '~') {
      query += char;
      triggerSearch(query);
    }
  }
}

Supported key bindings:

Key Action
↑ / ↓ Navigate results
Enter Select highlighted skill
Esc / Ctrl+C Cancel and exit
Backspace Delete last character
a-z, 0-9, etc. Append to search query

Integration with the add Command

After selection, runFind in src/find.ts (lines 10-68) reuses the existing installation logic:

// src/find.ts (simplified)
const selected = await runSearchPrompt();

if (!selected) {
  console.log(`${DIM}Search cancelled${RESET}`);
  return;
}

// Reuse the add command's implementation
const { source, options } = parseAddOptions([
  `${selected.owner}/${selected.repo}`,
  '--skill',
  selected.name
]);
await runAdd(source, options);

This design avoids code duplication—the interactive finder is purely a discovery layer that feeds into the proven add pipeline.


Summary

  • runSearchPrompt in src/find.ts implements the complete fzf-style interface using only Node.js built-ins
  • Raw terminal mode (setRawMode) and readline.emitKeypressEvents transform the terminal into a real-time input device
  • ANSI escape codes enable in-place screen updates without external TUI libraries
  • Adaptive debouncing (150-350ms) balances responsiveness with API efficiency
  • Keyboard navigation mirrors fzf conventions: arrows to move, Enter to select, Esc to cancel
  • Unified architecture routes selections through the existing add command, avoiding duplicated installation logic

Frequently Asked Questions

What makes the skills find interactive search different from using fzf directly?

The skills find command embeds a purpose-built fuzzy finder directly into the CLI. Unlike shelling out to an external fzf binary, the implementation in src/find.ts uses only Node.js core modules—readline, process.stdin, and process.stdout with ANSI escape codes. This guarantees consistent behavior across platforms without requiring users to install fzf separately.

How does the search debounce mechanism adapt to query length?

The debounce timing in triggerSearch calculates delay as Math.max(150, 350 - q.length * 50). This produces shorter waits for longer queries: a 2-character query waits 250ms, while queries of 4+ characters drop to the 150ms minimum. The adaptive logic prevents excessive API calls during rapid early typing while delivering faster feedback as the result set narrows.

Why does the selected skill get passed to runAdd instead of having dedicated install logic?

The architecture deliberately reuses runAdd from src/add.ts to maintain single-source-of-truth for skill installation. After runSearchPrompt returns a SearchSkill, runFind constructs the equivalent command-line arguments and invokes parseAddOptions followed by runAdd. This avoids duplicating clone logic, dependency resolution, and telemetry instrumentation across commands.

What terminal capabilities are required for the interactive search to function?

The implementation requires a TTY-attached stdin (process.stdin.isTTY) to enable raw mode and keypress event emission. The terminal must support ANSI escape sequences for cursor movement (\u001B[{n}A), line clearing (\u001B[0J), and text styling (bold, color). These capabilities are standard in modern terminal emulators, including iTerm2, Windows Terminal, and most Linux console implementations.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →