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

> Discover how the skills find command offers fzf-style interactive search directly in your terminal. Learn about raw terminal mode, debounced calls, and ANSI codes, all without external dependencies.

- Repository: [Vercel Labs/skills](https://github.com/vercel-labs/skills)
- Tags: internals
- Published: 2026-04-23

---

**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`](https://github.com/vercel-labs/skills/blob/main/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`](https://github.com/vercel-labs/skills/blob/main/src/find.ts)** (lines 70-78):

```typescript
// 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`](https://github.com/vercel-labs/skills/blob/main/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.

```typescript
// 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`](https://github.com/vercel-labs/skills/blob/main/src/find.ts)** (lines 44-80):

```typescript
// 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`](https://github.com/vercel-labs/skills/blob/main/src/find.ts)** (lines 200-246) maps terminal input to UI actions:

```typescript
// 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`](https://github.com/vercel-labs/skills/blob/main/src/find.ts)** (lines 10-68) reuses the existing installation logic:

```typescript
// 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`](https://github.com/vercel-labs/skills/blob/main/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`](https://github.com/vercel-labs/skills/blob/main/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`](https://github.com/vercel-labs/skills/blob/main/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.