From 237afedd691ec221fe7d93d8524359f439bce9b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Tue, 31 Mar 2026 17:13:53 +0200 Subject: [PATCH 1/6] Add arrow-based team re-pick mode; remove --dispatch option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re-pick mode (TUI 't' on a picked repo) now uses ←/→ to cycle candidate teams and Enter to confirm — consistent with pick mode UX. Single-line hints bar reuses renderTeamPickHeader. - Fix: undoing a pick now clears confirmedPicks so --pick-team is correctly removed from the replay command. - Remove --dispatch CLI option, applyDispatch helper, dispatchedTeam / extractDispatchedTeam types, and dispatch block from replay command. Repo moves go through the new moveRepoToSection pure helper. - Add moveRepoToSection to group.ts with companion tests. - Docs: keyboard-shortcuts.md, interactive-mode.md and team-grouping.md updated for consistent 't' key and re-pick mode descriptions. --- docs/reference/keyboard-shortcuts.md | 28 +++-- docs/usage/interactive-mode.md | 28 ++--- docs/usage/team-grouping.md | 42 ++++++- src/group.test.ts | 163 +++++++++++++++++++++++++++ src/group.ts | 90 +++++++++++++++ src/render.ts | 27 ++++- src/tui.ts | 97 ++++++++++++++-- 7 files changed, 439 insertions(+), 36 deletions(-) diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 22dec6a..861421a 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -29,11 +29,11 @@ Section header rows (shown when `--group-by-team-prefix` is active) are skipped ## Filtering -| Key | Action | -| --- | ------------------------------------------------------------------------------------------------------ | -| `f` | Open the filter bar and enter filter mode | -| `t` | Cycle the **filter target**: `path` → `content` → `repo` → `path`. Only works **outside** filter mode. | -| `r` | Reset the active filter and return to showing all repos / extracts | +| Key | Action | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `f` | Open the filter bar and enter filter mode | +| `t` | Cycle the **filter target**: `path` → `content` → `repo` → `path`. Only works **outside** filter mode and when **not** on a picked repo (see [Team ownership](#team-ownership) below). | +| `r` | Reset the active filter and return to showing all repos / extracts | ### Filter targets @@ -69,9 +69,10 @@ Invalid regex patterns do not crash the TUI but are treated as matching nothing Available only when `--group-by-team-prefix` is active. -| Key | Action | -| --- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `p` | On a **multi-team** section header: enter team pick mode to assign the section to a single owner. Does nothing on single-team section headers. | +| Key | Action | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `p` | On a **multi-team** section header: enter team pick mode to assign the section to a single owner. Does nothing on single-team section headers. | +| `t` | On a **picked repo** (marked `◈`, moved by a previous pick): enter re-pick mode to change its team assignment or restore it to the combined section. | ### Pick mode bindings @@ -83,6 +84,17 @@ When pick mode is active (after pressing `p` on a multi-team section header): | `Enter` | Confirm the pick and exit pick mode | | `Esc` | Cancel and exit pick mode without changes | +### Re-pick mode bindings + +When re-pick mode is active (after pressing `t` on a picked repo marked `◈`): + +| Key | Action | +| ----------- | ------------------------------------------------------------------------ | +| `←` / `→` | Cycle through candidate teams (from the original combined section label) | +| `Enter` | Confirm and move the repo to the focused candidate team | +| `0` / `u` | Restore the repo to its original combined section (undo the pick) | +| `Esc` / `t` | Exit re-pick mode without changes | + ## Help and exit | Key | Action | diff --git a/docs/usage/interactive-mode.md b/docs/usage/interactive-mode.md index e33fb85..e883bcd 100644 --- a/docs/usage/interactive-mode.md +++ b/docs/usage/interactive-mode.md @@ -33,20 +33,20 @@ github-code-search "useFeatureFlag" --org fulll ## Keyboard shortcuts -| Key | Action | -| -------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `↑` / `↓` | Navigate between repos and extracts | -| `←` | Fold the repo under the cursor | -| `→` | Unfold the repo under the cursor | -| `Space` | Select / deselect the current repo or extract | -| `a` | Select **all** — on a repo row: all repos and extracts; on an extract row: all extracts in that repo. Respects any active filter. | -| `n` | Select **none** — same context rules as `a`. Respects any active filter. | -| `f` | Open the **filter bar** — type to narrow visible repos or files | -| `t` | Cycle the **filter target**: `path` → `content` → `repo` → `path` | -| `r` | **Reset** the active filter and show all repos / extracts | -| `h` / `?` | Toggle the **help overlay** | -| `Enter` | Confirm and print selected results (also closes the help overlay) | -| `q` / `Ctrl+C` | Quit without printing | +| Key | Action | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `↑` / `↓` | Navigate between repos and extracts | +| `←` | Fold the repo under the cursor | +| `→` | Unfold the repo under the cursor | +| `Space` | Select / deselect the current repo or extract | +| `a` | Select **all** — on a repo row: all repos and extracts; on an extract row: all extracts in that repo. Respects any active filter. | +| `n` | Select **none** — same context rules as `a`. Respects any active filter. | +| `f` | Open the **filter bar** — type to narrow visible repos or files | +| `t` | Cycle the **filter target**: `path` → `content` → `repo` → `path`. When on a **picked repo** (marked `◈`, `--group-by-team-prefix` active): enter re-pick mode instead. | +| `r` | **Reset** the active filter and show all repos / extracts | +| `h` / `?` | Toggle the **help overlay** | +| `Enter` | Confirm and print selected results (also closes the help overlay) | +| `q` / `Ctrl+C` | Quit without printing | ## Selection behaviour diff --git a/docs/usage/team-grouping.md b/docs/usage/team-grouping.md index 7516c59..495cc5f 100644 --- a/docs/usage/team-grouping.md +++ b/docs/usage/team-grouping.md @@ -111,9 +111,45 @@ The flag is repeatable — add one `--pick-team` per combined section to resolve If the combined label is not found (typo, or the section was not formed), a warning is emitted on stderr listing the available combined sections — the run continues without error. -::: tip Combined with --dispatch -`--pick-team` resolves ownership at the **section level** (all repos in the section move to one team). For finer-grained control — assigning individual repos or extracts to different teams — see `--dispatch`. -::: +## Re-pick & undo pick + +After using `--pick-team` (or the interactive `p` shortcut) to assign a combined section to a team, individual repos marked `◈` can be re-assigned or restored to their original combined section at any time. + +### TUI — re-pick mode + +Navigate to any **picked repo** (marked `◈`) and press **`t`** to enter re-pick mode. + +```text +── squad-frontend +▶ ◈ fulll/frontend-app ← press t here +▶ ◈ fulll/mobile-sdk +``` + +The hints bar shows a horizontal pick bar — exactly like team pick mode — with the current focused team highlighted in `[ brackets ]`: + +```text +Re-pick: [ squad-frontend ] squad-mobile 0/u restore ← → move ↵ confirm Esc cancel +``` + +| Key | Action | +| ----------- | ------------------------------------------------- | +| `←` / `→` | Cycle through candidate teams | +| `Enter` | Confirm and move repo to the focused team | +| `0` / `u` | Restore the repo to its original combined section | +| `Esc` / `t` | Exit re-pick mode without changes | + +### Undoing a pick (merge) + +Pressing `0` or `u` in re-pick mode restores the repo to the combined section it came from (e.g. `squad-frontend + squad-mobile`). The `◈` badge is removed and the repo is treated as unassigned again. + +```text +── squad-frontend + squad-mobile ← restored +▶ ◉ fulll/mobile-sdk +── squad-frontend +▶ ◈ fulll/frontend-app +``` + +In **non-interactive mode**, undoing a pick is implicit: simply omit the `--pick-team` flag for the repo in the replay command. ## Team list cache diff --git a/src/group.test.ts b/src/group.test.ts index 9cab6f5..762476d 100644 --- a/src/group.test.ts +++ b/src/group.test.ts @@ -3,7 +3,9 @@ import { applyTeamPick, flattenTeamSections, groupByTeamPrefix, + moveRepoToSection, rebuildTeamSections, + undoPickedRepo, } from "./group.ts"; import type { RepoGroup, TeamSection } from "./types.ts"; @@ -279,3 +281,164 @@ describe("rebuildTeamSections", () => { expect(rebuildTeamSections([])).toEqual([]); }); }); + +// ─── moveRepoToSection ──────────────────────────────────────────────────────── + +function makeSimpleGroup(repo: string, teams: string[] = []): RepoGroup { + return { + repoFullName: repo, + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + teams, + }; +} + +describe("moveRepoToSection", () => { + it("moves a repo to an existing target section", () => { + const sections: TeamSection[] = [ + { + label: "squad-frontend + squad-mobile", + groups: [makeSimpleGroup("org/shared", ["squad-frontend", "squad-mobile"])], + }, + { label: "squad-mobile", groups: [makeSimpleGroup("org/b", ["squad-mobile"])] }, + ]; + const flat = flattenTeamSections(sections); + const result = moveRepoToSection(flat, "org/shared", "squad-mobile"); + const labels = [...new Set(result.filter((g) => g.sectionLabel).map((g) => g.sectionLabel))]; + expect(labels).not.toContain("squad-frontend + squad-mobile"); + expect(labels).toContain("squad-mobile"); + expect(result.find((g) => g.repoFullName === "org/shared")).toBeDefined(); + }); + + it("creates a new section when target team has no existing section", () => { + const sections: TeamSection[] = [ + { label: "squad-frontend + squad-mobile", groups: [makeSimpleGroup("org/shared")] }, + ]; + const flat = flattenTeamSections(sections); + const result = moveRepoToSection(flat, "org/shared", "squad-mobile"); + expect(result.find((g) => g.sectionLabel === "squad-mobile")).toBeDefined(); + }); + + it("is a no-op when the repo does not exist", () => { + const sections: TeamSection[] = [ + { label: "squad-frontend", groups: [makeSimpleGroup("org/a")] }, + ]; + const flat = flattenTeamSections(sections); + const result = moveRepoToSection(flat, "org/nonexistent", "squad-mobile"); + expect(result).toBe(flat); + }); +}); + +// ─── undoPickedRepo ─────────────────────────────────────────────────────────── + +function makePicked( + repo: string, + pickedFrom: string, + currentSection: string, + teams: string[] = [], +): RepoGroup { + return { + repoFullName: repo, + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + teams, + pickedFrom, + sectionLabel: currentSection, + }; +} + +describe("undoPickedRepo", () => { + it("restores a picked repo back to its original combined section", () => { + // squad-frontend + squad-mobile was picked to squad-frontend + const groups: RepoGroup[] = [ + { ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }, + { + repoFullName: "org/repoB", + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + teams: [], + pickedFrom: "squad-frontend + squad-mobile", + }, + ]; + const result = undoPickedRepo(groups, 0); + const a = result.find((g) => g.repoFullName === "org/repoA"); + expect(a).toBeDefined(); + expect(a!.pickedFrom).toBeUndefined(); + // Must appear in the restored combined section + const sectionRow = result.find((g) => g.sectionLabel === "squad-frontend + squad-mobile"); + expect(sectionRow).toBeDefined(); + expect(sectionRow!.repoFullName).toBe("org/repoA"); + }); + + it("no-op when repo has no pickedFrom", () => { + const groups: RepoGroup[] = [ + { + repoFullName: "org/repoA", + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + teams: [], + }, + ]; + const result = undoPickedRepo(groups, 0); + expect(result).toBe(groups); // same reference — no change + }); + + it("drops the current section when it becomes empty after undo", () => { + const groups: RepoGroup[] = [ + { ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }, + ]; + const result = undoPickedRepo(groups, 0); + // squad-frontend section should be gone (it had only repoA) + const frontendSection = result.find((g) => g.sectionLabel === "squad-frontend"); + expect(frontendSection).toBeUndefined(); + // Combined section should exist + const combinedSection = result.find((g) => g.sectionLabel === "squad-frontend + squad-mobile"); + expect(combinedSection).toBeDefined(); + }); + + it("appends to the existing combined section if it already exists", () => { + // repoA was picked to squad-frontend, but the combined section still has repoC + const sections: TeamSection[] = [ + { + label: "squad-frontend", + groups: [{ ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }], + }, + { + label: "squad-frontend + squad-mobile", + groups: [ + { + repoFullName: "org/repoC", + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + teams: ["squad-frontend", "squad-mobile"], + }, + ], + }, + ]; + const flat = flattenTeamSections(sections); + const repoAIndex = flat.findIndex((g) => g.repoFullName === "org/repoA"); + const result = undoPickedRepo(flat, repoAIndex); + const combinedGroups = (() => { + let inCombined = false; + const repos: string[] = []; + for (const g of result) { + if (g.sectionLabel === "squad-frontend + squad-mobile") inCombined = true; + else if (g.sectionLabel !== undefined) inCombined = false; + if (inCombined) repos.push(g.repoFullName); + } + return repos; + })(); + expect(combinedGroups).toContain("org/repoA"); + expect(combinedGroups).toContain("org/repoC"); + }); +}); diff --git a/src/group.ts b/src/group.ts index f3a5fa0..6f8661d 100644 --- a/src/group.ts +++ b/src/group.ts @@ -160,3 +160,93 @@ export function flattenTeamSections(sections: TeamSection[]): RepoGroup[] { } return result; } + +// ─── Undo pick helper ───────────────────────────────────────────────────────── + +/** + * Restores a previously picked repo back to its original combined section. + * + * The target repo is identified by `repoIndex` in the flat `groups` array. + * It must have a `pickedFrom` field set (otherwise the array is returned as-is). + * The repo is removed from its current section and placed in the `pickedFrom` + * combined section (which is created if it no longer exists). + * `pickedFrom` is stripped from the restored repo so it is treated as a plain + * unassigned entry again. + * + * Pure function — no mutation. + */ +export function undoPickedRepo(groups: RepoGroup[], repoIndex: number): RepoGroup[] { + const g = groups[repoIndex]; + if (!g?.pickedFrom) return groups; + + const combinedLabel = g.pickedFrom; + // Strip pick metadata from the repo being restored + const { pickedFrom: _p, ...restored } = g; + void _p; + const restoredRepo = restored as RepoGroup; + + let sections = rebuildTeamSections(groups); + + // Remove the repo from its current section (drop section if it becomes empty) + const srcIdx = sections.findIndex((s) => s.groups.some((r) => r.repoFullName === g.repoFullName)); + if (srcIdx !== -1) { + const newSrcGroups = sections[srcIdx].groups.filter((r) => r.repoFullName !== g.repoFullName); + sections = + newSrcGroups.length > 0 + ? sections.map((s, i) => (i === srcIdx ? { ...s, groups: newSrcGroups } : s)) + : sections.filter((_, i) => i !== srcIdx); + } + + // Place the repo into the original combined section (create if absent) + const dstIdx = sections.findIndex((s) => s.label === combinedLabel); + if (dstIdx !== -1) { + sections = sections.map((s, i) => + i === dstIdx ? { ...s, groups: [...s.groups, restoredRepo] } : s, + ); + } else { + // Re-insert at the position of the original source section (best-effort) + sections = [...sections, { label: combinedLabel, groups: [restoredRepo] }]; + } + + return flattenTeamSections(sections); +} + +// ─── Re-pick move helper ────────────────────────────────────────────────────── + +/** + * Moves a single repo (identified by its full `org/repo` name) into the + * target team's section. Used by the TUI re-pick confirmation handler. + * + * The repo's `pickedFrom` field is preserved so an undo can restore it later. + * + * Returns a new `RepoGroup[]` without mutating the input. + */ +export function moveRepoToSection( + groups: RepoGroup[], + repoFullName: string, + targetTeam: string, +): RepoGroup[] { + let sections = rebuildTeamSections(groups); + + const srcIdx = sections.findIndex((s) => s.groups.some((g) => g.repoFullName === repoFullName)); + if (srcIdx === -1) return groups; + + const groupToMove = sections[srcIdx].groups.find((g) => g.repoFullName === repoFullName)!; + const newSrcGroups = sections[srcIdx].groups.filter((g) => g.repoFullName !== repoFullName); + + const intermediate = + newSrcGroups.length > 0 + ? sections.map((s, i) => (i === srcIdx ? { ...s, groups: newSrcGroups } : s)) + : sections.filter((_, i) => i !== srcIdx); + + const dstIdx = intermediate.findIndex((s) => s.label === targetTeam); + if (dstIdx !== -1) { + sections = intermediate.map((s, i) => + i === dstIdx ? { ...s, groups: [...s.groups, groupToMove] } : s, + ); + } else { + sections = [...intermediate, { label: targetTeam, groups: [groupToMove] }]; + } + + return flattenTeamSections(sections); +} diff --git a/src/render.ts b/src/render.ts index 30e4052..6cb52de 100644 --- a/src/render.ts +++ b/src/render.ts @@ -320,6 +320,13 @@ interface RenderOptions { candidates: string[]; focusedIndex: number; }; + /** Active team dispatch (re-pick) mode state — when set, shows the re-pick bar in the hints line. */ + dispatchMode?: { + active: boolean; + repoIndex: number; + candidates: string[]; + focusedIndex: number; + }; } export function renderGroups( @@ -458,13 +465,29 @@ export function renderGroups( } // Fix: clip hints to termWidth visible chars so the line never wraps — see issue #105. - if (opts.teamPickMode?.active) { + if (opts.dispatchMode?.active) { + const dm = opts.dispatchMode; + // Re-pick bar: same layout as pick mode — focused team in [ brackets ], others dimmed. + // Suffix with 0/u undo and Esc/t cancel hints, clipped to one line with horizontal scroll. + const REPICK_PREFIX = "Re-pick: "; + const REPICK_SUFFIX = " 0/u restore ← → move ↵ confirm Esc cancel"; + const barWidth = Math.max(0, termWidth - REPICK_PREFIX.length - REPICK_SUFFIX.length); + const bar = renderTeamPickHeader(dm.candidates, dm.focusedIndex, barWidth); + const barPlain = stripAnsi(bar); + const suffix = + barPlain.length + REPICK_SUFFIX.length <= termWidth - REPICK_PREFIX.length + ? REPICK_SUFFIX + : barPlain.length < termWidth - REPICK_PREFIX.length + ? REPICK_SUFFIX.slice(0, termWidth - REPICK_PREFIX.length - barPlain.length) + : ""; + lines.push(pc.dim(REPICK_PREFIX) + bar + pc.dim(`${suffix}\n`)); + } else if (opts.teamPickMode?.active) { const PICK_HINTS = `Pick team: ← / → move focus ↵ confirm Esc cancel`; const clippedPick = PICK_HINTS.length > termWidth ? PICK_HINTS.slice(0, termWidth) : PICK_HINTS; lines.push(pc.dim(`${clippedPick}\n`)); } else { const HINTS_TEXT = - "← / → fold/unfold Z fold-all ↑ / ↓ navigate gg/G top/bot PgUp/Dn page spc select a all n none o open f filter t target p pick-team h help ↵ confirm q quit"; + "← / → fold/unfold Z fold-all ↑ / ↓ navigate gg/G top/bot PgUp/Dn page spc select a all n none o open f filter t target/re-pick p pick-team h help ↵ confirm q quit"; const clippedHints = HINTS_TEXT.length > termWidth ? HINTS_TEXT.slice(0, termWidth) : HINTS_TEXT; lines.push(pc.dim(`${clippedHints}\n`)); diff --git a/src/tui.ts b/src/tui.ts index 49d7157..33f7b0a 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -12,7 +12,13 @@ import { type FilterStats, } from "./render.ts"; import { buildOutput } from "./output.ts"; -import { applyTeamPick, flattenTeamSections, rebuildTeamSections } from "./group.ts"; +import { + applyTeamPick, + moveRepoToSection, + undoPickedRepo, + flattenTeamSections, + rebuildTeamSections, +} from "./group.ts"; import type { FilterTarget, OutputFormat, OutputType, RepoGroup, Row } from "./types.ts"; // ─── Key binding constants ──────────────────────────────────────────────────── @@ -194,6 +200,15 @@ export async function runInteractive( * so they are included in the replay command even if no additional interactive picks are made. */ const confirmedPicks: Record = { ...initialPickTeams }; + // ─── Team dispatch (re-pick) mode state ─────────────────────────────────────── + // Feat: re-pick mode — re-assign a picked (◈) repo to a different team — see issue #87 + let dispatchMode: { + active: boolean; + repoIndex: number; + candidates: string[]; + focusedIndex: number; + } = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; + /** Schedule a debounced stats recompute (while typing in filter bar). */ const scheduleStatsUpdate = () => { if (statsDebounceTimer !== null) clearTimeout(statsDebounceTimer); @@ -235,6 +250,7 @@ export async function runInteractive( filterTarget, filterRegex, teamPickMode: teamPickMode.active ? teamPickMode : undefined, + dispatchMode: dispatchMode.active ? dispatchMode : undefined, }); process.stdout.write(ANSI_CLEAR); process.stdout.write(rendered); @@ -292,6 +308,58 @@ export async function runInteractive( continue; } + // ── Team re-pick mode key handler ───────────────────────────────────────── + // Feat: re-pick mode — re-assign a picked (◈) repo to a different team — see issue #87 + if (dispatchMode.active) { + if (key === KEY_CTRL_C) { + process.stdout.write(ANSI_CLEAR); + process.stdin.setRawMode(false); + process.exit(0); + } else if (key === ANSI_ARROW_LEFT) { + // ← — cycle candidate teams backwards + dispatchMode = { + ...dispatchMode, + focusedIndex: + (dispatchMode.focusedIndex - 1 + dispatchMode.candidates.length) % + dispatchMode.candidates.length, + }; + } else if (key === ANSI_ARROW_RIGHT) { + // → — cycle candidate teams forwards + dispatchMode = { + ...dispatchMode, + focusedIndex: (dispatchMode.focusedIndex + 1) % dispatchMode.candidates.length, + }; + } else if (key === KEY_ENTER_CR || key === KEY_ENTER_LF) { + // Enter — confirm re-pick, move repo to the focused candidate team + const targetTeam = dispatchMode.candidates[dispatchMode.focusedIndex]; + const g = groups[dispatchMode.repoIndex]; + groups = moveRepoToSection(groups, g.repoFullName, targetTeam); + const newRows = buildRows(groups, filterPath, filterTarget, filterRegex); + cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); + scrollOffset = Math.min(scrollOffset, cursor); + dispatchMode = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; + } else if (key === "0" || key === "u") { + // 0 / u — undo pick, restore repo to its original combined section. + // Remove the confirmedPick entry for the combined label so the replay + // command no longer emits --pick-team for that section. + const combinedLabel = groups[dispatchMode.repoIndex]?.pickedFrom; + if (combinedLabel) delete confirmedPicks[combinedLabel]; + groups = undoPickedRepo(groups, dispatchMode.repoIndex); + const newRows = buildRows(groups, filterPath, filterTarget, filterRegex); + cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); + scrollOffset = Math.min(scrollOffset, cursor); + dispatchMode = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; + } else if (key === "\x1b" && !key.startsWith("\x1b[") && !key.startsWith("\x1b\x1b")) { + // Esc — cancel re-pick mode + dispatchMode = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; + } else if (key === "t") { + // t — toggle re-pick mode off + dispatchMode = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; + } + redraw(); + continue; + } + // ── Filter input mode ──────────────────────────────────────────────────── if (filterMode) { if (key === KEY_CTRL_C) { @@ -467,15 +535,26 @@ export async function runInteractive( continue; } - // `t` — cycle filter target: path → content → repo → path + // `t` — on a picked repo (◈, has pickedFrom): enter dispatch mode to re-assign to a + // different team. Otherwise cycle the filter target: path → content → repo → path. + // Feat: team dispatch mode — see issue #86 if (key === "t") { - filterTarget = - filterTarget === "path" ? "content" : filterTarget === "content" ? "repo" : "path"; - if (filterPath) { - // Rebuild rows with new target - const newRows = buildRows(groups, filterPath, filterTarget, filterRegex); - cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); - scrollOffset = Math.min(scrollOffset, cursor); + const isPickedRepo = + groupByTeamPrefix && row?.type === "repo" && !!groups[row.repoIndex]?.pickedFrom; + if (isPickedRepo) { + // Enter dispatch mode — candidates come from the original combined label + const pickedFrom = groups[row!.repoIndex].pickedFrom!; + const candidates = pickedFrom.split(" + ").map((c) => c.trim()); + dispatchMode = { active: true, repoIndex: row!.repoIndex, candidates, focusedIndex: 0 }; + } else { + // Cycle filter target when not on a picked repo + filterTarget = + filterTarget === "path" ? "content" : filterTarget === "content" ? "repo" : "path"; + if (filterPath) { + const newRows = buildRows(groups, filterPath, filterTarget, filterRegex); + cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); + scrollOffset = Math.min(scrollOffset, cursor); + } } redraw(); continue; From e431dfb03afe9f8dfe7f3542ec4f8a86c9a6ccba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Tue, 31 Mar 2026 17:30:20 +0200 Subject: [PATCH 2/6] =?UTF-8?q?Address=20PR=20review=20comments:=20rename?= =?UTF-8?q?=20dispatchMode=E2=86=92repickMode,=20fix=20hints=20text,=20add?= =?UTF-8?q?=20tests,=20update=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/usage/team-grouping.md | 4 +- src/group.ts | 2 +- src/render.test.ts | 79 +++++++++++++++++++++++++++++++++++++ src/render.ts | 10 ++--- src/tui.ts | 46 ++++++++++----------- 5 files changed, 111 insertions(+), 30 deletions(-) diff --git a/docs/usage/team-grouping.md b/docs/usage/team-grouping.md index 495cc5f..4a3b078 100644 --- a/docs/usage/team-grouping.md +++ b/docs/usage/team-grouping.md @@ -109,6 +109,8 @@ github-code-search query "useFeatureFlag" --org fulll \ The flag is repeatable — add one `--pick-team` per combined section to resolve. The replay command emits `--pick-team` automatically when a pick was confirmed in the TUI. +> **Note:** Per-repo re-picks performed in the TUI (pressing `t` on a `◈` repo) are **not** encoded in the replay command. They are interactive-only adjustments and must be repeated manually if you re-run the command. + If the combined label is not found (typo, or the section was not formed), a warning is emitted on stderr listing the available combined sections — the run continues without error. ## Re-pick & undo pick @@ -149,7 +151,7 @@ Pressing `0` or `u` in re-pick mode restores the repo to the combined section it ▶ ◈ fulll/frontend-app ``` -In **non-interactive mode**, undoing a pick is implicit: simply omit the `--pick-team` flag for the repo in the replay command. +In **non-interactive mode**, undoing a pick is implicit: simply omit the `--pick-team` flag for that combined section in the replay command. ## Team list cache diff --git a/src/group.ts b/src/group.ts index 6f8661d..fe87b2a 100644 --- a/src/group.ts +++ b/src/group.ts @@ -204,7 +204,7 @@ export function undoPickedRepo(groups: RepoGroup[], repoIndex: number): RepoGrou i === dstIdx ? { ...s, groups: [...s.groups, restoredRepo] } : s, ); } else { - // Re-insert at the position of the original source section (best-effort) + // No existing section found — append a new combined section at the end. sections = [...sections, { label: combinedLabel, groups: [restoredRepo] }]; } diff --git a/src/render.test.ts b/src/render.test.ts index efd15bb..bcb2028 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -1981,3 +1981,82 @@ describe("normalizeScrollOffset", () => { expect(normalizeScrollOffset(1, rows, groups, 3)).toBe(1); }); }); + +// ─── renderGroups — re-pick mode hints bar ──────────────────────────────────── + +describe("renderGroups — re-pick mode hints bar", () => { + it("shows Re-pick: prefix and candidate teams when repickMode is active", () => { + const groups = [makeGroup("org/repo", ["a.ts"])]; + const rows = buildRows(groups); + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + repickMode: { + active: true, + repoIndex: 0, + candidates: ["squad-frontend", "squad-mobile"], + focusedIndex: 0, + }, + termWidth: 120, + }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped).toContain("Re-pick:"); + expect(stripped).toContain("squad-frontend"); + expect(stripped).toContain("0/u restore"); + expect(stripped).toContain("Esc/t cancel"); + }); + + it("re-pick hints line visible width never exceeds termWidth", () => { + // Fix: clip hints to termWidth so the line never wraps — see issue #105. + const groups = [makeGroup("org/repo", ["a.ts"])]; + const rows = buildRows(groups); + const termWidth = 60; + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + repickMode: { + active: true, + repoIndex: 0, + candidates: ["squad-frontend", "squad-mobile"], + focusedIndex: 0, + }, + termWidth, + }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + const repickLine = stripped.split("\n").find((l) => l.includes("Re-pick:")); + expect(repickLine).toBeDefined(); + expect(repickLine!.length).toBeLessThanOrEqual(termWidth); + }); + + it("truncates suffix gracefully on very narrow terminal without crashing", () => { + const groups = [makeGroup("org/repo", ["a.ts"])]; + const rows = buildRows(groups); + const termWidth = 25; // narrow — full suffix won't fit + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + repickMode: { + active: true, + repoIndex: 0, + candidates: ["squad-frontend", "squad-mobile"], + focusedIndex: 0, + }, + termWidth, + }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + const repickLine = stripped.split("\n").find((l) => l.includes("Re-pick:")); + expect(repickLine).toBeDefined(); + expect(repickLine!.length).toBeLessThanOrEqual(termWidth); + }); + + it("focuses the correct candidate in [ brackets ]", () => { + const groups = [makeGroup("org/repo", ["a.ts"])]; + const rows = buildRows(groups); + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + repickMode: { + active: true, + repoIndex: 0, + candidates: ["squad-alpha", "squad-beta"], + focusedIndex: 1, + }, + termWidth: 120, + }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + // focusedIndex=1 → squad-beta is focused → should appear in [ brackets ] + expect(stripped).toContain("[ squad-beta ]"); + }); +}); diff --git a/src/render.ts b/src/render.ts index 6cb52de..a2f3d64 100644 --- a/src/render.ts +++ b/src/render.ts @@ -320,8 +320,8 @@ interface RenderOptions { candidates: string[]; focusedIndex: number; }; - /** Active team dispatch (re-pick) mode state — when set, shows the re-pick bar in the hints line. */ - dispatchMode?: { + /** Active team re-pick mode state — when set, shows the re-pick bar in the hints line. */ + repickMode?: { active: boolean; repoIndex: number; candidates: string[]; @@ -465,12 +465,12 @@ export function renderGroups( } // Fix: clip hints to termWidth visible chars so the line never wraps — see issue #105. - if (opts.dispatchMode?.active) { - const dm = opts.dispatchMode; + if (opts.repickMode?.active) { + const dm = opts.repickMode; // Re-pick bar: same layout as pick mode — focused team in [ brackets ], others dimmed. // Suffix with 0/u undo and Esc/t cancel hints, clipped to one line with horizontal scroll. const REPICK_PREFIX = "Re-pick: "; - const REPICK_SUFFIX = " 0/u restore ← → move ↵ confirm Esc cancel"; + const REPICK_SUFFIX = " 0/u restore ← → move ↵ confirm Esc/t cancel"; const barWidth = Math.max(0, termWidth - REPICK_PREFIX.length - REPICK_SUFFIX.length); const bar = renderTeamPickHeader(dm.candidates, dm.focusedIndex, barWidth); const barPlain = stripAnsi(bar); diff --git a/src/tui.ts b/src/tui.ts index 33f7b0a..c68a614 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -200,9 +200,9 @@ export async function runInteractive( * so they are included in the replay command even if no additional interactive picks are made. */ const confirmedPicks: Record = { ...initialPickTeams }; - // ─── Team dispatch (re-pick) mode state ─────────────────────────────────────── + // ─── Team re-pick mode state ────────────────────────────────────────────────── // Feat: re-pick mode — re-assign a picked (◈) repo to a different team — see issue #87 - let dispatchMode: { + let repickMode: { active: boolean; repoIndex: number; candidates: string[]; @@ -250,7 +250,7 @@ export async function runInteractive( filterTarget, filterRegex, teamPickMode: teamPickMode.active ? teamPickMode : undefined, - dispatchMode: dispatchMode.active ? dispatchMode : undefined, + repickMode: repickMode.active ? repickMode : undefined, }); process.stdout.write(ANSI_CLEAR); process.stdout.write(rendered); @@ -310,51 +310,51 @@ export async function runInteractive( // ── Team re-pick mode key handler ───────────────────────────────────────── // Feat: re-pick mode — re-assign a picked (◈) repo to a different team — see issue #87 - if (dispatchMode.active) { + if (repickMode.active) { if (key === KEY_CTRL_C) { process.stdout.write(ANSI_CLEAR); process.stdin.setRawMode(false); process.exit(0); } else if (key === ANSI_ARROW_LEFT) { // ← — cycle candidate teams backwards - dispatchMode = { - ...dispatchMode, + repickMode = { + ...repickMode, focusedIndex: - (dispatchMode.focusedIndex - 1 + dispatchMode.candidates.length) % - dispatchMode.candidates.length, + (repickMode.focusedIndex - 1 + repickMode.candidates.length) % + repickMode.candidates.length, }; } else if (key === ANSI_ARROW_RIGHT) { // → — cycle candidate teams forwards - dispatchMode = { - ...dispatchMode, - focusedIndex: (dispatchMode.focusedIndex + 1) % dispatchMode.candidates.length, + repickMode = { + ...repickMode, + focusedIndex: (repickMode.focusedIndex + 1) % repickMode.candidates.length, }; } else if (key === KEY_ENTER_CR || key === KEY_ENTER_LF) { // Enter — confirm re-pick, move repo to the focused candidate team - const targetTeam = dispatchMode.candidates[dispatchMode.focusedIndex]; - const g = groups[dispatchMode.repoIndex]; + const targetTeam = repickMode.candidates[repickMode.focusedIndex]; + const g = groups[repickMode.repoIndex]; groups = moveRepoToSection(groups, g.repoFullName, targetTeam); const newRows = buildRows(groups, filterPath, filterTarget, filterRegex); cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); scrollOffset = Math.min(scrollOffset, cursor); - dispatchMode = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; + repickMode = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; } else if (key === "0" || key === "u") { // 0 / u — undo pick, restore repo to its original combined section. // Remove the confirmedPick entry for the combined label so the replay // command no longer emits --pick-team for that section. - const combinedLabel = groups[dispatchMode.repoIndex]?.pickedFrom; + const combinedLabel = groups[repickMode.repoIndex]?.pickedFrom; if (combinedLabel) delete confirmedPicks[combinedLabel]; - groups = undoPickedRepo(groups, dispatchMode.repoIndex); + groups = undoPickedRepo(groups, repickMode.repoIndex); const newRows = buildRows(groups, filterPath, filterTarget, filterRegex); cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); scrollOffset = Math.min(scrollOffset, cursor); - dispatchMode = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; + repickMode = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; } else if (key === "\x1b" && !key.startsWith("\x1b[") && !key.startsWith("\x1b\x1b")) { // Esc — cancel re-pick mode - dispatchMode = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; + repickMode = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; } else if (key === "t") { // t — toggle re-pick mode off - dispatchMode = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; + repickMode = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; } redraw(); continue; @@ -535,17 +535,17 @@ export async function runInteractive( continue; } - // `t` — on a picked repo (◈, has pickedFrom): enter dispatch mode to re-assign to a + // `t` — on a picked repo (◈, has pickedFrom): enter re-pick mode to re-assign to a // different team. Otherwise cycle the filter target: path → content → repo → path. - // Feat: team dispatch mode — see issue #86 + // Feat: re-pick mode — see issue #87 if (key === "t") { const isPickedRepo = groupByTeamPrefix && row?.type === "repo" && !!groups[row.repoIndex]?.pickedFrom; if (isPickedRepo) { - // Enter dispatch mode — candidates come from the original combined label + // Enter re-pick mode — candidates come from the original combined label const pickedFrom = groups[row!.repoIndex].pickedFrom!; const candidates = pickedFrom.split(" + ").map((c) => c.trim()); - dispatchMode = { active: true, repoIndex: row!.repoIndex, candidates, focusedIndex: 0 }; + repickMode = { active: true, repoIndex: row!.repoIndex, candidates, focusedIndex: 0 }; } else { // Cycle filter target when not on a picked repo filterTarget = From 359a13e18c2db429edb262ed940ff8f657318c35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Tue, 31 Mar 2026 17:32:09 +0200 Subject: [PATCH 3/6] Fix re-pick hint code example in docs (Esc/t cancel) --- docs/usage/team-grouping.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/usage/team-grouping.md b/docs/usage/team-grouping.md index 4a3b078..d74d29b 100644 --- a/docs/usage/team-grouping.md +++ b/docs/usage/team-grouping.md @@ -130,7 +130,7 @@ Navigate to any **picked repo** (marked `◈`) and press **`t`** to enter re-pic The hints bar shows a horizontal pick bar — exactly like team pick mode — with the current focused team highlighted in `[ brackets ]`: ```text -Re-pick: [ squad-frontend ] squad-mobile 0/u restore ← → move ↵ confirm Esc cancel +Re-pick: [ squad-frontend ] squad-mobile 0/u restore ← → move ↵ confirm Esc/t cancel ``` | Key | Action | From b5906012830d1bf0a570c1ce92ade5efa9f4eba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Tue, 31 Mar 2026 20:47:10 +0200 Subject: [PATCH 4/6] Fix undo-pick bugs and whole-section undo semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - render.ts: wrap full re-pick hints line with clipAnsi() so it never wraps on extremely narrow terminals (prefix + bar + suffix all clipped) - group.ts: insertBeforeOther() helper keeps 'other' section last when undoPickedRepo or moveRepoToSection must create a new section - group.ts: add undoSectionPick() — restores ALL repos with matching pickedFrom back to the combined section in one operation (inverse of applyTeamPick); used by 0/u in re-pick mode - tui.ts: 0/u now calls undoSectionPick instead of undoPickedRepo, so the entire combined section is undone atomically — confirmedPicks stays in sync with the restored visual state - docs: update key table and Undoing a pick section to reflect that 0/u undoes the whole section pick, not just one repo - group.test.ts: new tests for insert-before-other (undoPickedRepo & moveRepoToSection) and for undoSectionPick (multi-repo restore, insert-before-other, append-to-existing) --- docs/usage/team-grouping.md | 19 ++- src/group.test.ts | 222 ++++++++++++++++++++++++++++++++++++ src/group.ts | 74 +++++++++++- src/render.ts | 6 +- src/tui.ts | 15 ++- 5 files changed, 315 insertions(+), 21 deletions(-) diff --git a/docs/usage/team-grouping.md b/docs/usage/team-grouping.md index d74d29b..1e45128 100644 --- a/docs/usage/team-grouping.md +++ b/docs/usage/team-grouping.md @@ -133,22 +133,21 @@ The hints bar shows a horizontal pick bar — exactly like team pick mode — wi Re-pick: [ squad-frontend ] squad-mobile 0/u restore ← → move ↵ confirm Esc/t cancel ``` -| Key | Action | -| ----------- | ------------------------------------------------- | -| `←` / `→` | Cycle through candidate teams | -| `Enter` | Confirm and move repo to the focused team | -| `0` / `u` | Restore the repo to its original combined section | -| `Esc` / `t` | Exit re-pick mode without changes | +| Key | Action | +| ----------- | --------------------------------------------------------------- | +| `←` / `→` | Cycle through candidate teams | +| `Enter` | Confirm and move repo to the focused team | +| `0` / `u` | Restore **all** repos from the combined section (undo the pick) | +| `Esc` / `t` | Exit re-pick mode without changes | ### Undoing a pick (merge) -Pressing `0` or `u` in re-pick mode restores the repo to the combined section it came from (e.g. `squad-frontend + squad-mobile`). The `◈` badge is removed and the repo is treated as unassigned again. +Pressing `0` or `u` in re-pick mode restores **all** repos from the same combined section back to where they came from (e.g. `squad-frontend + squad-mobile`). Every `◈` badge from that section is removed and all repos are treated as unassigned again. ```text -── squad-frontend + squad-mobile ← restored +── squad-frontend + squad-mobile ← all repos restored +▶ ◉ fulll/frontend-app ▶ ◉ fulll/mobile-sdk -── squad-frontend -▶ ◈ fulll/frontend-app ``` In **non-interactive mode**, undoing a pick is implicit: simply omit the `--pick-team` flag for that combined section in the replay command. diff --git a/src/group.test.ts b/src/group.test.ts index 762476d..29baf43 100644 --- a/src/group.test.ts +++ b/src/group.test.ts @@ -6,6 +6,7 @@ import { moveRepoToSection, rebuildTeamSections, undoPickedRepo, + undoSectionPick, } from "./group.ts"; import type { RepoGroup, TeamSection } from "./types.ts"; @@ -441,4 +442,225 @@ describe("undoPickedRepo", () => { expect(combinedGroups).toContain("org/repoA"); expect(combinedGroups).toContain("org/repoC"); }); + + it("inserts new combined section before 'other' when other exists", () => { + // repoA was picked to squad-frontend; repoB lives in "other" + // After undo, the new combined section must appear before "other" + const sections: TeamSection[] = [ + { + label: "squad-frontend", + groups: [ + { ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }, + ], + }, + { + label: "other", + groups: [ + { + repoFullName: "org/repoB", + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + teams: [], + }, + ], + }, + ]; + const flat = flattenTeamSections(sections); + const repoAIndex = flat.findIndex((g) => g.repoFullName === "org/repoA"); + const result = undoPickedRepo(flat, repoAIndex); + const sectionLabels = result + .filter((g) => g.sectionLabel !== undefined) + .map((g) => g.sectionLabel); + const combinedIdx = sectionLabels.indexOf("squad-frontend + squad-mobile"); + const otherIdx = sectionLabels.indexOf("other"); + expect(combinedIdx).not.toBe(-1); + expect(otherIdx).not.toBe(-1); + // Combined section must come before "other" + expect(combinedIdx).toBeLessThan(otherIdx); + }); +}); + +// ─── moveRepoToSection ──────────────────────────────────────────────────────── + +describe("moveRepoToSection — insert before other", () => { + it("inserts new target section before 'other' when other exists", () => { + // repoA (picked from combined) is moved to a new team section that doesn't exist yet + // "other" section is present — new section must appear before it + const sections: TeamSection[] = [ + { + label: "squad-frontend + squad-mobile", + groups: [ + { + ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend + squad-mobile"), + pickedFrom: undefined, + }, + ], + }, + { + label: "other", + groups: [ + { + repoFullName: "org/repoB", + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + teams: [], + }, + ], + }, + ]; + // Manually set pickedFrom so move is realistic + const flat = flattenTeamSections(sections).map((g) => + g.repoFullName === "org/repoA" ? { ...g, pickedFrom: "squad-frontend + squad-mobile" } : g, + ); + const result = moveRepoToSection(flat, "org/repoA", "squad-mobile"); + const sectionLabels = result + .filter((g) => g.sectionLabel !== undefined) + .map((g) => g.sectionLabel); + const mobileIdx = sectionLabels.indexOf("squad-mobile"); + const otherIdx = sectionLabels.indexOf("other"); + expect(mobileIdx).not.toBe(-1); + expect(otherIdx).not.toBe(-1); + // New squad-mobile section must come before "other" + expect(mobileIdx).toBeLessThan(otherIdx); + }); +}); + +// ─── undoSectionPick ────────────────────────────────────────────────────────── + +describe("undoSectionPick", () => { + it("no-op when no repos have the matching pickedFrom", () => { + const groups: RepoGroup[] = [ + { + repoFullName: "org/repoA", + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + teams: [], + }, + ]; + const result = undoSectionPick(groups, "squad-frontend + squad-mobile"); + expect(result).toBe(groups); // same reference — no change + }); + + it("restores all repos with matching pickedFrom to the combined section", () => { + // Two repos were both picked to different teams from the same combined section + const sections: TeamSection[] = [ + { + label: "squad-frontend", + groups: [ + { ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }, + ], + }, + { + label: "squad-mobile", + groups: [ + { ...makePicked("org/repoB", "squad-frontend + squad-mobile", "squad-mobile") }, + ], + }, + ]; + const flat = flattenTeamSections(sections); + const result = undoSectionPick(flat, "squad-frontend + squad-mobile"); + + // Both repos should appear in the restored combined section + const combinedSection = result.find((g) => g.sectionLabel === "squad-frontend + squad-mobile"); + expect(combinedSection).toBeDefined(); + const inCombined = (() => { + let collecting = false; + const repos: string[] = []; + for (const g of result) { + if (g.sectionLabel === "squad-frontend + squad-mobile") collecting = true; + else if (g.sectionLabel !== undefined) collecting = false; + if (collecting) repos.push(g.repoFullName); + } + return repos; + })(); + expect(inCombined).toContain("org/repoA"); + expect(inCombined).toContain("org/repoB"); + // pickedFrom must be stripped + for (const g of result) { + expect(g.pickedFrom).toBeUndefined(); + } + // Source sections should be gone (empty after restore) + expect(result.find((g) => g.sectionLabel === "squad-frontend")).toBeUndefined(); + expect(result.find((g) => g.sectionLabel === "squad-mobile")).toBeUndefined(); + }); + + it("inserts restored combined section before 'other'", () => { + // repoA was picked from combined to squad-frontend; repoB is in "other" + const sections: TeamSection[] = [ + { + label: "squad-frontend", + groups: [ + { ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }, + ], + }, + { + label: "other", + groups: [ + { + repoFullName: "org/repoB", + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + teams: [], + }, + ], + }, + ]; + const flat = flattenTeamSections(sections); + const result = undoSectionPick(flat, "squad-frontend + squad-mobile"); + const sectionLabels = result + .filter((g) => g.sectionLabel !== undefined) + .map((g) => g.sectionLabel); + const combinedIdx = sectionLabels.indexOf("squad-frontend + squad-mobile"); + const otherIdx = sectionLabels.indexOf("other"); + expect(combinedIdx).not.toBe(-1); + expect(otherIdx).not.toBe(-1); + expect(combinedIdx).toBeLessThan(otherIdx); + }); + + it("appends to existing combined section when it still has other repos", () => { + // repoA was picked to squad-frontend, but repoC still lives in the combined section + const sections: TeamSection[] = [ + { + label: "squad-frontend", + groups: [ + { ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }, + ], + }, + { + label: "squad-frontend + squad-mobile", + groups: [ + { + repoFullName: "org/repoC", + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + teams: ["squad-frontend", "squad-mobile"], + }, + ], + }, + ]; + const flat = flattenTeamSections(sections); + const result = undoSectionPick(flat, "squad-frontend + squad-mobile"); + const inCombined = (() => { + let collecting = false; + const repos: string[] = []; + for (const g of result) { + if (g.sectionLabel === "squad-frontend + squad-mobile") collecting = true; + else if (g.sectionLabel !== undefined) collecting = false; + if (collecting) repos.push(g.repoFullName); + } + return repos; + })(); + expect(inCombined).toContain("org/repoA"); + expect(inCombined).toContain("org/repoC"); + }); }); diff --git a/src/group.ts b/src/group.ts index fe87b2a..37e7c6d 100644 --- a/src/group.ts +++ b/src/group.ts @@ -161,6 +161,20 @@ export function flattenTeamSections(sections: TeamSection[]): RepoGroup[] { return result; } +// ─── Internal helpers ───────────────────────────────────────────────────────── + +/** + * Inserts `newSection` before the `"other"` section, or appends it at the end + * when no `"other"` section exists. Keeps `"other"` as the last section, which + * is an invariant established by `groupByTeamPrefix`. + */ +function insertBeforeOther(sections: TeamSection[], newSection: TeamSection): TeamSection[] { + const otherIdx = sections.findIndex((s) => s.label === "other"); + return otherIdx === -1 + ? [...sections, newSection] + : [...sections.slice(0, otherIdx), newSection, ...sections.slice(otherIdx)]; +} + // ─── Undo pick helper ───────────────────────────────────────────────────────── /** @@ -204,8 +218,9 @@ export function undoPickedRepo(groups: RepoGroup[], repoIndex: number): RepoGrou i === dstIdx ? { ...s, groups: [...s.groups, restoredRepo] } : s, ); } else { - // No existing section found — append a new combined section at the end. - sections = [...sections, { label: combinedLabel, groups: [restoredRepo] }]; + // No existing section found — insert before "other" (which must remain last) + // or append at the end when "other" is absent. + sections = insertBeforeOther(sections, { label: combinedLabel, groups: [restoredRepo] }); } return flattenTeamSections(sections); @@ -245,7 +260,60 @@ export function moveRepoToSection( i === dstIdx ? { ...s, groups: [...s.groups, groupToMove] } : s, ); } else { - sections = [...intermediate, { label: targetTeam, groups: [groupToMove] }]; + // Target section doesn't exist yet — insert before "other" (which must remain last) + // or append at the end when "other" is absent. + sections = insertBeforeOther(intermediate, { label: targetTeam, groups: [groupToMove] }); + } + + return flattenTeamSections(sections); +} + +// ─── Undo section pick helper ───────────────────────────────────────────────── + +/** + * Restores ALL repos that were assigned from `combinedLabel` (every repo whose + * `pickedFrom === combinedLabel`) back to the combined section in one operation. + * + * This is the inverse of the full `applyTeamPick` for that label — it removes + * all per-repo picks made from a combined section, so that the `confirmedPicks` + * entry and the replay `--pick-team` flag can be cleanly removed without leaving + * the interactive state in a partially-undone, non-replayable configuration. + * + * Pure function — no mutation. + */ +export function undoSectionPick(groups: RepoGroup[], combinedLabel: string): RepoGroup[] { + if (!groups.some((g) => g.pickedFrom === combinedLabel)) return groups; + + let sections = rebuildTeamSections(groups); + + // Collect all repos to restore (preserving their relative order across sections) + const toRestore: RepoGroup[] = []; + sections = sections + .map((s) => { + const keep: RepoGroup[] = []; + for (const g of s.groups) { + if (g.pickedFrom === combinedLabel) { + const { pickedFrom: _p, ...rest } = g; + void _p; + toRestore.push(rest as RepoGroup); + } else { + keep.push(g); + } + } + return { ...s, groups: keep }; + }) + .filter((s) => s.groups.length > 0); + + if (toRestore.length === 0) return groups; + + // Append to existing combined section or create a new one + const dstIdx = sections.findIndex((s) => s.label === combinedLabel); + if (dstIdx !== -1) { + sections = sections.map((s, i) => + i === dstIdx ? { ...s, groups: [...s.groups, ...toRestore] } : s, + ); + } else { + sections = insertBeforeOther(sections, { label: combinedLabel, groups: toRestore }); } return flattenTeamSections(sections); diff --git a/src/render.ts b/src/render.ts index a2f3d64..9b0e748 100644 --- a/src/render.ts +++ b/src/render.ts @@ -468,7 +468,9 @@ export function renderGroups( if (opts.repickMode?.active) { const dm = opts.repickMode; // Re-pick bar: same layout as pick mode — focused team in [ brackets ], others dimmed. - // Suffix with 0/u undo and Esc/t cancel hints, clipped to one line with horizontal scroll. + // Suffix with 0/u undo and Esc/t cancel hints. The entire constructed line is passed + // through clipAnsi() so it never wraps regardless of terminal width (including when + // termWidth is narrower than the "Re-pick: " prefix itself). const REPICK_PREFIX = "Re-pick: "; const REPICK_SUFFIX = " 0/u restore ← → move ↵ confirm Esc/t cancel"; const barWidth = Math.max(0, termWidth - REPICK_PREFIX.length - REPICK_SUFFIX.length); @@ -480,7 +482,7 @@ export function renderGroups( : barPlain.length < termWidth - REPICK_PREFIX.length ? REPICK_SUFFIX.slice(0, termWidth - REPICK_PREFIX.length - barPlain.length) : ""; - lines.push(pc.dim(REPICK_PREFIX) + bar + pc.dim(`${suffix}\n`)); + lines.push(clipAnsi(pc.dim(REPICK_PREFIX) + bar + pc.dim(suffix), termWidth) + "\n"); } else if (opts.teamPickMode?.active) { const PICK_HINTS = `Pick team: ← / → move focus ↵ confirm Esc cancel`; const clippedPick = PICK_HINTS.length > termWidth ? PICK_HINTS.slice(0, termWidth) : PICK_HINTS; diff --git a/src/tui.ts b/src/tui.ts index c68a614..049ea4e 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -15,7 +15,7 @@ import { buildOutput } from "./output.ts"; import { applyTeamPick, moveRepoToSection, - undoPickedRepo, + undoSectionPick, flattenTeamSections, rebuildTeamSections, } from "./group.ts"; @@ -339,12 +339,15 @@ export async function runInteractive( scrollOffset = Math.min(scrollOffset, cursor); repickMode = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; } else if (key === "0" || key === "u") { - // 0 / u — undo pick, restore repo to its original combined section. - // Remove the confirmedPick entry for the combined label so the replay - // command no longer emits --pick-team for that section. + // 0 / u — undo the entire section pick: restore ALL repos that were + // assigned from the same combined label back to the combined section. + // Deleting confirmedPicks keeps the replay command in sync — omitting + // --pick-team for that label is the exact non-interactive equivalent. const combinedLabel = groups[repickMode.repoIndex]?.pickedFrom; - if (combinedLabel) delete confirmedPicks[combinedLabel]; - groups = undoPickedRepo(groups, repickMode.repoIndex); + if (combinedLabel) { + delete confirmedPicks[combinedLabel]; + groups = undoSectionPick(groups, combinedLabel); + } const newRows = buildRows(groups, filterPath, filterTarget, filterRegex); cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); scrollOffset = Math.min(scrollOffset, cursor); From ce6e76a860b60f9a592ba0e7e34c3335a406757e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Tue, 31 Mar 2026 21:18:32 +0200 Subject: [PATCH 5/6] Fix re-pick horizontal scrolling and right-align hints block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderTeamPickHeader now uses a sliding window algorithm: - The focused team is always visible in [ brackets ], regardless of how many candidates exist and how narrow the available bar width is. - Candidates hidden to the left/right of the visible window are indicated with '…' ellipsis markers (left and right independently). - The window expands greedily right then left from the focused item to fill as much of maxWidth as possible. - Previously the function always rendered from candidate 0 and clipped at the right; scrolling past the second item left the focused team off-screen even though the index was advancing. Re-pick bar right-alignment: - The hints block ('0/u restore ← → move ↵ confirm Esc/t cancel') is now always anchored at the right terminal edge via padding, instead of being attached directly to the bar content (which caused it to drift left when the candidate bar was shorter than its allocated width). New tests: - renderTeamPickHeader: left ellipsis, both ellipses, focused always visible for all indices, window shift, clipped focused, symmetrical fill. - renderGroups re-pick bar: right-alignment assertion (suffix ends at termWidth), suffix fixed position as focusedIndex changes, focused team visible for all 8 candidates on a narrow (80-char) terminal. --- src/group.test.ts | 26 +++----- src/render.test.ts | 87 +++++++++++++++++++++++++ src/render.ts | 22 +++---- src/render/team-pick.test.ts | 85 +++++++++++++++++++++++-- src/render/team-pick.ts | 119 ++++++++++++++++++++++++++++------- 5 files changed, 283 insertions(+), 56 deletions(-) diff --git a/src/group.test.ts b/src/group.test.ts index 29baf43..ea25f01 100644 --- a/src/group.test.ts +++ b/src/group.test.ts @@ -449,9 +449,7 @@ describe("undoPickedRepo", () => { const sections: TeamSection[] = [ { label: "squad-frontend", - groups: [ - { ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }, - ], + groups: [{ ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }], }, { label: "other", @@ -493,7 +491,11 @@ describe("moveRepoToSection — insert before other", () => { label: "squad-frontend + squad-mobile", groups: [ { - ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend + squad-mobile"), + ...makePicked( + "org/repoA", + "squad-frontend + squad-mobile", + "squad-frontend + squad-mobile", + ), pickedFrom: undefined, }, ], @@ -552,15 +554,11 @@ describe("undoSectionPick", () => { const sections: TeamSection[] = [ { label: "squad-frontend", - groups: [ - { ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }, - ], + groups: [{ ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }], }, { label: "squad-mobile", - groups: [ - { ...makePicked("org/repoB", "squad-frontend + squad-mobile", "squad-mobile") }, - ], + groups: [{ ...makePicked("org/repoB", "squad-frontend + squad-mobile", "squad-mobile") }], }, ]; const flat = flattenTeamSections(sections); @@ -595,9 +593,7 @@ describe("undoSectionPick", () => { const sections: TeamSection[] = [ { label: "squad-frontend", - groups: [ - { ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }, - ], + groups: [{ ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }], }, { label: "other", @@ -630,9 +626,7 @@ describe("undoSectionPick", () => { const sections: TeamSection[] = [ { label: "squad-frontend", - groups: [ - { ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }, - ], + groups: [{ ...makePicked("org/repoA", "squad-frontend + squad-mobile", "squad-frontend") }], }, { label: "squad-frontend + squad-mobile", diff --git a/src/render.test.ts b/src/render.test.ts index bcb2028..3e2806a 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -2059,4 +2059,91 @@ describe("renderGroups — re-pick mode hints bar", () => { // focusedIndex=1 → squad-beta is focused → should appear in [ brackets ] expect(stripped).toContain("[ squad-beta ]"); }); + + it("suffix is right-aligned: hints block always ends at the terminal edge", () => { + // With a wide terminal, the bar is short but the hints must still be at the right edge. + const groups = [makeGroup("org/repo", ["a.ts"])]; + const rows = buildRows(groups); + const termWidth = 100; + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + repickMode: { + active: true, + repoIndex: 0, + candidates: ["squad-a", "squad-b"], // short names → bar << barWidth + focusedIndex: 0, + }, + termWidth, + }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + const repickLine = stripped.split("\n").find((l) => l.includes("Re-pick:")); + expect(repickLine).toBeDefined(); + // Line should extend to termWidth (padded) — suffix anchored at right edge. + expect(repickLine!.length).toBe(termWidth); + // Suffix must be present and not truncated. + expect(repickLine!).toContain("0/u restore"); + expect(repickLine!).toContain("Esc/t cancel"); + }); + + it("suffix position stays fixed as focusedIndex changes", () => { + // Switching focus must not shift the suffix — only the middle bar content changes. + const groups = [makeGroup("org/repo", ["a.ts"])]; + const rows = buildRows(groups); + const termWidth = 120; + const candidates = ["squad-alpha", "squad-beta", "squad-gamma"]; + + const lineFor = (focusedIndex: number) => { + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + repickMode: { active: true, repoIndex: 0, candidates, focusedIndex }, + termWidth, + }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + return stripped.split("\n").find((l) => l.includes("Re-pick:"))!; + }; + + const line0 = lineFor(0); + const line1 = lineFor(1); + const line2 = lineFor(2); + + // All lines same length (padded to termWidth). + expect(line0.length).toBe(termWidth); + expect(line1.length).toBe(termWidth); + expect(line2.length).toBe(termWidth); + + // "Esc/t cancel" always ends at the same column (right edge). + const suffix = "Esc/t cancel"; + expect(line0.endsWith(suffix)).toBe(true); + expect(line1.endsWith(suffix)).toBe(true); + expect(line2.endsWith(suffix)).toBe(true); + }); + + it("focused team is always visible with many candidates (windowing)", () => { + // 8 teams — the bar is too narrow to show all at once. + // Every focusedIndex must produce a line containing that team in [ brackets ]. + const groups = [makeGroup("org/repo", ["a.ts"])]; + const rows = buildRows(groups); + const termWidth = 80; // narrow: barWidth = 80−9−49 = 22 + const candidates = [ + "chapter-a", + "chapter-b", + "chapter-c", + "chapter-d", + "chapter-e", + "chapter-f", + "chapter-g", + "chapter-h", + ]; + + for (let focusedIndex = 0; focusedIndex < candidates.length; focusedIndex++) { + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { + repickMode: { active: true, repoIndex: 0, candidates, focusedIndex }, + termWidth, + }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + const repickLine = stripped.split("\n").find((l) => l.includes("Re-pick:")); + expect(repickLine).toBeDefined(); + expect(repickLine!.length).toBeLessThanOrEqual(termWidth); + // The focused team must be visible in [ brackets ] no matter where focus is. + expect(repickLine!).toContain(`[ ${candidates[focusedIndex]} ]`); + } + }); }); diff --git a/src/render.ts b/src/render.ts index 9b0e748..13ba7c1 100644 --- a/src/render.ts +++ b/src/render.ts @@ -467,22 +467,22 @@ export function renderGroups( // Fix: clip hints to termWidth visible chars so the line never wraps — see issue #105. if (opts.repickMode?.active) { const dm = opts.repickMode; - // Re-pick bar: same layout as pick mode — focused team in [ brackets ], others dimmed. - // Suffix with 0/u undo and Esc/t cancel hints. The entire constructed line is passed - // through clipAnsi() so it never wraps regardless of terminal width (including when - // termWidth is narrower than the "Re-pick: " prefix itself). + // Re-pick bar layout: + // "Re-pick: " | | | " 0/u restore ← → …" + // + // The candidate bar uses a sliding window (renderTeamPickHeader) so the + // focused team is always visible regardless of how many teams exist. + // The suffix is right-aligned by padding with spaces between the bar and + // the suffix so the hints block always sits at the right terminal edge. const REPICK_PREFIX = "Re-pick: "; const REPICK_SUFFIX = " 0/u restore ← → move ↵ confirm Esc/t cancel"; const barWidth = Math.max(0, termWidth - REPICK_PREFIX.length - REPICK_SUFFIX.length); const bar = renderTeamPickHeader(dm.candidates, dm.focusedIndex, barWidth); const barPlain = stripAnsi(bar); - const suffix = - barPlain.length + REPICK_SUFFIX.length <= termWidth - REPICK_PREFIX.length - ? REPICK_SUFFIX - : barPlain.length < termWidth - REPICK_PREFIX.length - ? REPICK_SUFFIX.slice(0, termWidth - REPICK_PREFIX.length - barPlain.length) - : ""; - lines.push(clipAnsi(pc.dim(REPICK_PREFIX) + bar + pc.dim(suffix), termWidth) + "\n"); + // Pad between bar content and suffix to keep suffix right-aligned. + const padLen = Math.max(0, barWidth - barPlain.length); + const line = pc.dim(REPICK_PREFIX) + bar + " ".repeat(padLen) + pc.dim(REPICK_SUFFIX); + lines.push(clipAnsi(line, termWidth) + "\n"); } else if (opts.teamPickMode?.active) { const PICK_HINTS = `Pick team: ← / → move focus ↵ confirm Esc cancel`; const clippedPick = PICK_HINTS.length > termWidth ? PICK_HINTS.slice(0, termWidth) : PICK_HINTS; diff --git a/src/render/team-pick.test.ts b/src/render/team-pick.test.ts index bb38794..a0f5999 100644 --- a/src/render/team-pick.test.ts +++ b/src/render/team-pick.test.ts @@ -55,24 +55,24 @@ describe("renderTeamPickHeader — maxWidth clipping", () => { expect(result).toBe("[ squad-a ] squad-b"); }); - it("clips and appends … when bar exceeds maxWidth", () => { + it("clips and appends … when focused=0 and bar exceeds maxWidth", () => { // "[ squad-a ]" = 11 chars; " squad-b" = 9 more = 20 total; limit to 15 + // Window=[0,0]: items(11) + right-ellipsis(3) = 14 ≤ 15 → show "[ squad-a ] …" const result = strip(renderTeamPickHeader(["squad-a", "squad-b"], 0, 15)); expect(result).toContain("[ squad-a ]"); expect(result).toContain("…"); expect(result).not.toContain("squad-b"); }); - it("omits … when even the ellipsis does not fit", () => { - // maxWidth=0: no room for any char, not even "…" + it("omits … when even the ellipsis does not fit (maxWidth=0)", () => { const result = strip(renderTeamPickHeader(["squad-a", "squad-b"], 0, 0)); expect(result).not.toContain("…"); expect(result).toBe(""); }); - it("clips three candidates to two + ellipsis", () => { - // "[ squad-a ]" = 11, " squad-b" = 9 → 20 total; " …" = 3 → needs maxWidth ≥ 23 - // With maxWidth=23: squad-a + squad-b + " …" fits; squad-c does not + it("clips three candidates to two + ellipsis when focused=0", () => { + // "[ squad-a ]" = 11, " squad-b" = 9 → 20; " …" = 3 → need ≥ 23 + // With maxWidth=23: squad-a + squad-b (20) + right-ellipsis (3) = 23 ✓; squad-c hidden const result = strip(renderTeamPickHeader(["squad-a", "squad-b", "squad-c"], 0, 23)); expect(result).toContain("[ squad-a ]"); expect(result).toContain("squad-b"); @@ -80,3 +80,76 @@ describe("renderTeamPickHeader — maxWidth clipping", () => { expect(result).not.toContain("squad-c"); }); }); + +// ─── renderTeamPickHeader — windowed scrolling (focused always visible) ──────── + +describe("renderTeamPickHeader — windowed scrolling", () => { + it("shows left ellipsis when focused is not the first candidate", () => { + // candidates=["A","B","C"], focused=2, maxWidth=10 + // "[ C ]"=5; window=[2,2]: items(5)+left(3)+right(0)=8 ≤ 10 ✓ + // Try expand left to 1: items(5+2+1=8)+left(3)=11 > 10 ✗ → A not included + // Result: "… [ C ]" (8 chars) + const result = strip(renderTeamPickHeader(["A", "B", "C"], 2, 10)); + expect(result).toContain("[ C ]"); + expect(result.startsWith("…")).toBe(true); // left ellipsis + expect(result).not.toContain(" A"); // A hidden on left + }); + + it("shows both ellipses when focused is in the middle and candidates overflow both sides", () => { + // 5 equal-width candidates ("tm1"–"tm5", each 3 chars); focused=2; maxWidth=16 + // "[ tm3 ]"=7; + right(3)=10; + left(3)=13; expand right: 7+SEP+3=12 → 12+3+3=18>16 + // window=[2,2]; totalWidth=7+3+3=13 ≤ 16; both ellipses shown → "… [ tm3 ] …" (13 chars) + const result = strip(renderTeamPickHeader(["tm1", "tm2", "tm3", "tm4", "tm5"], 2, 16)); + expect(result).toContain("[ tm3 ]"); + expect(result.startsWith("…")).toBe(true); + expect(result.endsWith("…")).toBe(true); + expect(result).not.toContain("tm1"); + expect(result).not.toContain("tm5"); + }); + + it("focused team is always visible regardless of focusedIndex", () => { + const teams = ["squad-a", "squad-b", "squad-c", "squad-d", "squad-e"]; + // Use a narrow maxWidth so not all teams fit at once + const maxWidth = 22; + for (let i = 0; i < teams.length; i++) { + const result = strip(renderTeamPickHeader(teams, i, maxWidth)); + expect(result).toContain(`[ ${teams[i]} ]`); + expect(result.length).toBeLessThanOrEqual(maxWidth); + } + }); + + it("window shifts rightward as focusedIndex increases", () => { + const teams = ["A", "B", "C", "D", "E"]; // 1 char each + // "[ X ]"=5 chars; with maxWidth=10: items(5)+right(3)=8 OR left(3)+items(5)=8 + // Window holds 1 item at a time with ellipses + const result0 = strip(renderTeamPickHeader(teams, 0, 10)); + const result4 = strip(renderTeamPickHeader(teams, 4, 10)); + // First focused: no left ellipsis + expect(result0.startsWith("…")).toBe(false); + expect(result0).toContain("[ A ]"); + // Last focused: no right ellipsis + expect(result4.endsWith("…")).toBe(false); + expect(result4).toContain("[ E ]"); + }); + + it("shows the focused item clipped when it alone exceeds maxWidth", () => { + // "[ very-long-team-name ]" = 23 chars > maxWidth=10 → clip + const result = strip(renderTeamPickHeader(["very-long-team-name"], 0, 10)); + expect(result.length).toBeLessThanOrEqual(10); + expect(result.endsWith("…")).toBe(true); + }); + + it("expands to fill available space symmetrically around focused", () => { + // 5 teams of 3 chars each; focused=2 (middle) + const teams = ["tm1", "tm2", "tm3", "tm4", "tm5"]; + // maxWidth=30: "… tm2 [ tm3 ] tm4 …" = 3+3+2+7+2+3+3=23 ≤ 30 → add tm1 on left + // Let's use a width where all 5 fit: widths=[3,3,7,3,3], SEP between=(4*2)=8, total=19 + const result = strip(renderTeamPickHeader(teams, 2, 30)); + expect(result).toContain("tm1"); + expect(result).toContain("tm2"); + expect(result).toContain("[ tm3 ]"); + expect(result).toContain("tm4"); + expect(result).toContain("tm5"); + expect(result).not.toContain("…"); // all teams fit → no ellipsis + }); +}); diff --git a/src/render/team-pick.ts b/src/render/team-pick.ts index 7a1c9cd..5ed6670 100644 --- a/src/render/team-pick.ts +++ b/src/render/team-pick.ts @@ -2,48 +2,121 @@ import pc from "picocolors"; const SEP = " "; +// Visible-char cost of the overflow ellipsis indicators. +// Left side: "…" (1) + SEP before first windowed item (2) = 3 +// Right side: SEP after last windowed item (2) + "…" (1) = 3 +const EL_LEFT = 1 + SEP.length; // 3 +const EL_RIGHT = SEP.length + 1; // 3 + /** * Renders a horizontal pick bar for team pick mode. * * The focused team is shown in bold + full colour (magenta); the others are - * dimmed. Used as the section header content when team pick mode is active. + * dimmed. Used as the section header content when team pick mode is active or + * as the candidate list in the re-pick mode hints bar. * - * When `maxWidth` is provided the bar is clipped to that many visible - * characters (ANSI codes do not count) and a "…" is appended when candidates - * are omitted, so the line never wraps. + * When `maxWidth` is provided the bar is rendered with a **sliding window** so + * that the focused team is always visible, regardless of how many other teams + * are listed. Candidates hidden to the left of the window are indicated with a + * leading `…`; candidates hidden to the right with a trailing `…` — like a + * horizontal tab strip. The total visible width (ANSI codes excluded) never + * exceeds `maxWidth`. * * Example output (focusedIndex = 0, * candidates = ["squad-frontend", "squad-mobile"]): * * [ squad-frontend ] squad-mobile + * + * Example output when many candidates don't fit (focusedIndex = 2, + * maxWidth constrains to 3 teams): + * + * … squad-b [ squad-c ] squad-d … */ export function renderTeamPickHeader( candidateTeams: string[], focusedIndex: number, maxWidth?: number, ): string { - const result: string[] = []; - let visibleWidth = 0; - - for (let i = 0; i < candidateTeams.length; i++) { - const team = candidateTeams[i]; - const visibleText = i === focusedIndex ? `[ ${team} ]` : team; - const sep = i > 0 ? SEP : ""; - const cost = sep.length + visibleText.length; - - if (maxWidth !== undefined && visibleWidth + cost > maxWidth) { - // Append "…" only if it fits within the remaining space. - const ellipsisWidth = (i > 0 ? SEP.length : 0) + 1; - if (visibleWidth + ellipsisWidth <= maxWidth) { - result.push((i > 0 ? SEP : "") + "…"); + const n = candidateTeams.length; + // Pre-compute visible text for each candidate (focused gets [ ] brackets). + const texts = candidateTeams.map((t, i) => (i === focusedIndex ? `[ ${t} ]` : t)); + const widths = texts.map((t) => t.length); + + if (maxWidth === undefined) { + // No width constraint — render all candidates. + return texts + .map( + (text, i) => + (i > 0 ? SEP : "") + (i === focusedIndex ? pc.bold(pc.magenta(text)) : pc.dim(text)), + ) + .join(""); + } + + if (maxWidth <= 0) return ""; + + // If the focused item alone is wider than maxWidth, clip it. + if (widths[focusedIndex] > maxWidth) { + const clipped = texts[focusedIndex].slice(0, maxWidth - 1) + "…"; + return pc.bold(pc.magenta(clipped)); + } + + // ── Windowed rendering ──────────────────────────────────────────────────── + // Find the largest window [start, end] that contains focusedIndex and whose + // total visible width — including overflow ellipsis indicators — fits within + // maxWidth. Width model: + // + // • items: sum of widths[start..end] + (end-start) × SEP.length + // • left ellipsis ("…" + SEP before first item): EL_LEFT = 3 (when start > 0) + // • right ellipsis (SEP + "…" after last item): EL_RIGHT = 3 (when end < n-1) + // + // Start with a single-item window at focusedIndex, then greedily expand + // right then left until neither direction fits any more. + + let start = focusedIndex; + let end = focusedIndex; + let usedWidth = widths[focusedIndex]; // width of all items in [start,end] + inter-item SEPs + + const totalWidth = (s: number, e: number, itemsW: number): number => + itemsW + (s > 0 ? EL_LEFT : 0) + (e < n - 1 ? EL_RIGHT : 0); + + for (;;) { + let expanded = false; + // Try expanding right. + if (end + 1 < n) { + const addCost = SEP.length + widths[end + 1]; + if (totalWidth(start, end + 1, usedWidth + addCost) <= maxWidth) { + usedWidth += addCost; + end++; + expanded = true; } - break; } + // Try expanding left. + if (start > 0) { + const addCost = widths[start - 1] + SEP.length; + if (totalWidth(start - 1, end, usedWidth + addCost) <= maxWidth) { + usedWidth += addCost; + start--; + expanded = true; + } + } + if (!expanded) break; + } + + // ── Build the bar string ────────────────────────────────────────────────── + // Guard: only emit overflow ellipsis when the items + both ellipses actually + // fit. In the edge case where the focused item fills maxWidth (leaving no + // room for a 3-char ellipsis), omit the indicator rather than overflowing. + const needed = totalWidth(start, end, usedWidth); + const addLeftEl = start > 0 && needed <= maxWidth; + const addRightEl = end < n - 1 && needed <= maxWidth; - const ansi = i === focusedIndex ? pc.bold(pc.magenta(visibleText)) : pc.dim(visibleText); - result.push(sep + ansi); - visibleWidth += cost; + const parts: string[] = []; + if (addLeftEl) parts.push(pc.dim("…")); + for (let i = start; i <= end; i++) { + if (i > start || addLeftEl) parts.push(SEP); + parts.push(i === focusedIndex ? pc.bold(pc.magenta(texts[i])) : pc.dim(texts[i])); } + if (addRightEl) parts.push(SEP + pc.dim("…")); - return result.join(""); + return parts.join(""); } From 143c58238bdddaa9d503a7c24bf742fc361e5d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Tue, 31 Mar 2026 21:51:17 +0200 Subject: [PATCH 6/6] Address unresolved review comments: guard renderTeamPickHeader + fix 0/u doc --- docs/reference/keyboard-shortcuts.md | 12 +++++----- src/render/team-pick.test.ts | 34 ++++++++++++++++++++++++++++ src/render/team-pick.ts | 23 +++++++++++-------- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 861421a..d530895 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -88,12 +88,12 @@ When pick mode is active (after pressing `p` on a multi-team section header): When re-pick mode is active (after pressing `t` on a picked repo marked `◈`): -| Key | Action | -| ----------- | ------------------------------------------------------------------------ | -| `←` / `→` | Cycle through candidate teams (from the original combined section label) | -| `Enter` | Confirm and move the repo to the focused candidate team | -| `0` / `u` | Restore the repo to its original combined section (undo the pick) | -| `Esc` / `t` | Exit re-pick mode without changes | +| Key | Action | +| ----------- | --------------------------------------------------------------------------------------------------- | +| `←` / `→` | Cycle through candidate teams (from the original combined section label) | +| `Enter` | Confirm and move the repo to the focused candidate team | +| `0` / `u` | Undo the entire section pick — restore all repos from that combined section to their original label | +| `Esc` / `t` | Exit re-pick mode without changes | ## Help and exit diff --git a/src/render/team-pick.test.ts b/src/render/team-pick.test.ts index a0f5999..4ba15ba 100644 --- a/src/render/team-pick.test.ts +++ b/src/render/team-pick.test.ts @@ -153,3 +153,37 @@ describe("renderTeamPickHeader — windowed scrolling", () => { expect(result).not.toContain("…"); // all teams fit → no ellipsis }); }); + +// ─── renderTeamPickHeader — guard conditions ────────────────────────────────── + +describe("renderTeamPickHeader — guard conditions", () => { + it("returns empty string for empty candidateTeams (no maxWidth)", () => { + expect(renderTeamPickHeader([], 0)).toBe(""); + }); + + it("returns empty string for empty candidateTeams (with maxWidth)", () => { + expect(renderTeamPickHeader([], 0, 80)).toBe(""); + }); + + it("clamps negative focusedIndex to 0", () => { + const result = strip(renderTeamPickHeader(["squad-a", "squad-b"], -1)); + expect(result).toContain("[ squad-a ]"); + expect(result).not.toMatch(/\[\s*squad-b\s*\]/); + }); + + it("clamps out-of-bounds focusedIndex to last candidate", () => { + const result = strip(renderTeamPickHeader(["squad-a", "squad-b"], 99)); + expect(result).toContain("[ squad-b ]"); + expect(result).not.toMatch(/\[\s*squad-a\s*\]/); + }); + + it("clamps negative focusedIndex to first candidate with maxWidth", () => { + const result = strip(renderTeamPickHeader(["squad-a", "squad-b"], -1, 80)); + expect(result).toContain("[ squad-a ]"); + }); + + it("clamps out-of-bounds focusedIndex to last candidate with maxWidth", () => { + const result = strip(renderTeamPickHeader(["squad-a", "squad-b"], 99, 80)); + expect(result).toContain("[ squad-b ]"); + }); +}); diff --git a/src/render/team-pick.ts b/src/render/team-pick.ts index 5ed6670..ef711e3 100644 --- a/src/render/team-pick.ts +++ b/src/render/team-pick.ts @@ -38,8 +38,13 @@ export function renderTeamPickHeader( maxWidth?: number, ): string { const n = candidateTeams.length; + // Guard: empty list — nothing to render. + if (n === 0) return ""; + // Guard: clamp focusedIndex so an out-of-range value never reaches array indexing. + const safeIndex = Math.max(0, Math.min(focusedIndex, n - 1)); + // Pre-compute visible text for each candidate (focused gets [ ] brackets). - const texts = candidateTeams.map((t, i) => (i === focusedIndex ? `[ ${t} ]` : t)); + const texts = candidateTeams.map((t, i) => (i === safeIndex ? `[ ${t} ]` : t)); const widths = texts.map((t) => t.length); if (maxWidth === undefined) { @@ -47,7 +52,7 @@ export function renderTeamPickHeader( return texts .map( (text, i) => - (i > 0 ? SEP : "") + (i === focusedIndex ? pc.bold(pc.magenta(text)) : pc.dim(text)), + (i > 0 ? SEP : "") + (i === safeIndex ? pc.bold(pc.magenta(text)) : pc.dim(text)), ) .join(""); } @@ -55,8 +60,8 @@ export function renderTeamPickHeader( if (maxWidth <= 0) return ""; // If the focused item alone is wider than maxWidth, clip it. - if (widths[focusedIndex] > maxWidth) { - const clipped = texts[focusedIndex].slice(0, maxWidth - 1) + "…"; + if (widths[safeIndex] > maxWidth) { + const clipped = texts[safeIndex].slice(0, maxWidth - 1) + "…"; return pc.bold(pc.magenta(clipped)); } @@ -69,12 +74,12 @@ export function renderTeamPickHeader( // • left ellipsis ("…" + SEP before first item): EL_LEFT = 3 (when start > 0) // • right ellipsis (SEP + "…" after last item): EL_RIGHT = 3 (when end < n-1) // - // Start with a single-item window at focusedIndex, then greedily expand + // Start with a single-item window at safeIndex, then greedily expand // right then left until neither direction fits any more. - let start = focusedIndex; - let end = focusedIndex; - let usedWidth = widths[focusedIndex]; // width of all items in [start,end] + inter-item SEPs + let start = safeIndex; + let end = safeIndex; + let usedWidth = widths[safeIndex]; // width of all items in [start,end] + inter-item SEPs const totalWidth = (s: number, e: number, itemsW: number): number => itemsW + (s > 0 ? EL_LEFT : 0) + (e < n - 1 ? EL_RIGHT : 0); @@ -114,7 +119,7 @@ export function renderTeamPickHeader( if (addLeftEl) parts.push(pc.dim("…")); for (let i = start; i <= end; i++) { if (i > start || addLeftEl) parts.push(SEP); - parts.push(i === focusedIndex ? pc.bold(pc.magenta(texts[i])) : pc.dim(texts[i])); + parts.push(i === safeIndex ? pc.bold(pc.magenta(texts[i])) : pc.dim(texts[i])); } if (addRightEl) parts.push(SEP + pc.dim("…"));