Add interactive session picker and human-friendly CLI output#261
Add interactive session picker and human-friendly CLI output#261fan-zhang-sv wants to merge 3 commits intomasterfrom
Conversation
abcfdaf to
48dacea
Compare
| 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
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)
✅ Heimdall Review Status
|
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
8e8fb86 to
b721a2d
Compare
spencerstock
left a comment
There was a problem hiding this comment.
🟡 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:8 — suggestion
The prompt message is hardcoded to
'Multiple sessions found. Select one:'. When a user runssession destroywithout 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
messageparameter 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 destroycommand, you can pass a more appropriate message like'Select the session to destroy:'or dynamically adjust based onsessions.length.Pass 2: The primary reviewer correctly identifies that the hardcoded message 'Multiple sessions found. Select one:' is misleading in the
session destroycontext wherepromptSessionPickeris called directly with all sessions (even if there's only one). Looking at the destroy command (line 152-160), it callspromptSessionPicker(sessions)whensessions.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:25 — nit
formatTableandformatKeyValueuseMath.max(...rows.map(...))which will throw aRangeErrorif called with an emptyrowsarray (sinceMath.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,formatTableis exported as a general utility. Consider adding a guard:if (rows.length === 0) return headers.join(' ');
🆕 packages/account-cli/src/commands/session/index.ts:152 — suggestion
When
session destroyis invoked with no identifier and!globalOpts.json, the picker is shown even for a single session. This is slightly inconsistent withsession infowhereresolveSessionInteractive()auto-selects if there's only one session (viaresolveSession()returningauto_select). Consider either reusingresolveSessionInteractive()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
| 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 }, |
There was a problem hiding this comment.
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:').
| } 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}`); |
There was a problem hiding this comment.
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.
| export function formatTable(headers: string[], rows: string[][]): string { | ||
| const colWidths = headers.map((h, i) => | ||
| Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)) |
There was a problem hiding this comment.
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(' ');
Summary
--jsonis not set, shows an interactive arrow-key picker (via@clack/prompts) for the user to select a session, instead of throwingMULTIPLE_SESSIONS. When--jsonis true, preserves the existing error for programmatic consumers.session listshows an aligned table in a stylednote()box,session infoshows key-value pairs, andsession destroyshows styled confirmation messages viaoutro().session destroyinteractive mode: when no identifier is provided and--jsonis false, shows the session picker to choose which session to destroy.Test plan
session listwith 0, 1, and 3+ sessions (human vs--json)session infowith multiple sessions triggers picker, single session auto-selectssession info --jsonwith multiple sessions returnsMULTIPLE_SESSIONSerrorsession destroywith no args triggers picker, destroys selected sessionCANCELLEDerrorBASE_SESSIONenv var bypasses pickerHuman Output

Made with Cursor