diff --git a/demo/demo.gif b/demo/demo.gif index 42a5156..d35e6e3 100644 Binary files a/demo/demo.gif and b/demo/demo.gif differ diff --git a/demo/demo.tape b/demo/demo.tape index cbef9c0..bf425bc 100644 --- a/demo/demo.tape +++ b/demo/demo.tape @@ -16,7 +16,6 @@ Set Shell "bash" Set FontSize 15 Set Width 1200 Set Height 600 -Set Theme "Catppuccin Mocha" Set Padding 24 Set TypingSpeed 60ms diff --git a/demo/demo.webm b/demo/demo.webm new file mode 100644 index 0000000..9562c6c Binary files /dev/null and b/demo/demo.webm differ diff --git a/src/render.test.ts b/src/render.test.ts index 8fc2e67..efd15bb 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -11,6 +11,7 @@ import { buildSummaryFull, highlightFragment, isCursorVisible, + normalizeScrollOffset, renderGroups, renderHelpOverlay, rowTerminalLines, @@ -1057,7 +1058,9 @@ describe("renderGroups filter opts", () => { it("status bar hint line includes all navigation hint shortcuts", () => { const groups = [makeGroup("org/repo", ["a.ts"])]; const rows = buildRows(groups); - const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", {}); + // Use a wide terminal so hints are not clipped — this test validates content, + // not the clipping behaviour (which is covered by the "hints clipping" test). + const out = renderGroups(groups, 0, rows, 40, 0, "q", "org", { termWidth: 200 }); const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); expect(stripped).toContain("Z fold-all"); expect(stripped).toContain("o open"); @@ -1543,4 +1546,438 @@ describe("renderGroups — position indicator", () => { const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); expect(stripped).toContain("↕ row 1 of 15"); }); + + it("rendered output never exceeds termHeight lines when termWidth is narrow (hints clipping)", () => { + // Regression guard for issue #105 (root cause: hints line wraps on narrow terminals). + // The hints text is ~158 visible chars. On an 80-col terminal it wraps to 2 rows, making + // the rendered output termHeight+1 lines long → terminal scrolls → title disappears from top. + // Fix: hints are clipped to termWidth so the line always occupies exactly 1 terminal row. + const termWidth = 80; // narrower than the full hints text (~158 chars) + const groups = [makeGroup("org/repoA", ["a.ts", "b.ts", "c.ts"], false)]; + const rows = buildRows(groups); + const out = renderGroups(groups, 0, rows, 20, 0, "q", "org", { termWidth }); + // The hints line must not exceed termWidth visible chars — if it does it wraps + // and the terminal displays one extra line, pushing the title off the top. + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + const hintsLine = stripped.split("\n").find((l) => l.startsWith("← / →")); + expect(hintsLine).toBeDefined(); + expect(hintsLine!.length).toBeLessThanOrEqual(termWidth); + // Title must still appear as the very first content + expect(stripped.startsWith(" github-code-search ")).toBe(true); + }); + + it("section label never wraps — long label is clipped to termWidth (regression #105)", () => { + // When a team/section name is longer than termWidth, the rendered "── label " line + // would wrap to 2+ physical lines. usedLines += 2 only accounts for 1 physical label + // line → the viewport overflows by 1 → title scrolls off. Fix: clip label to termWidth-4. + const termWidth = 60; + const longLabel = "squad-architecture-and-platform-with-extra-words-that-exceed-width"; + const groups = [ + { + ...makeGroup("org/repoA", ["a.ts"], true), + sectionLabel: longLabel, + }, + ]; + const rows = buildRows(groups); + const out = renderGroups(groups, 0, rows, 20, 0, "q", "org", { termWidth }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + // Find the section line (contains "──") + const sectionLine = stripped.split("\n").find((l) => l.startsWith("── ")); + expect(sectionLine).toBeDefined(); + // "── " (3) + label + " " (1) must fit in termWidth + expect(sectionLine!.length).toBeLessThanOrEqual(termWidth); + // Title must still appear at the top + expect(stripped.startsWith(" github-code-search ")).toBe(true); + }); + + it("fragment lines never wrap — clipped to termWidth minus indent (regression #105)", () => { + // Fragment lines truncated to MAX_LINE_CHARS=120 + 6 chars indent = 126 visible chars. + // On a terminal < 127 cols they wrap, causing the render budget to undercount physical + // lines, which makes the output exceed termHeight and the title scrolls off. + // Fix: fragmentMaxChars = termWidth - 6 so rendered lines are always ≤ termWidth chars. + const termWidth = 80; + const longFragment = "x".repeat(200); // single very long code line (no newlines) + const groups: import("./types.ts").RepoGroup[] = [ + { + repoFullName: "org/repo", + matches: [ + { + path: "src/file.ts", + repoFullName: "org/repo", + htmlUrl: "https://github.com/org/repo/blob/main/src/file.ts", + archived: false, + textMatches: [{ fragment: longFragment, matches: [] }], + }, + ], + folded: false, + repoSelected: true, + extractSelected: [true], + }, + ]; + const rows = buildRows(groups); + const out = renderGroups(groups, 0, rows, 20, 0, "q", "org", { termWidth }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + // Find the fragment line (indented with 6 spaces before code) + const fragLine = stripped.split("\n").find((l) => l.startsWith(" ") && l.includes("x")); + expect(fragLine).toBeDefined(); + // Visible width must not exceed termWidth (clip ensures no wrap) + expect(fragLine!.length).toBeLessThanOrEqual(termWidth); + }); + + it("title line never wraps — long query+org clipped to termWidth (regression #105)", () => { + // " github-code-search " prefix is 22 visible chars. + // A very long query or org can push the title past termWidth → wraps → title gone. + const termWidth = 40; + const groups = [makeGroup("org/repoA", ["a.ts"], true)]; + const rows = buildRows(groups); + const out = renderGroups( + groups, + 0, + rows, + 20, + 0, + "this-is-a-very-long-query-string-that-exceeds-width", + "my-very-long-org-name", + { termWidth }, + ); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + const titleLine = stripped.split("\n")[0]; + expect(titleLine.length).toBeLessThanOrEqual(termWidth); + expect(stripped.startsWith(" github-code-search ")).toBe(true); + }); + + it("summary line never wraps — clipped to termWidth (regression #105)", () => { + // buildSummaryFull can produce long strings when selected counts are shown. + const termWidth = 40; + // Create many repos/files/matches with partial selection to force the long form. + const groups = Array.from({ length: 50 }, (_, i) => + makeGroup( + `org/repo${i}`, + Array.from({ length: 20 }, (__, j) => `file${j}.ts`), + ), + ); + // Deselect one to force the "(X selected)" annotation + groups[0].repoSelected = false; + groups[0].extractSelected[0] = false; + const rows = buildRows(groups); + const out = renderGroups(groups, 0, rows, 20, 0, "q", "org", { termWidth }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + const lines = stripped.split("\n"); + // Summary is the second line (index 1) + expect(lines[1].length).toBeLessThanOrEqual(termWidth); + }); + + it("repo line never wraps — long repoFullName clipped to termWidth (regression #105)", () => { + // A very long repo name with right-aligned match count should never exceed termWidth. + const termWidth = 40; + const groups = [ + makeGroup("org/a-very-long-repository-name-that-exceeds-terminal-width", ["a.ts"], true), + ]; + const rows = buildRows(groups); + const out = renderGroups(groups, 0, rows, 20, 0, "q", "org", { termWidth }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + // Find the repo line — may start with ▌ (active bar) + ▾/▸, or directly ▾/▸ when inactive + const repoLine = stripped.split("\n").find((l) => l.includes("▾") || l.includes("▸")); + expect(repoLine).toBeDefined(); + expect(repoLine!.length).toBeLessThanOrEqual(termWidth); + }); + + it("extract path line never wraps — long file path clipped to termWidth (regression #105)", () => { + // A very long file path in an extract row should be clipped. + const termWidth = 40; + const groups: import("./types.ts").RepoGroup[] = [ + { + repoFullName: "org/repo", + matches: [ + { + path: "src/this/is/a/very/deeply/nested/path/that/exceeds/terminal/width.ts", + repoFullName: "org/repo", + htmlUrl: "https://github.com/org/repo/blob/main/deeply/nested.ts", + archived: false, + textMatches: [ + { fragment: "code", matches: [{ text: "code", indices: [0, 4], line: 42, col: 1 }] }, + ], + }, + ], + folded: false, + repoSelected: true, + extractSelected: [true], + }, + ]; + const rows = buildRows(groups); + const out = renderGroups(groups, 0, rows, 20, 0, "q", "org", { termWidth }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + // Extract path line: inactive form = " ✓ " (6 visible chars) + path + const extractLine = stripped.split("\n").find((l) => l.match(/^ [✓ ]/)); + expect(extractLine).toBeDefined(); + expect(extractLine!.length).toBeLessThanOrEqual(termWidth); + }); + + it("extract path line (cursor=active) never wraps — correct active prefix width (regression #106)", () => { + // Active prefix: ACTIVE_BAR_WIDTH (1) + " " (2) + checkbox (1) + space (1) = 5 visible chars. + // Previous code subtracted PATH_INDENT twice for inactive rows which over-clipped. + // This test verifies the active (cursor=1) form uses the correct prefix width. + const termWidth = 40; + const groups: import("./types.ts").RepoGroup[] = [ + { + repoFullName: "org/repo", + matches: [ + { + path: "src/this/is/a/very/deeply/nested/path/that/exceeds/terminal/width.ts", + repoFullName: "org/repo", + htmlUrl: "https://github.com/org/repo/blob/main/deeply/nested.ts", + archived: false, + textMatches: [ + { fragment: "code", matches: [{ text: "code", indices: [0, 4], line: 42, col: 1 }] }, + ], + }, + ], + folded: false, + repoSelected: true, + extractSelected: [true], + }, + ]; + const rows = buildRows(groups); + // cursor=1 → extract row is the cursor (active) form + const out = renderGroups(groups, 1, rows, 20, 0, "q", "org", { termWidth }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + // Active extract line starts with "▌ ✓ " (bar + 2-space indent + checkbox + space) + const extractLine = stripped.split("\n").find((l) => l.match(/^▌\s+[✓ ]/)); + expect(extractLine).toBeDefined(); + expect(extractLine!.length).toBeLessThanOrEqual(termWidth); + }); + + it("fragment lines never wrap on very narrow terminal — Math.max(1,…) not Math.max(20,…) (regression #106)", () => { + // fragmentMaxChars = Math.max(1, termWidth - FRAGMENT_INDENT - 1). + // With the old Math.max(20, …) floor, on a very narrow terminal (termWidth ≤ 26) + // fragmentMaxChars could be clamped to 20. Combined with FRAGMENT_INDENT=6, the rendered + // fragment line (6+20 = 26 chars) would exceed termWidth and wrap. + // Fix: use Math.max(1, …) so the fragment never exceeds termWidth. + const termWidth = 15; // narrower than FRAGMENT_INDENT(6) + old floor(20) = 26 + const longFragment = "x".repeat(200); + const groups: import("./types.ts").RepoGroup[] = [ + { + repoFullName: "org/repo", + matches: [ + { + path: "f.ts", + repoFullName: "org/repo", + htmlUrl: "https://github.com/org/repo/blob/main/f.ts", + archived: false, + textMatches: [{ fragment: longFragment, matches: [] }], + }, + ], + folded: false, + repoSelected: true, + extractSelected: [true], + }, + ]; + const rows = buildRows(groups); + const out = renderGroups(groups, 0, rows, 20, 0, "q", "org", { termWidth }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + const fragLine = stripped.split("\n").find((l) => l.startsWith(" ") && l.includes("x")); + expect(fragLine).toBeDefined(); + expect(fragLine!.length).toBeLessThanOrEqual(termWidth); + }); + + it("sticky repo line never wraps — long repoFullName clipped to termWidth (regression #105)", () => { + // When a repo header has scrolled past the viewport, the sticky line must still fit. + const termWidth = 40; + const longRepoName = "org/a-very-long-repository-name-that-exceeds-terminal-width"; + const groups: import("./types.ts").RepoGroup[] = [ + { + repoFullName: longRepoName, + matches: [ + { + path: "a.ts", + repoFullName: longRepoName, + htmlUrl: "https://github.com/org/repo/blob/main/a.ts", + archived: false, + textMatches: [{ fragment: "x", matches: [] }], + }, + { + path: "b.ts", + repoFullName: longRepoName, + htmlUrl: "https://github.com/org/repo/blob/main/b.ts", + archived: false, + textMatches: [{ fragment: "x", matches: [] }], + }, + ], + folded: false, + repoSelected: true, + extractSelected: [true, true], + }, + ]; + const rows = buildRows(groups); + // cursor points to second extract (index 2), scrollOffset=1 (repo row scrolled off) + const out = renderGroups(groups, 2, rows, 20, 1, "q", "org", { termWidth }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + // Sticky line starts with "▲" + const stickyLine = stripped.split("\n").find((l) => l.startsWith("▲")); + expect(stickyLine).toBeDefined(); + expect(stickyLine!.length).toBeLessThanOrEqual(termWidth); + }); + + it("section after folded-repo cursor never overflows viewport (regression #105)", () => { + // Root cause: for `section` rows, the render loop was pushing the line to output + // BEFORE checking whether the 2-line section budget fit in the remaining viewport. + // Contrast with repo/extract rows which check first. + // Symptom: cursor on a folded repo (1 line), next row is a section (2 lines). + // If only 1 line remained in the viewport, the section was still rendered → output + // exceeded termHeight by 1 → title scrolled off the top. + // This is exactly the "folded: title gone, unfolded: title back" behaviour reported. + // + // Setup: termHeight=14 → viewportHeight=8. + // 6 folded repo rows fill usedLines=6 before cursor. + // Cursor on folded repo: usedLines=6+1=7, 7 < 8 → loop continues. + // Section row next: usedLines+2=9 > 8 → must break WITHOUT rendering (with fix). + // Without the guard: section is pushed to lines[] first, usedLines=9, then break + // → output has 9 viewport lines instead of max 8 → total 15 > termHeight=14 → title scrolls off. + const termHeight = 14; // viewportHeight = termHeight - 6 = 8 + // 6 folded repos to fill usedLines=6 before the cursor repo + const prefixGroups = Array.from({ length: 6 }, (_, i) => + makeGroup(`org/repo${i}`, ["f.ts"], true), + ); + // cursor repo: folded (1 line). The NEXT group has a sectionLabel so buildRows emits + // a section row before it. + const cursorGroup = makeGroup("org/cursor-repo", ["f.ts"], true); + const nextGroup = { + ...makeGroup("org/next-repo", ["f.ts"], true), + sectionLabel: "squad-portal", + }; + const allGroups = [...prefixGroups, cursorGroup, nextGroup]; + const rows = buildRows(allGroups); + // cursor=6 (the 7th row, 0-indexed: rows 0..5 are the 6 prefix repos, row 6 = cursorRepo) + const cursorIndex = 6; + const out = renderGroups(allGroups, cursorIndex, rows, termHeight, 0, "q", "org", { + termWidth: 80, + }); + const outputLines = out.split("\n"); + // The rendered output must never exceed termHeight physical lines. + expect(outputLines.length).toBeLessThanOrEqual(termHeight); + // Title must still be the first line. + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(stripped.startsWith(" github-code-search ")).toBe(true); + // Footer must be anchored to the last line. + const strippedLines = stripped.split("\n"); + expect(strippedLines[strippedLines.length - 1].trim()).toMatch(/^↕ row \d+ of \d+$/); + }); + + it("section as first viewport row costs 1 line — footer does not disappear (regression #105)", () => { + // Root cause: section rows embedded a leading \\n in their string. + // When the section is the FIRST row rendered in the viewport (usedLines===0), + // the hints header already ends with \\n, and lines.join("\\n") adds a second \\n + // before the section's own \\n prefix → 2 blank lines (3 physical lines for a + // "2-line" element) → 1 extra line over budget → footer "↕ row X of Y" pushed + // below the bottom of the terminal and lost. + // + // Fix: blank separator emitted only when usedLines > 0 (not embedded in the string). + // sectionCost = usedLines === 0 ? 1 : 2. + // + // Setup: termHeight=8 → viewportHeight=2. + // scrollOffset=1 → rows[1] is a section row → first viewport row is a section. + // Old code: section cost = 2 → fills viewport; repo behind section is skipped. + // But worse: embed \\n → actual physical lines = 3 → total output = 9 > 8 → title gone. + // New code: section cost = 1 → 1 line remaining → next repo row fits → footer stays. + // In both cases the total output lines must not exceed termHeight. + const termHeight = 8; // viewportHeight = 8 - 6 = 2 + const group0 = makeGroup("org/repo0", ["a.ts"], true); + const group1 = { + ...makeGroup("org/repo1", ["b.ts"], true), + sectionLabel: "squad-portal", + }; + const groups = [group0, group1]; + const rows = buildRows(groups); + // rows[0]=repo0, rows[1]=section for squad-portal, rows[2]=repo1 + // scrollOffset=1 → section is first in viewport + const cursorIndex = 2; // cursor on repo1 + const out = renderGroups(groups, cursorIndex, rows, termHeight, 1, "q", "org", { + termWidth: 80, + }); + const outputLines = out.split("\n"); + // The rendered output must never exceed termHeight physical lines. + expect(outputLines.length).toBeLessThanOrEqual(termHeight); + // Title must still be the first line. + const strippedOut = out.replace(/\x1b\[[0-9;]*m/g, ""); + expect(strippedOut.startsWith(" github-code-search ")).toBe(true); + // Footer position indicator (↕) must appear on the very last line so that + // it is anchored to the bottom of the terminal (padding test). + const strippedLines = strippedOut.split("\n"); + expect(strippedLines[strippedLines.length - 1].trim()).toMatch(/^↕ row \d+ of \d+$/); + }); + + it("footer is fixed at the last line even when viewport content is sparse (regression #105)", () => { + // Root cause: the footer was appended immediately after the last rendered item. + // When the viewport was not full (few results, everything folded, or cursor near + // the end), the footer floated up instead of staying at the bottom. + // Fix: push (viewportHeight - usedLines) blank lines before the footer so that + // the total rendered line count is always exactly termHeight. + // + // Setup: 2 folded repos → 2 viewport lines used. + // termHeight=10 → viewportHeight=4 → 2 unused lines above footer. + // With fix: output = 10 lines, footer on line 10. + // Without fix: output = 8 lines, footer on line 8 (floats 2 lines above bottom). + const termHeight = 10; + const groups = [makeGroup("org/repo0", ["a.ts"], true), makeGroup("org/repo1", ["b.ts"], true)]; + const rows = buildRows(groups); + const out = renderGroups(groups, 0, rows, termHeight, 0, "q", "org", { termWidth: 80 }); + const stripped = out.replace(/\x1b\[[0-9;]*m/g, ""); + const lines = stripped.split("\n"); + // Output must be exactly termHeight lines (not fewer due to missing padding). + expect(lines).toHaveLength(termHeight); + // Footer must be the last line. + expect(lines[lines.length - 1].trim()).toMatch(/^↕ row \d+ of \d+$/); + // Second-to-last must be blank (the \n separator before the indicator). + expect(lines[lines.length - 2].trim()).toBe(""); + }); +}); + +// ─── normalizeScrollOffset ──────────────────────────────────────────────────── + +describe("normalizeScrollOffset", () => { + it("returns 0 when scrollOffset is already 0", () => { + const groups = [makeGroup("org/repo0", ["a.ts"], true)]; + const rows = buildRows(groups); + expect(normalizeScrollOffset(0, rows, groups, 10)).toBe(0); + }); + + it("does not decrease scrollOffset when viewport is already full", () => { + // 5 folded repos, scrollOffset=2, viewportHeight=3 → rows 2..4 fill exactly 3 lines. + // Prepending row 1 would need 4 lines → scrollOffset stays at 2. + const groups = Array.from({ length: 5 }, (_, i) => makeGroup(`org/repo${i}`, ["f.ts"], true)); + const rows = buildRows(groups); + expect(normalizeScrollOffset(2, rows, groups, 3)).toBe(2); + }); + + it("decreases scrollOffset to fill empty space at the bottom", () => { + // 5 folded repos. scrollOffset=4 → only row 4 (1 line) visible in a viewport of 3. + // Prepending row 3: 2 lines ≤ 3 → scrollOffset decreases to 3. + // Prepending row 2: 3 lines ≤ 3 → scrollOffset decreases to 2. + // Prepending row 1: 4 lines > 3 → stop. + const groups = Array.from({ length: 5 }, (_, i) => makeGroup(`org/repo${i}`, ["f.ts"], true)); + const rows = buildRows(groups); + expect(normalizeScrollOffset(4, rows, groups, 3)).toBe(2); + }); + + it("normalizes all the way to 0 when all rows fit", () => { + // 2 folded repos (2 lines total) in a viewport of 5 → scrollOffset pulled back to 0. + const groups = [makeGroup("org/repo0", ["a.ts"], true), makeGroup("org/repo1", ["b.ts"], true)]; + const rows = buildRows(groups); + expect(normalizeScrollOffset(2, rows, groups, 5)).toBe(0); + }); + + it("accounts for section cost when section is first in candidate viewport", () => { + // rows: [repo0(1), section(1 as first), repo1(1)] → total 3 lines when starting at 0. + // viewportHeight=3, scrollOffset=1 (section is first) → cost=1 for section → total=2 ≤ 3. + // But adding row 0 (repo0): it is no longer first, section becomes 2nd → section costs 2 → total=4 > 3. + // Wait: from index 0: repo0=1, then section (used=1, not 0) = 2 lines, repo1=1 → total=4 > 3. + // So scrollOffset should stay at 1. + const groups = [ + makeGroup("org/repo0", ["a.ts"], true), + { ...makeGroup("org/repo1", ["b.ts"], true), sectionLabel: "squad-portal" }, + ]; + const rows = buildRows(groups); + // rows: [repo0, section:squad-portal, repo1] + expect(normalizeScrollOffset(1, rows, groups, 3)).toBe(1); + }); }); diff --git a/src/render.ts b/src/render.ts index 220e4c1..e0520c9 100644 --- a/src/render.ts +++ b/src/render.ts @@ -11,7 +11,12 @@ import { applySelectAll, applySelectNone } from "./render/selection.ts"; export { highlightFragment } from "./render/highlight.ts"; export { buildFilterStats, type FilterStats } from "./render/filter.ts"; -export { rowTerminalLines, buildRows, isCursorVisible } from "./render/rows.ts"; +export { + rowTerminalLines, + buildRows, + isCursorVisible, + normalizeScrollOffset, +} from "./render/rows.ts"; export { buildMatchCountLabel, buildSummary, @@ -195,6 +200,52 @@ function stripAnsi(str: string): string { return str.replace(/\x1b\[[\d;]*[mGKHF]/g, ""); } +/** + * Clip a string (which may contain ANSI escape sequences) to at most + * `maxVisible` visible characters. Correctly skips over escape sequences + * when counting, and iterates by Unicode code point (not UTF-16 code unit) + * so clipping never splits a surrogate pair (e.g. emoji like 🔍). + * + * At the cut point a partial SGR reset (`\x1b[22;39m`) is appended to + * clear bold and foreground colour while deliberately **leaving background + * colour intact** (no `\x1b[49m`). A full `\x1b[0m` reset would undo any + * background applied by the caller (e.g. renderActiveLine's dark-purple + * highlight), causing the remainder of the active row to lose its colour + * on narrow terminals — see issue #105. + * + * If the string already fits within `maxVisible` visible chars it is + * returned unchanged. + */ +function clipAnsi(str: string, maxVisible: number): string { + if (maxVisible <= 0) return ""; + if (stripAnsi(str).length <= maxVisible) return str; + + let visCount = 0; + let i = 0; + while (i < str.length) { + // ANSI escape sequence: \x1b[ … + if (str[i] === "\x1b" && i + 1 < str.length && str[i + 1] === "[") { + // Skip to the terminating letter (one of m G K H F) + let j = i + 2; + while (j < str.length && !/[mGKHF]/.test(str[j])) j++; + i = j + 1; // skip the terminating letter too + continue; + } + // Advance by code-point width (2 UTF-16 units for non-BMP chars like emoji) + // so we never split a surrogate pair at a cut boundary. + const cp = str.codePointAt(i)!; + const cpLen = cp > 0xffff ? 2 : 1; + visCount++; + if (visCount === maxVisible) { + // Cut after this visible char. Use a partial SGR reset (bold + fg only) + // so that the caller's background colour is preserved. + return str.slice(0, i + cpLen) + "\x1b[22;39m"; + } + i += cpLen; + } + return str; +} + /** * Returns a text-highlight function compiled once per renderGroups call. * The returned function applies bold-yellow highlighting to every occurrence of @@ -285,9 +336,12 @@ export function renderGroups( const lines: string[] = []; lines.push( - `${pc.bgMagenta(pc.bold(" github-code-search "))} ${pc.bold(pc.cyan(query))} ${pc.dim("in")} ${pc.bold(pc.yellow(org))}`, + clipAnsi( + `${pc.bgMagenta(pc.black(pc.bold(" github-code-search ")))} ${pc.bold(pc.cyan(query))} ${pc.dim("in")} ${pc.bold(pc.yellow(org))}`, + termWidth, + ), ); - lines.push(buildSummaryFull(groups)); + lines.push(clipAnsi(buildSummaryFull(groups), termWidth)); // Active filter text used for in-row highlighting (filterInput while typing, filterPath once confirmed) const activeFilter = filterMode ? filterInput : filterPath; @@ -373,19 +427,28 @@ export function renderGroups( stats.hiddenMatches } hidden in ${stats.hiddenRepos} repo${stats.hiddenRepos !== 1 ? "s" : ""} r to reset`, ); - lines.push(`🔍${targetBadge}${pc.bold("filter:")} ${pc.yellow(filterPath)} ${statsStr}`); + // Fix: clip so the filter status line never wraps — see issue #105. + lines.push( + clipAnsi( + `🔍${targetBadge}${pc.bold("filter:")} ${pc.yellow(filterPath)} ${statsStr}`, + termWidth, + ), + ); filterBarLines = 1; } else if (filterTarget !== "path" || filterRegex) { // No active filter text, but non-default mode selected — remind the user. - lines.push(`🔍${targetBadge}${pc.dim("f to filter")}`); + lines.push(clipAnsi(`🔍${targetBadge}${pc.dim("f to filter")}`, termWidth)); filterBarLines = 1; } - lines.push( - pc.dim( - "← / → 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 h help ↵ confirm q quit\n", - ), - ); + // Fix: clip hints to termWidth visible chars so the line never wraps and always + // occupies exactly 1 terminal row. Without clipping the ~158-char hint string + // wraps on typical terminals (< 160 cols), making the rendered output exceed + // termHeight by 1 line and causing the title to scroll off the top — see issue #105. + 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 h help ↵ confirm q quit"; + const clippedHints = HINTS_TEXT.length > termWidth ? HINTS_TEXT.slice(0, termWidth) : HINTS_TEXT; + lines.push(pc.dim(`${clippedHints}\n`)); // ── Sticky current-repo ─────────────────────────────────────────────────── // When the cursor is on an extract row whose repo header has scrolled above @@ -405,8 +468,10 @@ export function renderGroups( if (repoRowIndex >= 0 && repoRowIndex < scrollOffset) { const g = groups[cursorRow.repoIndex]; const checkbox = g.repoSelected ? pc.green("✓") : " "; - stickyRepoLine = pc.dim( - `▲ ${checkbox} ${pc.bold(g.repoFullName)} ${pc.dim(buildMatchCountLabel(g))}`, + // Fix: clip to termWidth so the sticky line never wraps — see issue #105. + stickyRepoLine = clipAnsi( + pc.dim(`▲ ${checkbox} ${pc.bold(g.repoFullName)} ${pc.dim(buildMatchCountLabel(g))}`), + termWidth, ); lines.push(stickyRepoLine); } @@ -414,6 +479,16 @@ export function renderGroups( const viewportHeight = termHeight - HEADER_LINES - filterBarLines - 2 - (stickyRepoLine !== null ? 1 : 0); + // Fix: clip fragment lines to termWidth minus the 3-level indent (INDENT*3 = 6 chars) + // so that no fragment line wraps in the terminal. Without clipping, MAX_LINE_CHARS=120 + // produces lines up to 126 visible chars with indent, which wraps on typical terminals + // (≤120 cols) and causes the rendered output to exceed termHeight — see issue #105. + // The -1 accounts for the "…" appended by highlightFragment when a line is truncated. + const FRAGMENT_INDENT = INDENT.length * 3; // 6 chars: " " + // Use Math.max(1, …) rather than Math.max(20, …) so that on very narrow terminals + // (termWidth < FRAGMENT_INDENT + 1 + 20) the clamped floor of 20 can't exceed the + // available width and still cause lines wider than termWidth — see review on #106. + const fragmentMaxChars = Math.max(1, termWidth - FRAGMENT_INDENT - 1); let usedLines = 0; for (let i = scrollOffset; i < rows.length; i++) { @@ -421,8 +496,46 @@ export function renderGroups( // ── Section header row ──────────────────────────────────────────────── if (row.type === "section") { - lines.push(pc.magenta(pc.bold(`\n── ${row.sectionLabel} `))); - usedLines += 2; // blank separator line + label line + // A section occupies 2 physical lines (blank separator + label) when it + // follows other viewport content, but only 1 line (label only) when it + // is the very first row rendered. + // + // The reason: the hints header line ends with a trailing \n. When joined + // with `lines.join("\n")`, this already produces 1 blank line before the + // first viewport element. If the section also prepends \n (embedded in + // the string), we get a *double* blank — 1 extra physical line — which + // pushes the footer position indicator off the bottom of the terminal. + // See issue #105. + const sectionCost = usedLines === 0 ? 1 : 2; + if (sectionCost + usedLines > viewportHeight && usedLines > 0) break; + // Fix: clip section label to termWidth so the label line never wraps. + // "── " prefix is 3 visible chars + 1 trailing space = 4 chars total. + const SECTION_FIXED = 4; // "── " (3) + trailing " " (1) + // Use Math.max(0, …) — not Math.max(4, …) — so that on very narrow + // terminals the label budget never exceeds the actual available width + // and forces a line wider than termWidth. When the budget is 0 or 1 + // we skip rendering the section entirely to avoid wrapping. + const maxLabelChars = Math.max(0, termWidth - SECTION_FIXED); + if (maxLabelChars === 0) { + // Terminal too narrow to render even a minimal label. Push blank + // placeholder lines so that lines[] stays in sync with usedLines + // and the footer-padding arithmetic remains correct — see review #106. + if (usedLines > 0) lines.push(""); // blank separator when not first + lines.push(""); // empty label placeholder + usedLines += sectionCost; + if (usedLines >= viewportHeight) break; + continue; + } + const label = + row.sectionLabel.length > maxLabelChars + ? row.sectionLabel.slice(0, maxLabelChars - 1) + "…" + : row.sectionLabel; + // Emit the blank separator only when there are rows above in the viewport. + // The leading \n is no longer embedded in the label string — it is managed + // here to keep the line-cost calculation exact. + if (usedLines > 0) lines.push(""); + lines.push(pc.magenta(pc.bold(`── ${label} `))); + usedLines += sectionCost; if (usedLines >= viewportHeight) break; continue; } @@ -451,10 +564,19 @@ export function renderGroups( // Right-align the match count flush to the terminal edge. // When active, subtract ACTIVE_BAR_WIDTH from padding so that // bar (1 char) + line content = termWidth total. - const leftPart = `${arrow} ${checkbox} ${repoName}`; - const leftLen = stripAnsi(leftPart).length; + const leftPartRaw = `${arrow} ${checkbox} ${repoName}`; const countLen = stripAnsi(count).length; const barAdjust = isCursor ? ACTIVE_BAR_WIDTH : 0; + // Use Math.max(1, …) so that on very narrow terminals the floor of 1 + // never exceeds the available width (unlike Math.max(4, …) which can + // produce a maxLeftVisible wider than the actual space and reintroduce + // wrapping — see review on #106). + const maxLeftVisible = Math.max(1, termWidth - countLen - barAdjust); + const leftPart = + stripAnsi(leftPartRaw).length > maxLeftVisible + ? clipAnsi(leftPartRaw, maxLeftVisible) + : leftPartRaw; + const leftLen = stripAnsi(leftPart).length; const pad = Math.max(0, termWidth - leftLen - countLen - barAdjust); const lineContent = pad > 0 ? `${leftPart}${" ".repeat(pad)}${count}` : `${leftPart}${count}`; lines.push(isCursor ? renderActiveLine(lineContent) : lineContent); @@ -468,9 +590,27 @@ export function renderGroups( // Active extract row: locSuffix uses bold+white (same as path) for // visual homogeneity. Inactive: dim to de-emphasise the coordinates. const styledLocSuffix = isCursor ? pc.bold(pc.white(locSuffix)) : pc.dim(locSuffix); - const filePath = isCursor + // Fix: clip the path to fit within termWidth — use the *actual* visible prefix + // width for each render form rather than a shared PATH_INDENT constant. + // Active: ACTIVE_BAR_WIDTH (1) + " " (2) + checkbox (1) + space (1) = 5 + // Inactive: " " (2) + " " (2) + checkbox (1) + space (1) = 6 + // See issue #105 and review on #106 (previous code subtracted PATH_INDENT twice + // for inactive rows, over-clipping by 4 chars). + const prefixWidth = isCursor + ? ACTIVE_BAR_WIDTH + INDENT.length + 1 + 1 // bar + " " + checkbox + space = 5 + : INDENT.length * 2 + 1 + 1; // " " + " " + checkbox + space = 6 + // Use Math.max(1, …) so that on very narrow terminals the floor of 1 + // never exceeds the available width (unlike Math.max(10, …) which can + // produce a maxPathVisible wider than termWidth - prefixWidth, + // reintroducing line wrapping — see review on #106). + const maxPathVisible = Math.max(1, termWidth - prefixWidth - locSuffix.length); + const rawPath = isCursor ? `${highlightText(match.path, "path", (s) => pc.bold(pc.white(s)))}${styledLocSuffix}` : `${highlightText(match.path, "path", pc.cyan)}${styledLocSuffix}`; + const filePath = + stripAnsi(rawPath).length > maxPathVisible + locSuffix.length + ? clipAnsi(rawPath, maxPathVisible + locSuffix.length) + : rawPath; const extractLineContent = `${INDENT}${checkbox} ${filePath}`; lines.push( isCursor @@ -489,6 +629,7 @@ export function renderGroups( tm.fragment, mergeSegments([...tm.matches, ...extraSegs]), match.path, + fragmentMaxChars, ); for (const fl of fragmentLines) { lines.push(`${INDENT}${INDENT}${INDENT}${fl}`); @@ -500,8 +641,21 @@ export function renderGroups( if (usedLines >= viewportHeight) break; } + // Pad the unused viewport space so the position indicator is always fixed at + // the bottom of the terminal. Without padding, when the rendered content is + // shorter than viewportHeight (e.g. few results, many repos folded, or cursor + // near the bottom of the list), the footer floats immediately after the last + // item instead of staying at the bottom — see issue #105. + // Each pushed "" contributes exactly 1 physical blank line in lines.join("\n"). + for (let i = usedLines; i < viewportHeight; i++) { + lines.push(""); + } + // Position indicator — uses cursor position so it always updates on every // navigation keystroke, regardless of whether scrollOffset changed. + // The leading \n produces a blank separator between the viewport content and + // the indicator (the separator is the last padding blank when full, or an + // explicit extra blank here when viewport is full). if (rows.length > 0) { lines.push(pc.dim(`\n ↕ row ${cursor + 1} of ${rows.length}`)); } diff --git a/src/render/highlight.ts b/src/render/highlight.ts index 7c78e7a..873a08e 100644 --- a/src/render/highlight.ts +++ b/src/render/highlight.ts @@ -298,11 +298,16 @@ const MAX_LINE_CHARS = 120; * - Preserves newlines (each code line → one terminal line). * - Applies language-aware syntax coloring. * - Overlays bold-yellow highlights for matched segments. + * + * @param maxLineChars - Maximum visible characters per line before truncation. + * Pass `termWidth - indent` so that rendered lines never wrap in the terminal. + * Defaults to MAX_LINE_CHARS (120) for callers that don't know termWidth. */ export function highlightFragment( fragment: string, segments: TextMatchSegment[], filePath: string, + maxLineChars = MAX_LINE_CHARS, ): string[] { const lang = detectLang(filePath); const rawLines = fragment.split("\n"); @@ -314,7 +319,7 @@ export function highlightFragment( for (let li = 0; li < linesToShow; li++) { const line = rawLines[li]; const lineEnd = offset + line.length; - const raw = line.length > MAX_LINE_CHARS ? line.slice(0, MAX_LINE_CHARS) + "…" : line; + const raw = line.length > maxLineChars ? line.slice(0, maxLineChars) + "…" : line; // Find segments that overlap this line, adjusted to line-local offsets const localSegs = segments diff --git a/src/render/rows.ts b/src/render/rows.ts index 6ab95da..9466c98 100644 --- a/src/render/rows.ts +++ b/src/render/rows.ts @@ -94,6 +94,51 @@ export function buildRows( return rows; } +/** + * Normalises scrollOffset downward so the viewport is always packed from the + * bottom. After a fold, a filter change, or navigating near the end of the + * list, the rows visible from scrollOffset to rows.length-1 can occupy fewer + * than viewportHeight lines — leaving blank padding above the footer even + * though rows above scrollOffset could fill it. + * + * The function decreases scrollOffset as long as prepending the next row above + * still fits within viewportHeight, using the same section-cost rules as + * renderGroups (section first-in-viewport costs 1 line, otherwise 2). + * + * It is a pure function: no mutation, no I/O — see issue #105. + */ +export function normalizeScrollOffset( + scrollOffset: number, + rows: Row[], + groups: RepoGroup[], + viewportHeight: number, +): number { + while (scrollOffset > 0) { + // Count lines that rows[scrollOffset-1 .. end] would occupy. + let used = 0; + for (let i = scrollOffset - 1; i < rows.length; i++) { + const row = rows[i]; + let h: number; + if (row.type === "section") { + // Mirror renderGroups section cost: 1 when first in viewport, 2 otherwise. + h = used === 0 ? 1 : 2; + } else { + const group = row.repoIndex >= 0 ? groups[row.repoIndex] : undefined; + h = rowTerminalLines(group, row); + } + used += h; + if (used > viewportHeight) break; + } + if (used <= viewportHeight) { + // One more row above still fits — pull back to fill the empty space. + scrollOffset--; + } else { + break; + } + } + return scrollOffset; +} + /** Returns true if the cursor row is currently within the visible viewport. * * Mirrors the renderGroups break condition exactly: @@ -112,8 +157,19 @@ export function isCursorVisible( for (let i = scrollOffset; i < rows.length; i++) { if (usedLines >= viewportHeight) return false; const row = rows[i]; - const group = row.repoIndex >= 0 ? groups[row.repoIndex] : undefined; - const h = rowTerminalLines(group, row); + let h: number; + if (row.type === "section") { + // Mirror renderGroups: a section costs 1 line when first in the viewport + // (label only — no blank separator), 2 lines otherwise (blank + label). + // Using the fixed rowTerminalLines value of 2 here was off by 1 for the + // first-in-viewport case, causing isCursorVisible to report the cursor + // as hidden 1 step too early and triggering an unnecessary scrollOffset + // advance — see issue #105. + h = usedLines === 0 ? 1 : 2; + } else { + const group = row.repoIndex >= 0 ? groups[row.repoIndex] : undefined; + h = rowTerminalLines(group, row); + } if (i === cursor) { // The row is visible only if it actually fits in the remaining space // (same rule as renderGroups: first row always shows, others need room). diff --git a/src/tui.ts b/src/tui.ts index 0cd368c..f514f67 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -7,11 +7,12 @@ import { buildFilterStats, buildRows, isCursorVisible, + normalizeScrollOffset, renderGroups, type FilterStats, } from "./render.ts"; import { buildOutput } from "./output.ts"; -import type { FilterTarget, OutputFormat, OutputType, RepoGroup } from "./types.ts"; +import type { FilterTarget, OutputFormat, OutputType, RepoGroup, Row } from "./types.ts"; // ─── Key binding constants ──────────────────────────────────────────────────── @@ -123,16 +124,44 @@ export async function runInteractive( // HEADER_LINES (4) + position indicator (2) = 6 fixed lines consumed by renderGroups. // filterBarLines (0–2) and the sticky repo line (0–1) are added dynamically below. // Use getViewportHeight() for scroll decisions so they match what renderGroups actually renders. - const getViewportHeight = () => { + // + // Precompute a repoIndex→rowIndex map so that getViewportHeight() runs O(1) instead of + // O(n) per call. The map is rebuilt lazily whenever the `rows` reference changes — which + // happens at most once per keypress (in redraw) — keeping the amortised cost O(n) total + // rather than O(n²) when scrolling through large result sets. See review on #106. + let _cachedRowsForMap: Row[] | null = null; + let _repoRowIndexMap: Map = new Map(); + const getRepoRowIndexMap = (rows: Row[]): Map => { + if (rows !== _cachedRowsForMap) { + _cachedRowsForMap = rows; + _repoRowIndexMap = new Map( + rows.flatMap((r, i) => (r.type === "repo" ? [[r.repoIndex, i] as [number, number]] : [])), + ); + } + return _repoRowIndexMap; + }; + + const getViewportHeight = (rows: Row[]) => { let barLines = 0; if (filterMode) barLines = 2; else if (filterPath || filterTarget !== "path" || filterRegex) barLines = 1; - // When scrolled past the top and the cursor is within the visible window, - // renderGroups may show a sticky repo header that consumes one extra line. - // Mirror the condition precisely: sticky only appears when the cursor row is - // an extract whose repo row has scrolled above the viewport (repoRowIndex < - // scrollOffset). `cursor >= scrollOffset` is the necessary pre-condition. - const stickyHeaderLines = scrollOffset > 0 && cursor >= scrollOffset ? 1 : 0; + // Fix: mirror renderGroups exactly — sticky header only appears when the + // cursor is on an *extract* row whose repo header has scrolled above the + // viewport (repoRowIndex < scrollOffset). Using a broader condition (any + // scroll + cursor in viewport) caused getViewportHeight to return termHeight-7 + // when renderGroups used termHeight-6, making isCursorVisible return false + // one step too early and advancing scrollOffset past the first visible row. + // See issue #105. + let stickyHeaderLines = 0; + if (scrollOffset > 0) { + const cursorRow = rows[cursor]; + if (cursorRow?.type === "extract" && cursorRow.repoIndex >= 0) { + const repoRowIndex = getRepoRowIndexMap(rows).get(cursorRow.repoIndex) ?? -1; + if (repoRowIndex >= 0 && repoRowIndex < scrollOffset) { + stickyHeaderLines = 1; + } + } + } return termHeight - 6 - barLines - stickyHeaderLines; }; @@ -163,6 +192,22 @@ export async function runInteractive( const redraw = () => { const activeFilter = filterMode ? filterInput : filterPath; const rows = buildRows(groups, activeFilter, filterTarget, filterRegex); + // Normalise scrollOffset downward so the viewport is packed to the bottom. + // After a fold/unfold, filter change, or navigation near the end of the + // list, the rows visible from scrollOffset onwards can be fewer than + // viewportHeight — leaving blank space above the footer even though rows + // above scrollOffset could fill it. See issue #105. + // + // Iterate to a fixed point: getViewportHeight depends on scrollOffset (via + // the sticky-header condition). Decreasing scrollOffset can change whether + // the sticky header is shown, which changes viewportHeight by 1, so a + // single pass can stop 1 row early. Loop until both values stabilise. + for (;;) { + const vh = getViewportHeight(rows); + const next = normalizeScrollOffset(scrollOffset, rows, groups, vh); + if (next === scrollOffset) break; + scrollOffset = next; + } const rendered = renderGroups(groups, cursor, rows, termHeight, scrollOffset, query, org, { filterPath, filterMode, @@ -406,7 +451,7 @@ export async function runInteractive( cursor = next; while ( scrollOffset < cursor && - !isCursorVisible(rows, groups, cursor, scrollOffset, getViewportHeight()) + !isCursorVisible(rows, groups, cursor, scrollOffset, getViewportHeight(rows)) ) { scrollOffset++; } @@ -482,7 +527,7 @@ export async function runInteractive( while (cursor > 0 && rows[cursor]?.type === "section") cursor--; while ( scrollOffset < cursor && - !isCursorVisible(rows, groups, cursor, scrollOffset, getViewportHeight()) + !isCursorVisible(rows, groups, cursor, scrollOffset, getViewportHeight(rows)) ) { scrollOffset++; } @@ -490,7 +535,7 @@ export async function runInteractive( // Page Up / Ctrl+U — scroll up by a full page if (key === KEY_PAGE_UP || key === KEY_CTRL_U) { - const pageSize = Math.max(1, getViewportHeight()); + const pageSize = Math.max(1, getViewportHeight(rows)); cursor = Math.max(0, cursor - pageSize); while (cursor > 0 && rows[cursor]?.type === "section") cursor--; // If we've paged up to the top and the first row is a section, @@ -503,12 +548,12 @@ export async function runInteractive( // Page Down / Ctrl+D — scroll down by a full page if (key === KEY_PAGE_DOWN || key === KEY_CTRL_D) { - const pageSize = Math.max(1, getViewportHeight()); + const pageSize = Math.max(1, getViewportHeight(rows)); cursor = Math.min(rows.length - 1, cursor + pageSize); while (cursor < rows.length - 1 && rows[cursor]?.type === "section") cursor++; while ( scrollOffset < cursor && - !isCursorVisible(rows, groups, cursor, scrollOffset, getViewportHeight()) + !isCursorVisible(rows, groups, cursor, scrollOffset, getViewportHeight(rows)) ) { scrollOffset++; }