Skip to content

feat(agent): typed UI-hint protocol on needs_input + projects list command#299

Merged
kelsonpw merged 6 commits intomainfrom
kelsonpw/redo-257-ui-hints
Apr 27, 2026
Merged

feat(agent): typed UI-hint protocol on needs_input + projects list command#299
kelsonpw merged 6 commits intomainfrom
kelsonpw/redo-257-ui-hints

Conversation

@kelsonpw
Copy link
Copy Markdown
Collaborator

@kelsonpw kelsonpw commented Apr 26, 2026

Summary

The wizard can't render Claude/Codex/Cursor UI directly, but it can strongly influence what the outer agent shows by emitting structured, typed events with display hints. This PR turns the existing needs_input event into a small "UI protocol over NDJSON" so outer agents can produce a much better human-facing UX without us shipping a UI.

This is Gap 5 in the wizard sub-agent design rollout. Redoes the closed #257 against current main after the original was rebase-orphaned by stack churn.

Relationship to #285: The recently-merged #285 (expose plan / verify on wizard-mcp-server) shipped the plan and verify agent ops but dropped the list_projects tool that originated in #257. This PR re-introduces the projects-listing path as a wizard projects list CLI command (alongside the richer needs_input UI hints), so outer agents can drive the same picker they would for the inline prompt.

What's new on needs_input

{
  v: 1, type: "needs_input",
  message: "Pick an Amplitude project",
  data: {
    code: "project_selection",
    ui: {
      component: "searchable_select",   // | select | multiselect | confirmation | secret_input | text_input
      priority: "required",             // | recommended | optional
      title: "Select an Amplitude project",
      description: "Choose where events from this app should be sent.",
      searchPlaceholder: "Search projects, orgs, workspaces, environments…",
      emptyState: "No projects matched. Try a different query."
    },
    choices: [{
      value: "769610",
      label: "Amplitude / Growth / Production",
      description: "Amplitude > Growth > Production",  // NEW
      hint: "Production",
      metadata: {                                       // NEW
        orgId: "...", orgName: "Amplitude",
        workspaceName: "Growth", envName: "Production", rank: 1
      },
      resumeFlags: ["--app-id", "769610"]               // NEW (per-choice)
    }],
    recommended: "769610",
    recommendedReason: "Highest-ranked environment in the first available workspace.",
    pagination: {                                       // NEW
      total: 312,
      returned: 25,
      query: "growth",
      nextCommand: ["npx", "@amplitude/wizard", "projects", "list", "--agent", "--offset", "25", "--limit", "25", "--query", "growth"]
    },
    allowManualEntry: true,                             // NEW
    manualEntry: {                                      // NEW
      flag: "--app-id",
      placeholder: "Enter Amplitude app ID (e.g. 769610)",
      pattern: "^\\d+$"
    }
  }
}

Outer agents that respect ui.component produce a true searchable picker; those that don't fall back to a plain numbered list — both work, the rich one is just dramatically better.

What changed

  • src/lib/agent-events.ts — new UiHints, PaginationInfo, ManualEntryHint types; expanded NeedsInputChoice with description, metadata, per-choice resumeFlags; expanded NeedsInputData with ui, recommendedReason, pagination, allowManualEntry, manualEntry.
  • AgentUI.emitNeedsInput forwards all the new fields.
  • AgentUI.promptEnvironmentSelection now ships rich searchable_select UI with breadcrumb descriptions, recommendedReason explaining the auto-pick, pagination pointing at projects list, and --app-id manual entry.
  • AgentUI.promptChoice auto-selects the widget by list size (≥10 → searchable_select, else select).
  • AgentUI.promptConfirm emits the confirmation widget.
  • New command: npx wizard projects list --agent [--query <q>] [--limit N] [--offset N] returns the same needs_input-shaped envelope so outer agents render the same picker as the inline prompt.
  • runProjectsList(input) — pure function in agent-ops.ts (no UI, no process.exit) that future MCP-style transports can reuse directly. Returns warning instead of throwing when not logged in so the CLI can still emit a useful envelope.

Smoke tests

$ pnpm tsx bin.ts projects list --json --query nope
{"v":1,"@timestamp":"...","type":"needs_input","message":"0 projects available matching \"nope\".","data":{...rich envelope with ui hints, pagination, manualEntry...}}

Test plan

  • pnpm test1555 passed, 17 skipped (15 new tests: 11 in projects-list.test.ts, 4 in agent-ui.test.ts)
  • pnpm tsc --noEmit clean
  • pnpm lint clean (one pre-existing warning unrelated to this PR)
  • Smoke: wizard projects list --json --query nope emits the rich needs_input-shaped envelope
  • Manual smoke against a logged-in account: wizard projects list --agent emits the rich picker; --query growth filters correctly; --limit 5 --offset 5 paginates with a nextCommand cursor
  • Smoke: wizard --agent end-to-end shows searchable_select envelope on multi-env auth

Out of scope (follow-up)

  • TUI fallback rendering of the pagination cursor — the TUI uses Ink + nanostores and doesn't read NDJSON, so no change needed there
  • Wiring ui.component: 'secret_input' into manual API-key entry — small follow-up that surfaces the dev-only --api-key flag as a structured prompt
  • multiselect component — reserved for future flows (not used today)
  • If refactor(cli): split bin.ts into per-command CommandModule files #296 (per-command bin.ts split) lands first, extracting the projects command to its own file is a trivial follow-up.

