Skip to content

Add interactive session picker and human-friendly CLI output#261

Open
fan-zhang-sv wants to merge 3 commits intomasterfrom
felix/cli-interactive-session-picker
Open

Add interactive session picker and human-friendly CLI output#261
fan-zhang-sv wants to merge 3 commits intomasterfrom
felix/cli-interactive-session-picker

Conversation

@fan-zhang-sv
Copy link
Copy Markdown
Collaborator

@fan-zhang-sv fan-zhang-sv commented Mar 24, 2026

Summary

  • When multiple sessions exist and --json is not set, shows an interactive arrow-key picker (via @clack/prompts) for the user to select a session, instead of throwing MULTIPLE_SESSIONS. When --json is true, preserves the existing error for programmatic consumers.
  • Adds human-readable formatting for non-JSON output: session list shows an aligned table in a styled note() box, session info shows key-value pairs, and session destroy shows styled confirmation messages via outro().
  • Adds session destroy interactive mode: when no identifier is provided and --json is false, shows the session picker to choose which session to destroy.

Test plan

  • All 126 unit + integration tests pass
  • Biome lint/format clean
  • Manual test: session list with 0, 1, and 3+ sessions (human vs --json)
  • Manual test: session info with multiple sessions triggers picker, single session auto-selects
  • Manual test: session info --json with multiple sessions returns MULTIPLE_SESSIONS error
  • Manual test: session destroy with no args triggers picker, destroys selected session
  • Manual test: Ctrl+C during picker exits with CANCELLED error
  • Manual test: BASE_SESSION env var bypasses picker

Human Output
Screenshot 2026-03-24 at 1 55 49 PM

Made with Cursor

