Skip to content

cli: interactive persona picker on bare invocation#83

Merged
willwashburn merged 2 commits into
mainfrom
claude/persona-picker-tui
May 11, 2026
Merged

cli: interactive persona picker on bare invocation#83
willwashburn merged 2 commits into
mainfrom
claude/persona-picker-tui

Conversation

@willwashburn
Copy link
Copy Markdown
Member

Summary

  • Running agentworkforce with no args now opens a TUI (TTY only) instead of dumping help. The top 3 most-recently-used personas are shown first; typing fuzzy-matches across persona name and description.
  • Each row shows <id> <source> <description> where source is the cascade label (cwd, user, dir:n, library) — same vocabulary as agentworkforce list.
  • Recents persist to ~/.agentworkforce/workforce/recents.json (dedup, cap 20) and are updated inside runAgentSelector, so explicit agent <id> launches feed the list as well.
  • Non-TTY pipes keep the previous behavior (print USAGE, exit 1), so any scripts that probed exit codes are unaffected.

Implementation notes

  • New module packages/cli/src/persona-tui.ts: pure helpers (fuzzyScore, rankCandidates, recentCandidates, parseRecents, nextRecents), a small fs-backed recents store, and the interactive runner that uses the alternate screen buffer + raw mode.
  • packages/cli/src/cli.ts wires the bare-invocation path, adds buildTuiCandidates() (carries source labels alongside id/description), and records the chosen persona id inside runAgentSelector.
  • 13 new unit tests in packages/cli/src/persona-tui.test.ts cover fuzzy scoring/ranking edge cases, recents merge logic, garbage-input tolerance, and the fs round-trip.

Test plan

  • pnpm --filter @agentworkforce/cli test — 160/160 pass
  • pnpm -r typecheck — clean
  • Smoke: non-TTY (< /dev/null) falls back to USAGE; -h, -v, list unaffected
  • Manual: run agentworkforce in a real terminal — confirm arrows/enter/esc, fuzzy search across name + description, and that the recents list updates after launching an agent

🤖 Generated with Claude Code

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 <id>` 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) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment thread packages/cli/src/cli.ts Outdated
inputValues?: Record<string, string>
): Promise<never> {
const target = parseSelector(selector);
recordRecent(target.spec.id);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 recordRecent persists side-effect during --dry-run

At packages/cli/src/cli.ts:2517, recordRecent(target.spec.id) is called unconditionally before the flags.dryRun check at line 2523. The --dry-run flag's documented intent is to "validate the persona without spawning the harness or burning tier-model tokens" — it should be a side-effect-free validation pass. Instead, every --dry-run invocation writes to the recents file on disk, causing the persona to appear in the interactive TUI's "RECENT" section even though it was never actually launched.

Prompt for agents
The call to recordRecent at line 2517 happens before the dryRun check at line 2523. Move the recordRecent call to after the dryRun early-exit, so that dry-run invocations do not produce side effects. Specifically, in runAgentSelector in packages/cli/src/cli.ts, the recordRecent call should be placed after the if (flags.dryRun) block (after line 2526) so it only runs when the persona is actually launched.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2f1d8c8 — moved recordRecent(target.spec.id) past the dry-run early-exit. --dry-run no longer touches the recents file, so validation passes leave the MRU list alone.

Comment thread packages/cli/src/persona-tui.ts Outdated

function render(): void {
const cols = stderr.columns ?? 100;
const showingRecents = !query.trim() && opts.recentIds.length > 0 && visible.length > 0;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 TUI shows "RECENT" header when all recent ids are stale and full candidate list is displayed instead

In packages/cli/src/persona-tui.ts:231, showingRecents checks opts.recentIds.length > 0, but computeVisible() (line 220-227) checks whether recentCandidates(...) actually resolved any ids against the current candidate list. When the user has recents on disk but all referenced personas have been uninstalled or renamed, recentCandidates returns [], so computeVisible falls back to the full candidate list. However, showingRecents still evaluates to true (because opts.recentIds.length > 0), causing the header to display "RECENT" even though the full candidate list is shown — not the recents.

Suggested change
const showingRecents = !query.trim() && opts.recentIds.length > 0 && visible.length > 0;
const showingRecents = !query.trim() && recentCandidates(opts.candidates, opts.recentIds, RECENT_DEFAULT_VISIBLE).length > 0 && visible.length > 0;
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2f1d8c8 — extracted a pure computeTuiView() helper that returns { mode: 'recents' | 'all' | 'matches', items }. mode is now derived from whether recentCandidates() actually resolved anything, so the header reads "PERSONAS" (not "RECENT") when every previously-used persona has been uninstalled/renamed. Added a regression test in persona-tui.test.ts for the stale-recents case.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e31a382fed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/cli/src/persona-tui.ts Outdated
Comment on lines +291 to +293
if (text === '\x03' || text === '\x1b') {
settle(undefined);
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Buffer escape sequences before handling Escape key

Treating a single "\x1b" chunk as immediate quit can terminate the picker when users press arrow keys, because terminal escape sequences may arrive split across data events (first ESC, then [A/[B). In that case the first chunk hits this branch and exits with code 130 instead of moving selection, making navigation unreliable on some terminals/SSH sessions unless sequence buffering or a timeout-based disambiguation is added.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2f1d8c8 — bare \x1b now goes into a 50ms buffer; if more bytes arrive within the window the combined chunk is dispatched (so split \x1b + [A reads as Arrow Up), and the bare-Esc path only fires after the timer elapses. 50ms is well below human-perceptible Esc latency but plenty of slack for fragmented sequences over SSH/multiplexers.

Comment thread packages/cli/src/cli.ts Outdated
inputValues?: Record<string, string>
): Promise<never> {
const target = parseSelector(selector);
recordRecent(target.spec.id);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip recents updates for dry-run selector calls

recordRecent is executed before the dryRun early-exit path, so agentworkforce agent <id> --dry-run marks personas as recently used even though no interactive launch occurred. This pollutes the MRU list used by bare invocation and can push out actually launched personas after repeated validations; moving the write after the dry-run branch (or gating on !flags.dryRun) avoids this behavior.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2f1d8c8 — same change as the Devin comment above. recordRecent now runs only on real interactive launches, after the --dry-run early-exit.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 11, 2026

Review Change Stack
No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7355eaa5-9cc5-494e-b630-c27265027f18

📥 Commits

Reviewing files that changed from the base of the PR and between e31a382 and 2f1d8c8.

📒 Files selected for processing (3)
  • packages/cli/src/cli.ts
  • packages/cli/src/persona-tui.test.ts
  • packages/cli/src/persona-tui.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/cli/src/cli.ts

📝 Walkthrough

Walkthrough

A new interactive terminal persona picker is added to the CLI. When invoked with no subcommand and both stdin/stderr are TTYs, it launches a fuzzy-searchable menu of personas with recent selections promoted at the top. A new persona-tui module provides TUI rendering, recents persistence, and fuzzy matching. Selected personas are recorded for recency ordering.

Changes

Interactive Persona Picker with TUI and Recents

Layer / File(s) Summary
Data Model & Types
packages/cli/src/persona-tui.ts
TuiCandidate interface, RunPersonaTuiOptions config, and defaultRecentsPath() locate the on-disk recents JSON.
Recents Storage & Persistence
packages/cli/src/persona-tui.ts
parseRecents() validates and deduplicates JSON, loadRecents() reads from disk with fallback to [], nextRecents() updates and caps the list, and recordRecent() persists to disk while suppressing errors.
Fuzzy Matching & Ranking
packages/cli/src/persona-tui.ts
fuzzyScore() implements subsequence matching with position/gap-based scoring, rankCandidates() ranks against id and description with name bias, and recentCandidates() projects and orders recent ids.
View Model & Utilities
packages/cli/src/persona-tui.ts
ANSI formatting helpers, truncate() and computeTuiView() decide between recents, all, or matches and cap visible results.
TUI Rendering & Input Handler
packages/cli/src/persona-tui.ts
runPersonaPickerTui() manages raw mode, alternate screen, menu rendering, and keystroke handling (navigation, search, selection, quit with ESC debounce).
CLI Candidate Builder & Picker Entry
packages/cli/src/cli.ts
Added imports, updated USAGE text, buildTuiCandidates() enumerates personas with source tags, and runInteractivePicker() orchestrates the picker flow and forwards selection to runAgentSelector.
Main Entry Point Dispatch
packages/cli/src/cli.ts
main() now routes no-subcommand + TTY to runInteractivePicker(), -h/--help to USAGE, and other cases to error exit.
Recording Selection as Recent
packages/cli/src/cli.ts
recordRecent() persists selected persona id after runAgentSelector() resolves the target (only on real launches).
Comprehensive Test Suite
packages/cli/src/persona-tui.test.ts
Tests cover fuzzy matching, ranking, recency ordering, parsing, persistence round-trips, and deduplication with filesystem isolation.

Sequence Diagram

sequenceDiagram
  participant User
  participant main
  participant runInteractivePicker
  participant buildTuiCandidates
  participant loadRecents
  participant runPersonaPickerTui
  participant runAgentSelector
  participant recordRecent
  
  User->>main: CLI with no subcommand in TTY
  main->>runInteractivePicker: no args, TTYs detected
  runInteractivePicker->>buildTuiCandidates: enumerate personas
  buildTuiCandidates->>runInteractivePicker: return candidates
  runInteractivePicker->>loadRecents: fetch recent ids
  loadRecents->>runInteractivePicker: return recents
  runInteractivePicker->>runPersonaPickerTui: launch picker
  User->>runPersonaPickerTui: search, navigate, select
  runPersonaPickerTui->>runInteractivePicker: return selected id
  runInteractivePicker->>runAgentSelector: forward persona id
  runAgentSelector->>runInteractivePicker: resolve target
  runInteractivePicker->>recordRecent: persist selection
  recordRecent->>User: CLI completes
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🐰 I hopped into the TTY night,
Fuzzy matches glowing bright,
Arrows skitter, recents in line—
I pick a persona, all feels fine. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding an interactive persona picker TUI when the CLI is invoked without arguments.
Description check ✅ Passed The description is well-detailed and directly related to the changeset, covering the TUI functionality, recents persistence, file structure changes, and test plan.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/persona-picker-tui

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/cli/src/persona-tui.ts (1)

288-294: 💤 Low value

Bare escape key handling may conflict with partial escape sequences on slow terminals.

In raw mode, escape sequences (e.g., arrow keys: \x1b[A) can occasionally arrive in multiple chunks on slower connections or under high load. If \x1b arrives alone before [A, the picker will quit unexpectedly.

This is a known tradeoff in raw-mode TUIs and works fine on modern local terminals. Consider documenting this or adding a short debounce if users report issues over SSH.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/persona-tui.ts` around lines 288 - 294, The handler in
function onData currently quits immediately on a lone '\x1b', which can be a
partial escape sequence arriving before the rest of the bytes; change onData to
accumulate input bytes and/or add a short debounce when receiving exactly '\x1b'
so we wait a few milliseconds for additional bytes before calling
settle(undefined). Concretely: in onData, when chunk === '\x1b' (or
buffer.toString() === '\x1b'), start a short timer (e.g., 30–100ms) and if no
further data arrives append to the pending buffer and then call
settle(undefined); cancel the timer and process the full sequence if more bytes
arrive (treating '['/letters as part of a sequence). Ensure settle is only
invoked after the debounce or after confirming the sequence is a true lone
Escape; alternatively maintain a small inputBuffer to combine chunks and parse
multi-byte escape sequences before deciding to quit.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/cli/src/persona-tui.ts`:
- Around line 288-294: The handler in function onData currently quits
immediately on a lone '\x1b', which can be a partial escape sequence arriving
before the rest of the bytes; change onData to accumulate input bytes and/or add
a short debounce when receiving exactly '\x1b' so we wait a few milliseconds for
additional bytes before calling settle(undefined). Concretely: in onData, when
chunk === '\x1b' (or buffer.toString() === '\x1b'), start a short timer (e.g.,
30–100ms) and if no further data arrives append to the pending buffer and then
call settle(undefined); cancel the timer and process the full sequence if more
bytes arrive (treating '['/letters as part of a sequence). Ensure settle is only
invoked after the debounce or after confirming the sequence is a true lone
Escape; alternatively maintain a small inputBuffer to combine chunks and parse
multi-byte escape sequences before deciding to quit.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2101dd77-4662-4be4-b888-9cd491ce3e11

📥 Commits

Reviewing files that changed from the base of the PR and between d8f48e5 and e31a382.

📒 Files selected for processing (3)
  • packages/cli/src/cli.ts
  • packages/cli/src/persona-tui.test.ts
  • packages/cli/src/persona-tui.ts

- 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) <noreply@anthropic.com>
@willwashburn willwashburn merged commit bf9c750 into main May 11, 2026
2 checks passed
@willwashburn willwashburn deleted the claude/persona-picker-tui branch May 11, 2026 16:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant