Skip to content
Draft
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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"version-packages": "changeset version && bun run format"
},
"dependencies": {
"@clack/core": "1.4.1",
"@clack/prompts": "1.5.1",
"@napi-rs/keyring": "1.3.0",
"@oxc-node/core": "0.1.0",
Expand Down
3 changes: 3 additions & 0 deletions src/core/ansi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@ import { displayWidth } from "./displayWidth.js";
// the outer one off.
export const bold = (text: string): string => `\x1b[1m${text}\x1b[22m`;
export const dim = (text: string): string => `\x1b[2m${text}\x1b[22m`;
export const strike = (text: string): string => `\x1b[9m${text}\x1b[29m`;
export const inverse = (text: string): string => `\x1b[7m${text}\x1b[27m`;
export const cyan = (text: string): string => `\x1b[36m${text}\x1b[39m`;

export const visibleLength = displayWidth;
24 changes: 22 additions & 2 deletions src/core/displayWidth.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "bun:test";
import { stripVTControlCharacters } from "node:util";

import { displayWidth, padColumns } from "./displayWidth.js";
import { clipColumns, displayWidth, padColumns } from "./displayWidth.js";

describe("display columns", () => {
it.each([
Expand All @@ -16,7 +17,26 @@ describe("display columns", () => {
expect(displayWidth(text)).toBe(width);
});

it("pads by display columns", () => {
it("clips at grapheme boundaries and preserves ANSI resets", () => {
const clipped = clipColumns("\x1b[2m登录👩🏽‍💻text\x1b[22m", 6);
expect(stripVTControlCharacters(clipped)).toBe("登录…");
expect(clipped).toEndWith("\x1b[22m");
expect(clipColumns("caféx", 5)).toBe("caféx");
expect(clipColumns("caféxy", 5)).toBe("café…");
});

it("keeps a whole Unicode suffix for paths", () => {
expect(clipColumns("prefix/登录.ts", 8, true)).toBe("…登录.ts");
});

it("preserves ANSI spans across grapheme clusters", () => {
const clipped = clipColumns("e\x1b[2ḿx👩🏽‍💻\x1b[22m", 3);
expect(stripVTControlCharacters(clipped)).toBe("éx…");
expect(clipped).toEndWith("\x1b[22m");
});

it("returns nothing for zero available columns and pads by columns", () => {
expect(clipColumns("abc", 0)).toBe("");
expect(padColumns("登录", 6)).toBe("登录 ");
});
});
62 changes: 62 additions & 0 deletions src/core/displayWidth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,68 @@
import { stripVTControlCharacters } from "node:util";
import stringWidth from "fast-string-width";

const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
const escapeSequence =
// oxlint-disable-next-line no-control-regex
/\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g;

export const displayWidth = (text: string): number => stringWidth(text);

function styledSlice(
text: string,
start: number,
end: number,
keepEnd: boolean,
): string {
let output = keepEnd ? "…" : "";
let offset = 0;
let cursor = 0;
let ended = false;
const append = (part: string): void => {
const from = Math.max(0, start - offset);
const to = Math.max(0, Math.min(part.length, end - offset));
output += part.slice(from, to);
offset += part.length;
if (!keepEnd && !ended && offset >= end) {
output += "…";
ended = true;
}
};
// Keep escape sequences outside the slice too: their resets close styles
// opened before the clipping boundary, including styles around the cursor.
for (const match of text.matchAll(escapeSequence)) {
append(text.slice(cursor, match.index));
output += match[0];
cursor = match.index + match[0].length;
}
append(text.slice(cursor));
return output;
}

export function clipColumns(
text: string,
width: number,
keepEnd = false,
): string {
if (width <= 0) return "";
if (displayWidth(text) <= width) return text;
const plain = stripVTControlCharacters(text);
const segments = [...segmenter.segment(plain)];
if (keepEnd) segments.reverse();
let used = 0;
let length = 0;
for (const { segment } of segments) {
used += displayWidth(segment);
if (used > width - 1) break;
length += segment.length;
}
return styledSlice(
text,
keepEnd ? plain.length - length : 0,
keepEnd ? plain.length : length,
keepEnd,
);
}

export const padColumns = (text: string, width: number): string =>
`${text}${" ".repeat(Math.max(0, width - displayWidth(text)))}`;
53 changes: 53 additions & 0 deletions src/shell/ui/renderers/altScreen.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { expect, it } from "bun:test";

import { openAltScreen } from "./altScreen.js";

function terminal(columns: number, rows: number) {
const cells = Array.from({ length: rows }, () =>
Array<string>(columns).fill(" "),
);
let x = 0;
let y = 0;
return {
lines: () => cells.map((row) => row.join("")),
write(chunk: string) {
// A terminal keeps the cursor in its last column until another printable
// character wraps it; erase commands act on that last column immediately.
// oxlint-disable-next-line no-control-regex
for (const token of chunk.match(/\x1b\[[\d;?]*[A-Za-z]|[^]/g) ?? []) {
if (token === "\x1b[H") {
x = 0;
y = 0;
} else if (token === "\x1b[K" || token === "\x1b[J") {
cells[y]?.fill(" ", x);
if (token === "\x1b[J")
for (let row = y + 1; row < rows; row++) cells[row]?.fill(" ");
} else if (token === "\r") x = 0;
else if (token === "\n") y++;
else if (!token.startsWith("\x1b")) {
const row = cells[y];
if (row) row[x] = token;
x = Math.min(columns - 1, x + 1);
}
}
return true;
},
};
}

it("keeps the final cell of rows that fill the terminal width", () => {
const output = terminal(10, 2);
const screen = openAltScreen(output);
screen.paint("1234567890\nabcdefghij");
expect(output.lines()).toEqual(["1234567890", "abcdefghij"]);
screen.close();
});

it("clears stale cells and rows when the next frame gets smaller", () => {
const output = terminal(10, 3);
const screen = openAltScreen(output);
screen.paint("1234567890\nabcdefghij\n0123456789");
screen.paint("short\nx");
expect(output.lines()).toEqual(["short ", "x ", " "]);
screen.close();
});
46 changes: 46 additions & 0 deletions src/shell/ui/renderers/altScreen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// The alternate screen prevents resized frames from entering scrollback and
// restores the user's original screen when the view closes.
const enterAltScreen = "\x1b[?1049h";
const leaveAltScreen = "\x1b[?1049l";
const hideCursor = "\x1b[?25l";
const showCursor = "\x1b[?25h";
// Synchronized output: supporting terminals show each frame whole rather than
// half-written; the rest ignore the sequence.
const beginFrame = "\x1b[?2026h\x1b[H";
const endFrame = "\x1b[?2026l";
const clearToLineEnd = "\x1b[K";
const clearToScreenEnd = "\x1b[J";

type Writer = { write(chunk: string): unknown };

export function openAltScreen(output: Writer): {
paint: (frame: string) => void;
close: () => void;
} {
let open = true;
const close = (): void => {
if (!open) return;
open = false;
process.off("exit", close);
output.write(`${showCursor}${leaveAltScreen}`);
};
// Handed back however the view ends — even if the process exits first.
process.once("exit", close);
output.write(`${enterAltScreen}${hideCursor}`);

return {
paint(frame) {
if (!open) return;
// Clear before writing: after an exact-width line, the cursor still
// occupies its last cell and a trailing erase would remove that cell.
const lines = frame
.split("\n")
.map(
(line, index, all) =>
`${index === all.length - 1 ? clearToScreenEnd : clearToLineEnd}${line}`,
);
output.write(`${beginFrame}${lines.join("\r\n")}${endFrame}`);
},
close,
};
}
97 changes: 97 additions & 0 deletions src/shell/ui/renderers/debouncedResize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, mock } from "bun:test";

import { sleep } from "~/core/sleep.js";

import { withDebouncedResize } from "./debouncedResize.js";

/** `later` calls back the way a real terminal does, after the write returns. */
function fakeTerminal(callBack: "now" | "later" = "now") {
const writes: string[] = [];
return Object.assign(new EventEmitter(), {
columns: 100,
rows: 30,
isTTY: true,
writes,
write(
chunk: Uint8Array | string,
callback: (error?: Error | null) => void,
) {
writes.push(String(chunk));
if (callBack === "now") callback();
else setImmediate(() => callback());
return true;
},
});
}

const settleMs = 20;

describe("withDebouncedResize", () => {
it("passes writes straight through to the terminal", async () => {
const terminal = fakeTerminal();
const { output, dispose } = withDebouncedResize(terminal, settleMs);

await new Promise<void>((resolve) =>
output.write("frame", () => resolve()),
);

expect(terminal.writes.join("")).toBe("frame");
dispose();
});

// A write held back until the terminal calls back is overtaken by whatever
// is written straight to the terminal meanwhile — a command's own output
// landing above the prompt's summary.
it("hands each write on at once, even while the terminal has yet to call back", () => {
const terminal = fakeTerminal("later");
const { output, dispose } = withDebouncedResize(terminal, settleMs);

output.write("summary");
output.write("\n");

expect(terminal.writes).toEqual(["summary", "\n"]);
dispose();
});

it("reports the terminal's size as it is now, not as it was", () => {
const terminal = fakeTerminal();
const { output, dispose } = withDebouncedResize(terminal, settleMs);

terminal.columns = 70;
terminal.rows = 18;

expect(Reflect.get(output, "columns")).toBe(70);
expect(Reflect.get(output, "rows")).toBe(18);
dispose();
});

// Dragging a window edge fires a resize for every size on the way.
it("turns a burst of resizes into one, after they stop", async () => {
const terminal = fakeTerminal();
const { output, dispose } = withDebouncedResize(terminal, settleMs);
const resized = mock();
output.on("resize", resized);

for (let i = 0; i < 10; i += 1) terminal.emit("resize");
expect(resized).not.toHaveBeenCalled();
await sleep(settleMs * 3);

expect(resized).toHaveBeenCalledTimes(1);
dispose();
});

it("drops a pending resize once disposed", async () => {
const terminal = fakeTerminal();
const { output, dispose } = withDebouncedResize(terminal, settleMs);
const resized = mock();
output.on("resize", resized);

terminal.emit("resize");
dispose();
terminal.emit("resize");
await sleep(settleMs * 3);

expect(resized).not.toHaveBeenCalled();
});
});
62 changes: 62 additions & 0 deletions src/shell/ui/renderers/debouncedResize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Writable } from "node:stream";

/** The parts of a terminal stream a prompt draws with; `process.stdout` is one. */
export type TerminalStream = {
readonly columns?: number | undefined;
readonly rows?: number | undefined;
readonly isTTY?: boolean | undefined;
write(
chunk: Uint8Array | string,
callback: (error?: Error | null) => void,
): boolean;
on(event: "resize", listener: () => void): unknown;
off(event: "resize", listener: () => void): unknown;
};

/**
* A stand-in for `terminal` whose "resize" fires once, after the window stops
* changing size, instead of once for every size it passes through.
*/
export function withDebouncedResize(
terminal: TerminalStream,
settleMs: number,
): { output: Writable; dispose: () => void } {
const output = new Writable({
decodeStrings: false,
// Handed on and called back at once. A terminal calls back later, and
// until then this stream would hold further writes in a queue of its own —
// letting what is written straight to the terminal in the meantime, such
// as what a command prints once the prompt ends, overtake them. The
// terminal keeps its own queue, in order.
write(chunk: Uint8Array | string, _encoding, callback) {
terminal.write(chunk, () => {});
callback();
},
});
// Read live rather than copied: a prompt measures the stream it draws to on
// every frame, and the frame must match the window as it is now.
for (const key of ["columns", "rows", "isTTY"] as const) {
Object.defineProperty(output, key, {
enumerable: true,
get: () => terminal[key],
});
}

let timer: ReturnType<typeof setTimeout> | undefined;
const onResize = (): void => {
if (timer !== undefined) clearTimeout(timer);
timer = setTimeout(() => {
timer = undefined;
output.emit("resize");
}, settleMs);
};
terminal.on("resize", onResize);

return {
output,
dispose() {
terminal.off("resize", onResize);
if (timer !== undefined) clearTimeout(timer);
},
};
}
Loading
Loading