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.emitKeypressEventstransforms raw byte sequences into structuredkeypressevents with properties likekey.name,key.ctrl, andkey.sequence.HIDE_CURSORis 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
runSearchPromptinsrc/find.tsimplements the complete fzf-style interface using only Node.js built-ins- Raw terminal mode (
setRawMode) andreadline.emitKeypressEventstransform 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
addcommand, 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →