Skip to content

Commit 4804e57

Browse files
committed
Fix re-pick horizontal scrolling and right-align hints block
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.
1 parent b590601 commit 4804e57

4 files changed

Lines changed: 273 additions & 40 deletions

File tree

src/render.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2059,4 +2059,91 @@ describe("renderGroups — re-pick mode hints bar", () => {
20592059
// focusedIndex=1 → squad-beta is focused → should appear in [ brackets ]
20602060
expect(stripped).toContain("[ squad-beta ]");
20612061
});
2062+
2063+
it("suffix is right-aligned: hints block always ends at the terminal edge", () => {
2064+
// With a wide terminal, the bar is short but the hints must still be at the right edge.
2065+
const groups = [makeGroup("org/repo", ["a.ts"])];
2066+
const rows = buildRows(groups);
2067+
const termWidth = 100;
2068+
const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", {
2069+
repickMode: {
2070+
active: true,
2071+
repoIndex: 0,
2072+
candidates: ["squad-a", "squad-b"], // short names → bar << barWidth
2073+
focusedIndex: 0,
2074+
},
2075+
termWidth,
2076+
});
2077+
const stripped = out.replace(/\x1b\[[0-9;]*m/g, "");
2078+
const repickLine = stripped.split("\n").find((l) => l.includes("Re-pick:"));
2079+
expect(repickLine).toBeDefined();
2080+
// Line should extend to termWidth (padded) — suffix anchored at right edge.
2081+
expect(repickLine!.length).toBe(termWidth);
2082+
// Suffix must be present and not truncated.
2083+
expect(repickLine!).toContain("0/u restore");
2084+
expect(repickLine!).toContain("Esc/t cancel");
2085+
});
2086+
2087+
it("suffix position stays fixed as focusedIndex changes", () => {
2088+
// Switching focus must not shift the suffix — only the middle bar content changes.
2089+
const groups = [makeGroup("org/repo", ["a.ts"])];
2090+
const rows = buildRows(groups);
2091+
const termWidth = 120;
2092+
const candidates = ["squad-alpha", "squad-beta", "squad-gamma"];
2093+
2094+
const lineFor = (focusedIndex: number) => {
2095+
const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", {
2096+
repickMode: { active: true, repoIndex: 0, candidates, focusedIndex },
2097+
termWidth,
2098+
});
2099+
const stripped = out.replace(/\x1b\[[0-9;]*m/g, "");
2100+
return stripped.split("\n").find((l) => l.includes("Re-pick:"))!;
2101+
};
2102+
2103+
const line0 = lineFor(0);
2104+
const line1 = lineFor(1);
2105+
const line2 = lineFor(2);
2106+
2107+
// All lines same length (padded to termWidth).
2108+
expect(line0.length).toBe(termWidth);
2109+
expect(line1.length).toBe(termWidth);
2110+
expect(line2.length).toBe(termWidth);
2111+
2112+
// "Esc/t cancel" always ends at the same column (right edge).
2113+
const suffix = "Esc/t cancel";
2114+
expect(line0.endsWith(suffix)).toBe(true);
2115+
expect(line1.endsWith(suffix)).toBe(true);
2116+
expect(line2.endsWith(suffix)).toBe(true);
2117+
});
2118+
2119+
it("focused team is always visible with many candidates (windowing)", () => {
2120+
// 8 teams — the bar is too narrow to show all at once.
2121+
// Every focusedIndex must produce a line containing that team in [ brackets ].
2122+
const groups = [makeGroup("org/repo", ["a.ts"])];
2123+
const rows = buildRows(groups);
2124+
const termWidth = 80; // narrow: barWidth = 80−9−49 = 22
2125+
const candidates = [
2126+
"chapter-a",
2127+
"chapter-b",
2128+
"chapter-c",
2129+
"chapter-d",
2130+
"chapter-e",
2131+
"chapter-f",
2132+
"chapter-g",
2133+
"chapter-h",
2134+
];
2135+
2136+
for (let focusedIndex = 0; focusedIndex < candidates.length; focusedIndex++) {
2137+
const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", {
2138+
repickMode: { active: true, repoIndex: 0, candidates, focusedIndex },
2139+
termWidth,
2140+
});
2141+
const stripped = out.replace(/\x1b\[[0-9;]*m/g, "");
2142+
const repickLine = stripped.split("\n").find((l) => l.includes("Re-pick:"));
2143+
expect(repickLine).toBeDefined();
2144+
expect(repickLine!.length).toBeLessThanOrEqual(termWidth);
2145+
// The focused team must be visible in [ brackets ] no matter where focus is.
2146+
expect(repickLine!).toContain(`[ ${candidates[focusedIndex]} ]`);
2147+
}
2148+
});
20622149
});

