feat(tui): PR 5 — ProjectPicker with fuzzy + column scoping (gated)#787
feat(tui): PR 5 — ProjectPicker with fuzzy + column scoping (gated)#787kelsonpw wants to merge 1 commit into
Conversation
…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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Inline
.map()defeatsuseMemowith unstable array ref- Memoized the mapped projects array in AuthScreen using
useMemokeyed oneffectiveOrg, soProjectPicker's internaluseMemoforfilterAndRanknow caches correctly across re-renders.
- Memoized the mapped projects array in AuthScreen using
- ✅ Fixed:
onCreatehandler silently discards inline form input- Changed
onCreateto destructure and forward the inline form's{ name }tohandleCreateProjectassuggestedName, which passes it through tostore.startCreateProjectso CreateProjectScreen receives the pre-filled project name.
- Changed
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, | ||
| }), | ||
| )} |
There was a problem hiding this comment.
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)
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')} |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit b5ba236. Configure here.
|
Closing — redesign track abandoned per user feedback. Not merging. |



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 legacyPickerMenupath onAuthScreenis preserved untouched.Acceptance criteria
showing 50 of 5000footer. Verified by the line-count assertion in the load-tolerance test.%org,%name,%env. Multiple scopes stack (%org acme %name web). Without a%, the residual fuzzy-matches across all three columns.rankMatch). PR 3'sfuzzyRankis not yet on thefeat/timeline-uxbase — swap on merge (see Deferred).nopens inline new-project form when picker is focused and the query is empty. Esc cancels back to picker.no matches — keep typing or press n to create oneinColors.muted.useScreenInputused so Esc bubbles to parent screen.AuthScreenforks the project step onprocess.env.WIZARD_NEW_UX === '1'; old path is byte-for-byte preserved.overflow="hidden"(defense against fix(tui): stop overdraw across Setup Report, /diff overlay, slash palette, outro #779 overdraw).src/utils/wizard-abort.tsis 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 testssrc/ui/tui/screens/AuthScreen.tsx— gated swap at the project-picker step onlyWindowing approach
useStdoutDimensions()+ a hard cap of 50 visible rows (MAX_VISIBLE_ROWS). Filter + rank is computed lazily viauseMemoagainst the full project list; theslice(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
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 legacyPickerMenubranch is preserved in full; a new branch onprocess.env.WIZARD_NEW_UX === '1'renders<ProjectPicker />against the sameeffectiveOrg.projectsdata, calling the existingsetSelectedProject+store.setOrgAndProjectsetters.onCreateroutes to the existinghandleCreateProject('project').Deferred
fuzzyRankfromsrc/ui/tui/lib/fuzzyRank.ts(PR 3 feat(tui): PR 3 — ScreenHotkeyBar + SlashPalette skeleton + fuzzyRank #785) — not yet onfeat/timeline-ux. Inlined a minimal substring + position-bonus ranker. Swap on merge.terminalCapabilitiesfrom PR 1 — not on base. InlinedWIZARD_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:≤ 50.< 100lines. This is the explicit Text-node-budget guard.%org acmesmoke test, new-project sub-state switch onn, andngated by non-empty query (viainitialQueryto avoid an ink-testing-library stdin batching race).Verification
pnpm exec tsc --noEmit— cleanpnpm exec eslint <changed files>— cleanpnpm exec vitest run --pool=forks --maxWorkers=1 src/ui/tui/components/__tests__/ProjectPicker.test.tsx— 25 / 25 passingpnpm test— full suite 4279 / 4279 passingpnpm exec vitest run --pool=forks --maxWorkers=1 src/ui/tui/__tests__/router.test.ts src/ui/tui/__tests__/flow-invariants.test.ts— 137 / 137 passingsrc/utils/wizard-abort.tsuntouchedNote
Medium Risk
Moderate risk: introduces a new interaction-heavy picker (filtering, ranking, key handling, inline create flow) that’s now wired into
AuthScreenwhenWIZARD_NEW_UX=1, which could affect project selection UX/perf in that gated path.Overview
Adds a new
ProjectPickerTUI component that supports windowed rendering (hard capMAX_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 bynwhen the query is empty.Updates
AuthScreenstep 3 to swap the legacyPickerMenuproject selector forProjectPickeronly whenprocess.env.WIZARD_NEW_UX === '1', mapping org projects intoProjectPickerEntryand 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.