Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified demo/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 0 additions & 1 deletion demo/demo.tape
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Binary file added demo/demo.webm
Binary file not shown.
439 changes: 438 additions & 1 deletion src/render.test.ts

Large diffs are not rendered by default.

188 changes: 171 additions & 17 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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[ … <letter>
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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -405,24 +468,74 @@ 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);
}
}

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++) {
const row = rows[i];

// ── 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;
}
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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}`);
Expand All @@ -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}`));
}
Expand Down
7 changes: 6 additions & 1 deletion src/render/highlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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
Expand Down
60 changes: 58 additions & 2 deletions src/render/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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).
Expand Down
Loading