src/render.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -467,22 +467,22 @@ export function renderGroups(
467467
// Fix: clip hints to termWidth visible chars so the line never wraps — see issue #105.
468468
if (opts.repickMode?.active) {
469469
const dm = opts.repickMode;
470-
// Re-pick bar: same layout as pick mode — focused team in [ brackets ], others dimmed.
471-
// Suffix with 0/u undo and Esc/t cancel hints. The entire constructed line is passed
472-
// through clipAnsi() so it never wraps regardless of terminal width (including when
473-
// termWidth is narrower than the "Re-pick: " prefix itself).
470+
// Re-pick bar layout:
471+
// "Re-pick: " | <scrollable candidate bar> | <padding> | " 0/u restore ← → …"
472+
//
473+
// The candidate bar uses a sliding window (renderTeamPickHeader) so the
474+
// focused team is always visible regardless of how many teams exist.
475+
// The suffix is right-aligned by padding with spaces between the bar and
476+
// the suffix so the hints block always sits at the right terminal edge.
474477
const REPICK_PREFIX = "Re-pick: ";
475478
const REPICK_SUFFIX = " 0/u restore ← → move ↵ confirm Esc/t cancel";
476479
const barWidth = Math.max(0, termWidth - REPICK_PREFIX.length - REPICK_SUFFIX.length);
477480
const bar = renderTeamPickHeader(dm.candidates, dm.focusedIndex, barWidth);
478481
const barPlain = stripAnsi(bar);
479-
const suffix =
480-
barPlain.length + REPICK_SUFFIX.length <= termWidth - REPICK_PREFIX.length
481-
? REPICK_SUFFIX
482-
: barPlain.length < termWidth - REPICK_PREFIX.length
483-
? REPICK_SUFFIX.slice(0, termWidth - REPICK_PREFIX.length - barPlain.length)
484-
: "";
485-
lines.push(clipAnsi(pc.dim(REPICK_PREFIX) + bar + pc.dim(suffix), termWidth) + "\n");
482+
// Pad between bar content and suffix to keep suffix right-aligned.
483+
const padLen = Math.max(0, barWidth - barPlain.length);
484+
const line = pc.dim(REPICK_PREFIX) + bar + " ".repeat(padLen) + pc.dim(REPICK_SUFFIX);
485+
lines.push(clipAnsi(line, termWidth) + "\n");
486486
} else if (opts.teamPickMode?.active) {
487487
const PICK_HINTS = `Pick team: ← / → move focus ↵ confirm Esc cancel`;
488488
const clippedPick = PICK_HINTS.length > termWidth ? PICK_HINTS.slice(0, termWidth) : PICK_HINTS;

src/render/team-pick.test.ts

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,28 +55,101 @@ describe("renderTeamPickHeader — maxWidth clipping", () => {
5555
expect(result).toBe("[ squad-a ] squad-b");
5656
});
5757

58-
it("clips and appends … when bar exceeds maxWidth", () => {
58+
it("clips and appends … when focused=0 and bar exceeds maxWidth", () => {
5959
// "[ squad-a ]" = 11 chars; " squad-b" = 9 more = 20 total; limit to 15
60+
// Window=[0,0]: items(11) + right-ellipsis(3) = 14 ≤ 15 → show "[ squad-a ] …"
6061
const result = strip(renderTeamPickHeader(["squad-a", "squad-b"], 0, 15));
6162
expect(result).toContain("[ squad-a ]");
6263
expect(result).toContain("…");
6364
expect(result).not.toContain("squad-b");
6465
});
6566

66-
it("omits … when even the ellipsis does not fit", () => {
67-
// maxWidth=0: no room for any char, not even "…"
67+
it("omits … when even the ellipsis does not fit (maxWidth=0)", () => {
6868
const result = strip(renderTeamPickHeader(["squad-a", "squad-b"], 0, 0));
6969
expect(result).not.toContain("…");
7070
expect(result).toBe("");
7171
});
7272

73-
it("clips three candidates to two + ellipsis", () => {
74-
// "[ squad-a ]" = 11, " squad-b" = 9 → 20 total; " …" = 3 → needs maxWidth ≥ 23
75-
// With maxWidth=23: squad-a + squad-b + " …" fits; squad-c does not
73+
it("clips three candidates to two + ellipsis when focused=0", () => {
74+
// "[ squad-a ]" = 11, " squad-b" = 9 → 20; " …" = 3 → need ≥ 23
75+
// With maxWidth=23: squad-a + squad-b (20) + right-ellipsis (3) = 23 ✓; squad-c hidden
7676
const result = strip(renderTeamPickHeader(["squad-a", "squad-b", "squad-c"], 0, 23));
7777
expect(result).toContain("[ squad-a ]");
7878
expect(result).toContain("squad-b");
7979
expect(result).toContain("…");
8080
expect(result).not.toContain("squad-c");
8181
});
8282
});
83+
84+
// ─── renderTeamPickHeader — windowed scrolling (focused always visible) ────────
85+
86+
describe("renderTeamPickHeader — windowed scrolling", () => {
87+
it("shows left ellipsis when focused is not the first candidate", () => {
88+
// candidates=["A","B","C"], focused=2, maxWidth=10
89+
// "[ C ]"=5; window=[2,2]: items(5)+left(3)+right(0)=8 ≤ 10 ✓
90+
// Try expand left to 1: items(5+2+1=8)+left(3)=11 > 10 ✗ → A not included
91+
// Result: "… [ C ]" (8 chars)
92+
const result = strip(renderTeamPickHeader(["A", "B", "C"], 2, 10));
93+
expect(result).toContain("[ C ]");
94+
expect(result.startsWith("…")).toBe(true); // left ellipsis
95+
expect(result).not.toContain(" A"); // A hidden on left
96+
});
97+
98+
it("shows both ellipses when focused is in the middle and candidates overflow both sides", () => {
99+
// 5 equal-width candidates ("tm1"–"tm5", each 3 chars); focused=2; maxWidth=16
100+
// "[ tm3 ]"=7; + right(3)=10; + left(3)=13; expand right: 7+SEP+3=12 → 12+3+3=18>16
101+
// window=[2,2]; totalWidth=7+3+3=13 ≤ 16; both ellipses shown → "… [ tm3 ] …" (13 chars)
102+
const result = strip(renderTeamPickHeader(["tm1", "tm2", "tm3", "tm4", "tm5"], 2, 16));
103+
expect(result).toContain("[ tm3 ]");
104+
expect(result.startsWith("…")).toBe(true);
105+
expect(result.endsWith("…")).toBe(true);
106+
expect(result).not.toContain("tm1");
107+
expect(result).not.toContain("tm5");
108+
});
109+
110+
it("focused team is always visible regardless of focusedIndex", () => {
111+
const teams = ["squad-a", "squad-b", "squad-c", "squad-d", "squad-e"];
112+
// Use a narrow maxWidth so not all teams fit at once
113+
const maxWidth = 22;
114+
for (let i = 0; i < teams.length; i++) {
115+
const result = strip(renderTeamPickHeader(teams, i, maxWidth));
116+
expect(result).toContain(`[ ${teams[i]} ]`);
117+
expect(result.length).toBeLessThanOrEqual(maxWidth);
118+
}
119+
});
120+
121+
it("window shifts rightward as focusedIndex increases", () => {
122+
const teams = ["A", "B", "C", "D", "E"]; // 1 char each
123+
// "[ X ]"=5 chars; with maxWidth=10: items(5)+right(3)=8 OR left(3)+items(5)=8
124+
// Window holds 1 item at a time with ellipses
125+
const result0 = strip(renderTeamPickHeader(teams, 0, 10));
126+
const result4 = strip(renderTeamPickHeader(teams, 4, 10));
127+
// First focused: no left ellipsis
128+
expect(result0.startsWith("…")).toBe(false);
129+
expect(result0).toContain("[ A ]");
130+
// Last focused: no right ellipsis
131+
expect(result4.endsWith("…")).toBe(false);
132+
expect(result4).toContain("[ E ]");
133+
});
134+
135+
it("shows the focused item clipped when it alone exceeds maxWidth", () => {
136+
// "[ very-long-team-name ]" = 23 chars > maxWidth=10 → clip
137+
const result = strip(renderTeamPickHeader(["very-long-team-name"], 0, 10));
138+
expect(result.length).toBeLessThanOrEqual(10);
139+
expect(result.endsWith("…")).toBe(true);
140+
});
141+
142+
it("expands to fill available space symmetrically around focused", () => {
143+
// 5 teams of 3 chars each; focused=2 (middle)
144+
const teams = ["tm1", "tm2", "tm3", "tm4", "tm5"];
145+
// maxWidth=30: "… tm2 [ tm3 ] tm4 …" = 3+3+2+7+2+3+3=23 ≤ 30 → add tm1 on left
146+
// Let's use a width where all 5 fit: widths=[3,3,7,3,3], SEP between=(4*2)=8, total=19
147+
const result = strip(renderTeamPickHeader(teams, 2, 30));
148+
expect(result).toContain("tm1");
149+
expect(result).toContain("tm2");
150+
expect(result).toContain("[ tm3 ]");
151+
expect(result).toContain("tm4");
152+
expect(result).toContain("tm5");
153+
expect(result).not.toContain("…"); // all teams fit → no ellipsis
154+
});
155+
});

src/render/team-pick.ts

Lines changed: 96 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,48 +2,121 @@ import pc from "picocolors";
22

33
const SEP = " ";
44

5+
// Visible-char cost of the overflow ellipsis indicators.
6+
// Left side: "…" (1) + SEP before first windowed item (2) = 3
7+
// Right side: SEP after last windowed item (2) + "…" (1) = 3
8+
const EL_LEFT = 1 + SEP.length; // 3
9+
const EL_RIGHT = SEP.length + 1; // 3
10+
511
/**
612
* Renders a horizontal pick bar for team pick mode.
713
*
814
* The focused team is shown in bold + full colour (magenta); the others are
9-
* dimmed. Used as the section header content when team pick mode is active.
15+
* dimmed. Used as the section header content when team pick mode is active or
16+
* as the candidate list in the re-pick mode hints bar.
1017
*
11-
* When `maxWidth` is provided the bar is clipped to that many visible
12-
* characters (ANSI codes do not count) and a "…" is appended when candidates
13-
* are omitted, so the line never wraps.
18+
* When `maxWidth` is provided the bar is rendered with a **sliding window** so
19+
* that the focused team is always visible, regardless of how many other teams
20+
* are listed. Candidates hidden to the left of the window are indicated with a
21+
* leading `…`; candidates hidden to the right with a trailing `…` — like a
22+
* horizontal tab strip. The total visible width (ANSI codes excluded) never
23+
* exceeds `maxWidth`.
1424
*
1525
* Example output (focusedIndex = 0,
1626
* candidates = ["squad-frontend", "squad-mobile"]):
1727
*
1828
* [ squad-frontend ] squad-mobile
29+
*
30+
* Example output when many candidates don't fit (focusedIndex = 2,
31+
* maxWidth constrains to 3 teams):
32+
*
33+
* … squad-b [ squad-c ] squad-d …
1934
*/
2035
export function renderTeamPickHeader(
2136
candidateTeams: string[],
2237
focusedIndex: number,
2338
maxWidth?: number,
2439
): string {
25-
const result: string[] = [];
26-
let visibleWidth = 0;
27-
28-
for (let i = 0; i < candidateTeams.length; i++) {
29-
const team = candidateTeams[i];
30-
const visibleText = i === focusedIndex ? `[ ${team} ]` : team;
31-
const sep = i > 0 ? SEP : "";
32-
const cost = sep.length + visibleText.length;
33-
34-
if (maxWidth !== undefined && visibleWidth + cost > maxWidth) {
35-
// Append "…" only if it fits within the remaining space.
36-
const ellipsisWidth = (i > 0 ? SEP.length : 0) + 1;
37-
if (visibleWidth + ellipsisWidth <= maxWidth) {
38-
result.push((i > 0 ? SEP : "") + "…");
40+
const n = candidateTeams.length;
41+
// Pre-compute visible text for each candidate (focused gets [ ] brackets).
42+
const texts = candidateTeams.map((t, i) => (i === focusedIndex ? `[ ${t} ]` : t));
43+
const widths = texts.map((t) => t.length);
44+
45+
if (maxWidth === undefined) {
46+
// No width constraint — render all candidates.
47+
return texts
48+
.map(
49+
(text, i) =>
50+
(i > 0 ? SEP : "") + (i === focusedIndex ? pc.bold(pc.magenta(text)) : pc.dim(text)),
51+
)
52+
.join("");
53+
}
54+
55+
if (maxWidth <= 0) return "";
56+
57+
// If the focused item alone is wider than maxWidth, clip it.
58+
if (widths[focusedIndex] > maxWidth) {
59+
const clipped = texts[focusedIndex].slice(0, maxWidth - 1) + "…";
60+
return pc.bold(pc.magenta(clipped));
61+
}
62+
63+
// ── Windowed rendering ────────────────────────────────────────────────────
64+
// Find the largest window [start, end] that contains focusedIndex and whose
65+
// total visible width — including overflow ellipsis indicators — fits within
66+
// maxWidth. Width model:
67+
//
68+
// • items: sum of widths[start..end] + (end-start) × SEP.length
69+
// • left ellipsis ("…" + SEP before first item): EL_LEFT = 3 (when start > 0)
70+
// • right ellipsis (SEP + "…" after last item): EL_RIGHT = 3 (when end < n-1)
71+
//
72+
// Start with a single-item window at focusedIndex, then greedily expand
73+
// right then left until neither direction fits any more.
74+
75+
let start = focusedIndex;
76+
let end = focusedIndex;
77+
let usedWidth = widths[focusedIndex]; // width of all items in [start,end] + inter-item SEPs
78+
79+
const totalWidth = (s: number, e: number, itemsW: number): number =>
80+
itemsW + (s > 0 ? EL_LEFT : 0) + (e < n - 1 ? EL_RIGHT : 0);
81+
82+
for (;;) {
83+
let expanded = false;
84+
// Try expanding right.
85+
if (end + 1 < n) {
86+
const addCost = SEP.length + widths[end + 1];
87+
if (totalWidth(start, end + 1, usedWidth + addCost) <= maxWidth) {
88+
usedWidth += addCost;
89+
end++;
90+
expanded = true;
3991
}
40-
break;
4192
}
93+
// Try expanding left.
94+
if (start > 0) {
95+
const addCost = widths[start - 1] + SEP.length;
96+
if (totalWidth(start - 1, end, usedWidth + addCost) <= maxWidth) {
97+
usedWidth += addCost;
98+
start--;
99+
expanded = true;
100+
}
101+
}
102+
if (!expanded) break;
103+
}
104+
105+
// ── Build the bar string ──────────────────────────────────────────────────
106+
// Guard: only emit overflow ellipsis when the items + both ellipses actually
107+
// fit. In the edge case where the focused item fills maxWidth (leaving no
108+
// room for a 3-char ellipsis), omit the indicator rather than overflowing.
109+
const needed = totalWidth(start, end, usedWidth);
110+
const addLeftEl = start > 0 && needed <= maxWidth;
111+
const addRightEl = end < n - 1 && needed <= maxWidth;
42112

43-
const ansi = i === focusedIndex ? pc.bold(pc.magenta(visibleText)) : pc.dim(visibleText);
44-
result.push(sep + ansi);
45-
visibleWidth += cost;
113+
const parts: string[] = [];
114+
if (addLeftEl) parts.push(pc.dim("…"));
115+
for (let i = start; i <= end; i++) {
116+
if (i > start || addLeftEl) parts.push(SEP);
117+
parts.push(i === focusedIndex ? pc.bold(pc.magenta(texts[i])) : pc.dim(texts[i]));
46118
}
119+
if (addRightEl) parts.push(SEP + pc.dim("…"));
47120

48-
return result.join("");
121+
return parts.join("");
49122
}

0 commit comments

Comments
 (0)