Skip to content

Commit bf9c750

Browse files
authored
Merge pull request #83 from AgentWorkforce/claude/persona-picker-tui
cli: interactive persona picker on bare invocation
2 parents d8f48e5 + 2f1d8c8 commit bf9c750

3 files changed

Lines changed: 664 additions & 2 deletions

File tree

‎packages/cli/src/cli.ts‎

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,14 @@ import {
7575
} from './local-personas.js';
7676
import { installPersonas, type PersonaInstallResult } from './persona-install.js';
7777
import { pickPersona, type PickCandidate, type PickResult } from './persona-picker.js';
78+
import { recordRecent, loadRecents, runPersonaPickerTui, type TuiCandidate } from './persona-tui.js';
7879

7980
const USAGE = `Usage: agentworkforce <command> [args...]
8081
82+
Run with no arguments inside a TTY to open an interactive persona picker —
83+
the top 3 most recently used personas are shown first, and typing fuzzy-
84+
searches across persona names and descriptions.
85+
8186
Commands:
8287
create [flags] Opens persona-maker@best for creating a new
8388
persona, with target path passed as persona inputs.
@@ -2519,6 +2524,9 @@ async function runAgentSelector(
25192524
process.exit(code);
25202525
}
25212526

2527+
// Record only on real launches so `--dry-run` validations don't pollute the
2528+
// MRU list used by the bare-invocation picker.
2529+
recordRecent(target.spec.id);
25222530
const capture: RunInteractiveCapture = {};
25232531
const code = await runInteractive(selection, {
25242532
installInRepo: flags.installInRepo,
@@ -3518,6 +3526,59 @@ function applyPatchInPlace(root: Record<string, unknown>, patch: ImproverPatch):
35183526
cursor[finalSeg] = patch.value;
35193527
}
35203528

3529+
/**
3530+
* Enumerate personas for the interactive TUI. Source label mirrors the cascade
3531+
* shown by `agentworkforce list` so the picker tells the user *where* a
3532+
* persona is coming from (cwd, user, dir:n, library) without a separate
3533+
* lookup.
3534+
*/
3535+
export function buildTuiCandidates(): TuiCandidate[] {
3536+
const byId = new Map<string, TuiCandidate>();
3537+
for (const spec of listBuiltInPersonas()) {
3538+
byId.set(spec.id, { id: spec.id, description: spec.description, source: 'library' });
3539+
}
3540+
for (const [id, spec] of local.byId.entries()) {
3541+
byId.set(id, {
3542+
id,
3543+
description: spec.description,
3544+
source: local.sources.get(id) ?? 'library'
3545+
});
3546+
}
3547+
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
3548+
}
3549+
3550+
/**
3551+
* Bare-invocation flow: open the interactive TUI, then hand the chosen
3552+
* persona to {@link runAgentSelector}. Quitting the picker (Esc / Ctrl-C)
3553+
* exits with conventional 130 so shell pipelines see SIGINT-style failure.
3554+
*
3555+
* runAgentSelector terminates the process via process.exit; this function
3556+
* only returns when the picker is dismissed without a selection.
3557+
*/
3558+
async function runInteractivePicker(): Promise<never> {
3559+
const candidates = buildTuiCandidates();
3560+
if (candidates.length === 0) {
3561+
process.stderr.write(
3562+
'No personas available. Try `agentworkforce install <pack>` or run with --help.\n'
3563+
);
3564+
process.exit(1);
3565+
}
3566+
const selected = await runPersonaPickerTui({
3567+
candidates,
3568+
recentIds: loadRecents()
3569+
});
3570+
if (!selected) {
3571+
process.exit(130);
3572+
}
3573+
await runAgentSelector(selected, {
3574+
installInRepo: false,
3575+
noLaunchMetadata: false,
3576+
dryRun: false
3577+
});
3578+
// runAgentSelector has Promise<never> return type; this is unreachable.
3579+
process.exit(0);
3580+
}
3581+
35213582
/**
35223583
* Enumerate persona candidates for the picker. Local overrides win over the
35233584
* built-in catalog when ids collide; the picker only needs the projection
@@ -3671,9 +3732,20 @@ export async function main(): Promise<void> {
36713732
const argv = process.argv.slice(2);
36723733
const [subcommand, ...rest] = argv;
36733734

3674-
if (!subcommand || subcommand === '-h' || subcommand === '--help') {
3735+
if (subcommand === '-h' || subcommand === '--help') {
36753736
process.stdout.write(USAGE);
3676-
process.exit(subcommand ? 0 : 1);
3737+
process.exit(0);
3738+
}
3739+
3740+
if (!subcommand) {
3741+
if (process.stdin.isTTY && process.stderr.isTTY) {
3742+
await runInteractivePicker();
3743+
// runInteractivePicker either runAgentSelector → process.exit, or
3744+
// exits itself on quit / no-match. Satisfy TS's unreachable check.
3745+
process.exit(0);
3746+
}
3747+
process.stdout.write(USAGE);
3748+
process.exit(1);
36773749
}
36783750

36793751
if (subcommand === '-v' || subcommand === '--version') {
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4+
import { tmpdir } from 'node:os';
5+
import { join } from 'node:path';
6+
7+
import {
8+
computeTuiView,
9+
fuzzyScore,
10+
nextRecents,
11+
parseRecents,
12+
rankCandidates,
13+
recentCandidates,
14+
recordRecent,
15+
loadRecents,
16+
type TuiCandidate
17+
} from './persona-tui.js';
18+
19+
const CANDIDATES: TuiCandidate[] = [
20+
{
21+
id: 'code-reviewer',
22+
description: 'Reviews pull requests for quality, correctness and security.',
23+
source: 'library'
24+
},
25+
{
26+
id: 'fix-flaky',
27+
description: 'Repairs flaky tests across the test suite.',
28+
source: 'user'
29+
},
30+
{
31+
id: 'persona-maker',
32+
description: 'Scaffolds a new persona via interactive Q&A.',
33+
source: 'library'
34+
},
35+
{
36+
id: 'my-reviewer',
37+
description: 'Local reviewer override with team-specific style rules.',
38+
source: 'cwd'
39+
}
40+
];
41+
42+
test('fuzzyScore returns null when chars are absent or out of order', () => {
43+
assert.equal(fuzzyScore('zzz', 'code-reviewer'), null);
44+
assert.equal(fuzzyScore('reverse', 'reviewer'), null);
45+
});
46+
47+
test('fuzzyScore prefers prefix and dense matches', () => {
48+
const prefix = fuzzyScore('code', 'code-reviewer');
49+
const scattered = fuzzyScore('code', 'committed-old-de');
50+
assert.ok(prefix !== null && scattered !== null);
51+
assert.ok(prefix! < scattered!, `expected prefix=${prefix} < scattered=${scattered}`);
52+
});
53+
54+
test('rankCandidates surfaces name matches over description matches', () => {
55+
// Both reviewer ids match by name; "review" doesn't subsequence-match
56+
// anything else, so the two name matches are the only results and rank
57+
// by leading-offset (my-reviewer first because "r" appears earlier).
58+
const ranked = rankCandidates(CANDIDATES, 'review');
59+
assert.deepEqual(ranked.map((c) => c.id), ['my-reviewer', 'code-reviewer']);
60+
});
61+
62+
test('rankCandidates returns empty array when nothing matches', () => {
63+
assert.deepEqual(rankCandidates(CANDIDATES, 'xxxxxxxx'), []);
64+
});
65+
66+
test('rankCandidates returns all candidates with empty query', () => {
67+
const ranked = rankCandidates(CANDIDATES, ' ');
68+
assert.equal(ranked.length, CANDIDATES.length);
69+
});
70+
71+
test('rankCandidates can match purely from description text', () => {
72+
const ranked = rankCandidates(CANDIDATES, 'flaky');
73+
assert.equal(ranked[0].id, 'fix-flaky');
74+
});
75+
76+
test('recentCandidates preserves order and drops unknown ids', () => {
77+
const recents = recentCandidates(
78+
CANDIDATES,
79+
['fix-flaky', 'gone-persona', 'code-reviewer', 'my-reviewer'],
80+
3
81+
);
82+
assert.deepEqual(
83+
recents.map((c) => c.id),
84+
['fix-flaky', 'code-reviewer', 'my-reviewer']
85+
);
86+
});
87+
88+
test('nextRecents moves an existing id to the front and caps the list', () => {
89+
const result = nextRecents(['a', 'b', 'c', 'd', 'e'], 'c', 3);
90+
assert.deepEqual(result, ['c', 'a', 'b']);
91+
});
92+
93+
test('nextRecents prepends a new id', () => {
94+
assert.deepEqual(nextRecents(['a', 'b'], 'z'), ['z', 'a', 'b']);
95+
});
96+
97+
test('parseRecents tolerates garbage input', () => {
98+
assert.deepEqual(parseRecents('not json'), []);
99+
assert.deepEqual(parseRecents('null'), []);
100+
assert.deepEqual(parseRecents('{"ids": "nope"}'), []);
101+
assert.deepEqual(parseRecents('{"ids": [1, "ok", " ", "ok"]}'), ['ok']);
102+
});
103+
104+
test('recordRecent + loadRecents round-trip via the filesystem', () => {
105+
const dir = mkdtempSync(join(tmpdir(), 'aw-tui-'));
106+
const path = join(dir, 'nested', 'recents.json');
107+
try {
108+
recordRecent('code-reviewer', path);
109+
recordRecent('fix-flaky', path);
110+
recordRecent('code-reviewer', path);
111+
assert.deepEqual(loadRecents(path), ['code-reviewer', 'fix-flaky']);
112+
const onDisk = JSON.parse(readFileSync(path, 'utf8')) as { version: number; ids: string[] };
113+
assert.equal(onDisk.version, 1);
114+
assert.deepEqual(onDisk.ids, ['code-reviewer', 'fix-flaky']);
115+
} finally {
116+
rmSync(dir, { recursive: true, force: true });
117+
}
118+
});
119+
120+
test('computeTuiView: empty query with resolved recents → recents mode', () => {
121+
const view = computeTuiView(CANDIDATES, ['fix-flaky', 'code-reviewer'], '');
122+
assert.equal(view.mode, 'recents');
123+
assert.deepEqual(view.items.map((c) => c.id), ['fix-flaky', 'code-reviewer']);
124+
});
125+
126+
test('computeTuiView: recents pointing only at unknown ids → all mode (regression)', () => {
127+
// Prior bug: header said "RECENT" because recentIds was non-empty even
128+
// though every id had been uninstalled/renamed and the full catalog was
129+
// being shown.
130+
const view = computeTuiView(CANDIDATES, ['ghost-persona', 'also-gone'], '');
131+
assert.equal(view.mode, 'all');
132+
assert.equal(view.items.length, CANDIDATES.length);
133+
});
134+
135+
test('computeTuiView: empty query with no recents → all mode', () => {
136+
const view = computeTuiView(CANDIDATES, [], '');
137+
assert.equal(view.mode, 'all');
138+
});
139+
140+
test('computeTuiView: non-empty query → matches mode', () => {
141+
const view = computeTuiView(CANDIDATES, ['fix-flaky'], 'review');
142+
assert.equal(view.mode, 'matches');
143+
assert.ok(view.items.length > 0);
144+
assert.ok(view.items.every((c) => c.id.includes('review')));
145+
});
146+
147+
test('computeTuiView: matches mode honors visibleCap', () => {
148+
const view = computeTuiView(CANDIDATES, [], 'e', 2);
149+
assert.equal(view.mode, 'matches');
150+
assert.ok(view.items.length <= 2);
151+
});
152+
153+
test('loadRecents returns [] when the file is absent or corrupt', () => {
154+
const dir = mkdtempSync(join(tmpdir(), 'aw-tui-'));
155+
const path = join(dir, 'recents.json');
156+
try {
157+
assert.deepEqual(loadRecents(path), []);
158+
writeFileSync(path, '{ not json', 'utf8');
159+
assert.deepEqual(loadRecents(path), []);
160+
} finally {
161+
rmSync(dir, { recursive: true, force: true });
162+
}
163+
});

0 commit comments

Comments
 (0)