Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 74 additions & 2 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command> [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.
Expand Down Expand Up @@ -2519,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,
Expand Down Expand Up @@ -3518,6 +3526,59 @@ function applyPatchInPlace(root: Record<string, unknown>, 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<string, TuiCandidate>();
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<never> {
const candidates = buildTuiCandidates();
if (candidates.length === 0) {
process.stderr.write(
'No personas available. Try `agentworkforce install <pack>` 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<never> 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
Expand Down Expand Up @@ -3671,9 +3732,20 @@ export async function main(): Promise<void> {
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') {
Expand Down
163 changes: 163 additions & 0 deletions packages/cli/src/persona-tui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
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 {
computeTuiView,
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('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');
try {
assert.deepEqual(loadRecents(path), []);
writeFileSync(path, '{ not json', 'utf8');
assert.deepEqual(loadRecents(path), []);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
Loading
Loading