cc @amplitude/growth

🤖 Generated with Claude Code


Note

Medium Risk
Medium risk because it expands the agent-mode NDJSON wire schema and changes how environment/project selection prompts are emitted/consumed by external orchestrators, plus adds a new CLI surface that depends on cached auth tokens.

Overview
Adds a typed "UI protocol over NDJSON" to needs_input events by extending the schema with ui rendering hints, richer per-choice fields (description, metadata, resumeFlags), recommendedReason, pagination (pagination.nextCommand), and optional manual entry (manualEntry). AgentUI now forwards these fields and emits better-structured prompts (confirmation/choice widgets, searchable environment picker with deterministic recommendation and pagination metadata).

Introduces wizard projects list (search + pagination) that returns a needs_input-shaped envelope for outer agents, backed by a new pure runProjectsList op that flattens accessible org/workspace/environment tuples (filtering to envs with API keys), supports case-insensitive query, clamps limit, and ensures deterministic ordering; includes new Vitest coverage for both the project listing and the new needs_input fields.

Reviewed by Cursor Bugbot for commit d21e473. Bugbot is set up for automated code reviews on this repo. Configure here.

@kelsonpw kelsonpw requested a review from a team April 26, 2026 18:14
@github-actions
Copy link
Copy Markdown
Contributor

🧙 Wizard CI

Run the Wizard CI and test your changes against wizard-workbench example apps by replying with a GitHub comment using one of the following commands:

Test all apps:

  • /wizard-ci all

Test all apps in a directory:

  • /wizard-ci django
  • /wizard-ci fastapi
  • /wizard-ci flask
  • /wizard-ci javascript-node
  • /wizard-ci javascript-web
  • /wizard-ci next-js
  • /wizard-ci python
  • /wizard-ci react-router
  • /wizard-ci vue

Test an individual app:

  • /wizard-ci django/django3-saas
  • /wizard-ci fastapi/fastapi3-ai-saas
  • /wizard-ci flask/flask3-social-media
Show more apps
  • /wizard-ci javascript-node/express-todo
  • /wizard-ci javascript-node/fastify-blog
  • /wizard-ci javascript-node/hono-links
  • /wizard-ci javascript-node/koa-notes
  • /wizard-ci javascript-node/native-http-contacts
  • /wizard-ci javascript-web/saas-dashboard
  • /wizard-ci next-js/15-app-router-saas
  • /wizard-ci next-js/15-app-router-todo
  • /wizard-ci next-js/15-pages-router-saas
  • /wizard-ci next-js/15-pages-router-todo
  • /wizard-ci python/meeting-summarizer
  • /wizard-ci react-router/react-router-v7-project
  • /wizard-ci react-router/rrv7-starter
  • /wizard-ci react-router/saas-template
  • /wizard-ci react-router/shopper
  • /wizard-ci vue/movies

Results will be posted here when complete.

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: --agent flag silently ignored in projects list handler
    • Added the missing agent: argv.agent as boolean | undefined parameter to the resolveMode call in the projects list handler, matching the pattern used by the apply command.
  • ✅ Fixed: New type identifiers use "Project" instead of "App"
    • Renamed ProjectChoice to AppChoice, ProjectsListResult to AppListResult, ProjectsListInput to AppListInput, and runProjectsList to runAppList across agent-ops.ts, bin.ts, and the test file.

Create PR

Or push these changes by commenting:

@cursor push f19d9b9142
Preview (f19d9b9142)
diff --git a/bin.ts b/bin.ts
--- a/bin.ts
+++ b/bin.ts
@@ -2400,16 +2400,15 @@
               const { jsonOutput } = resolveMode({
                 json: argv.json as boolean | undefined,
                 human: argv.human as boolean | undefined,
+                agent: argv.agent as boolean | undefined,
                 requireExplicitWrites: true,
                 isTTY: Boolean(process.stdout.isTTY),
               });
               try {
-                const { runProjectsList } = await import(
-                  './src/lib/agent-ops.js'
-                );
+                const { runAppList } = await import('./src/lib/agent-ops.js');
                 const offset = (argv.offset as number | undefined) ?? 0;
                 const limit = (argv.limit as number | undefined) ?? 25;
-                const result = await runProjectsList({
+                const result = await runAppList({
                   query: argv.query,
                   limit,
                   offset,

diff --git a/src/lib/__tests__/projects-list.test.ts b/src/lib/__tests__/projects-list.test.ts
--- a/src/lib/__tests__/projects-list.test.ts
+++ b/src/lib/__tests__/projects-list.test.ts
@@ -16,7 +16,7 @@
   };
 });
 
-import { runProjectsList } from '../agent-ops.js';
+import { runAppList } from '../agent-ops.js';
 import { getStoredUser, getStoredToken } from '../../utils/ampli-settings.js';
 import { fetchAmplitudeUser } from '../api.js';
 
@@ -89,7 +89,7 @@
   ],
 };
 
