From c572ce4968aa280e208fbb1a33184f43738e832c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Thu, 29 Oct 2026 12:44:05 +0100 Subject: [PATCH 1/3] Reduce terminal flickering during result navigation Replace full-screen redraws with buffered differential line updates. Clear stale tails, clip output to the viewport and invalidate the frame on resize. Add regression coverage for navigation, ANSI styles and Unicode.\n\nFixes #251 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 2 + src/render.ts | 1 + src/render/frame.test.ts | 105 +++++++++++++++++++++++++++++++++++++++ src/render/frame.ts | 30 +++++++++++ src/tui.ts | 10 ++-- 5 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 src/render/frame.test.ts create mode 100644 src/render/frame.ts diff --git a/AGENTS.md b/AGENTS.md index 3fa85c8..b2b141a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,6 +142,8 @@ src/ # styles — no I/O render/ + frame.ts # Pure differential terminal updates — repaints changed lines, + # erases stale content and clips frames to the viewport terminal.ts # Bun 1.4+ native API wrappers (stringWidth, stripANSI, sliceAnsi) # sole authorized call site for these APIs highlight.ts # Syntax highlighting (language detection + token rules) diff --git a/src/render.ts b/src/render.ts index 5719edc..2cf0754 100644 --- a/src/render.ts +++ b/src/render.ts @@ -32,6 +32,7 @@ export { buildSelectionSummary, } from "./render/summary.ts"; export { applySelectAll, applySelectNone } from "./render/selection.ts"; +export { buildFrameUpdate } from "./render/frame.ts"; // ─── buildFileUrl ───────────────────────────────────────────────────────────── diff --git a/src/render/frame.test.ts b/src/render/frame.test.ts new file mode 100644 index 0000000..6a6513a --- /dev/null +++ b/src/render/frame.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "bun:test"; +import { buildFrameUpdate, buildRows, renderGroups } from "../render.ts"; +import type { RepoGroup } from "../types.ts"; + +describe("buildFrameUpdate", () => { + it("paints the initial frame without a full-screen erase", () => { + const update = buildFrameUpdate("header\nresult", null, 80, 24); + expect(update).toContain("\x1b[1;1H\x1b[0mheader\x1b[0m\x1b[K"); + expect(update).toContain("\x1b[2;1H\x1b[0mresult\x1b[0m\x1b[K"); + expect(update).toContain("\x1b[3;1H\x1b[J"); + expect(update).not.toContain("\x1b[2J"); + }); + + it("updates only changed lines during cursor navigation", () => { + const previous = "header\n> first\n second\nrow 1"; + const next = "header\n first\n> second\nrow 2"; + const update = buildFrameUpdate(next, previous, 80, 24); + expect(update).not.toContain("header"); + expect(update).toContain("\x1b[2;1H"); + expect(update).toContain("\x1b[3;1H"); + expect(update).toContain("\x1b[4;1H"); + expect(update).not.toContain("\x1b[2J"); + expect(update).not.toContain("\x1b[J"); + }); + + it("emits nothing for an unchanged frame", () => { + expect(buildFrameUpdate("same\nframe", "same\nframe", 80, 24)).toBe(""); + }); + + it("erases the remainder of shorter lines after writing their replacement", () => { + expect(buildFrameUpdate("short", "a longer line", 80, 24)).toBe( + "\x1b[1;1H\x1b[0mshort\x1b[0m\x1b[K\x1b[H", + ); + }); + + it("clears obsolete rows when help or a filter makes the frame shorter", () => { + const update = buildFrameUpdate("header\nresult", "header\nresult\nstale\ntext", 80, 24); + expect(update).toBe("\x1b[3;1H\x1b[J\x1b[H"); + }); + + it("clears all old content for an empty frame", () => { + const update = buildFrameUpdate("", "old\nframe", 80, 24); + expect(update).toContain("\x1b[1;1H\x1b[0m\x1b[0m\x1b[K"); + expect(update).toContain("\x1b[2;1H\x1b[J"); + }); + + it("repaints changed ANSI styles even when visible text is unchanged", () => { + const update = buildFrameUpdate("\x1b[31mtext\x1b[39m", "text", 80, 24); + expect(update).toContain("\x1b[31mtext\x1b[39m"); + expect(update).toContain("\x1b[0m\x1b[K"); + }); + + it("clips wide Unicode and styled lines to avoid wrapping into unchanged rows", () => { + const update = buildFrameUpdate("\x1b[31m前端abc\x1b[39m\nnext", null, 5, 2); + expect(update).toContain("前端a"); + expect(update).not.toContain("abc"); + expect(update).toContain("\x1b[2;1H"); + expect(update).not.toContain("\n"); + }); + + it("limits output to the terminal height without scrolling at the bottom edge", () => { + const update = buildFrameUpdate("first\nsecond\nhidden", null, 6, 2); + expect(update).not.toContain("hidden"); + expect(update).not.toContain("\x1b[3;1H"); + expect(update).not.toContain("\n"); + expect(update).toEndWith("\x1b[H"); + }); + + it("ignores changes outside the visible viewport", () => { + expect(buildFrameUpdate("same\nnew", "same\nold", 80, 1)).toBe(""); + expect(buildFrameUpdate("same new", "same old", 4, 24)).toBe(""); + }); + + it("repaints a resized viewport when the previous frame is invalidated", () => { + const update = buildFrameUpdate("header\nresult", null, 4, 2); + expect(update).toContain("head"); + expect(update).toContain("resu"); + expect(update).not.toContain("\x1b[3;1H"); + }); + + it("keeps real result navigation to two highlighted rows and the position indicator", () => { + const groups: RepoGroup[] = Array.from({ length: 10 }, (_, i) => ({ + repoFullName: `org/repository-${i}`, + matches: [], + folded: true, + repoSelected: true, + extractSelected: [], + })); + const rows = buildRows(groups); + const render = (cursor: number) => + renderGroups(groups, cursor, rows, 24, 0, "query", "org", { termWidth: 80 }); + const first = render(0); + const second = render(1); + for (const [previous, next] of [ + [first, second], + [second, first], + ]) { + const update = buildFrameUpdate(next, previous, 80, 24); + expect(update.match(/\x1b\[\d+;1H/g)).toHaveLength(3); + expect(update).not.toContain("github-code-search"); + expect(update).not.toContain("\x1b[2J"); + expect(update.length).toBeLessThan(next.length / 2); + } + }); +}); diff --git a/src/render/frame.ts b/src/render/frame.ts new file mode 100644 index 0000000..6b27664 --- /dev/null +++ b/src/render/frame.ts @@ -0,0 +1,30 @@ +import { clipToWidth } from "./terminal.ts"; + +/** Build one in-place update, leaving unchanged terminal lines untouched. */ +export function buildFrameUpdate( + rendered: string, + previous: string | null, + termWidth: number, + termHeight: number, +): string { + const toLines = (frame: string) => + frame + .split("\n") + .slice(0, Math.max(1, termHeight)) + .map((line) => clipToWidth(line, Math.max(1, termWidth))); + const lines = toLines(rendered); + const oldLines = previous === null ? [] : toLines(previous); + const updates: string[] = []; + + for (let i = 0; i < lines.length; i++) { + if (lines[i] === oldLines[i]) continue; + // Overwrite before erasing the tail so no blank frame is exposed (issue #251). + updates.push(`\x1b[${i + 1};1H\x1b[0m${lines[i]}\x1b[0m\x1b[K`); + } + + if ((previous === null || lines.length < oldLines.length) && lines.length < termHeight) { + updates.push(`\x1b[${lines.length + 1};1H\x1b[J`); + } + + return updates.length > 0 ? updates.join("") + "\x1b[H" : ""; +} diff --git a/src/tui.ts b/src/tui.ts index 585e275..0cfcb39 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -5,6 +5,7 @@ import { applySelectNone, buildFileUrl, buildFilterStats, + buildFrameUpdate, buildRows, getSectionPath, isCursorVisible, @@ -162,6 +163,7 @@ export async function runInteractive( let scrollOffset = 0; let termHeight = process.stdout.rows ?? 40; let termWidth = process.stdout.columns ?? 120; + let previousFrame: string | null = null; // 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. @@ -296,14 +298,15 @@ export async function runInteractive( filterCursor, filterLiveStats, showHelp, - termWidth: process.stdout.columns ?? 80, + termWidth, filterTarget, filterRegex, teamPickMode: teamPickMode.active ? teamPickMode : undefined, repickMode: repickMode.active ? repickMode : undefined, }); - process.stdout.write(ANSI_CLEAR); - process.stdout.write(rendered); + const update = buildFrameUpdate(rendered, previousFrame, termWidth, termHeight); + if (update) process.stdout.write(update); + previousFrame = rendered; }; redraw(); @@ -332,6 +335,7 @@ export async function runInteractive( if (newHeight !== termHeight || newWidth !== termWidth) { termHeight = newHeight; termWidth = newWidth; + previousFrame = null; redraw(); } }; From ee4aed1a7c3203aaf2e8f9641847fba5c0a5245a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Thu, 29 Oct 2026 13:09:43 +0100 Subject: [PATCH 2/3] Fix reviewed terminal redraw edge cases and centralize controls Normalize CRLF and tab-indented snippets before diffing, preserve full-width edge cells, and add cell-level scrolling regressions. Reuse shared named controls across terminal consumers and enforce the convention through agent instructions and CI architecture tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/instructions/terminal.instructions.md | 30 +++ .github/skills/bug-fixing.md | 56 ++--- AGENTS.md | 5 + github-code-search.ts | 95 ++++---- src/api.ts | 29 ++- src/render.ts | 40 ++-- src/render/frame.test.ts | 202 +++++++++++++++--- src/render/frame.ts | 56 ++++- src/render/highlight.ts | 5 +- src/render/mouse.ts | 5 +- src/render/rows.ts | 3 +- src/render/terminal.ts | 5 +- src/terminal-control.test.ts | 121 +++++++++++ src/terminal-control.ts | 58 +++++ src/tui.ts | 78 ++++--- 15 files changed, 604 insertions(+), 184 deletions(-) create mode 100644 .github/instructions/terminal.instructions.md create mode 100644 src/terminal-control.test.ts create mode 100644 src/terminal-control.ts diff --git a/.github/instructions/terminal.instructions.md b/.github/instructions/terminal.instructions.md new file mode 100644 index 0000000..aafc55b --- /dev/null +++ b/.github/instructions/terminal.instructions.md @@ -0,0 +1,30 @@ +--- +applyTo: "src/**/*.ts,github-code-search.ts" +--- + +# Terminal controls and redraw safety + +- Read `AGENTS.md` and search `src/terminal-control.ts` before adding terminal controls. + Reuse named constants instead of duplicating encoded ANSI, keyboard or mouse bytes. + Add a missing control once in that module; use `cursorPosition()` for coordinates. +- In terminal output and new tests, compose `LINE_FEED`, `CARRIAGE_RETURN` and `TAB` + rather than embedding encoded line breaks or long protocol strings. + Preserve existing key-binding names when extracting them to shared vocabulary. +- Use `src/style.ts` for standard text styling. Named custom-colour/reset constants + are for the existing protocol-level rendering, not a second styling framework. +- Normalize carriage returns and tabs before clipping or comparing differential + frames. Measure visible columns through `render/terminal.ts`, ignoring SGR bytes. + A tab must overwrite skipped cells with spaces, not retain old text/highlights. +- Do not append a tail erase to an exactly full-width line: it erases the final + cell, including the edge of a wide glyph. Preserve unchanged rows and submit + changed frames in one write. +- Add failing regressions before fixes. Assert resulting terminal cells for CRLF, + tab-indented scrolling and full-width ASCII/wide-glyph lines; string-sequence + assertions alone do not prove terminal correctness. +- Keep the read-only architecture guard in `src/terminal-control.test.ts` passing + through the normal `bun test` suite. New render modules are covered automatically; + add other new terminal emitters to its surface set. Do not exempt production code. + Independent vocabulary wire-format tests, existing legacy protocol fixtures and + syntax-parsing regexes may contain literal encodings; new frame tests may not. +- Run `bun test --dots`, `bun run typecheck`, `bun run format:check`, + `bun run knip` and `bun run build.ts` before publishing changes. diff --git a/.github/skills/bug-fixing.md b/.github/skills/bug-fixing.md index 91eb8a3..ba6b257 100644 --- a/.github/skills/bug-fixing.md +++ b/.github/skills/bug-fixing.md @@ -7,31 +7,32 @@ This skill complements `.github/instructions/bug-fixing.instructions.md`. ## Symptom → module diagnostic table -| Symptom | Primary suspect | Secondary suspect | -| ----------------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------- | -| Results missing or duplicated | `src/aggregate.ts` | `src/api.ts` (pagination) | -| Wrong repository grouping | `src/group.ts` | `src/aggregate.ts` | -| `--exclude-repositories` / `--exclude-extracts` not working | `src/aggregate.ts` | `github-code-search.ts` (parsing) | -| Markdown output malformed | `src/output.ts` | — | -| JSON output missing fields or wrong shape | `src/output.ts` | `src/types.ts` (interface) | -| Syntax highlighting wrong colour / wrong language | `src/render/highlight.ts` | — | -| Row navigation skips or wraps incorrectly | `src/render/rows.ts` | `src/tui.ts` (key handler) | -| Select-all / select-none inconsistent | `src/render/selection.ts` | `src/tui.ts` | -| Filter count / stats incorrect | `src/render/filter.ts`, `src/render/summary.ts` | — | -| Path filter (`/regex/`) doesn't match expected | `src/render/filter-match.ts` | `src/tui.ts` (filter state) | -| API returns 0 results or stops paginating | `src/api.ts` | `src/api-utils.ts` | -| Rate limit hit / 429 not retried | `src/api-utils.ts` (`fetchWithRetry`) | — | -| TUI shows blank screen or wrong row | `src/tui.ts` | `src/render/rows.ts` | -| Mouse clicks land on the wrong row/zone | `src/render/mouse-hit.ts` | `src/tui.ts` (click dispatch) | -| Mouse escape sequences not parsed / terminal stuck in mouse mode | `src/render/mouse.ts` | `src/tui.ts` (enable/disable mouse reporting) | -| Wheel scroll wrong step size or accidental clicks after scrolling | `src/render/layout-constants.ts` (`MOUSE_SCROLL_STEP`) | `src/scroll-cooldown.ts` | -| Help overlay doesn't appear / has wrong keys | `src/render.ts` (`renderHelpOverlay`) | `src/tui.ts` | -| Upgrade fails or replaces wrong binary | `src/upgrade.ts` | — | -| Completion script wrong content | `src/completions.ts` | — | -| Completion file written to wrong path | `src/completions.ts` (`getCompletionFilePath`) | env vars (`XDG_*`, `ZDOTDIR`) | -| Completion not refreshed after upgrade | `src/upgrade.ts` (`refreshCompletions`) | — | -| `--version` shows wrong info | `build.ts` (SHA injection) | — | -| CLI option ignored or parsed wrong | `github-code-search.ts` | `src/types.ts` (`OutputType`) | +| Symptom | Primary suspect | Secondary suspect | +| -------------------------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------- | +| Results missing or duplicated | `src/aggregate.ts` | `src/api.ts` (pagination) | +| Wrong repository grouping | `src/group.ts` | `src/aggregate.ts` | +| `--exclude-repositories` / `--exclude-extracts` not working | `src/aggregate.ts` | `github-code-search.ts` (parsing) | +| Markdown output malformed | `src/output.ts` | — | +| JSON output missing fields or wrong shape | `src/output.ts` | `src/types.ts` (interface) | +| Syntax highlighting wrong colour / wrong language | `src/render/highlight.ts` | — | +| Row navigation skips or wraps incorrectly | `src/render/rows.ts` | `src/tui.ts` (key handler) | +| Select-all / select-none inconsistent | `src/render/selection.ts` | `src/tui.ts` | +| Filter count / stats incorrect | `src/render/filter.ts`, `src/render/summary.ts` | — | +| Path filter (`/regex/`) doesn't match expected | `src/render/filter-match.ts` | `src/tui.ts` (filter state) | +| API returns 0 results or stops paginating | `src/api.ts` | `src/api-utils.ts` | +| Rate limit hit / 429 not retried | `src/api-utils.ts` (`fetchWithRetry`) | — | +| TUI shows blank screen or wrong row | `src/tui.ts` | `src/render/rows.ts` | +| Flicker, erased CRLF snippets, stale tab indentation or missing edge cells | `src/render/frame.ts` | `src/terminal-control.ts`, `src/render/terminal.ts` | +| Mouse clicks land on the wrong row/zone | `src/render/mouse-hit.ts` | `src/tui.ts` (click dispatch) | +| Mouse escape sequences not parsed / terminal stuck in mouse mode | `src/render/mouse.ts` | `src/tui.ts` (enable/disable mouse reporting) | +| Wheel scroll wrong step size or accidental clicks after scrolling | `src/render/layout-constants.ts` (`MOUSE_SCROLL_STEP`) | `src/scroll-cooldown.ts` | +| Help overlay doesn't appear / has wrong keys | `src/render.ts` (`renderHelpOverlay`) | `src/tui.ts` | +| Upgrade fails or replaces wrong binary | `src/upgrade.ts` | — | +| Completion script wrong content | `src/completions.ts` | — | +| Completion file written to wrong path | `src/completions.ts` (`getCompletionFilePath`) | env vars (`XDG_*`, `ZDOTDIR`) | +| Completion not refreshed after upgrade | `src/upgrade.ts` (`refreshCompletions`) | — | +| `--version` shows wrong info | `build.ts` (SHA injection) | — | +| CLI option ignored or parsed wrong | `github-code-search.ts` | `src/types.ts` (`OutputType`) | --- @@ -109,6 +110,11 @@ Document manual repro steps in the PR description: - **Touch only the root cause.** Do not opportunistically refactor neighbouring code in the same PR — it makes the fix harder to review and risks introducing new bugs. - **Respect pure/IO layering**: a fix in `aggregate.ts` must not add a `console.log` call. +- **Terminal regressions need cell assertions.** Reuse `src/terminal-control.ts` + constants (including named line breaks) in fixes and new tests. Follow + `.github/instructions/terminal.instructions.md`; the normal Bun suite enforces + the architecture guard. For redraw changes reproduce CRLF, tab indentation, + full-width cells and scrolling over old content, not just emitted string shapes. - **Type changes cascade**: if `src/types.ts` must change, run `bun run knip` to find all affected consumers and update them all in the same PR. - **Regression comment**: if the root cause is non-obvious, add one line above the fix: ```typescript diff --git a/AGENTS.md b/AGENTS.md index b2b141a..cb541df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,6 +140,8 @@ src/ style.ts # Sole call site for node:util's styleText — dim/bold/italic/ # underline/colors + style(names[], text) composer for combined # styles — no I/O + terminal-control.ts # Shared named terminal controls, key sequences, line breaks, + # tab stops and cursorPosition() — no I/O render/ frame.ts # Pure differential terminal updates — repaints changed lines, @@ -170,6 +172,8 @@ src/ - **`render.ts` is a façade.** It re-exports everything from `render/` and adds two top-level rendering functions. Consumers import from `render.ts`, not directly from sub-modules. Exceptions: `render/team-pick.ts`, `render/mouse.ts` and `render/mouse-hit.ts` are pure modules imported **directly** by their sole consumer (`render.ts` for `team-pick.ts`, `tui.ts` for the mouse modules) and are not re-exported publicly (knip would flag unused re-exports otherwise). - **`render/terminal.ts` is the sole Bun API call site.** All calls to `Bun.stringWidth()`, `Bun.stripANSI()`, and `Bun.sliceAnsi()` must go through the `terminal.ts` wrapper functions (`visibleWidth()`, `stripAnsi()`, `clipToWidth()`, `hasAnsi()`). This centralizes terminal handling logic and makes it easy to verify correct Unicode handling (graphemes, emoji, CJK, ZWJ sequences). - **`src/style.ts` is the sole call site for `node:util`'s `styleText`.** All ANSI styling across the codebase (`render.ts`, `render/highlight.ts`, `render/summary.ts`, `render/team-pick.ts`, `tui.ts`, `upgrade.ts`, `api.ts`, `github-code-search.ts`) goes through this facade instead of importing `styleText` directly or reintroducing a styling npm package. Multi-style compositions (e.g. bold+yellow) must use the `style(names[], text)` array form, not chained/nested calls — `bold` and `dim` share the same SGR reset code, so naive chaining can produce incorrect output when there is trailing content after a nested style. +- **`src/terminal-control.ts` owns encoded terminal controls.** Reuse its named cursor, erase, SGR, keyboard and mouse constants; use `cursorPosition()` for parameterized movement. Terminal surfaces and their new tests must compose named controls, including `LINE_FEED`, `CARRIAGE_RETURN` and `TAB`, instead of embedding escape strings. Standard text styling still uses `src/style.ts`; the shared constants cover protocol resets and the existing custom 256-colour styles. Raw bytes are allowed in the vocabulary's independent wire-format tests, existing legacy protocol fixtures and syntax-parsing regular expressions, not in new terminal output code. +- **Differential redraws must preserve terminal cells, not merely strings.** Remove carriage returns and expand tabs to visible-column-aware spaces before clipping/comparing frames. Ignore ANSI styles when measuring tab stops. Never erase a line tail when the line fills the terminal width: the terminal cursor still occupies its final cell. Test CRLF snippets, styled/Unicode tab indentation, full-width ASCII/wide-glyph lines and scrolling over occupied cells. - **`types.ts` is the single source of truth** for all shared interfaces. Any new shared type must go there. - **No classes** — the codebase uses plain TypeScript interfaces and functions throughout. @@ -184,6 +188,7 @@ src/ - When adding a function to an existing module, add the corresponding test case in the existing `.test.ts`. - When creating a new module that contains pure functions, create a companion `.test.ts`. - Tests must be self-contained: no network calls, no filesystem side effects. +- `src/terminal-control.test.ts` includes a read-only architecture guard in the normal `bun test`/CI suite. It rejects encoded control bytes outside the shared vocabulary and raw line/tab escapes in terminal surfaces and the frame regression tests. New render modules are covered automatically; extend the terminal-surface set for new emitters outside `render/`. Do not bypass the guard by adding exceptions for production emitters. Existing text parsers/fixtures are not a mandate to replace every data-format newline in the repository. ## Git conventions diff --git a/github-code-search.ts b/github-code-search.ts index 24733ba..61dfd57 100644 --- a/github-code-search.ts +++ b/github-code-search.ts @@ -36,6 +36,7 @@ import { runInteractive } from "./src/tui.ts"; import { generateCompletion, detectShell } from "./src/completions.ts"; import { getGhAuthToken } from "./src/gh-cli.ts"; import { clipToWidth } from "./src/render.ts"; +import { ANSI_ERASE_LINE, CARRIAGE_RETURN, LINE_FEED } from "./src/terminal-control.ts"; import { buildApiQuery, detectPathWildcardLimitation, @@ -76,7 +77,7 @@ const HAS_COLOR = Boolean(process.stdout.isTTY); function colorDesc(s: string): string { if (!HAS_COLOR) return s; return s - .split("\n") + .split(LINE_FEED) .map((line) => { const docsMatch = line.match(/^(\s*Docs:\s*)(https?:\/\/\S+)$/); if (docsMatch) @@ -91,7 +92,7 @@ function colorDesc(s: string): string { // Colorize any remaining bare URL (http/https) anywhere in the line return line.replace(/(https?:\/\/\S+)/g, (url) => style.style(["cyan", "underline"], url)); }) - .join("\n"); + .join(LINE_FEED); } /** Colored hyperlink (cyan + underline), falls back to plain when not a TTY. */ @@ -105,7 +106,7 @@ function helpLink(url: string): string { */ function helpSection(label: string, url: string): string { const t = HAS_COLOR ? style.bold(label) : label; - return `\n${t}\n ${helpLink(url)}`; + return `${LINE_FEED}${t}${LINE_FEED} ${helpLink(url)}`; } /** @@ -147,7 +148,7 @@ function addSearchOptions(cmd: Command): Command { [ "GitHub organization to search in.", "Falls back to the GCS_DEFAULT_ORG environment variable when omitted.", - ].join("\n"), + ].join(LINE_FEED), ).env("GCS_DEFAULT_ORG"), ) .option( @@ -157,7 +158,7 @@ function addSearchOptions(cmd: Command): Command { "Short form (without org prefix) or full form accepted:", "repoA,repoB OR myorg/repoA,myorg/repoB", "Docs: https://fulll.github.io/github-code-search/usage/filtering", - ].join("\n"), + ].join(LINE_FEED), "", ) .option( @@ -168,7 +169,7 @@ function addSearchOptions(cmd: Command): Command { "e.g. repoA:src/foo.ts:0,repoB:lib/core.ts:2", "Full form also accepted: myorg/repoA:src/foo.ts:0", "Docs: https://fulll.github.io/github-code-search/usage/filtering", - ].join("\n"), + ].join(LINE_FEED), "", ) .option( @@ -180,7 +181,7 @@ function addSearchOptions(cmd: Command): Command { [ "Output format: markdown (default) or json.", "Docs: https://fulll.github.io/github-code-search/usage/output-formats", - ].join("\n"), + ].join(LINE_FEED), "markdown", ) .option( @@ -188,7 +189,7 @@ function addSearchOptions(cmd: Command): Command { [ "Output type: repo-and-matches (default) or repo-only.", "Docs: https://fulll.github.io/github-code-search/usage/output-formats", - ].join("\n"), + ].join(LINE_FEED), "repo-and-matches", ) .option("--include-archived", "Include archived repositories in results", false) @@ -204,7 +205,7 @@ function addSearchOptions(cmd: Command): Command { "level. Repos matching no prefix go into 'other'. Team names that overlap", "(e.g. squad-a and squad-a-legacy) are combined automatically.", "Docs: https://fulll.github.io/github-code-search/usage/team-grouping", - ].join("\n"), + ].join(LINE_FEED), "", ) .option( @@ -217,7 +218,7 @@ function addSearchOptions(cmd: Command): Command { "A repo left with no matching team after exclusion falls into 'other'.", "Only applies with --group-by-team-prefix.", "Docs: https://fulll.github.io/github-code-search/usage/team-grouping#excluding-noisy-team-prefixes", - ].join("\n"), + ].join(LINE_FEED), "", ) .option( @@ -231,7 +232,7 @@ function addSearchOptions(cmd: Command): Command { "Repeatable — one flag per combined section to resolve.", "Only applies with --group-by-team-prefix.", "Docs: https://fulll.github.io/github-code-search/usage/team-grouping#team-pick-mode", - ].join("\n"), + ].join(LINE_FEED), (val: string, list: string[]) => [...list, val], [] as string[], ) @@ -245,7 +246,7 @@ function addSearchOptions(cmd: Command): Command { "unresolved. An explicit --pick-team for the same section always wins.", "Applies at every hierarchy depth. Only applies with --group-by-team-prefix.", "Docs: https://fulll.github.io/github-code-search/usage/team-grouping#auto-pick-by-common-prefix", - ].join("\n"), + ].join(LINE_FEED), false, ) .option( @@ -259,7 +260,7 @@ function addSearchOptions(cmd: Command): Command { "Useful when auto-extraction produces a term that is too broad or too narrow.", 'Example: --regex-hint "axios" (for query /from.*[\'"]axios/)', "Docs: https://fulll.github.io/github-code-search/usage/search-syntax#regex-queries", - ].join("\n"), + ].join(LINE_FEED), ); } @@ -369,14 +370,16 @@ async function searchAction( const remaining = cooldownUntil - Date.now(); if (remaining <= 0) break; process.stderr.write( - `\x1b[2K\r${clipToWidth( - ` ${style.yellow("Rate limited")} — resuming in ${formatRetryWait(remaining)}\u2026`, - Math.max(0, (process.stderr.columns ?? 80) - 1), - )}`, + ANSI_ERASE_LINE + + CARRIAGE_RETURN + + clipToWidth( + ` ${style.yellow("Rate limited")} — resuming in ${formatRetryWait(remaining)}\u2026`, + Math.max(0, (process.stderr.columns ?? 80) - 1), + ), ); await new Promise((r) => setTimeout(r, 1_000)); } - process.stderr.write("\x1b[2K\r"); + process.stderr.write(ANSI_ERASE_LINE + CARRIAGE_RETURN); })().finally(() => { activeCooldown = null; cooldownUntil = 0; @@ -403,7 +406,9 @@ async function searchAction( effectiveQuery = opts.regexHint ?? apiQuery; regexFilter = rf ?? undefined; process.stderr.write( - style.dim(` ℹ Regex mode — GitHub query: "${effectiveQuery}", local filter: ${query}\n`), + style.dim( + ` ℹ Regex mode — GitHub query: "${effectiveQuery}", local filter: ${query}${LINE_FEED}`, + ), ); } @@ -430,18 +435,18 @@ async function searchAction( if (!opts.groupByTeamPrefix && opts.pickTeam && opts.pickTeam.length > 0) { for (const assignment of opts.pickTeam) { process.stderr.write( - `warning: --pick-team "${assignment}" requires --group-by-team-prefix; skipping\n`, + `warning: --pick-team "${assignment}" requires --group-by-team-prefix; skipping${LINE_FEED}`, ); } } if (!opts.groupByTeamPrefix && opts.excludeTeamPrefixes) { process.stderr.write( - "warning: --exclude-team-prefixes requires --group-by-team-prefix; skipping\n", + "warning: --exclude-team-prefixes requires --group-by-team-prefix; skipping" + LINE_FEED, ); } if (opts.groupByTeamPrefix) { const { chains, warnings: chainWarnings } = parseTeamPrefixChains(opts.groupByTeamPrefix); - for (const w of chainWarnings) process.stderr.write(`warning: ${w}\n`); + for (const w of chainWarnings) process.stderr.write(`warning: ${w}${LINE_FEED}`); if (chains.length > 0) { const allPrefixes = [...new Set(chains.flat())]; @@ -472,7 +477,7 @@ async function searchAction( for (const assignment of opts.pickTeam) { const resolution = resolvePickTeamAssignment(sections, assignment); if ("error" in resolution) { - process.stderr.write(`warning: ${resolution.error}\n`); + process.stderr.write(`warning: ${resolution.error}${LINE_FEED}`); continue; } sections = applyTeamPickInTree(sections, resolution.path, resolution.chosen); @@ -492,9 +497,9 @@ async function searchAction( if (unresolved.length > 0 && (opts.pickTeam.length > 0 || opts.pickTeamAuto)) { process.stderr.write( `note: ${unresolved.length} combined section${unresolved.length !== 1 ? "s" : ""} still unresolved ` + - `(press "p" in TUI or use --pick-team to assign):\n` + - unresolved.map((p) => ` "${p.join(" > ")}"`).join("\n") + - "\n", + `(press "p" in TUI or use --pick-team to assign):${LINE_FEED}` + + unresolved.map((p) => ` "${p.join(" > ")}"`).join(LINE_FEED) + + LINE_FEED, ); } @@ -554,7 +559,7 @@ async function searchAction( `│ ${pad("Run: github-code-search upgrade")} │`, `╰${bottomBar}╯`, "", - ].join("\n"), + ].join(LINE_FEED), ), ); } @@ -614,31 +619,33 @@ program ? process.execPath : resolve(process.argv[0]); if (opts.debug) { - process.stdout.write(`[debug] process.execPath = ${process.execPath}\n`); - process.stdout.write(`[debug] process.argv[0] = ${process.argv[0]}\n`); - process.stdout.write(`[debug] selfPath (dest) = ${selfPath}\n`); - process.stdout.write(`[debug] process.platform = ${process.platform}\n`); - process.stdout.write(`[debug] process.arch = ${process.arch}\n`); - process.stdout.write(`[debug] VERSION = ${VERSION}\n`); + process.stdout.write(`[debug] process.execPath = ${process.execPath}${LINE_FEED}`); + process.stdout.write(`[debug] process.argv[0] = ${process.argv[0]}${LINE_FEED}`); + process.stdout.write(`[debug] selfPath (dest) = ${selfPath}${LINE_FEED}`); + process.stdout.write(`[debug] process.platform = ${process.platform}${LINE_FEED}`); + process.stdout.write(`[debug] process.arch = ${process.arch}${LINE_FEED}`); + process.stdout.write(`[debug] VERSION = ${VERSION}${LINE_FEED}`); } try { await performUpgrade(VERSION, selfPath, token, opts.debug); } catch (e: unknown) { // Print upgrade errors to stdout so they are always visible (stderr // is sometimes swallowed by shells or terminal multiplexers). - process.stdout.write(`error: ${e instanceof Error ? e.message : String(e)}\n`); + process.stdout.write(`error: ${e instanceof Error ? e.message : String(e)}${LINE_FEED}`); process.exit(1); } try { const { refreshCompletions } = await import("./src/upgrade.ts"); const refreshedPath = await refreshCompletions(detectShell(), undefined, opts.debug); if (refreshedPath) { - process.stdout.write(`✓ Shell completions installed/refreshed at ${refreshedPath}\n`); + process.stdout.write( + `✓ Shell completions installed/refreshed at ${refreshedPath}${LINE_FEED}`, + ); } } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); const prefix = opts.debug ? "[debug] " : "warning: "; - process.stdout.write(`${prefix}failed to refresh shell completions: ${message}\n`); + process.stdout.write(`${prefix}failed to refresh shell completions: ${message}${LINE_FEED}`); } process.exit(0); }); @@ -654,21 +661,21 @@ program ) .option( "--shell ", - ["Target shell: bash, zsh or fish.", "Auto-detected from $SHELL when omitted."].join("\n"), + ["Target shell: bash, zsh or fish.", "Auto-detected from $SHELL when omitted."].join(LINE_FEED), ) .action((opts: { shell?: string }) => { const shell = opts.shell ?? detectShell(); if (!shell) { writeFileSync( 2, - `error: could not detect shell. Use --shell bash|zsh|fish to specify it explicitly.\n`, + `error: could not detect shell. Use --shell bash|zsh|fish to specify it explicitly.${LINE_FEED}`, ); process.exit(1); } try { - writeFileSync(1, generateCompletion(shell) + "\n"); + writeFileSync(1, generateCompletion(shell) + LINE_FEED); } catch (e: unknown) { - writeFileSync(2, `error: ${e instanceof Error ? e.message : String(e)}\n`); + writeFileSync(2, `error: ${e instanceof Error ? e.message : String(e)}${LINE_FEED}`); process.exit(1); } process.exit(0); @@ -708,10 +715,10 @@ queryCmd.exitOverride().configureOutput(silenceErrors); async function main(): Promise { const rawArgs = process.argv.slice(2); if (rawArgs.includes("--version") || rawArgs.includes("-V")) { - writeFileSync(1, VERSION_FULL + "\n"); + writeFileSync(1, VERSION_FULL + LINE_FEED); process.exit(0); } else if (rawArgs.includes("--help") || rawArgs.includes("-h")) { - writeFileSync(1, program.helpInformation() + "\n"); + writeFileSync(1, program.helpInformation() + LINE_FEED); process.exit(0); } @@ -727,13 +734,13 @@ async function main(): Promise { typeof (e as { code: unknown }).code === "string" && (e as { code: string }).code.startsWith("commander.") ) { - writeFileSync(2, queryCmd.helpInformation() + "\n"); + writeFileSync(2, queryCmd.helpInformation() + LINE_FEED); process.exit(1); } // Any other known Error (e.g. rate-limit exceeded) → print a clean message // to stderr without a stack trace, then exit 1. if (e instanceof Error) { - writeFileSync(2, `error: ${e.message}\n`); + writeFileSync(2, `error: ${e.message}${LINE_FEED}`); process.exit(1); } throw e; diff --git a/src/api.ts b/src/api.ts index 133c4eb..e017e11 100644 --- a/src/api.ts +++ b/src/api.ts @@ -3,6 +3,14 @@ import type { CodeMatch } from "./types.ts"; import { concurrentMap, fetchWithRetry, formatRetryWait, paginatedFetch } from "./api-utils.ts"; import { getCacheKey, readCache, writeCache } from "./cache.ts"; import { clipToWidth, visibleWidth } from "./render.ts"; +import { + ANSI_ACCENT_FOREGROUND, + ANSI_MUTED_FOREGROUND, + ANSI_STYLE_RESET, + ANSI_ERASE_LINE, + CARRIAGE_RETURN, + LINE_FEED, +} from "./terminal-control.ts"; // ─── Raw GitHub API types (internal) ───────────────────────────────────────── @@ -107,7 +115,7 @@ function computeFragmentStartLine(fileContent: string, fragment: string): number if (!fragment) return 1; const idx = fileContent.indexOf(fragment); if (idx === -1) return 1; - return fileContent.slice(0, idx).split("\n").length; + return fileContent.slice(0, idx).split(LINE_FEED).length; } /** @@ -117,7 +125,7 @@ function computeFragmentStartLine(fileContent: string, fragment: string): number */ export function segmentLineCol(fragment: string, offset: number): { line: number; col: number } { const before = fragment.slice(0, offset); - const lines = before.split("\n"); + const lines = before.split(LINE_FEED); return { line: lines.length, // 1-based col: (lines[lines.length - 1]?.length ?? 0) + 1, // 1-based @@ -168,8 +176,7 @@ const PROGRESS_BAR_WIDTH = 20; /** * Build the coloured block portion of a progress bar. * Pure function — no side effects. - * \x1b[38;5;129m — bright purple (filled blocks) - * \x1b[38;5;240m — dark grey (empty blocks) + * Bright purple filled blocks and dark grey empty blocks. * * `filled` is clamped to [0, PROGRESS_BAR_WIDTH] so callers never produce a * negative repeat count (which would throw a RangeError) — e.g. when @@ -179,12 +186,12 @@ const PROGRESS_BAR_WIDTH = 20; function buildProgressBar(filled: number): string { const clampedFilled = Math.min(Math.max(0, Math.round(filled)), PROGRESS_BAR_WIDTH); const clampedEmpty = PROGRESS_BAR_WIDTH - clampedFilled; - return `\x1b[38;5;129m${"▓".repeat(clampedFilled)}\x1b[38;5;240m${"░".repeat(clampedEmpty)}\x1b[0m`; + return `${ANSI_ACCENT_FOREGROUND}${"▓".repeat(clampedFilled)}${ANSI_MUTED_FOREGROUND}${"░".repeat(clampedEmpty)}${ANSI_STYLE_RESET}`; } /** * Build a single-line purple progress bar string suitable for writing to - * stderr with a leading \r so it overwrites the current line. + * stderr with a leading carriage return so it overwrites the current line. * * Pure function — no side effects; exported for unit testing. */ @@ -217,7 +224,7 @@ function buildProgressLine( : visibleWidth(label) > labelWidth ? `${clipToWidth(label, labelWidth - 1)}…` : label; - return `\r${clipToWidth(` ${shortened}${suffix}`, width)}`; + return CARRIAGE_RETURN + clipToWidth(` ${shortened}${suffix}`, width); } function buildCountProgress( @@ -234,7 +241,7 @@ function createFetchProgress(onRateLimit?: (waitMs: number) => Promise) { let latest = ""; let waiting = 0; const clear = () => { - process.stderr.write("\x1b[2K\r"); + process.stderr.write(ANSI_ERASE_LINE + CARRIAGE_RETURN); }; const write = (line: string) => { latest = line; @@ -283,7 +290,7 @@ export async function fetchAllResults( // downloaded raw file alive in memory for the whole run — see PR #154 review. keepFileContent = false, ): Promise { - // Write the initial progress line (no newline — will be overwritten by \r). + // Write the initial progress line (no newline — will be overwritten in place). const progress = createFetchProgress(onRateLimit); progress.write( clipToWidth( @@ -420,7 +427,9 @@ export async function fetchRepoTeams( if (useCache) { const cached = readCache<[string, string[]][]>(cacheKey); if (cached !== null) { - process.stderr.write(style.dim("Using cached team data (— use --no-cache to refresh)\n")); + process.stderr.write( + style.dim("Using cached team data (— use --no-cache to refresh)" + LINE_FEED), + ); return new Map(cached); } } diff --git a/src/render.ts b/src/render.ts index c881667..fae4308 100644 --- a/src/render.ts +++ b/src/render.ts @@ -6,6 +6,18 @@ import { getSectionPath, rowTerminalLines } from "./render/rows.ts"; import { buildMatchCountLabel, buildSummaryFull } from "./render/summary.ts"; import { renderTeamPickHeader } from "./render/team-pick.ts"; import { visibleWidth, stripAnsi, clipToWidth } from "./render/terminal.ts"; +import { + ANSI_ACTIVE_BACKGROUND, + ANSI_ACCENT_FOREGROUND, + ANSI_FOREGROUND_RESET, + ANSI_BACKGROUND_RESET, + ANSI_COUNT_FOREGROUND, + ANSI_UNDERLINE, + ANSI_UNDERLINE_OFF, + ANSI_INVERSE, + ANSI_INVERSE_OFF, + LINE_FEED, +} from "./terminal-control.ts"; // ─── Re-exports ─────────────────────────────────────────────────────────────── // Consumers (tui.ts, output.ts, tests) continue to import from render.ts. @@ -132,7 +144,7 @@ export function renderHelpOverlay(): string { ), bot, ]; - return rows.join("\n"); + return rows.join(LINE_FEED); } // ─── Rendering ──────────────────────────────────────────────────────────────── @@ -151,11 +163,7 @@ const HEADER_LINES = 4; // title + summaryFull + hints + blank * width to equal `termWidth`. */ function renderActiveLine(content: string): string { - // \x1b[48;5;53m — dark purple background (256-colour bg) - // \x1b[38;5;129m — saturated purple foreground for the ▌ bar - // \x1b[39m — reset foreground only (background stays active) - // \x1b[49m — reset background at the end of the line - return `\x1b[48;5;53m\x1b[38;5;129m▌\x1b[39m${content}\x1b[49m`; + return `${ANSI_ACTIVE_BACKGROUND}${ANSI_ACCENT_FOREGROUND}▌${ANSI_FOREGROUND_RESET}${content}${ANSI_BACKGROUND_RESET}`; } /** Width in visible columns of the left-bar character. */ @@ -387,7 +395,7 @@ export function renderGroups( // Underline the whole field; cursor char gets inverse video on top const inputLine = `🔍${targetBadge}` + - `\x1b[4m${before}\x1b[7m${atCursor}\x1b[27m${after}${pad}\x1b[24m` + + `${ANSI_UNDERLINE}${before}${ANSI_INVERSE}${atCursor}${ANSI_INVERSE_OFF}${after}${pad}${ANSI_UNDERLINE_OFF}` + statsRight; lines.push(inputLine); @@ -449,17 +457,17 @@ export function renderGroups( // Use visibleWidth to account for CJK and multi-code-point graphemes. const padLen = Math.max(0, barWidth - visibleWidth(barPlain)); const line = style.dim(REPICK_PREFIX) + bar + " ".repeat(padLen) + style.dim(REPICK_SUFFIX); - lines.push(clipToWidth(line, termWidth) + "\n"); + lines.push(clipToWidth(line, termWidth) + LINE_FEED); } 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(style.dim(`${clippedPick}\n`)); + lines.push(style.dim(clippedPick + LINE_FEED)); } 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/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(style.dim(`${clippedHints}\n`)); + lines.push(style.dim(clippedHints + LINE_FEED)); } // ── Sticky current-repo ─────────────────────────────────────────────────── @@ -616,13 +624,13 @@ export function renderGroups( : highlightText( group.repoFullName, "repo", - (s) => `\x1b[38;5;129m${style.bold(s)}\x1b[39m`, + (s) => `${ANSI_ACCENT_FOREGROUND}${style.bold(s)}${ANSI_FOREGROUND_RESET}`, ); // ◈ badge — signals the repo was moved from a combined section via pick. // Future split mode will use this to identify pickable repos and add a hint. const pickedBadge = group.pickedFrom ? ` ${style.dim("◈")}` : ""; // Use muted purple for the match count (both active and inactive rows). - const count = `\x1b[38;5;99m${buildMatchCountLabel(group)}\x1b[39m`; + const count = `${ANSI_COUNT_FOREGROUND}${buildMatchCountLabel(group)}${ANSI_FOREGROUND_RESET}`; // 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. @@ -710,19 +718,19 @@ export function renderGroups( // 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"). + // Each pushed "" contributes exactly 1 physical blank line in the joined frame. 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 leading line feed 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(style.dim(`\n ↕ row ${cursor + 1} of ${rows.length}`)); + lines.push(style.dim(`${LINE_FEED} ↕ row ${cursor + 1} of ${rows.length}`)); } - return lines.join("\n"); + return lines.join(LINE_FEED); } diff --git a/src/render/frame.test.ts b/src/render/frame.test.ts index 6a6513a..7e18ca9 100644 --- a/src/render/frame.test.ts +++ b/src/render/frame.test.ts @@ -1,81 +1,147 @@ import { describe, expect, it } from "bun:test"; import { buildFrameUpdate, buildRows, renderGroups } from "../render.ts"; +import { stripAnsi, visibleWidth } from "./terminal.ts"; +import * as style from "../style.ts"; import type { RepoGroup } from "../types.ts"; +import { + ANSI_CSI, + ANSI_CURSOR_HOME, + ANSI_ERASE_DISPLAY, + ANSI_ERASE_DISPLAY_END, + ANSI_ERASE_LINE_END, + ANSI_STYLE_RESET, + CARRIAGE_RETURN, + LINE_FEED, + TAB, + TERMINAL_TAB_WIDTH, + cursorPosition, +} from "../terminal-control.ts"; + +const frame = (...lines: string[]) => lines.join(LINE_FEED); +const paintLine = (row: number, text: string) => + cursorPosition(row) + ANSI_STYLE_RESET + text + ANSI_STYLE_RESET + ANSI_ERASE_LINE_END; + +/** Small cell model for the cursor/erase protocol emitted by buildFrameUpdate. */ +function terminalCells(initial: string[], update: string, width: number): string[] { + const cells = initial.map((line) => Array.from(line.padEnd(width).slice(0, width))); + let row = 0; + let column = 0; + let offset = 0; + const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + while (offset < update.length) { + if (update.startsWith(ANSI_CSI, offset)) { + const command = update.slice(offset + ANSI_CSI.length).match(/^([0-9;]*)([HmKJ])/); + if (!command) throw new Error(`Unsupported terminal command at offset ${offset}`); + const [, parameters, operation] = command; + if (operation === "H") { + const [y = 1, x = 1] = parameters ? parameters.split(";").map(Number) : []; + row = y - 1; + column = x - 1; + } else if (operation === "K") { + cells[row].fill(" ", column); + } else if (operation === "J") { + cells[row].fill(" ", column); + for (let y = row + 1; y < cells.length; y++) cells[y].fill(" "); + } + offset += ANSI_CSI.length + command[0].length; + continue; + } + const character = graphemes.segment(update.slice(offset))[Symbol.iterator]().next().value! + .segment; + if (character === CARRIAGE_RETURN) column = 0; + else if (character === TAB) { + column = Math.min(width - 1, column + TERMINAL_TAB_WIDTH - (column % TERMINAL_TAB_WIDTH)); + } else if (character === LINE_FEED) row++; + else { + const size = visibleWidth(character); + cells[row][column] = character; + if (size === 2) cells[row][column + 1] = ""; + column = Math.min(width - 1, column + size); + } + offset += character.length; + } + return cells.map((line) => line.join("").trimEnd()); +} describe("buildFrameUpdate", () => { it("paints the initial frame without a full-screen erase", () => { - const update = buildFrameUpdate("header\nresult", null, 80, 24); - expect(update).toContain("\x1b[1;1H\x1b[0mheader\x1b[0m\x1b[K"); - expect(update).toContain("\x1b[2;1H\x1b[0mresult\x1b[0m\x1b[K"); - expect(update).toContain("\x1b[3;1H\x1b[J"); - expect(update).not.toContain("\x1b[2J"); + const update = buildFrameUpdate(frame("header", "result"), null, 80, 24); + expect(update).toContain(paintLine(1, "header")); + expect(update).toContain(paintLine(2, "result")); + expect(update).toContain(cursorPosition(3) + ANSI_ERASE_DISPLAY_END); + expect(update).not.toContain(ANSI_ERASE_DISPLAY); }); it("updates only changed lines during cursor navigation", () => { - const previous = "header\n> first\n second\nrow 1"; - const next = "header\n first\n> second\nrow 2"; + const previous = frame("header", "> first", " second", "row 1"); + const next = frame("header", " first", "> second", "row 2"); const update = buildFrameUpdate(next, previous, 80, 24); expect(update).not.toContain("header"); - expect(update).toContain("\x1b[2;1H"); - expect(update).toContain("\x1b[3;1H"); - expect(update).toContain("\x1b[4;1H"); - expect(update).not.toContain("\x1b[2J"); - expect(update).not.toContain("\x1b[J"); + for (const row of [2, 3, 4]) expect(update).toContain(cursorPosition(row)); + expect(update).not.toContain(ANSI_ERASE_DISPLAY); + expect(update).not.toContain(ANSI_ERASE_DISPLAY_END); }); it("emits nothing for an unchanged frame", () => { - expect(buildFrameUpdate("same\nframe", "same\nframe", 80, 24)).toBe(""); + const unchanged = frame("same", "frame"); + expect(buildFrameUpdate(unchanged, unchanged, 80, 24)).toBe(""); }); it("erases the remainder of shorter lines after writing their replacement", () => { expect(buildFrameUpdate("short", "a longer line", 80, 24)).toBe( - "\x1b[1;1H\x1b[0mshort\x1b[0m\x1b[K\x1b[H", + paintLine(1, "short") + ANSI_CURSOR_HOME, ); }); it("clears obsolete rows when help or a filter makes the frame shorter", () => { - const update = buildFrameUpdate("header\nresult", "header\nresult\nstale\ntext", 80, 24); - expect(update).toBe("\x1b[3;1H\x1b[J\x1b[H"); + const update = buildFrameUpdate( + frame("header", "result"), + frame("header", "result", "stale", "text"), + 80, + 24, + ); + expect(update).toBe(cursorPosition(3) + ANSI_ERASE_DISPLAY_END + ANSI_CURSOR_HOME); }); it("clears all old content for an empty frame", () => { - const update = buildFrameUpdate("", "old\nframe", 80, 24); - expect(update).toContain("\x1b[1;1H\x1b[0m\x1b[0m\x1b[K"); - expect(update).toContain("\x1b[2;1H\x1b[J"); + const update = buildFrameUpdate("", frame("old", "frame"), 80, 24); + expect(update).toContain(paintLine(1, "")); + expect(update).toContain(cursorPosition(2) + ANSI_ERASE_DISPLAY_END); }); it("repaints changed ANSI styles even when visible text is unchanged", () => { - const update = buildFrameUpdate("\x1b[31mtext\x1b[39m", "text", 80, 24); - expect(update).toContain("\x1b[31mtext\x1b[39m"); - expect(update).toContain("\x1b[0m\x1b[K"); + const styled = style.red("text"); + const update = buildFrameUpdate(styled, "text", 80, 24); + expect(update).toContain(styled); + expect(update).toContain(ANSI_STYLE_RESET + ANSI_ERASE_LINE_END); }); it("clips wide Unicode and styled lines to avoid wrapping into unchanged rows", () => { - const update = buildFrameUpdate("\x1b[31m前端abc\x1b[39m\nnext", null, 5, 2); + const update = buildFrameUpdate(frame(style.red("前端abc"), "next"), null, 5, 2); expect(update).toContain("前端a"); expect(update).not.toContain("abc"); - expect(update).toContain("\x1b[2;1H"); - expect(update).not.toContain("\n"); + expect(update).toContain(cursorPosition(2)); + expect(update).not.toContain(LINE_FEED); }); it("limits output to the terminal height without scrolling at the bottom edge", () => { - const update = buildFrameUpdate("first\nsecond\nhidden", null, 6, 2); + const update = buildFrameUpdate(frame("first", "second", "hidden"), null, 6, 2); expect(update).not.toContain("hidden"); - expect(update).not.toContain("\x1b[3;1H"); - expect(update).not.toContain("\n"); - expect(update).toEndWith("\x1b[H"); + expect(update).not.toContain(cursorPosition(3)); + expect(update).not.toContain(LINE_FEED); + expect(update).toEndWith(ANSI_CURSOR_HOME); }); it("ignores changes outside the visible viewport", () => { - expect(buildFrameUpdate("same\nnew", "same\nold", 80, 1)).toBe(""); + expect(buildFrameUpdate(frame("same", "new"), frame("same", "old"), 80, 1)).toBe(""); expect(buildFrameUpdate("same new", "same old", 4, 24)).toBe(""); }); it("repaints a resized viewport when the previous frame is invalidated", () => { - const update = buildFrameUpdate("header\nresult", null, 4, 2); + const update = buildFrameUpdate(frame("header", "result"), null, 4, 2); expect(update).toContain("head"); expect(update).toContain("resu"); - expect(update).not.toContain("\x1b[3;1H"); + expect(update).not.toContain(cursorPosition(3)); }); it("keeps real result navigation to two highlighted rows and the position indicator", () => { @@ -96,10 +162,78 @@ describe("buildFrameUpdate", () => { [second, first], ]) { const update = buildFrameUpdate(next, previous, 80, 24); - expect(update.match(/\x1b\[\d+;1H/g)).toHaveLength(3); + expect(update.split(ANSI_CSI).filter((part) => /^\d+;1H/.test(part))).toHaveLength(3); expect(update).not.toContain("github-code-search"); - expect(update).not.toContain("\x1b[2J"); + expect(update).not.toContain(ANSI_ERASE_DISPLAY); expect(update.length).toBeLessThan(next.length / 2); } }); + + it("keeps CRLF snippet cells visible and compares normalized lines", () => { + const next = frame(`snippet${CARRIAGE_RETURN}`, "next"); + const update = buildFrameUpdate(next, frame("old", "next"), 16, 2); + expect(terminalCells(["old", "next"], update, 16)).toEqual(["snippet", "next"]); + expect(update).not.toContain(CARRIAGE_RETURN); + expect(buildFrameUpdate(next, frame("snippet", "next"), 16, 2)).toBe(""); + }); + + it("overwrites every cell skipped by a source tab instead of leaving old text", () => { + const update = buildFrameUpdate(` ${TAB}X`, "abcdefghijklmnop", 16, 1); + expect(terminalCells(["abcdefghijklmnop"], update, 16)).toEqual([" X"]); + expect(update).not.toContain(TAB); + }); + + it("expands tabs using visible Unicode columns, ignoring SGR styles", () => { + const next = style.red(`前${TAB}`) + `X${TAB}Y`; + const update = buildFrameUpdate(next, "old", 24, 1); + expect(terminalCells(["old"], update, 24)).toEqual(["前 X Y"]); + expect(update).not.toContain(TAB); + expect(buildFrameUpdate(next, style.red("前 ") + "X Y", 24, 1)).toBe(""); + }); + + it("measures complete graphemes across SGR boundaries before a tab", () => { + const emoji = "👩‍💻"; + const styledEmoji = Array.from(emoji, (character) => style.dim(character)).join(""); + const update = buildFrameUpdate(styledEmoji + TAB + "X", "old", 24, 1); + expect(stripAnsi(update)).toBe(emoji + " ".repeat(6) + "X"); + }); + + it("preserves the final cell of a full-width ASCII or wide-glyph line", () => { + for (const next of ["12345678", "123456前", style.red("12345678")]) { + const update = buildFrameUpdate(next, "old", 8, 1); + expect(terminalCells(["old"], update, 8)).toEqual([stripAnsi(next)]); + expect(update).not.toContain(ANSI_ERASE_LINE_END); + } + }); + + it("scrolls tab-indented source over previously occupied snippet cells", () => { + const groups: RepoGroup[] = ["abcdefghijklmnop", `${TAB}X`].map((fragment, i) => ({ + repoFullName: `org/repository-${i}`, + matches: [ + { + path: "file.ts", + repoFullName: `org/repository-${i}`, + htmlUrl: "https://github.com/org/repository/blob/main/file.ts", + archived: false, + textMatches: [{ fragment, matches: [] }], + }, + ], + folded: false, + repoSelected: true, + extractSelected: [true], + })); + const rows = buildRows(groups); + const render = (cursor: number, scroll: number) => + renderGroups(groups, cursor, rows, 10, scroll, "query", "org", { termWidth: 32 }); + const previous = render(1, 0); + const next = render(3, 2); + const initialCells = terminalCells( + Array(10).fill(""), + buildFrameUpdate(previous, null, 32, 10), + 32, + ); + const cells = terminalCells(initialCells, buildFrameUpdate(next, previous, 32, 10), 32); + expect(cells).toContain(" X"); + expect(cells.some((line) => line.includes("abcdefghijklmnop"))).toBe(false); + }); }); diff --git a/src/render/frame.ts b/src/render/frame.ts index 6b27664..da42945 100644 --- a/src/render/frame.ts +++ b/src/render/frame.ts @@ -1,4 +1,44 @@ -import { clipToWidth } from "./terminal.ts"; +import { clipToWidth, visibleWidth } from "./terminal.ts"; +import { + ANSI_CSI, + ANSI_CURSOR_HOME, + ANSI_ERASE_DISPLAY_END, + ANSI_ERASE_LINE_END, + ANSI_SGR_PATTERN, + ANSI_STYLE_RESET, + CARRIAGE_RETURN, + LINE_FEED, + TAB, + TERMINAL_TAB_WIDTH, + cursorPosition, +} from "../terminal-control.ts"; + +function normalizeLine(line: string): string { + const withoutCarriageReturns = line.replaceAll(CARRIAGE_RETURN, ""); + if (!withoutCarriageReturns.includes(TAB)) return withoutCarriageReturns; + let column = 0; + let textSinceTab = ""; + return withoutCarriageReturns + .split(ANSI_SGR_PATTERN) + .map((part) => { + if (part.startsWith(ANSI_CSI)) return part; + return part + .split(TAB) + .map((text, index) => { + if (index === 0) { + textSinceTab += text; + return text; + } + column += visibleWidth(textSinceTab); + const padding = TERMINAL_TAB_WIDTH - (column % TERMINAL_TAB_WIDTH); + column += padding; + textSinceTab = text; + return " ".repeat(padding) + text; + }) + .join(""); + }) + .join(""); +} /** Build one in-place update, leaving unchanged terminal lines untouched. */ export function buildFrameUpdate( @@ -9,9 +49,9 @@ export function buildFrameUpdate( ): string { const toLines = (frame: string) => frame - .split("\n") + .split(LINE_FEED) .slice(0, Math.max(1, termHeight)) - .map((line) => clipToWidth(line, Math.max(1, termWidth))); + .map((line) => clipToWidth(normalizeLine(line), Math.max(1, termWidth))); const lines = toLines(rendered); const oldLines = previous === null ? [] : toLines(previous); const updates: string[] = []; @@ -19,12 +59,16 @@ export function buildFrameUpdate( for (let i = 0; i < lines.length; i++) { if (lines[i] === oldLines[i]) continue; // Overwrite before erasing the tail so no blank frame is exposed (issue #251). - updates.push(`\x1b[${i + 1};1H\x1b[0m${lines[i]}\x1b[0m\x1b[K`); + // At the right margin an erase would remove the last cell, not an empty tail. + const eraseTail = visibleWidth(lines[i]) < termWidth ? ANSI_ERASE_LINE_END : ""; + updates.push( + cursorPosition(i + 1) + ANSI_STYLE_RESET + lines[i] + ANSI_STYLE_RESET + eraseTail, + ); } if ((previous === null || lines.length < oldLines.length) && lines.length < termHeight) { - updates.push(`\x1b[${lines.length + 1};1H\x1b[J`); + updates.push(cursorPosition(lines.length + 1) + ANSI_ERASE_DISPLAY_END); } - return updates.length > 0 ? updates.join("") + "\x1b[H" : ""; + return updates.length > 0 ? updates.join("") + ANSI_CURSOR_HOME : ""; } diff --git a/src/render/highlight.ts b/src/render/highlight.ts index 5152a3f..803efe1 100644 --- a/src/render/highlight.ts +++ b/src/render/highlight.ts @@ -1,5 +1,6 @@ import * as style from "../style.ts"; import type { TextMatchSegment } from "../types.ts"; +import { LINE_FEED } from "../terminal-control.ts"; // ─── Language detection ─────────────────────────────────────────────────────── @@ -310,7 +311,7 @@ export function highlightFragment( maxLineChars = MAX_LINE_CHARS, ): string[] { const lang = detectLang(filePath); - const rawLines = fragment.split("\n"); + const rawLines = fragment.split(LINE_FEED); const result: string[] = []; let offset = 0; @@ -345,7 +346,7 @@ export function highlightFragment( } result.push(colored); - offset += line.length + 1; // +1 for the consumed '\n' + offset += line.length + LINE_FEED.length; } if (rawLines.length > MAX_FRAGMENT_LINES) { diff --git a/src/render/mouse.ts b/src/render/mouse.ts index fc30322..4902334 100644 --- a/src/render/mouse.ts +++ b/src/render/mouse.ts @@ -1,5 +1,6 @@ // Mouse event parsing for SGR-mode terminal mouse reporting. // SGR format: CSI [< button ; x ; y M/m where M=press, m=release +import { SGR_MOUSE_PREFIX } from "../terminal-control.ts"; export interface MouseEvent { button: number; @@ -12,7 +13,7 @@ export interface MouseEvent { * Parses an SGR-format mouse event sequence. * Returns a structured event or null if the sequence doesn't match or is malformed. * - * SGR format: \x1b[ { + it("preserves the established cursor, erase and line-control encodings", () => { + expect(control.ESCAPE).toBe("\x1b"); + expect(control.ANSI_CSI).toBe("\x1b["); + expect(control.LINE_FEED).toBe("\n"); + expect(control.CARRIAGE_RETURN).toBe("\r"); + expect(control.TAB).toBe("\t"); + expect(control.ANSI_CURSOR_HOME).toBe("\x1b[H"); + expect(control.ANSI_CLEAR).toBe("\x1b[2J\x1b[H"); + expect(control.ANSI_ERASE_LINE).toBe("\x1b[2K"); + expect(control.ANSI_ERASE_LINE_END).toBe("\x1b[K"); + expect(control.ANSI_ERASE_DISPLAY_END).toBe("\x1b[J"); + expect(control.ANSI_STYLE_RESET).toBe("\x1b[0m"); + expect(control.cursorPosition(3, 5)).toBe("\x1b[3;5H"); + expect(control.cursorPosition(2)).toBe("\x1b[2;1H"); + expect(control.TERMINAL_TAB_WIDTH).toBe(8); + }); + + it("preserves existing input and mouse-reporting protocols", () => { + const keys = [ + [control.ANSI_ARROW_UP, "\x1b[A"], + [control.ANSI_ARROW_DOWN, "\x1b[B"], + [control.ANSI_ARROW_LEFT, "\x1b[D"], + [control.ANSI_ARROW_RIGHT, "\x1b[C"], + [control.KEY_CTRL_C, "\x03"], + [control.KEY_CTRL_A, "\x01"], + [control.KEY_CTRL_E, "\x05"], + [control.KEY_CTRL_W, "\x17"], + [control.KEY_CTRL_U, "\x15"], + [control.KEY_CTRL_D, "\x04"], + [control.KEY_ALT_BACKSPACE, "\x1b\x7f"], + [control.KEY_CTRL_ARROW_LEFT, "\x1b[1;5D"], + [control.KEY_CTRL_ARROW_RIGHT, "\x1b[1;5C"], + [control.KEY_ALT_ARROW_LEFT, "\x1b[1;3D"], + [control.KEY_ALT_ARROW_RIGHT, "\x1b[1;3C"], + [control.KEY_ALT_B, "\x1bb"], + [control.KEY_ALT_F, "\x1bf"], + [control.KEY_END, "\x1b[F"], + [control.KEY_DELETE, "\x1b[3~"], + [control.KEY_SHIFT_TAB, "\x1b[Z"], + [control.KEY_PAGE_UP, "\x1b[5~"], + [control.KEY_PAGE_DOWN, "\x1b[6~"], + [control.ANSI_ENABLE_MOUSE_REPORTING, "\x1b[?1000h\x1b[?1006h"], + [control.ANSI_DISABLE_MOUSE_REPORTING, "\x1b[?1000l\x1b[?1006l"], + [control.SGR_MOUSE_PREFIX, "\x1b[<"], + ]; + for (const [actual, expected] of keys) expect(actual).toBe(expected); + }); +}); + +const encodedControl = /\\(?:x(?:0[0-9a-f]|1[0-9a-f]|7f)|u00(?:0[0-9a-f]|1[0-9a-f]|7f))/i; +const encodedLineControl = /\\[rnt]/; +const transpiler = new Bun.Transpiler({ loader: "ts" }); + +describe("terminal control architecture guard", () => { + it("detects raw controls, including interpolated line breaks, but ignores comments", () => { + expect(transpiler.transformSync(String.raw`const value = "\x1b[H";`)).toMatch(encodedControl); + expect(transpiler.transformSync(String.raw`const value = "\u001b[H";`)).toMatch(encodedControl); + expect(transpiler.transformSync(String.raw`const value = "\x03";`)).toMatch(encodedControl); + for (const escaped of [String.raw`\r`, String.raw`\n`, String.raw`\t`]) { + expect(`const value = "text${escaped}";`).toMatch(encodedLineControl); + } + expect("const value = `text\\n${name}`;").toMatch(encodedLineControl); + expect(transpiler.transformSync(String.raw`// example: "\x1b[H"`)).not.toMatch(encodedControl); + expect(transpiler.transformSync("const value = ANSI_CURSOR_HOME + LINE_FEED;")).not.toMatch( + encodedLineControl, + ); + }); + + it("keeps raw control bytes out of first-party code and named line controls in terminal surfaces", () => { + const root = new URL("../", import.meta.url); + const files = [...new Bun.Glob("src/**/*.ts").scanSync({ cwd: fileURLToPath(root) })]; + files.push("github-code-search.ts"); + const terminalSurfaces = new Set([ + "github-code-search.ts", + "src/api.ts", + "src/tui.ts", + "src/render.ts", + "src/render/frame.ts", + "src/render/terminal.ts", + ]); + const legacyProtocolFixtures = new Set([ + "src/api.test.ts", + "src/render.test.ts", + "src/style.test.ts", + "src/render/highlight.test.ts", + "src/render/mouse.test.ts", + "src/render/team-pick.test.ts", + "src/render/terminal.test.ts", + ]); + for (const path of files) { + if ( + path === "src/terminal-control.ts" || + path === "src/terminal-control.test.ts" || + legacyProtocolFixtures.has(path) + ) + continue; + const source = readFileSync(new URL(path, root), "utf8"); + const code = transpiler.transformSync(source); + expect( + encodedControl.test(code), + `${path}: import control constants from terminal-control.ts`, + ).toBe(false); + if ( + terminalSurfaces.has(path) || + (path.startsWith("src/render/") && !path.endsWith("highlight.ts")) + ) { + expect( + encodedLineControl.test(source), + `${path}: use LINE_FEED, CARRIAGE_RETURN or TAB`, + ).toBe(false); + } + } + }); +}); diff --git a/src/terminal-control.ts b/src/terminal-control.ts new file mode 100644 index 0000000..85bb3de --- /dev/null +++ b/src/terminal-control.ts @@ -0,0 +1,58 @@ +// Shared terminal protocol vocabulary. Keep encoded control bytes in this module. +export const ESCAPE = "\x1b"; +export const LINE_FEED = "\n"; +export const CARRIAGE_RETURN = "\r"; +export const TAB = "\t"; +export const ANSI_CSI = `${ESCAPE}[`; +export const ANSI_CURSOR_HOME = `${ANSI_CSI}H`; +export const ANSI_ERASE_DISPLAY = `${ANSI_CSI}2J`; +export const ANSI_CLEAR = ANSI_ERASE_DISPLAY + ANSI_CURSOR_HOME; +export const ANSI_ERASE_LINE = `${ANSI_CSI}2K`; +export const ANSI_ERASE_LINE_END = `${ANSI_CSI}K`; +export const ANSI_ERASE_DISPLAY_END = `${ANSI_CSI}J`; +export const ANSI_STYLE_RESET = `${ANSI_CSI}0m`; +export const ANSI_FOREGROUND_RESET = `${ANSI_CSI}39m`; +export const ANSI_BACKGROUND_RESET = `${ANSI_CSI}49m`; +export const ANSI_BOLD_FOREGROUND_RESET = `${ANSI_CSI}22;39m`; +export const ANSI_ACTIVE_BACKGROUND = `${ANSI_CSI}48;5;53m`; +export const ANSI_ACCENT_FOREGROUND = `${ANSI_CSI}38;5;129m`; +export const ANSI_COUNT_FOREGROUND = `${ANSI_CSI}38;5;99m`; +export const ANSI_MUTED_FOREGROUND = `${ANSI_CSI}38;5;240m`; +export const ANSI_UNDERLINE = `${ANSI_CSI}4m`; +export const ANSI_UNDERLINE_OFF = `${ANSI_CSI}24m`; +export const ANSI_INVERSE = `${ANSI_CSI}7m`; +export const ANSI_INVERSE_OFF = `${ANSI_CSI}27m`; +export const ANSI_SGR_PATTERN = new RegExp(`(${ANSI_CSI.replace("[", "\\[")}[0-9;]*m)`, "g"); +export const TERMINAL_TAB_WIDTH = 8; + +export function cursorPosition(row: number, column = 1): string { + return `${ANSI_CSI}${row};${column}H`; +} + +export const ANSI_ARROW_UP = `${ANSI_CSI}A`; +export const ANSI_ARROW_DOWN = `${ANSI_CSI}B`; +export const ANSI_ARROW_LEFT = `${ANSI_CSI}D`; +export const ANSI_ARROW_RIGHT = `${ANSI_CSI}C`; +export const KEY_CTRL_C = "\x03"; +export const KEY_END = `${ANSI_CSI}F`; +export const KEY_CTRL_A = "\x01"; +export const KEY_CTRL_E = "\x05"; +export const KEY_CTRL_W = "\x17"; +export const KEY_ALT_BACKSPACE = `${ESCAPE}\x7f`; +export const KEY_CTRL_ARROW_LEFT = `${ANSI_CSI}1;5D`; +export const KEY_CTRL_ARROW_RIGHT = `${ANSI_CSI}1;5C`; +export const KEY_ALT_ARROW_LEFT = `${ANSI_CSI}1;3D`; +export const KEY_ALT_ARROW_RIGHT = `${ANSI_CSI}1;3C`; +export const KEY_ALT_B = `${ESCAPE}b`; +export const KEY_ALT_F = `${ESCAPE}f`; +export const KEY_DELETE = `${ANSI_CSI}3~`; +export const KEY_SHIFT_TAB = `${ANSI_CSI}Z`; +export const KEY_PAGE_UP = `${ANSI_CSI}5~`; +export const KEY_PAGE_DOWN = `${ANSI_CSI}6~`; +export const KEY_CTRL_U = "\x15"; +export const KEY_CTRL_D = "\x04"; + +// SGR mouse reporting: enable/disable both basic events and SGR encoding. +export const ANSI_ENABLE_MOUSE_REPORTING = `${ANSI_CSI}?1000h${ANSI_CSI}?1006h`; +export const ANSI_DISABLE_MOUSE_REPORTING = `${ANSI_CSI}?1000l${ANSI_CSI}?1006l`; +export const SGR_MOUSE_PREFIX = `${ANSI_CSI}<`; diff --git a/src/tui.ts b/src/tui.ts index 0cfcb39..17dcde8 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -37,42 +37,38 @@ import { import { hitTestClick } from "./render/mouse-hit.ts"; import type { FilterTarget, OutputFormat, OutputType, RepoGroup, Row } from "./types.ts"; -// ─── Key binding constants ──────────────────────────────────────────────────── - -const ANSI_CLEAR = "\x1b[2J\x1b[H"; -const ANSI_ARROW_UP = "\x1b[A"; -const ANSI_ARROW_DOWN = "\x1b[B"; -const ANSI_ARROW_LEFT = "\x1b[D"; -const ANSI_ARROW_RIGHT = "\x1b[C"; -const KEY_CTRL_C = "\u0003"; -const KEY_ENTER_CR = "\r"; -const KEY_ENTER_LF = "\n"; -// Cursor / word navigation (filter mode) -const KEY_TAB = "\t"; -const KEY_HOME = "\x1b[H"; -const KEY_END = "\x1b[F"; -const KEY_CTRL_A = "\x01"; -const KEY_CTRL_E = "\x05"; -const KEY_CTRL_W = "\x17"; -const KEY_ALT_BACKSPACE = "\x1b\x7f"; - -// ─── Terminal mouse reporting (SGR protocol) ────────────────────────────────── -// https://en.wikipedia.org/wiki/X11_mouse_protocol#SGR_1006_Protocol -// Enable/disable both basic mouse support (?1000) and SGR encoding (?1006) -const ANSI_ENABLE_MOUSE_REPORTING = "\x1b[?1000h\x1b[?1006h"; -const ANSI_DISABLE_MOUSE_REPORTING = "\x1b[?1000l\x1b[?1006l"; -const KEY_CTRL_ARROW_LEFT = "\x1b[1;5D"; -const KEY_CTRL_ARROW_RIGHT = "\x1b[1;5C"; -const KEY_ALT_ARROW_LEFT = "\x1b[1;3D"; // Alt/Option+← (xterm, iTerm2 with Use Option as Meta key) -const KEY_ALT_ARROW_RIGHT = "\x1b[1;3C"; // Alt/Option+→ -const KEY_ALT_B = "\x1bb"; -const KEY_ALT_F = "\x1bf"; -const KEY_DELETE = "\x1b[3~"; -const KEY_SHIFT_TAB = "\x1b[Z"; // Shift+Tab — cycle filter target in filter mode -const KEY_PAGE_UP = "\x1b[5~"; // Page Up — scroll up one page -const KEY_PAGE_DOWN = "\x1b[6~"; // Page Down — scroll down one page -const KEY_CTRL_U = "\x15"; // Ctrl+U — page up (Vim-style) -const KEY_CTRL_D = "\x04"; // Ctrl+D — page down (Vim-style) +import { + ANSI_CLEAR, + ANSI_ARROW_UP, + ANSI_ARROW_DOWN, + ANSI_ARROW_LEFT, + ANSI_ARROW_RIGHT, + ANSI_ENABLE_MOUSE_REPORTING, + ANSI_DISABLE_MOUSE_REPORTING, + ESCAPE, + KEY_CTRL_C, + CARRIAGE_RETURN as KEY_ENTER_CR, + LINE_FEED as KEY_ENTER_LF, + TAB as KEY_TAB, + ANSI_CURSOR_HOME as KEY_HOME, + KEY_END, + KEY_CTRL_A, + KEY_CTRL_E, + KEY_CTRL_W, + KEY_ALT_BACKSPACE, + KEY_CTRL_ARROW_LEFT, + KEY_CTRL_ARROW_RIGHT, + KEY_ALT_ARROW_LEFT, + KEY_ALT_ARROW_RIGHT, + KEY_ALT_B, + KEY_ALT_F, + KEY_DELETE, + KEY_SHIFT_TAB, + KEY_PAGE_UP, + KEY_PAGE_DOWN, + KEY_CTRL_U, + KEY_CTRL_D, +} from "./terminal-control.ts"; // ─── Word-boundary helpers ──────────────────────────────────────────────────── @@ -493,7 +489,7 @@ export async function runInteractive( const newRows = buildRows(groups, filterPath, filterTarget, filterRegex); cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); scrollOffset = Math.min(scrollOffset, cursor); - } else if (key === "\x1b" && !key.startsWith("\x1b[") && !key.startsWith("\x1b\x1b")) { + } else if (key === ESCAPE) { // Esc — cancel with no change teamPickMode = { active: false, @@ -555,7 +551,7 @@ export async function runInteractive( cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); scrollOffset = Math.min(scrollOffset, cursor); repickMode = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; - } else if (key === "\x1b" && !key.startsWith("\x1b[") && !key.startsWith("\x1b\x1b")) { + } else if (key === ESCAPE) { // Esc — cancel re-pick mode repickMode = { active: false, repoIndex: -1, candidates: [], focusedIndex: 0 }; } else if (key === "t") { @@ -570,7 +566,7 @@ export async function runInteractive( if (filterMode) { if (key === KEY_CTRL_C) { exit(); - } else if (key === "\x1b" && !key.startsWith("\x1b[") && !key.startsWith("\x1b\x1b")) { + } else if (key === ESCAPE) { // ESC (bare) — cancel filter input filterMode = false; filterInput = ""; @@ -652,7 +648,7 @@ export async function runInteractive( cursor = Math.min(cursor, Math.max(0, newRows.length - 1)); scrollOffset = Math.min(scrollOffset, cursor); scheduleStatsUpdate(); - } else if (!key.startsWith("\x1b")) { + } else if (!key.startsWith(ESCAPE)) { // Printable character(s) — insert at cursor. // Handles both single keystrokes and paste (multi-char chunk). // Discard control chars (code < 32 or DEL 127) without using a regex @@ -719,7 +715,7 @@ export async function runInteractive( } // `h` / `?` / Esc — toggle help overlay (Esc closes only, h/? toggle) - if (key === "h" || key === "?" || (key === "\x1b" && showHelp)) { + if (key === "h" || key === "?" || (key === ESCAPE && showHelp)) { showHelp = !showHelp; redraw(); continue; From 1facfddfa6e197376fb3ad1b97105da1f4f2509a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Thu, 29 Oct 2026 13:11:19 +0100 Subject: [PATCH 3/3] Escape all CSI brackets when building the SGR pattern Address the CodeQL incomplete-escaping finding and verify the wire-format matcher preserves surrounding text. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/terminal-control.test.ts | 10 ++++++++++ src/terminal-control.ts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/terminal-control.test.ts b/src/terminal-control.test.ts index ba4c2ae..5f91973 100644 --- a/src/terminal-control.test.ts +++ b/src/terminal-control.test.ts @@ -52,6 +52,16 @@ describe("terminal control vocabulary", () => { ]; for (const [actual, expected] of keys) expect(actual).toBe(expected); }); + + it("recognizes complete SGR controls without consuming their surrounding text", () => { + expect("\x1b[31mred\x1b[39m".split(control.ANSI_SGR_PATTERN)).toEqual([ + "", + "\x1b[31m", + "red", + "\x1b[39m", + "", + ]); + }); }); const encodedControl = /\\(?:x(?:0[0-9a-f]|1[0-9a-f]|7f)|u00(?:0[0-9a-f]|1[0-9a-f]|7f))/i; diff --git a/src/terminal-control.ts b/src/terminal-control.ts index 85bb3de..ff22120 100644 --- a/src/terminal-control.ts +++ b/src/terminal-control.ts @@ -22,7 +22,7 @@ export const ANSI_UNDERLINE = `${ANSI_CSI}4m`; export const ANSI_UNDERLINE_OFF = `${ANSI_CSI}24m`; export const ANSI_INVERSE = `${ANSI_CSI}7m`; export const ANSI_INVERSE_OFF = `${ANSI_CSI}27m`; -export const ANSI_SGR_PATTERN = new RegExp(`(${ANSI_CSI.replace("[", "\\[")}[0-9;]*m)`, "g"); +export const ANSI_SGR_PATTERN = new RegExp(`(${ANSI_CSI.replaceAll("[", "\\[")}[0-9;]*m)`, "g"); export const TERMINAL_TAB_WIDTH = 8; export function cursorPosition(row: number, column = 1): string {