From e31a382fed9aa81961b5ac9fc8199062f67d51d0 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 11 May 2026 11:43:44 -0400 Subject: [PATCH 1/2] cli: open interactive persona picker on bare invocation Running `agentworkforce` with no arguments now drops into a TUI when stdin/stderr are TTYs. The picker shows the 3 most recently launched personas by default, fuzzy-matches across name and description when the user types, and labels each row with its cascade source (cwd, user, dir:n, library) so it's obvious where a persona came from. Recents are persisted to ~/.agentworkforce/workforce/recents.json and updated inside runAgentSelector so explicit `agent ` launches feed the list too. Non-TTY pipes still print USAGE and exit 1, so scripts that probed the exit code keep working. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/cli.ts | 74 +++++- packages/cli/src/persona-tui.test.ts | 129 ++++++++++ packages/cli/src/persona-tui.ts | 346 +++++++++++++++++++++++++++ 3 files changed, 547 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/persona-tui.test.ts create mode 100644 packages/cli/src/persona-tui.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 946983ab..8a6e9c07 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -75,9 +75,14 @@ import { } from './local-personas.js'; import { installPersonas, type PersonaInstallResult } from './persona-install.js'; import { pickPersona, type PickCandidate, type PickResult } from './persona-picker.js'; +import { recordRecent, loadRecents, runPersonaPickerTui, type TuiCandidate } from './persona-tui.js'; const USAGE = `Usage: agentworkforce [args...] +Run with no arguments inside a TTY to open an interactive persona picker — +the top 3 most recently used personas are shown first, and typing fuzzy- +searches across persona names and descriptions. + Commands: create [flags] Opens persona-maker@best for creating a new persona, with target path passed as persona inputs. @@ -2509,6 +2514,7 @@ async function runAgentSelector( inputValues?: Record ): Promise { const target = parseSelector(selector); + recordRecent(target.spec.id); const selection = { ...buildSelection(target.spec, target.tier, target.kind), ...(inputValues ? { inputValues } : {}) @@ -3518,6 +3524,59 @@ function applyPatchInPlace(root: Record, patch: ImproverPatch): cursor[finalSeg] = patch.value; } +/** + * Enumerate personas for the interactive TUI. Source label mirrors the cascade + * shown by `agentworkforce list` so the picker tells the user *where* a + * persona is coming from (cwd, user, dir:n, library) without a separate + * lookup. + */ +export function buildTuiCandidates(): TuiCandidate[] { + const byId = new Map(); + for (const spec of listBuiltInPersonas()) { + byId.set(spec.id, { id: spec.id, description: spec.description, source: 'library' }); + } + for (const [id, spec] of local.byId.entries()) { + byId.set(id, { + id, + description: spec.description, + source: local.sources.get(id) ?? 'library' + }); + } + return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)); +} + +/** + * Bare-invocation flow: open the interactive TUI, then hand the chosen + * persona to {@link runAgentSelector}. Quitting the picker (Esc / Ctrl-C) + * exits with conventional 130 so shell pipelines see SIGINT-style failure. + * + * runAgentSelector terminates the process via process.exit; this function + * only returns when the picker is dismissed without a selection. + */ +async function runInteractivePicker(): Promise { + const candidates = buildTuiCandidates(); + if (candidates.length === 0) { + process.stderr.write( + 'No personas available. Try `agentworkforce install ` or run with --help.\n' + ); + process.exit(1); + } + const selected = await runPersonaPickerTui({ + candidates, + recentIds: loadRecents() + }); + if (!selected) { + process.exit(130); + } + await runAgentSelector(selected, { + installInRepo: false, + noLaunchMetadata: false, + dryRun: false + }); + // runAgentSelector has Promise return type; this is unreachable. + process.exit(0); +} + /** * Enumerate persona candidates for the picker. Local overrides win over the * built-in catalog when ids collide; the picker only needs the projection @@ -3671,9 +3730,20 @@ export async function main(): Promise { const argv = process.argv.slice(2); const [subcommand, ...rest] = argv; - if (!subcommand || subcommand === '-h' || subcommand === '--help') { + if (subcommand === '-h' || subcommand === '--help') { process.stdout.write(USAGE); - process.exit(subcommand ? 0 : 1); + process.exit(0); + } + + if (!subcommand) { + if (process.stdin.isTTY && process.stderr.isTTY) { + await runInteractivePicker(); + // runInteractivePicker either runAgentSelector → process.exit, or + // exits itself on quit / no-match. Satisfy TS's unreachable check. + process.exit(0); + } + process.stdout.write(USAGE); + process.exit(1); } if (subcommand === '-v' || subcommand === '--version') { diff --git a/packages/cli/src/persona-tui.test.ts b/packages/cli/src/persona-tui.test.ts new file mode 100644 index 00000000..fdf1a9f2 --- /dev/null +++ b/packages/cli/src/persona-tui.test.ts @@ -0,0 +1,129 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + fuzzyScore, + nextRecents, + parseRecents, + rankCandidates, + recentCandidates, + recordRecent, + loadRecents, + type TuiCandidate +} from './persona-tui.js'; + +const CANDIDATES: TuiCandidate[] = [ + { + id: 'code-reviewer', + description: 'Reviews pull requests for quality, correctness and security.', + source: 'library' + }, + { + id: 'fix-flaky', + description: 'Repairs flaky tests across the test suite.', + source: 'user' + }, + { + id: 'persona-maker', + description: 'Scaffolds a new persona via interactive Q&A.', + source: 'library' + }, + { + id: 'my-reviewer', + description: 'Local reviewer override with team-specific style rules.', + source: 'cwd' + } +]; + +test('fuzzyScore returns null when chars are absent or out of order', () => { + assert.equal(fuzzyScore('zzz', 'code-reviewer'), null); + assert.equal(fuzzyScore('reverse', 'reviewer'), null); +}); + +test('fuzzyScore prefers prefix and dense matches', () => { + const prefix = fuzzyScore('code', 'code-reviewer'); + const scattered = fuzzyScore('code', 'committed-old-de'); + assert.ok(prefix !== null && scattered !== null); + assert.ok(prefix! < scattered!, `expected prefix=${prefix} < scattered=${scattered}`); +}); + +test('rankCandidates surfaces name matches over description matches', () => { + // Both reviewer ids match by name; "review" doesn't subsequence-match + // anything else, so the two name matches are the only results and rank + // by leading-offset (my-reviewer first because "r" appears earlier). + const ranked = rankCandidates(CANDIDATES, 'review'); + assert.deepEqual(ranked.map((c) => c.id), ['my-reviewer', 'code-reviewer']); +}); + +test('rankCandidates returns empty array when nothing matches', () => { + assert.deepEqual(rankCandidates(CANDIDATES, 'xxxxxxxx'), []); +}); + +test('rankCandidates returns all candidates with empty query', () => { + const ranked = rankCandidates(CANDIDATES, ' '); + assert.equal(ranked.length, CANDIDATES.length); +}); + +test('rankCandidates can match purely from description text', () => { + const ranked = rankCandidates(CANDIDATES, 'flaky'); + assert.equal(ranked[0].id, 'fix-flaky'); +}); + +test('recentCandidates preserves order and drops unknown ids', () => { + const recents = recentCandidates( + CANDIDATES, + ['fix-flaky', 'gone-persona', 'code-reviewer', 'my-reviewer'], + 3 + ); + assert.deepEqual( + recents.map((c) => c.id), + ['fix-flaky', 'code-reviewer', 'my-reviewer'] + ); +}); + +test('nextRecents moves an existing id to the front and caps the list', () => { + const result = nextRecents(['a', 'b', 'c', 'd', 'e'], 'c', 3); + assert.deepEqual(result, ['c', 'a', 'b']); +}); + +test('nextRecents prepends a new id', () => { + assert.deepEqual(nextRecents(['a', 'b'], 'z'), ['z', 'a', 'b']); +}); + +test('parseRecents tolerates garbage input', () => { + assert.deepEqual(parseRecents('not json'), []); + assert.deepEqual(parseRecents('null'), []); + assert.deepEqual(parseRecents('{"ids": "nope"}'), []); + assert.deepEqual(parseRecents('{"ids": [1, "ok", " ", "ok"]}'), ['ok']); +}); + +test('recordRecent + loadRecents round-trip via the filesystem', () => { + const dir = mkdtempSync(join(tmpdir(), 'aw-tui-')); + const path = join(dir, 'nested', 'recents.json'); + try { + recordRecent('code-reviewer', path); + recordRecent('fix-flaky', path); + recordRecent('code-reviewer', path); + assert.deepEqual(loadRecents(path), ['code-reviewer', 'fix-flaky']); + const onDisk = JSON.parse(readFileSync(path, 'utf8')) as { version: number; ids: string[] }; + assert.equal(onDisk.version, 1); + assert.deepEqual(onDisk.ids, ['code-reviewer', 'fix-flaky']); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('loadRecents returns [] when the file is absent or corrupt', () => { + const dir = mkdtempSync(join(tmpdir(), 'aw-tui-')); + const path = join(dir, 'recents.json'); + try { + assert.deepEqual(loadRecents(path), []); + writeFileSync(path, '{ not json', 'utf8'); + assert.deepEqual(loadRecents(path), []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/src/persona-tui.ts b/packages/cli/src/persona-tui.ts new file mode 100644 index 00000000..dfe1efba --- /dev/null +++ b/packages/cli/src/persona-tui.ts @@ -0,0 +1,346 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { defaultWorkforceHomeDir } from './local-personas.js'; + +/** + * Persona projection shown in the interactive picker. Description and source + * label come straight from the resolved spec / cascade so the user sees what + * they'd see in `agentworkforce list`. + */ +export interface TuiCandidate { + id: string; + description: string; + source: string; +} + +const RECENTS_FILENAME = 'recents.json'; +const RECENTS_CAP = 20; +const RECENT_DEFAULT_VISIBLE = 3; + +export function defaultRecentsPath(workforceHomeDir = defaultWorkforceHomeDir()): string { + return join(workforceHomeDir, RECENTS_FILENAME); +} + +/** + * Parse the on-disk recents file. Returns an empty list on any shape problem — + * the recents store is best-effort UX and must never block the CLI from + * launching an agent. + */ +export function parseRecents(text: string): string[] { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return []; + } + if (typeof parsed !== 'object' || parsed === null) return []; + const ids = (parsed as { ids?: unknown }).ids; + if (!Array.isArray(ids)) return []; + const out: string[] = []; + const seen = new Set(); + for (const id of ids) { + if (typeof id !== 'string') continue; + const trimmed = id.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + out.push(trimmed); + if (out.length >= RECENTS_CAP) break; + } + return out; +} + +export function loadRecents(path = defaultRecentsPath()): string[] { + if (!existsSync(path)) return []; + try { + return parseRecents(readFileSync(path, 'utf8')); + } catch { + return []; + } +} + +/** + * Move `id` to the front of the recents list, dedup, cap. Pure so tests don't + * need a temp dir. + */ +export function nextRecents( + prev: readonly string[], + id: string, + cap = RECENTS_CAP +): string[] { + const filtered = prev.filter((x) => x !== id); + return [id, ...filtered].slice(0, cap); +} + +/** + * Persist a persona id at the top of the recents list. Swallows IO errors — + * a corrupt or unwritable recents file should never block a launch. + */ +export function recordRecent(id: string, path = defaultRecentsPath()): void { + if (!id) return; + try { + const prev = loadRecents(path); + const next = nextRecents(prev, id); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify({ version: 1, ids: next }, null, 2)}\n`, 'utf8'); + } catch { + /* best-effort */ + } +} + +/** + * Subsequence fuzzy match. Returns null when not all query chars appear in + * order; otherwise returns a numeric score where smaller is a better match. + * Score combines leading offset and gap size so prefix / dense matches sort + * above scattered ones. + */ +export function fuzzyScore(query: string, target: string): number | null { + if (!query) return 0; + const q = query.toLowerCase(); + const t = target.toLowerCase(); + let qi = 0; + let firstIdx = -1; + let lastIdx = -1; + let totalGap = 0; + for (let ti = 0; ti < t.length && qi < q.length; ti++) { + if (t[ti] === q[qi]) { + if (firstIdx === -1) firstIdx = ti; + if (lastIdx !== -1) totalGap += ti - lastIdx - 1; + lastIdx = ti; + qi += 1; + } + } + if (qi < q.length) return null; + return firstIdx + totalGap * 2 + Math.floor(t.length / 32); +} + +/** + * Rank candidates by best fuzzy match across name OR description. Name matches + * outrank description matches at equal score to keep the picker feeling + * direct — typing "rev" should surface "code-reviewer" not whichever persona + * happens to mention "reviews" in prose. + */ +export function rankCandidates( + candidates: readonly TuiCandidate[], + query: string +): TuiCandidate[] { + const trimmed = query.trim(); + if (!trimmed) return [...candidates]; + const DESCRIPTION_PENALTY = 5; + const scored: Array<{ c: TuiCandidate; s: number }> = []; + for (const c of candidates) { + const nameScore = fuzzyScore(trimmed, c.id); + const descScore = fuzzyScore(trimmed, c.description); + let score: number | null = null; + if (nameScore !== null && descScore !== null) { + score = Math.min(nameScore, descScore + DESCRIPTION_PENALTY); + } else if (nameScore !== null) { + score = nameScore; + } else if (descScore !== null) { + score = descScore + DESCRIPTION_PENALTY; + } + if (score !== null) scored.push({ c, s: score }); + } + scored.sort((a, b) => a.s - b.s || a.c.id.localeCompare(b.c.id)); + return scored.map((r) => r.c); +} + +/** + * Project recent ids onto the candidate list, preserving recency order and + * dropping ids that no longer resolve (uninstalled pack, renamed local + * persona, etc.). + */ +export function recentCandidates( + candidates: readonly TuiCandidate[], + recentIds: readonly string[], + cap = RECENT_DEFAULT_VISIBLE +): TuiCandidate[] { + const byId = new Map(candidates.map((c) => [c.id, c] as const)); + const out: TuiCandidate[] = []; + for (const id of recentIds) { + const c = byId.get(id); + if (!c) continue; + out.push(c); + if (out.length >= cap) break; + } + return out; +} + +const ESC = '\x1b'; +const SEQ = { + enterAlt: `${ESC}[?1049h`, + leaveAlt: `${ESC}[?1049l`, + hideCursor: `${ESC}[?25l`, + showCursor: `${ESC}[?25h`, + clear: `${ESC}[2J${ESC}[H`, + reset: `${ESC}[0m`, + inverse: `${ESC}[7m`, + dim: `${ESC}[2m`, + bold: `${ESC}[1m`, + cyan: `${ESC}[36m` +} as const; + +function truncate(text: string, max: number): string { + if (text.length <= max) return text; + if (max <= 1) return text.slice(0, Math.max(0, max)); + return `${text.slice(0, max - 1)}…`; +} + +export interface RunPersonaTuiOptions { + candidates: readonly TuiCandidate[]; + recentIds: readonly string[]; + stdin?: NodeJS.ReadStream; + stderr?: NodeJS.WriteStream; + /** Cap on visible rows. Tests inject a small number; default 20. */ + visibleCap?: number; +} + +const DEFAULT_VISIBLE_CAP = 20; + +/** + * Interactive persona picker. Renders inside the alternate screen buffer so + * scrollback survives, raw-mode reads single keystrokes for arrow/enter/esc + * handling, and resolves with the chosen persona id (or undefined on quit). + * + * Falls back to undefined immediately when stdin or stderr isn't a TTY — the + * caller should print the regular help text in that case. + */ +export async function runPersonaPickerTui( + opts: RunPersonaTuiOptions +): Promise { + const stdin = opts.stdin ?? process.stdin; + const stderr = opts.stderr ?? process.stderr; + if (!stdin.isTTY || !stderr.isTTY) return undefined; + + const visibleCap = opts.visibleCap ?? DEFAULT_VISIBLE_CAP; + let query = ''; + let cursor = 0; + let visible = computeVisible(); + + function computeVisible(): TuiCandidate[] { + if (!query.trim()) { + const recents = recentCandidates(opts.candidates, opts.recentIds, RECENT_DEFAULT_VISIBLE); + if (recents.length > 0) return recents; + return [...opts.candidates].slice(0, visibleCap); + } + return rankCandidates(opts.candidates, query).slice(0, visibleCap); + } + + function render(): void { + const cols = stderr.columns ?? 100; + const showingRecents = !query.trim() && opts.recentIds.length > 0 && visible.length > 0; + let out = SEQ.clear; + out += `${SEQ.bold}agentworkforce${SEQ.reset} · pick a persona\n`; + out += `${SEQ.dim}↑↓ navigate · enter run · esc quit · type to search${SEQ.reset}\n\n`; + out += `${SEQ.cyan}›${SEQ.reset} ${query || `${SEQ.dim}(type to search by name or description)${SEQ.reset}`}\n\n`; + const header = visible.length === 0 + ? 'NO MATCHES' + : showingRecents + ? 'RECENT' + : 'PERSONAS'; + out += `${SEQ.dim}${header}${SEQ.reset}\n`; + if (visible.length === 0) { + out += `${SEQ.dim}(no persona name or description matches "${query}")${SEQ.reset}\n`; + } else { + const nameWidth = Math.min( + 32, + Math.max(8, ...visible.map((c) => c.id.length)) + ); + const sourceWidth = Math.min( + 14, + Math.max(7, ...visible.map((c) => c.source.length)) + ); + const descBudget = Math.max(20, cols - nameWidth - sourceWidth - 7); + for (let i = 0; i < visible.length; i += 1) { + const c = visible[i]; + const isSel = i === cursor; + const marker = isSel ? `${SEQ.cyan}›${SEQ.reset}` : ' '; + const desc = truncate(c.description.replace(/\s+/g, ' ').trim(), descBudget); + const body = `${c.id.padEnd(nameWidth)} ${c.source.padEnd(sourceWidth)} ${desc}`; + const styled = isSel ? `${SEQ.inverse}${body}${SEQ.reset}` : `${SEQ.dim}${body}${SEQ.reset}`; + out += `${marker} ${styled}\n`; + } + } + stderr.write(out); + } + + function refresh(): void { + visible = computeVisible(); + if (cursor >= visible.length) cursor = Math.max(0, visible.length - 1); + render(); + } + + return new Promise((resolve) => { + let settled = false; + function settle(value: string | undefined): void { + if (settled) return; + settled = true; + stdin.removeListener('data', onData); + try { + stdin.setRawMode?.(false); + } catch { + /* not a TTY anymore */ + } + stdin.pause(); + stderr.write(`${SEQ.showCursor}${SEQ.leaveAlt}`); + resolve(value); + } + function onData(chunk: Buffer | string): void { + const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8'); + // Ctrl-C or bare Escape — quit. + if (text === '\x03' || text === '\x1b') { + settle(undefined); + return; + } + // Enter — accept current selection. + if (text === '\r' || text === '\n') { + const sel = visible[cursor]; + settle(sel?.id); + return; + } + // Arrow Up / Ctrl-P + if (text === '\x1b[A' || text === '\x10') { + if (visible.length === 0) return; + cursor = (cursor - 1 + visible.length) % visible.length; + render(); + return; + } + // Arrow Down / Ctrl-N + if (text === '\x1b[B' || text === '\x0e') { + if (visible.length === 0) return; + cursor = (cursor + 1) % visible.length; + render(); + return; + } + // Backspace + if (text === '\x7f' || text === '\b') { + if (query.length > 0) { + query = query.slice(0, -1); + cursor = 0; + refresh(); + } + return; + } + // Strip any remaining control bytes and append printable input. + let printable = ''; + for (const ch of text) { + const code = ch.charCodeAt(0); + if (code >= 0x20 && code !== 0x7f) printable += ch; + } + if (printable.length > 0) { + query += printable; + cursor = 0; + refresh(); + } + } + try { + stdin.setRawMode?.(true); + } catch { + /* not a TTY */ + } + stdin.resume(); + stderr.write(`${SEQ.enterAlt}${SEQ.hideCursor}`); + stdin.on('data', onData); + render(); + }); +} From 2f1d8c8eca1176b536fd8e6b1a8877dde6e13c4c Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 11 May 2026 12:26:35 -0400 Subject: [PATCH 2/2] persona-tui: address review feedback - Don't pollute recents under `--dry-run` (Devin/Codex): move recordRecent past the dry-run early-exit so validation runs leave the MRU list alone. - Fix stale "RECENT" header when every recent id has been uninstalled (Devin): factor view-mode selection into a pure computeTuiView() helper that returns mode='all' when recentCandidates() can't resolve anything, and have render() trust the helper's mode field. - Buffer bare Escape for 50ms before treating it as quit (Codex/CodeRabbit): on slow terminals (SSH, mux) arrow keys arrive as `\x1b` then `[A` in separate data events; without buffering the first chunk killed the picker. The debounce window is below human-perceptible Esc latency. Added unit tests for computeTuiView covering recents/all/matches modes and the regression where stale recents previously misreported the header. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/cli.ts | 4 +- packages/cli/src/persona-tui.test.ts | 34 +++++++ packages/cli/src/persona-tui.ts | 139 +++++++++++++++++++++------ 3 files changed, 147 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 8a6e9c07..d685a7e4 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2514,7 +2514,6 @@ async function runAgentSelector( inputValues?: Record ): Promise { const target = parseSelector(selector); - recordRecent(target.spec.id); const selection = { ...buildSelection(target.spec, target.tier, target.kind), ...(inputValues ? { inputValues } : {}) @@ -2525,6 +2524,9 @@ async function runAgentSelector( process.exit(code); } + // Record only on real launches so `--dry-run` validations don't pollute the + // MRU list used by the bare-invocation picker. + recordRecent(target.spec.id); const capture: RunInteractiveCapture = {}; const code = await runInteractive(selection, { installInRepo: flags.installInRepo, diff --git a/packages/cli/src/persona-tui.test.ts b/packages/cli/src/persona-tui.test.ts index fdf1a9f2..6209c0d7 100644 --- a/packages/cli/src/persona-tui.test.ts +++ b/packages/cli/src/persona-tui.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { + computeTuiView, fuzzyScore, nextRecents, parseRecents, @@ -116,6 +117,39 @@ test('recordRecent + loadRecents round-trip via the filesystem', () => { } }); +test('computeTuiView: empty query with resolved recents → recents mode', () => { + const view = computeTuiView(CANDIDATES, ['fix-flaky', 'code-reviewer'], ''); + assert.equal(view.mode, 'recents'); + assert.deepEqual(view.items.map((c) => c.id), ['fix-flaky', 'code-reviewer']); +}); + +test('computeTuiView: recents pointing only at unknown ids → all mode (regression)', () => { + // Prior bug: header said "RECENT" because recentIds was non-empty even + // though every id had been uninstalled/renamed and the full catalog was + // being shown. + const view = computeTuiView(CANDIDATES, ['ghost-persona', 'also-gone'], ''); + assert.equal(view.mode, 'all'); + assert.equal(view.items.length, CANDIDATES.length); +}); + +test('computeTuiView: empty query with no recents → all mode', () => { + const view = computeTuiView(CANDIDATES, [], ''); + assert.equal(view.mode, 'all'); +}); + +test('computeTuiView: non-empty query → matches mode', () => { + const view = computeTuiView(CANDIDATES, ['fix-flaky'], 'review'); + assert.equal(view.mode, 'matches'); + assert.ok(view.items.length > 0); + assert.ok(view.items.every((c) => c.id.includes('review'))); +}); + +test('computeTuiView: matches mode honors visibleCap', () => { + const view = computeTuiView(CANDIDATES, [], 'e', 2); + assert.equal(view.mode, 'matches'); + assert.ok(view.items.length <= 2); +}); + test('loadRecents returns [] when the file is absent or corrupt', () => { const dir = mkdtempSync(join(tmpdir(), 'aw-tui-')); const path = join(dir, 'recents.json'); diff --git a/packages/cli/src/persona-tui.ts b/packages/cli/src/persona-tui.ts index dfe1efba..9146c1e1 100644 --- a/packages/cli/src/persona-tui.ts +++ b/packages/cli/src/persona-tui.ts @@ -193,9 +193,54 @@ export interface RunPersonaTuiOptions { stderr?: NodeJS.WriteStream; /** Cap on visible rows. Tests inject a small number; default 20. */ visibleCap?: number; + /** Escape-sequence debounce in ms. Tests inject 0; default 50. */ + escapeTimeoutMs?: number; } const DEFAULT_VISIBLE_CAP = 20; +/** + * Window we wait for the rest of an escape sequence before treating a bare + * `\x1b` as a quit keystroke. Arrow keys arrive as `\x1b[A` / `\x1b[B`, and + * over slow connections (SSH, multiplexers, low-baud serial) those bytes can + * land in separate `data` events. 50ms is well below the human-perceptible + * delay for a real Esc press but plenty of slack for fragmented sequences. + */ +const DEFAULT_ESCAPE_TIMEOUT_MS = 50; + +export type TuiViewMode = 'recents' | 'all' | 'matches'; + +export interface TuiView { + mode: TuiViewMode; + items: TuiCandidate[]; +} + +/** + * Decide what the picker should show given the candidate set, recents list, + * and current query. Exported (and pure) so the recents-header logic can be + * unit-tested without spinning up a TTY. + * + * - `recents` — empty query AND at least one recent id still resolves to a + * known candidate. + * - `all` — empty query AND no recent ids resolve (fresh install, or all + * previously-used personas have been uninstalled/renamed). + * - `matches` — non-empty query; items are the ranked fuzzy hits. + */ +export function computeTuiView( + candidates: readonly TuiCandidate[], + recentIds: readonly string[], + query: string, + visibleCap: number = DEFAULT_VISIBLE_CAP +): TuiView { + if (!query.trim()) { + const recents = recentCandidates(candidates, recentIds, RECENT_DEFAULT_VISIBLE); + if (recents.length > 0) return { mode: 'recents', items: recents }; + return { mode: 'all', items: [...candidates].slice(0, visibleCap) }; + } + return { + mode: 'matches', + items: rankCandidates(candidates, query).slice(0, visibleCap) + }; +} /** * Interactive persona picker. Renders inside the alternate screen buffer so @@ -213,46 +258,38 @@ export async function runPersonaPickerTui( if (!stdin.isTTY || !stderr.isTTY) return undefined; const visibleCap = opts.visibleCap ?? DEFAULT_VISIBLE_CAP; + const escapeTimeoutMs = opts.escapeTimeoutMs ?? DEFAULT_ESCAPE_TIMEOUT_MS; let query = ''; let cursor = 0; - let visible = computeVisible(); - - function computeVisible(): TuiCandidate[] { - if (!query.trim()) { - const recents = recentCandidates(opts.candidates, opts.recentIds, RECENT_DEFAULT_VISIBLE); - if (recents.length > 0) return recents; - return [...opts.candidates].slice(0, visibleCap); - } - return rankCandidates(opts.candidates, query).slice(0, visibleCap); - } + let view = computeTuiView(opts.candidates, opts.recentIds, query, visibleCap); function render(): void { const cols = stderr.columns ?? 100; - const showingRecents = !query.trim() && opts.recentIds.length > 0 && visible.length > 0; + const items = view.items; let out = SEQ.clear; out += `${SEQ.bold}agentworkforce${SEQ.reset} · pick a persona\n`; out += `${SEQ.dim}↑↓ navigate · enter run · esc quit · type to search${SEQ.reset}\n\n`; out += `${SEQ.cyan}›${SEQ.reset} ${query || `${SEQ.dim}(type to search by name or description)${SEQ.reset}`}\n\n`; - const header = visible.length === 0 + const header = items.length === 0 ? 'NO MATCHES' - : showingRecents + : view.mode === 'recents' ? 'RECENT' : 'PERSONAS'; out += `${SEQ.dim}${header}${SEQ.reset}\n`; - if (visible.length === 0) { + if (items.length === 0) { out += `${SEQ.dim}(no persona name or description matches "${query}")${SEQ.reset}\n`; } else { const nameWidth = Math.min( 32, - Math.max(8, ...visible.map((c) => c.id.length)) + Math.max(8, ...items.map((c) => c.id.length)) ); const sourceWidth = Math.min( 14, - Math.max(7, ...visible.map((c) => c.source.length)) + Math.max(7, ...items.map((c) => c.source.length)) ); const descBudget = Math.max(20, cols - nameWidth - sourceWidth - 7); - for (let i = 0; i < visible.length; i += 1) { - const c = visible[i]; + for (let i = 0; i < items.length; i += 1) { + const c = items[i]; const isSel = i === cursor; const marker = isSel ? `${SEQ.cyan}›${SEQ.reset}` : ' '; const desc = truncate(c.description.replace(/\s+/g, ' ').trim(), descBudget); @@ -265,16 +302,29 @@ export async function runPersonaPickerTui( } function refresh(): void { - visible = computeVisible(); - if (cursor >= visible.length) cursor = Math.max(0, visible.length - 1); + view = computeTuiView(opts.candidates, opts.recentIds, query, visibleCap); + if (cursor >= view.items.length) cursor = Math.max(0, view.items.length - 1); render(); } return new Promise((resolve) => { let settled = false; + // Buffered bare-Escape: stays set while we wait for the rest of a possible + // escape sequence. If `escapeTimer` fires first the user pressed Esc on + // its own; if more bytes arrive first we glue them together and dispatch. + let pendingEscape = ''; + let escapeTimer: NodeJS.Timeout | undefined; + function clearEscapeBuffer(): void { + pendingEscape = ''; + if (escapeTimer) { + clearTimeout(escapeTimer); + escapeTimer = undefined; + } + } function settle(value: string | undefined): void { if (settled) return; settled = true; + clearEscapeBuffer(); stdin.removeListener('data', onData); try { stdin.setRawMode?.(false); @@ -285,30 +335,34 @@ export async function runPersonaPickerTui( stderr.write(`${SEQ.showCursor}${SEQ.leaveAlt}`); resolve(value); } - function onData(chunk: Buffer | string): void { - const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8'); - // Ctrl-C or bare Escape — quit. - if (text === '\x03' || text === '\x1b') { + function handleKey(text: string): void { + // Ctrl-C — quit. + if (text === '\x03') { + settle(undefined); + return; + } + // Bare Escape that has already cleared the debounce window. + if (text === '\x1b') { settle(undefined); return; } // Enter — accept current selection. if (text === '\r' || text === '\n') { - const sel = visible[cursor]; + const sel = view.items[cursor]; settle(sel?.id); return; } // Arrow Up / Ctrl-P if (text === '\x1b[A' || text === '\x10') { - if (visible.length === 0) return; - cursor = (cursor - 1 + visible.length) % visible.length; + if (view.items.length === 0) return; + cursor = (cursor - 1 + view.items.length) % view.items.length; render(); return; } // Arrow Down / Ctrl-N if (text === '\x1b[B' || text === '\x0e') { - if (visible.length === 0) return; - cursor = (cursor + 1) % visible.length; + if (view.items.length === 0) return; + cursor = (cursor + 1) % view.items.length; render(); return; } @@ -321,6 +375,10 @@ export async function runPersonaPickerTui( } return; } + // Anything else that *starts* with ESC is an unrecognized CSI / SS3 / + // function-key sequence — swallow it rather than typing the bytes into + // the search box. + if (text.startsWith('\x1b')) return; // Strip any remaining control bytes and append printable input. let printable = ''; for (const ch of text) { @@ -333,6 +391,29 @@ export async function runPersonaPickerTui( refresh(); } } + function onData(chunk: Buffer | string): void { + const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8'); + // Mid-sequence: the previous tick buffered a lone ESC. Glue and + // dispatch as a single keystroke so `\x1b` + `[A` is treated as an + // arrow press, not Esc-then-`[A`. + if (pendingEscape) { + const combined = pendingEscape + text; + clearEscapeBuffer(); + handleKey(combined); + return; + } + // Lone ESC: wait briefly for the rest of a possible sequence. + if (text === '\x1b' && escapeTimeoutMs > 0) { + pendingEscape = text; + escapeTimer = setTimeout(() => { + if (pendingEscape !== '\x1b') return; + clearEscapeBuffer(); + handleKey('\x1b'); + }, escapeTimeoutMs); + return; + } + handleKey(text); + } try { stdin.setRawMode?.(true); } catch {