Skip to content

feat(tui): PR 5 — ProjectPicker with fuzzy + column scoping (gated)#787

Closed
kelsonpw wants to merge 1 commit into
feat/timeline-uxfrom
feat/timeline-ux-pr-5
Closed

feat(tui): PR 5 — ProjectPicker with fuzzy + column scoping (gated)#787
kelsonpw wants to merge 1 commit into
feat/timeline-uxfrom
feat/timeline-ux-pr-5

Conversation

@kelsonpw
Copy link
Copy Markdown
Member

@kelsonpw kelsonpw commented May 15, 2026

PR 5 / 10 — ProjectPicker

A windowed, fuzzy-filterable project picker that scales to orgs with thousands of projects. Gated behind WIZARD_NEW_UX=1 — the legacy PickerMenu path on AuthScreen is preserved untouched.

Acceptance criteria

  • Windowing — never >100 Text nodes total; max 50 visible rows. A 5000-project dataset renders ~50 rows + a showing 50 of 5000 footer. Verified by the line-count assertion in the load-tolerance test.
  • Filter input supports column scoping: %org, %name, %env. Multiple scopes stack (%org acme %name web). Without a %, the residual fuzzy-matches across all three columns.
  • Fuzzy ranking: inline minimal substring + position-bonus ranker (rankMatch). PR 3's fuzzyRank is not yet on the feat/timeline-ux base — swap on merge (see Deferred).
  • n opens inline new-project form when picker is focused and the query is empty. Esc cancels back to picker.
  • Empty state renders no matches — keep typing or press n to create one in Colors.muted.
  • Snapshot tests at 10 / 250 / 5000 sizes. 5000-project frame asserts ≤50 visible rows AND total frame line count <100.
  • useScreenInput used so Esc bubbles to parent screen.
  • No legacy-screen behavior change unless gatedAuthScreen forks the project step on process.env.WIZARD_NEW_UX === '1'; old path is byte-for-byte preserved.
  • Outer Box uses overflow="hidden" (defense against fix(tui): stop overdraw across Setup Report, /diff overlay, slash palette, outro #779 overdraw).
  • src/utils/wizard-abort.ts is untouched.

What landed

  • src/ui/tui/components/ProjectPicker.tsx — 410 lines (presentational component, scope tokenizer, fuzzy ranker, sub-form)
  • src/ui/tui/components/__tests__/ProjectPicker.test.tsx — 360 lines, 25 tests
  • src/ui/tui/screens/AuthScreen.tsx — gated swap at the project-picker step only

Windowing approach

useStdoutDimensions() + a hard cap of 50 visible rows (MAX_VISIBLE_ROWS). Filter + rank is computed lazily via useMemo against the full project list; the slice(0, visibleWindow) happens at render time so we never instantiate React elements for invisible rows. With 5000 projects, the rendered frame stays under 100 total lines (50 rows + ~10 chrome lines) regardless of dataset size.

Column scoping parser shape

%org foo %name web  { scopes: { org: 'foo', name: 'web' }, residual: '' }
foo %env prod       { scopes: { env: 'prod' }, residual: 'foo' }

Tokenizer is a single regex (/%(org|name|env)\s+([^%]*?)(?=\s*%|$)/gi) with the residual taken from whatever didn't match. Scope values lowercased; scope keys case-insensitive. Mandatory scopes prune the result set; the residual fuzzy-ranks across name + org + env.

Where it's wired

AuthScreen.tsx — Step 3 (project picker). The legacy PickerMenu branch is preserved in full; a new branch on process.env.WIZARD_NEW_UX === '1' renders <ProjectPicker /> against the same effectiveOrg.projects data, calling the existing setSelectedProject + store.setOrgAndProject setters. onCreate routes to the existing handleCreateProject('project').

Deferred

  • fuzzyRank from src/ui/tui/lib/fuzzyRank.ts (PR 3 feat(tui): PR 3 — ScreenHotkeyBar + SlashPalette skeleton + fuzzyRank #785) — not yet on feat/timeline-ux. Inlined a minimal substring + position-bonus ranker. Swap on merge.
  • terminalCapabilities from PR 1 — not on base. Inlined WIZARD_FORCE_ASCII + LANG-lacks-UTF-8 check directly.
  • voice.* strings from PR 2 — using plain strings. PR 10 sweeps.

Tests (25 total)

  • parseQuery — 6 tests covering bare/scoped/stacked/case-insensitive/empty.
  • rankMatch — 4 tests covering null match, prefix bonus, case-insensitivity, empty needle.
  • filterAndRank — 5 tests covering empty query, single scope (%org, %env), stacked scopes, residual fuzzy.
  • ProjectPicker — 10 projects — 3 (full-list render, scope hint visible, no footer).
  • ProjectPicker — 250 projects — 1 (window cap + footer).
  • ProjectPicker — 5000 projects (load tolerance)2 tests:
    • "renders no more than MAX_VISIBLE_ROWS project rows" — counts unique project names in the frame, asserts ≤ 50.
    • "frame line count stays bounded — no overdraw on 5000 projects" — splits the frame and asserts < 100 lines. This is the explicit Text-node-budget guard.
  • Empty state, %org acme smoke test, new-project sub-state switch on n, and n gated by non-empty query (via initialQuery to avoid an ink-testing-library stdin batching race).

Verification

  • pnpm exec tsc --noEmit — clean
  • pnpm exec eslint <changed files> — clean
  • pnpm exec vitest run --pool=forks --maxWorkers=1 src/ui/tui/components/__tests__/ProjectPicker.test.tsx — 25 / 25 passing
  • pnpm test — full suite 4279 / 4279 passing
  • pnpm exec vitest run --pool=forks --maxWorkers=1 src/ui/tui/__tests__/router.test.ts src/ui/tui/__tests__/flow-invariants.test.ts — 137 / 137 passing
  • src/utils/wizard-abort.ts untouched
  • Worktree removed after push

Note

Medium Risk
Moderate risk: introduces a new interaction-heavy picker (filtering, ranking, key handling, inline create flow) that’s now wired into AuthScreen when WIZARD_NEW_UX=1, which could affect project selection UX/perf in that gated path.

Overview
Adds a new ProjectPicker TUI component that supports windowed rendering (hard cap MAX_VISIBLE_ROWS=50), fuzzy filtering + ranking, and column-scoped queries (%org, %name, %env), including an empty-state message and an inline “new project” sub-form triggered by n when the query is empty.

Updates AuthScreen step 3 to swap the legacy PickerMenu project selector for ProjectPicker only when process.env.WIZARD_NEW_UX === '1', mapping org projects into ProjectPickerEntry and keeping the existing selection and create-project handlers; the legacy path remains intact when the flag is off.

Adds comprehensive tests for parsing/ranking/filtering and for render windowing at 10/250/5000 projects to ensure output stays bounded (row cap + frame line-count ceiling).

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

…ZARD_NEW_UX=1)

PR 5 / 10 of the Timeline UX redesign. Windowed, scopeable project picker
that scales to orgs with thousands of projects.

Acceptance criteria:
- [x] Windowing — never >100 Text nodes, max 50 visible rows. 5000-project
      frame renders only the visible window with a "showing 50 of 5000" footer.
- [x] Filter input supports column scoping: %org, %name, %env (stackable).
      Residual fuzzy-matches across all three columns.
- [x] Fuzzy ranking — inline minimal substring + position-bonus ranker (PR 3's
      `fuzzyRank` is on un-merged branch; swap on merge).
- [x] `n` opens the inline new-project form when the query is empty. Esc
      cancels back to the picker.
- [x] Empty state — "no matches — keep typing or press n to create one" in
      Colors.muted.
- [x] Snapshot tests at 10 / 250 / 5000 project sizes. 5000-project test
      asserts ≤50 visible rows AND total frame line count <100.
- [x] useScreenInput is used so Esc bubbles to parent screen for back-nav.
- [x] No legacy-screen behavior change unless gated on
      `WIZARD_NEW_UX === '1'`. AuthScreen forks the project-picker step
      only when the env var is set; the existing PickerMenu path is
      preserved verbatim.

Deferred (will swap on merge):
- PR 1 `terminalCapabilities` — inline WIZARD_FORCE_ASCII + LANG check.
- PR 2 `voice.*` strings — plain strings; PR 10 sweeps.
- PR 3 `fuzzyRank` — minimal substring ranker inlined in ProjectPicker.tsx.

Out of scope:
- New-project API call (parent owns `onCreate`).
- Other picker callsites (Org picker, Env picker) — focused swap on the
  Project step only.
@kelsonpw kelsonpw requested a review from a team as a code owner May 15, 2026 16:50
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.

Fix All in Cursor

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

  • ✅ Fixed: Inline .map() defeats useMemo with unstable array ref
    • Memoized the mapped projects array in AuthScreen using useMemo keyed on effectiveOrg, so ProjectPicker's internal useMemo for filterAndRank now caches correctly across re-renders.
  • ✅ Fixed: onCreate handler silently discards inline form input
    • Changed onCreate to destructure and forward the inline form's { name } to handleCreateProject as suggestedName, which passes it through to store.startCreateProject so CreateProjectScreen receives the pre-filled project name.

Create PR

Or push these changes by commenting:

@cursor push f2082fb2f3
Preview (f2082fb2f3)
diff --git a/src/ui/tui/screens/AuthScreen.tsx b/src/ui/tui/screens/AuthScreen.tsx
--- a/src/ui/tui/screens/AuthScreen.tsx
+++ b/src/ui/tui/screens/AuthScreen.tsx
@@ -13,7 +13,7 @@
  */
 
 import { Box, Text, measureElement, type DOMElement } from 'ink';
-import { useState, useEffect, useRef, type RefObject } from 'react';
+import { useState, useEffect, useRef, useMemo, type RefObject } from 'react';
 import { TextInput } from '@inkjs/ui';
 import type { WizardStore } from '../store.js';
 import { useContentArea } from '../context/ContentAreaContext.js';
@@ -409,7 +409,24 @@
     // (either no envs available, or the env had no key)
     !selectedEnv?.app?.apiKey;
 
-  const handleCreateProject = (fromScreen: 'project' | 'environment') => {
+  const pickerProjects = useMemo<ProjectPickerEntry[]>(
+    () =>
+      effectiveOrg
+        ? effectiveOrg.projects.map((p) => ({
+            id: p.id,
+            name: p.name,
+            orgName: effectiveOrg.name,
+            orgId: effectiveOrg.id,
+            envName: p.environments?.[0]?.name ?? null,
+          }))
+        : [],
+    [effectiveOrg],
+  );
+
+  const handleCreateProject = (
+    fromScreen: 'project' | 'environment',
+    suggestedName?: string,
+  ) => {
     // Pre-resolve the org: during the project picker, session.selectedOrgId
     // may still be null even though effectiveOrg is known. Commit it now so
     // CreateProjectScreen has the orgId it needs to POST /projects.
@@ -430,7 +447,7 @@
     analytics.wizardCapture('create project link opened', {
       'from screen': fromScreen,
     });
-    store.startCreateProject(fromScreen);
+    store.startCreateProject(fromScreen, suggestedName);
   };
 
   const handleStartOver = (fromScreen: 'project' | 'environment') => {
@@ -898,15 +915,7 @@
               <Box marginTop={1} />
             </Box>
             <ProjectPicker
-              projects={effectiveOrg.projects.map(
-                (p): ProjectPickerEntry => ({
-                  id: p.id,
-                  name: p.name,
-                  orgName: effectiveOrg.name,
-                  orgId: effectiveOrg.id,
-                  envName: p.environments?.[0]?.name ?? null,
-                }),
-              )}
+              projects={pickerProjects}
               onSelect={(picked) => {
                 const projectObj = effectiveOrg.projects.find(
                   (p) => p.id === picked.id,
@@ -919,7 +928,7 @@
                   session.installDir,
                 );
               }}
-              onCreate={() => handleCreateProject('project')}
+              onCreate={({ name }) => handleCreateProject('project', name)}
             />
           </Box>
         )}

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

Reviewed by Cursor Bugbot for commit b5ba236. Configure here.

orgId: effectiveOrg.id,
envName: p.environments?.[0]?.name ?? null,
}),
)}
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.