@fan-zhang-sv fan-zhang-sv force-pushed the felix/cli-interactive-session-picker branch 3 times, most recently from abcfdaf to 48dacea Compare March 24, 2026 23:42
Comment on lines +83 to +93
try {
return resolveSession();
} catch (error) {
if (error instanceof CLIError && error.code === 'MULTIPLE_SESSIONS' && !json) {
const sessions = listSessions();
const selected = await promptSessionPicker(sessions);
return { ...selected, resolvedVia: 'interactive' };
}
throw error;
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

passing the json condition in here feels wrong considering the name of the method. we should either move the condition to the call site like this

if (allowInteractive) resolveSessionInteractive() else resolveSessionNoninteractive()

or rename the method resolveSession(allowInteractive: boolean)

stephancill
stephancill previously approved these changes Mar 25, 2026
Base automatically changed from felix/cli-session-management to master March 25, 2026 20:47
@fan-zhang-sv fan-zhang-sv dismissed stephancill’s stale review March 25, 2026 20:47

The base branch was changed.

@cb-heimdall
Copy link
Copy Markdown
Collaborator

cb-heimdall commented Mar 25, 2026

✅ Heimdall Review Status

Requirement Status More Info
Reviews 1/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 0
Global minimum 0
Max 1
1
1 if commit is unverified 0
Sum 1

When multiple sessions exist and --json is not set, show an interactive
arrow-key picker (via @clack/prompts) instead of throwing MULTIPLE_SESSIONS.
When --json is true, preserve the existing error for programmatic consumers.

Also adds human-readable formatting for non-JSON output: session list shows
an aligned table via note(), session info shows key-value pairs, and
session destroy shows styled confirmation messages.

Made-with: Cursor
Move the json/interactive branching to call sites so
resolveSessionInteractive always attempts the interactive picker.
The caller decides whether to use resolveSession (non-interactive)
or resolveSessionInteractive based on its own context.

Made-with: Cursor
Made-with: Cursor
@fan-zhang-sv fan-zhang-sv force-pushed the felix/cli-interactive-session-picker branch from 8e8fb86 to b721a2d Compare March 25, 2026 21:10
Copy link
Copy Markdown
Collaborator

@spencerstock spencerstock left a comment

Choose a reason for hiding this comment

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

🟡 Mostly good, a few suggestions · 87/100

Well-structured PR that cleanly separates interactive vs. JSON output paths and properly preserves programmatic error behavior. The @clack/prompts integration is nicely contained in prompt.ts. Two suggestions: (1) the hardcoded prompt message in promptSessionPicker should be parameterizable since it's used in different contexts (session selection vs. session destruction), and (2) session destroy with no arguments shows the picker even for a single session, unlike session info which auto-selects — this inconsistency should be resolved for a coherent UX.

Confidence: 87/100  ·  🟡 2 suggestions  ·  💬 1 nit

Pass 1 → Pass 2 full details

Pass 1 found 1 comment:

packages/account-cli/src/utils/prompt.ts:8suggestion

The prompt message is hardcoded to 'Multiple sessions found. Select one:'. When a user runs session destroy without arguments and only exactly one session exists, they will be prompted with this message, which is factually incorrect and potentially confusing.

Consider accepting an optional message parameter to allow the caller to provide context-specific text:

export async function promptSessionPicker(
  sessions: Session[],
  message = 'Multiple sessions found. Select one:'
): Promise<Session> {
  const selected = await select<SessionRef>({
    message,
    // ...

Then in the session destroy command, you can pass a more appropriate message like 'Select the session to destroy:' or dynamically adjust based on sessions.length.

Pass 2: The primary reviewer correctly identifies that the hardcoded message 'Multiple sessions found. Select one:' is misleading in the session destroy context where promptSessionPicker is called directly with all sessions (even if there's only one). Looking at the destroy command (line 152-160), it calls promptSessionPicker(sessions) when sessions.length >= 1 (it only throws for length 0). So if there's exactly 1 session and no identifier is provided, the user sees 'Multiple sessions found' which is factually wrong. Additionally, the destroy use case would benefit from a message like 'Select a session to destroy:' regardless of count. The suggestion to accept an optional message parameter is clean and appropriate.

Pass 2 added 2 new findings:

🆕 packages/account-cli/src/utils/index.ts:25nit

formatTable and formatKeyValue use Math.max(...rows.map(...)) which will throw a RangeError if called with an empty rows array (since Math.max() with no arguments returns -Infinity, and spreading a very large array could also overflow the call stack). While the callers currently guard against empty arrays for the list command, formatTable is exported as a general utility. Consider adding a guard:

if (rows.length === 0) return headers.join('  ');

🆕 packages/account-cli/src/commands/session/index.ts:152suggestion

When session destroy is invoked with no identifier and !globalOpts.json, the picker is shown even for a single session. This is slightly inconsistent with session info where resolveSessionInteractive() auto-selects if there's only one session (via resolveSession() returning auto_select). Consider either reusing resolveSessionInteractive() here or adding a similar single-session shortcut to keep UX consistent across commands.

🔧 Fix with prompt
A reviewer gave these comments as feedback. Validate them and fix all the ones that need to be fixed.

- [suggestion] packages/account-cli/src/utils/prompt.ts:8-12
  The prompt message is hardcoded to `'Multiple sessions found. Select one:'`. When called from `session destroy` with exactly one session, this message is factually incorrect. Consider accepting an optional `message` parameter:
  ```typescript
  export async function promptSessionPicker(
    sessions: Session[],
    message = 'Multiple sessions found. Select one:'
  ): Promise<Session> {
  ```
  Then pass context-specific text from each call site (e.g., `'Select a session to destroy:'`).

- [suggestion] packages/account-cli/src/commands/session/index.ts:152-160
  When `session destroy` is invoked with no identifier and `!globalOpts.json`, the picker is shown even for a single session. This is inconsistent with `session info`, which auto-selects via `resolveSessionInteractive()` when only one session exists. Consider either reusing `resolveSessionInteractive()` here or adding a similar single-session shortcut for destroy to keep the UX consistent across commands.

- [nit] packages/account-cli/src/utils/index.ts:25-27
  `formatTable` and `formatKeyValue` use `Math.max(...rows.map(...))` which will throw a `RangeError` if called with an empty `rows` array (since `Math.max()` with no arguments returns `-Infinity`, and spreading a very large array could also overflow the call stack). While the callers currently guard against empty arrays for the list command, `formatTable` is exported as a general utility. Consider adding a guard:
  ```typescript
  if (rows.length === 0) return headers.join('  ');
  ```

SHA b721a2dc · gemini-3.1-pro-preview → claude-opus-4-6

Comment on lines +8 to +12
export async function promptSessionPicker(sessions: Session[]): Promise<Session> {
const selected = await select<SessionRef>({
message: 'Multiple sessions found. Select one:',
options: sessions.map((s) => ({
value: { session: s },
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The prompt message is hardcoded to 'Multiple sessions found. Select one:'. When called from session destroy with exactly one session, this message is factually incorrect. Consider accepting an optional message parameter:

export async function promptSessionPicker(
  sessions: Session[],
  message = 'Multiple sessions found. Select one:'
): Promise<Session> {

Then pass context-specific text from each call site (e.g., 'Select a session to destroy:').

Comment on lines +152 to +160
} else if (!globalOpts.json) {
const sessions = listSessions();
if (sessions.length === 0) {
throw new CLIError('NO_SESSION', 'No sessions to destroy');
}
const selected = await promptSessionPicker(sessions);
const key = sessionKey(selected);
destroySession(selected.mode, key);
outro(`Destroyed ${selected.mode} session: ${key}`);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

When session destroy is invoked with no identifier and !globalOpts.json, the picker is shown even for a single session. This is inconsistent with session info, which auto-selects via resolveSessionInteractive() when only one session exists. Consider either reusing resolveSessionInteractive() here or adding a similar single-session shortcut for destroy to keep the UX consistent across commands.

Comment on lines +25 to +27
export function formatTable(headers: string[], rows: string[][]): string {
const colWidths = headers.map((h, i) =>
Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit

formatTable and formatKeyValue use Math.max(...rows.map(...)) which will throw a RangeError if called with an empty rows array (since Math.max() with no arguments returns -Infinity, and spreading a very large array could also overflow the call stack). While the callers currently guard against empty arrays for the list command, formatTable is exported as a general utility. Consider adding a guard:

if (rows.length === 0) return headers.join('  ');

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.

4 participants