-describe('runProjectsList', () => {
+describe('runAppList', () => {
   beforeEach(() => {
     vi.clearAllMocks();
     mockedGetUser.mockReturnValue({
@@ -109,7 +109,7 @@
   });
 
   it('returns one choice per environment with an apiKey', async () => {
-    const result = await runProjectsList();
+    const result = await runAppList();
     expect(result.warning).toBeUndefined();
     expect(result.total).toBe(4); // 2 + 1 (filtered) + 1 = 4
     expect(result.returned).toBe(4);
@@ -122,7 +122,7 @@
   });
 
   it('builds breadcrumb description and per-choice resumeFlags', async () => {
-    const result = await runProjectsList();
+    const result = await runAppList();
     const sample = result.choices.find((c) => c.appId === '769610');
     expect(sample).toMatchObject({
       label: 'Amplitude / Growth / Production',
@@ -137,20 +137,20 @@
   });
 
   it('filters by query case-insensitively across all label fields', async () => {
-    const r = await runProjectsList({ query: 'GROWTH' });
+    const r = await runAppList({ query: 'GROWTH' });
     expect(r.total).toBe(2);
     expect(r.choices.every((c) => c.label.includes('Growth'))).toBe(true);
   });
 
   it('matches against app id', async () => {
-    const r = await runProjectsList({ query: '999001' });
+    const r = await runAppList({ query: '999001' });
     expect(r.total).toBe(1);
     expect(r.choices[0].appId).toBe('999001');
   });
 
   it('paginates with limit + offset, deterministically ordered', async () => {
-    const page1 = await runProjectsList({ limit: 2, offset: 0 });
-    const page2 = await runProjectsList({ limit: 2, offset: 2 });
+    const page1 = await runAppList({ limit: 2, offset: 0 });
+    const page2 = await runAppList({ limit: 2, offset: 2 });
     expect(page1.returned).toBe(2);
     expect(page2.returned).toBe(2);
     // No overlap, full coverage
@@ -159,15 +159,15 @@
   });
 
   it('clamps limit to [1, 200]', async () => {
-    const r1 = await runProjectsList({ limit: -10 });
+    const r1 = await runAppList({ limit: -10 });
     expect(r1.returned).toBeLessThanOrEqual(1);
-    const r2 = await runProjectsList({ limit: 9999 });
+    const r2 = await runAppList({ limit: 9999 });
     expect(r2.returned).toBe(4); // total fixture size
   });
 
   it('returns warning when not logged in (no user)', async () => {
     mockedGetUser.mockReturnValue(null);
-    const r = await runProjectsList();
+    const r = await runAppList();
     expect(r.warning).toMatch(/Not logged in/);
     expect(r.choices).toEqual([]);
     expect(mockedFetch).not.toHaveBeenCalled();
@@ -181,25 +181,25 @@
       email: '',
       zone: 'us',
     });
-    const r = await runProjectsList();
+    const r = await runAppList();
     expect(r.warning).toMatch(/Not logged in/);
     expect(mockedFetch).not.toHaveBeenCalled();
   });
 
   it('returns warning when no idToken is stored', async () => {
     mockedGetToken.mockReturnValue(null);
-    const r = await runProjectsList();
+    const r = await runAppList();
     expect(r.warning).toMatch(/id_token/);
     expect(mockedFetch).not.toHaveBeenCalled();
   });
 
   it('echoes back the lowercased query for paginator cursor reasoning', async () => {
-    const r = await runProjectsList({ query: '  Growth  ' });
+    const r = await runAppList({ query: '  Growth  ' });
     expect(r.query).toBe('growth');
   });
 
   it('orders by rank then label for deterministic pagination', async () => {
-    const r = await runProjectsList();
+    const r = await runAppList();
     // rank=1 entries first, alphabetized by label
     expect(r.choices[0].rank).toBe(1);
     const ranks = r.choices.map((c) => c.rank);

diff --git a/src/lib/agent-ops.ts b/src/lib/agent-ops.ts
--- a/src/lib/agent-ops.ts
+++ b/src/lib/agent-ops.ts
@@ -343,7 +343,7 @@
 
 // ── projects list ───────────────────────────────────────────────────
 
-export interface ProjectChoice {
+export interface AppChoice {
   /** Numeric Amplitude app ID — the canonical selector. */
   appId: string;
   /** Pre-built one-line label for picker rendering. */
@@ -360,13 +360,13 @@
   resumeFlags: string[];
 }
 
-export interface ProjectsListResult {
+export interface AppListResult {
   /** All choices matching the query (pre-pagination). */
   total: number;
   /** Choices included in this response (after query + pagination). */
   returned: number;
   /** Choice page. */
-  choices: ProjectChoice[];
+  choices: AppChoice[];
   /** Original query string, echoed back for pagination cursor reasoning. */
   query: string | null;
   /**
@@ -376,7 +376,7 @@
   warning?: string;
 }
 
-export interface ProjectsListInput {
+export interface AppListInput {
   /** Optional case-insensitive substring match across label fields. */
   query?: string;
   /** Page size. Defaults to 25, capped at 200. */
@@ -397,9 +397,9 @@
  * warning instead of throwing when the user is logged out so the CLI can
  * still emit a useful NDJSON envelope.
  */
-export async function runProjectsList(
-  input: ProjectsListInput = {},
-): Promise<ProjectsListResult> {
+export async function runAppList(
+  input: AppListInput = {},
+): Promise<AppListResult> {
   const limit = Math.max(1, Math.min(input.limit ?? 25, 200));
   const offset = Math.max(0, input.offset ?? 0);
   const query = input.query?.trim().toLowerCase() ?? null;
@@ -430,7 +430,7 @@
   const userInfo = await fetchAmplitudeUser(stored.idToken, user.zone);
 
   // Flatten orgs/workspaces/environments to one choice per (env with apiKey).
-  const allChoices: ProjectChoice[] = [];
+  const allChoices: AppChoice[] = [];
   for (const org of userInfo.orgs) {
     for (const ws of org.workspaces) {
       const envs = (ws.environments ?? [])

You can send follow-ups to the cloud agent here.

Comment thread bin.ts
Comment thread src/lib/agent-ops.ts
rank: number;
/** Per-choice resume flags: `['--app-id', appId]`. */
resumeFlags: string[];
}
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.

New type identifiers use "Project" instead of "App"

Low Severity

Newly declared TypeScript type/function identifiers ProjectChoice, ProjectsListResult, ProjectsListInput, and runProjectsList reintroduce "project" for what the canonical terminology glossary calls "app" (the Amplitude ingestion unit renamed in PR #120). While the internal fields correctly use appId, the type names themselves use "Project" to describe individual app/environment entries.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by learned rule: Canonical terminology: "app" not "project" for the Amplitude ingestion unit

Reviewed by Cursor Bugbot for commit ea8a241. Configure here.

…mmand

The wizard can't render Claude/Codex/Cursor UI directly, but it can
strongly influence what the outer agent shows by emitting structured,
typed events with display hints. This PR turns the existing `needs_input`
event into a small "UI protocol over NDJSON" so outer agents can produce
a much better human-facing UX without us shipping a UI.

New on the `needs_input` payload:

  ui.component        — searchable_select | select | multiselect |
                        confirmation | secret_input | text_input
  ui.priority         — required | recommended | optional
  ui.title            — heading
  ui.description      — supporting context line
  ui.searchPlaceholder
  ui.emptyState
  recommendedReason   — why `recommended` was picked, surfaced as tooltip
  pagination          — { total, returned, query, nextCommand }
  allowManualEntry    — outer agent may collect free text instead
  manualEntry         — { flag, placeholder, pattern }

Per-choice additions:
  description         — second line under the label
  metadata            — { orgName, envName, region, … } for rich rendering
  resumeFlags         — per-choice argv (was top-level only)

`promptEnvironmentSelection` now ships:
  - searchable_select widget with title + breadcrumb description
  - rich metadata per choice (org/workspace/env/region/rank)
  - recommendedReason explaining the auto-pick
  - pagination signal pointing at `wizard projects list --agent --query`
  - allowManualEntry + --app-id manualEntry flag for missing projects

`promptChoice` auto-picks the widget based on list size: ≥10 options →
searchable_select with placeholder; otherwise plain select. `promptConfirm`
emits the confirmation widget.

New command: `npx wizard projects list --agent [--query <q>] [--limit N]
[--offset N]`. Returns a paginated, search-filtered list of every (org,
workspace, env) tuple the user has access to that has an API key. Emits
the SAME `needs_input` envelope as the inline prompt so outer agents can
render the same picker for both — and chain pages via `nextCommand` for
500-row lists without dumping them all at once.

`runProjectsList` is a pure function in `agent-ops.ts` — no UI, no
process.exit — so future MCP-style transports can reuse it directly.

Tests: +15 (11 in projects-list.test.ts, 4 in agent-ui.test.ts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kelsonpw kelsonpw force-pushed the kelsonpw/redo-257-ui-hints branch from ea8a241 to b59d6f0 Compare April 27, 2026 03:30
The `wizard projects list --agent` invocation parsed `--agent` from the
global hidden flag but never passed it to `resolveMode`, so on a TTY the
command would emit human output instead of the documented NDJSON
envelope. Pass `agent: argv.agent` to match the pattern used by `apply`.

Caught by Cursor Bugbot.
@kelsonpw
Copy link
Copy Markdown
Collaborator Author

Re Bugbot findings on commit ea8a241:

  1. --agent flag silently ignored in projects list handler — Real bug. Fixed in commit 8331cbb (one-line: pass agent: argv.agent to resolveMode to match the apply handler).

  2. Rename Project types to App — Declining. The codebase uses Project pervasively (ApiProject, ProjectActivationStatus, ProjectNameValidationIssue, ProjectData), and the command itself is projects list. Renaming to App* would create more inconsistency, not less. Bugbot conflated the public-facing appId (a numeric identifier inside a project envelope) with the type name.

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Pagination cursor uses unclamped limit causing skipped items
    • Changed nextOffset calculation from offset + limit (unclamped user input) to offset + result.returned (actual items returned after clamping), ensuring the pagination cursor always advances exactly past the items in the current page.

Create PR

Or push these changes by commenting:

@cursor push b07bfe9759
Preview (b07bfe9759)
diff --git a/bin.ts b/bin.ts
--- a/bin.ts
+++ b/bin.ts
@@ -137,7 +137,7 @@
 // Dynamic import to avoid preloading wizard-session.ts as CJS, which
 // prevents the TUI's ESM dynamic imports from resolving named exports.
 const lazyRunWizard = async (
-  ...args: Parameters<typeof import('./src/run')['runWizard']>
+  ...args: Parameters<(typeof import('./src/run'))['runWizard']>
 ) => {
   const { runWizard } = await import('./src/run.js');
   return runWizard(...args);
@@ -242,9 +242,8 @@
   // the project API key.
   const envAccessToken =
     process.env.AMPLITUDE_TOKEN ?? process.env.AMPLITUDE_WIZARD_TOKEN;
-  const { resolveCredentials, resolveEnvironmentSelection } = await import(
-    './src/lib/credential-resolution.js'
-  );
+  const { resolveCredentials, resolveEnvironmentSelection } =
+    await import('./src/lib/credential-resolution.js');
   await resolveCredentials(session, {
     requireOrgId: false,
     org: options.org as string | undefined,
@@ -511,9 +510,8 @@
   if (!session.signup || !session.signupEmail || !session.signupFullName) {
     return;
   }
-  const { performSignupOrAuth, trackSignupAttempt } = await import(
-    './src/utils/signup-or-auth.js'
-  );
+  const { performSignupOrAuth, trackSignupAttempt } =
+    await import('./src/utils/signup-or-auth.js');
   const { tryResolveZone } = await import('./src/lib/zone-resolution.js');
 
   // Non-TUI modes have no RegionSelect screen to disambiguate — and the
@@ -1052,9 +1050,8 @@
           // selected. Without this, a successful signup would get silently
           // cleared and the browser would open anyway, defeating the point.
           await runDirectSignupIfRequested(session, 'OAuth', async () => {
-            const { resolveCredentials } = await import(
-              './src/lib/credential-resolution.js'
-            );
+            const { resolveCredentials } =
+              await import('./src/lib/credential-resolution.js');
             await resolveCredentials(session, { requireOrgId: false });
           });
 
@@ -1080,9 +1077,8 @@
             // fires — placing this after startTUI but before registering the
             // handler avoids the TDZ issue that would occur if we referenced
             // a `const` binding from a later dynamic import.
-            const { performGracefulExit } = await import(
-              './src/lib/graceful-exit.js'
-            );
+            const { performGracefulExit } =
+              await import('./src/lib/graceful-exit.js');
             let sigintReceived = false;
             process.on('SIGINT', () => {
               if (sigintReceived) {
@@ -1102,9 +1098,8 @@
 
             // If --api-key was provided, skip the OAuth/TUI auth flow entirely.
             if (session.apiKey) {
-              const { DEFAULT_HOST_URL } = await import(
-                './src/lib/constants.js'
-              );
+              const { DEFAULT_HOST_URL } =
+                await import('./src/lib/constants.js');
               session.credentials = {
                 accessToken: session.apiKey,
                 projectApiKey: session.apiKey,
@@ -1117,9 +1112,8 @@
               const { logToFile } = await import('./src/utils/debug.js');
 
               // Check for crash-recovery checkpoint
-              const { loadCheckpoint } = await import(
-                './src/lib/session-checkpoint.js'
-              );
+              const { loadCheckpoint } =
+                await import('./src/lib/session-checkpoint.js');
               const checkpoint = await loadCheckpoint(session.installDir);
               if (checkpoint) {
                 Object.assign(session, checkpoint);
@@ -1132,9 +1126,8 @@
 
               // Resolve credentials using shared logic (token refresh,
               // env auto-select, pendingOrgs population)
-              const { resolveCredentials } = await import(
-                './src/lib/credential-resolution.js'
-              );
+              const { resolveCredentials } =
+                await import('./src/lib/credential-resolution.js');
               await resolveCredentials(session);
 
               // Resolve org/workspace display names so /whoami shows them.
@@ -1147,18 +1140,14 @@
               // agent-mode users whose zone comes from storedUser, not an
               // explicit flag.
               if (session.credentials && session.selectedOrgId) {
-                const { getStoredUser, getStoredToken } = await import(
-                  './src/utils/ampli-settings.js'
-                );
-                const { fetchAmplitudeUser, extractAppId } = await import(
-                  './src/lib/api.js'
-                );
-                const { resolveZone } = await import(
-                  './src/lib/zone-resolution.js'
-                );
-                const { DEFAULT_AMPLITUDE_ZONE } = await import(
-                  './src/lib/constants.js'
-                );
+                const { getStoredUser, getStoredToken } =
+                  await import('./src/utils/ampli-settings.js');
+                const { fetchAmplitudeUser, extractAppId } =
+                  await import('./src/lib/api.js');
+                const { resolveZone } =
+                  await import('./src/lib/zone-resolution.js');
+                const { DEFAULT_AMPLITUDE_ZONE } =
+                  await import('./src/lib/constants.js');
                 const storedUser = getStoredUser();
                 const realUser =
                   storedUser && storedUser.id !== 'pending' ? storedUser : null;
@@ -1218,9 +1207,9 @@
                           changed = true;
                           // Fall back to the first workspace if the stored ID is stale.
                           const ws = session.selectedWorkspaceId
-                            ? org.workspaces.find(
+                            ? (org.workspaces.find(
                                 (w) => w.id === session.selectedWorkspaceId,
-                              ) ?? org.workspaces[0]
+                              ) ?? org.workspaces[0])
                             : org.workspaces[0];
                           if (ws) {
                             session.selectedWorkspaceName = ws.name;
@@ -1261,9 +1250,8 @@
             // Dynamic-import keeps the Claude Agent SDK out of bin.ts load.
             try {
               const fs = await import('fs');
-              const { parseEventPlanContent } = await import(
-                './src/lib/agent-interface.js'
-              );
+              const { parseEventPlanContent } =
+                await import('./src/lib/agent-interface.js');
               const evtPath = resolve(
                 session.installDir,
                 '.amplitude-events.json',
@@ -1281,9 +1269,8 @@
             }
 
             // Initialize Amplitude Experiment feature flags (non-blocking).
-            const { initFeatureFlags } = await import(
-              './src/lib/feature-flags.js'
-            );
+            const { initFeatureFlags } =
+              await import('./src/lib/feature-flags.js');
             await initFeatureFlags().catch(() => {
               // Flag init failure is non-fatal — all flags default to off
             });
@@ -1291,18 +1278,16 @@
             // Apply SDK-level opt-out based on feature flags
             analytics.applyOptOut();
 
-            const { FRAMEWORK_REGISTRY } = await import(
-              './src/lib/registry.js'
-            );
+            const { FRAMEWORK_REGISTRY } =
+              await import('./src/lib/registry.js');
             const { detectAllFrameworks } = await import('./src/run.js');
             const installDir = session.installDir ?? process.cwd();
 
             // Verbose startup diagnostics — always written to the log file;
             // visible in the RunScreen "Logs" tab.
             if (session.verbose || session.debug) {
-              const { enableDebugLogs, logToFile } = await import(
-                './src/utils/debug.js'
-              );
+              const { enableDebugLogs, logToFile } =
+                await import('./src/utils/debug.js');
               enableDebugLogs();
               logToFile('[verbose] Amplitude Wizard starting');
               logToFile(`[verbose] node          : ${process.version}`);
@@ -1312,9 +1297,8 @@
               logToFile(`[verbose] argv          : ${process.argv.join(' ')}`);
             }
 
-            const { DETECTION_TIMEOUT_MS } = await import(
-              './src/lib/constants.js'
-            );
+            const { DETECTION_TIMEOUT_MS } =
+              await import('./src/lib/constants.js');
 
             // ── OAuth + account setup ──────────────────────────────
             // Runs concurrently with framework detection while AuthScreen shows.
@@ -1328,19 +1312,15 @@
               if (tui.store.session.credentials !== null) return;
 
               try {
-                const { ampliConfigExists } = await import(
-                  './src/lib/ampli-config.js'
-                );
-                const { performAmplitudeAuth } = await import(
-                  './src/utils/oauth.js'
-                );
+                const { ampliConfigExists } =
+                  await import('./src/lib/ampli-config.js');
+                const { performAmplitudeAuth } =
+                  await import('./src/utils/oauth.js');
                 const { fetchAmplitudeUser } = await import('./src/lib/api.js');
-                const { DEFAULT_AMPLITUDE_ZONE } = await import(
-                  './src/lib/constants.js'
-                );
-                const { storeToken } = await import(
-                  './src/utils/ampli-settings.js'
-                );
+                const { DEFAULT_AMPLITUDE_ZONE } =
+                  await import('./src/lib/constants.js');
+                const { storeToken } =
+                  await import('./src/utils/ampli-settings.js');
 
                 const forceFresh = !ampliConfigExists(installDir);
 
@@ -1365,9 +1345,8 @@
                     }
                   });
                 });
-                const { resolveZone } = await import(
-                  './src/lib/zone-resolution.js'
-                );
+                const { resolveZone } =
+                  await import('./src/lib/zone-resolution.js');
                 const zone = resolveZone(
                   tui.store.session,
                   DEFAULT_AMPLITUDE_ZONE,
@@ -1396,14 +1375,12 @@
                 // but user data not yet available) from the normal
                 // expired-token case.
                 let signupTokensObtained = false;
-                const { trackSignupAttempt } = await import(
-                  './src/utils/signup-or-auth.js'
-                );
+                const { trackSignupAttempt } =
+                  await import('./src/utils/signup-or-auth.js');
                 const s = tui.store.session;
                 if (s.signup && s.signupEmail && s.signupFullName) {
-                  const { performSignupOrAuth } = await import(
-                    './src/utils/signup-or-auth.js'
-                  );
+                  const { performSignupOrAuth } =
+                    await import('./src/utils/signup-or-auth.js');
                   try {
                     const signupResult = await performSignupOrAuth({
                       email: s.signupEmail,
@@ -1578,9 +1555,8 @@
 
               // Feature discovery — same helper that CI/agent uses, so the
               // package and integration lists never drift between modes.
-              const { discoverFeatures } = await import(
-                './src/lib/feature-discovery.js'
-              );
+              const { discoverFeatures } =
+                await import('./src/lib/feature-discovery.js');
               const runDiscovery = () => {
                 for (const f of discoverFeatures({
                   installDir,
@@ -1615,9 +1591,8 @@
 
             // Session checkpointing — save at key transitions so crash
             // recovery can skip already-completed steps.
-            const { saveCheckpoint, clearCheckpoint } = await import(
-              './src/lib/session-checkpoint.js'
-            );
+            const { saveCheckpoint, clearCheckpoint } =
+              await import('./src/lib/session-checkpoint.js');
             // After auth completes (most expensive step to repeat)
             tui.store.onEnterScreen(Screen.DataSetup, () => {
               saveCheckpoint(tui.store.session);
@@ -1666,9 +1641,8 @@
             // Before calling the AI agent, do a quick static check to see if
             // Amplitude is already installed in the project. If so, skip the
             // agent entirely and advance directly to MCP setup.
-            const { detectAmplitudeInProject } = await import(
-              './src/lib/detect-amplitude.js'
-            );
+            const { detectAmplitudeInProject } =
+              await import('./src/lib/detect-amplitude.js');
             const localDetection = detectAmplitudeInProject(installDir);
 
             if (localDetection.confidence !== 'none') {
@@ -1678,9 +1652,8 @@
                   localDetection.reason ?? 'unknown'
                 }) — prompting on MCP screen (continue vs run wizard)`,
               );
-              const { RunPhase, OutroKind } = await import(
-                './src/lib/wizard-session.js'
-              );
+              const { RunPhase, OutroKind } =
+                await import('./src/lib/wizard-session.js');
               tui.store.setAmplitudePreDetected();
               tui.store.setRunPhase(RunPhase.Completed);
               const runWizardAnyway =
@@ -1759,9 +1732,8 @@
         const zone = argv.region as 'us' | 'eu';
 
         try {
-          const { getStoredUser, getStoredToken } = await import(
-            './src/utils/ampli-settings.js'
-          );
+          const { getStoredUser, getStoredToken } =
+            await import('./src/utils/ampli-settings.js');
           // If a valid cached session exists, display the stored user without
           // re-fetching from the API (the cached idToken may be expired).
           const cachedToken = getStoredToken(undefined, zone);
@@ -1816,13 +1788,11 @@
     () => {},
     (argv) => {
       void (async () => {
-        const { getStoredUser, clearStoredCredentials } = await import(
-          './src/utils/ampli-settings.js'
-        );
+        const { getStoredUser, clearStoredCredentials } =
+          await import('./src/utils/ampli-settings.js');
         const { clearApiKey } = await import('./src/utils/api-key-store.js');
-        const { clearCheckpoint } = await import(
-          './src/lib/session-checkpoint.js'
-        );
+        const { clearCheckpoint } =
+          await import('./src/lib/session-checkpoint.js');
         const installDir =
           (argv.installDir as string | undefined) ?? process.cwd();
         const user = getStoredUser();
@@ -1849,9 +1819,8 @@
     () => {},
     (_argv) => {
       void (async () => {
-        const { getStoredUser, getStoredToken } = await import(
-          './src/utils/ampli-settings.js'
-        );
+        const { getStoredUser, getStoredToken } =
+          await import('./src/utils/ampli-settings.js');
         const user = getStoredUser();
         const token = getStoredToken();
         if (user && token && user.id !== 'pending') {
@@ -1905,9 +1874,8 @@
           return;
         }
         try {
-          const { trackWizardFeedback } = await import(
-            './src/utils/track-wizard-feedback.js'
-          );
+          const { trackWizardFeedback } =
+            await import('./src/utils/track-wizard-feedback.js');
           await trackWizardFeedback(message);
           getUI().log.success('Thanks — your feedback was sent.');
           process.exit(0);
@@ -2415,9 +2383,8 @@
                 isTTY: Boolean(process.stdout.isTTY),
               });
               try {
-                const { runProjectsList } = await import(
-                  './src/lib/agent-ops.js'
-                );
+                const { runProjectsList } =
+                  await import('./src/lib/agent-ops.js');
                 const offset = (argv.offset as number | undefined) ?? 0;
                 const limit = (argv.limit as number | undefined) ?? 25;
                 const result = await runProjectsList({
@@ -2430,7 +2397,7 @@
                   // Emit a `needs_input`-shaped envelope so outer agents can
                   // render the same picker they would for the inline prompt.
                   const hasMore = offset + result.returned < result.total;
-                  const nextOffset = offset + limit;
+                  const nextOffset = offset + result.returned;
                   process.stdout.write(
                     JSON.stringify({
                       v: 1,
@@ -2815,9 +2782,8 @@
           void (async () => {
             try {
               const { startTUI } = await import('./src/ui/tui/start-tui.js');
-              const { buildSession } = await import(
-                './src/lib/wizard-session.js'
-              );
+              const { buildSession } =
+                await import('./src/lib/wizard-session.js');
 
               const { Flow } = await import('./src/ui/tui/router.js');
               const tui = startTUI(WIZARD_VERSION, Flow.McpAdd);
@@ -2829,9 +2795,8 @@
             } catch {
               // TUI unavailable — fallback to logging
               setUI(new LoggingUI());
-              const { addMCPServerToClientsStep } = await import(
-                './src/steps/add-mcp-server-to-clients/index.js'
-              );
+              const { addMCPServerToClientsStep } =
+                await import('./src/steps/add-mcp-server-to-clients/index.js');
               await addMCPServerToClientsStep({
                 local: options.local,
               });
@@ -2857,9 +2822,8 @@
           void (async () => {
             try {
               const { startTUI } = await import('./src/ui/tui/start-tui.js');
-              const { buildSession } = await import(
-                './src/lib/wizard-session.js'
-              );
+              const { buildSession } =
+                await import('./src/lib/wizard-session.js');
 
               const { Flow } = await import('./src/ui/tui/router.js');
               const tui = startTUI(WIZARD_VERSION, Flow.McpRemove);
@@ -2871,9 +2835,8 @@
             } catch {
               // TUI unavailable — fallback to logging
               setUI(new LoggingUI());
-              const { removeMCPServerFromClientsStep } = await import(
-                './src/steps/add-mcp-server-to-clients/index.js'
-              );
+              const { removeMCPServerFromClientsStep } =
+                await import('./src/steps/add-mcp-server-to-clients/index.js');
               await removeMCPServerFromClientsStep({
                 local: options.local,
               });
@@ -2888,9 +2851,8 @@
         () => {
           void (async () => {
             try {
-              const { startAgentMcpServer } = await import(
-                './src/lib/wizard-mcp-server.js'
-              );
+              const { startAgentMcpServer } =
+                await import('./src/lib/wizard-mcp-server.js');
               await startAgentMcpServer();
             } catch (err) {
               const msg = err instanceof Error ? err.message : String(err);
@@ -2911,9 +2873,8 @@
     () => {},
     () => {
       void (async () => {
-        const { getAgentManifest } = await import(
-          './src/lib/agent-manifest.js'
-        );
+        const { getAgentManifest } =
+          await import('./src/lib/agent-manifest.js');
         process.stdout.write(
           JSON.stringify(getAgentManifest(), null, 2) + '\n',
         );

You can send follow-ups to the cloud agent here.

Comment thread bin.ts Outdated
…t pagination cursor

`runProjectsList` clamps `limit` to `[1, 200]` internally. The
`projects list --agent` envelope was advancing `nextOffset` by the
unclamped user input (e.g. `--limit 9999` would skip from offset 0 to
9999, dropping rows 200..9998). Use `result.returned` so the cursor
always advances exactly past the items in the current page.

Caught by Cursor Bugbot.
@kelsonpw
Copy link
Copy Markdown
Collaborator Author

Re commit ee4086e: fixed the pagination cursor finding by advancing nextOffset by result.returned (clamped) rather than the user-supplied limit (potentially unclamped).

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Pagination nextCommand is unusable search template, not executable command
    • Removed the unusable nextCommand stub (with empty --query and no --offset/--limit) from promptEnvironmentSelection's pagination object, aligning with bin.ts semantics where nextCommand is only emitted when there are more pages to fetch.

Create PR

Or push these changes by commenting:

@cursor push bedf65b3ef
Preview (bedf65b3ef)
diff --git a/src/ui/agent-ui.ts b/src/ui/agent-ui.ts
--- a/src/ui/agent-ui.ts
+++ b/src/ui/agent-ui.ts
@@ -930,21 +930,9 @@
       responseSchema: {
         appId: 'string (required, from choices[].value)',
       },
-      // Pagination is signalled even when all choices fit so outer agents can
-      // surface the total. `nextCommand` lets them re-fetch a search-filtered
-      // subset via `wizard projects list --agent --query <q>`.
       pagination: {
         total: choices.length,
         returned: needsInputChoices.length,
-        nextCommand: [
-          'npx',
-          '@amplitude/wizard',
-          'projects',
-          'list',
-          '--agent',
-          '--query',
-          '',
-        ],
       },
       // Allow free-form `--app-id` entry when none of the listed choices fit
       // (e.g. a brand-new project the cached fetch missed).

You can send follow-ups to the cloud agent here.

Comment thread src/ui/agent-ui.ts
…ination

`promptEnvironmentSelection` returns every choice up front, so there is
no next page to fetch. Emitting a `nextCommand` with empty `--query`
and no `--offset`/`--limit` overloaded the field's semantics — outer
agents would treat it as an executable cursor command. Drop it; the
search affordance is already discoverable via `ui.searchPlaceholder`
and `wizard projects list --agent --query <q>`.

Caught by Cursor Bugbot.
@kelsonpw
Copy link
Copy Markdown
Collaborator Author

Re commit 185f09f: fixed the third Bugbot finding by dropping the stub nextCommand from promptEnvironmentSelection's pagination object. That prompt returns every choice up front, so there's no next page; the search affordance stays discoverable via ui.searchPlaceholder + the standalone wizard projects list --agent --query <q> command (which emits a real cursor).

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Misleading "Lowest rank" wording in user-facing recommendedReason
    • Changed bin.ts recommendedReason from 'Lowest rank in the first matching workspace' to 'Highest-ranked environment in the first matching workspace' to match the correct semantics used in agent-ui.ts.

Create PR

Or push these changes by commenting:

@cursor push dea2d3de5d
Preview (dea2d3de5d)
diff --git a/bin.ts b/bin.ts
--- a/bin.ts
+++ b/bin.ts
@@ -2479,7 +2479,7 @@
                         })),
                         recommended: result.choices[0]?.appId,
                         recommendedReason: result.choices[0]
-                          ? `Lowest rank in the first matching workspace (${result.choices[0].description}).`
+                          ? `Highest-ranked environment in the first matching workspace (${result.choices[0].description}).`
                           : undefined,
                         responseSchema: {
                           appId: 'string (required, from choices[].value)',

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit a137b63. Configure here.

Comment thread bin.ts
Mirror the agent-ui wording so outer agents and humans don't read
'lowest rank' as 'lowest priority'. Rank 1 is numerically lowest but
semantically highest (Production at most orgs).
@kelsonpw
Copy link
Copy Markdown
Collaborator Author

Re commit d21e473: aligned the recommendedReason wording in bin.ts with agent-ui.ts ("Highest-ranked environment in the first matching workspace"). Same item, clearer semantics — rank 1 is numerically lowest but semantically highest priority.

@kelsonpw kelsonpw merged commit 8fc25a9 into main Apr 27, 2026
10 checks passed
@kelsonpw kelsonpw deleted the kelsonpw/redo-257-ui-hints branch April 27, 2026 23:12
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