Inline .map() defeats useMemo with unstable array ref

Medium Severity

The projects prop is created via an inline .map() call on every render of AuthScreen, producing a fresh array reference each time. Inside ProjectPicker, the useMemo guarding the O(n) filterAndRank computation depends on [projects, parsed] — since projects is always a new ref, the memo never caches and the expensive filter+sort runs on every parent re-render. AuthScreen subscribes to useWizardStore, useContentArea, and useTimedCoaching, any of which can trigger re-renders while the picker is visible. With 5000 projects this is a significant amount of wasted work.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by learned rule: useMemo guarding expensive ops must key on stable primitives — not object refs from rebuilt arrays

Reviewed by Cursor Bugbot for commit b5ba236. Configure here.

session.installDir,
);
}}
onCreate={() => handleCreateProject('project')}
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.

onCreate handler silently discards inline form input

Medium Severity

The ProjectPicker's NewProjectInlineForm collects a project name and environment name from the user across two input steps, then calls onCreate({ name, envName }). But AuthScreen passes onCreate={() => handleCreateProject('project')}, which ignores the { name, envName } argument entirely. The user completes a multi-step form whose data is silently thrown away, and is then routed to a separate CreateProjectScreen where they'd have to re-enter the same information.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b5ba236. Configure here.

@kelsonpw
Copy link
Copy Markdown
Member Author

Closing — redesign track abandoned per user feedback. Not merging.

@kelsonpw kelsonpw closed this May 15, 2026
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