fix(desktop): harden terminal mounted boundaries

Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
This commit is contained in:
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
2026-08-01 21:36:41 -04:00
parent 56721b9698
commit ef0decaef5
4 changed files with 342 additions and 12 deletions
+2
View File
@@ -91,9 +91,11 @@
"@tanstack/router-plugin": "^1.167.12",
"@tanstack/virtual-file-routes": "^1.161.7",
"@tauri-apps/cli": "~2.11",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^6.0.0",
"jsdom": "^27.4.0",
"nostr-tools": "^2.23.3",
"postcss": "^8.5.8",
"tailwindcss": "^4.3.0",
@@ -0,0 +1,233 @@
import assert from "node:assert/strict";
import { after, before, beforeEach, test } from "node:test";
import { JSDOM } from "jsdom";
let act;
let cleanup;
let createElement;
let fireEvent;
let render;
let waitFor;
let ThemeProvider;
let TerminalSubstrate;
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
before(async () => {
Object.assign(globalThis, {
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
HTMLCanvasElement: dom.window.HTMLCanvasElement,
KeyboardEvent: dom.window.KeyboardEvent,
window: dom.window,
IS_REACT_ACT_ENVIRONMENT: true,
});
dom.window.matchMedia = () => ({
matches: false,
addEventListener() {},
removeEventListener() {},
});
dom.window.HTMLElement.prototype.animate = () => ({
cancel() {},
currentTime: 0,
finished: new Promise(() => {}),
play() {},
playbackRate: 1,
reverse() {},
});
({ act, cleanup, fireEvent, render, waitFor } = await import(
"@testing-library/react"
));
({ createElement } = await import("react"));
({ ThemeProvider } = await import("@/shared/theme/ThemeProvider"));
({ TerminalSubstrate } = await import("./TerminalSubstrate.tsx"));
});
after(() => dom.window.close());
beforeEach(() => {
cleanup?.();
dom.window.localStorage.clear();
dom.window.HTMLCanvasElement.prototype.getContext = () => ({
fillRect() {},
fillStyle: "",
fillText() {},
font: "",
restore() {},
save() {},
setTransform() {},
textBaseline: "",
});
});
function fixture(overrides = {}) {
const calls = { input: [], scroll: [] };
const props = {
bracketedPaste: false,
channelName: "terminal-test",
focusReportingEnabled: false,
onCloseSession() {},
onInput(value) {
calls.input.push(value);
},
onNewSession() {},
onScroll(lines) {
calls.scroll.push(lines);
},
onSelectSession() {},
onTerminalFocusChange() {},
sessions: [{ active: true, closing: false, id: "one", title: "SHELL" }],
...overrides,
};
const view = render(
createElement(
ThemeProvider,
null,
createElement("div", {
className: "buzz-huddle-app-surface",
tabIndex: -1,
}),
createElement(TerminalSubstrate, props),
),
);
return { calls, props, view };
}
async function ready(view) {
await waitFor(() =>
assert.ok(view.container.querySelector(".buzz-terminal-substrate")),
);
}
function toggleChord(composing = false) {
const init = {
bubbles: true,
code: "KeyJ",
isComposing: composing,
metaKey: true,
};
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", init));
window.dispatchEvent(new KeyboardEvent("keyup", init));
});
}
test("mounted IME paths neither toggle nor emit preedit text", async () => {
const { calls, view } = fixture();
await ready(view);
const substrate = view.container.querySelector(".buzz-terminal-substrate");
toggleChord(true);
assert.equal(substrate.dataset.terminalOwner, "buzz");
toggleChord();
await waitFor(() =>
assert.equal(substrate.dataset.terminalOwner, "terminal"),
);
const input = view.getByLabelText("Terminal input");
fireEvent.compositionStart(input);
fireEvent.input(input, { target: { value: "k" } });
fireEvent.input(input, { target: { value: "か" } });
assert.deepEqual(calls.input, []);
fireEvent.compositionEnd(input, { data: "か" });
assert.deepEqual(calls.input, ["か"]);
});
test("welcome survives non-intersecting frame and dismisses on input or intersecting damage", async () => {
const baseFrame = {
cursor: { column: 0, line: 0, visible: false },
full: false,
rows: [
{
line: 0,
spans: [
{
style: { fg: 0, bg: 0, flags: 0 },
clusters: [{ column: 0, text: "$", width: 1 }],
},
],
},
],
viewport: { columns: 80, generation: 1, screenLines: 24 },
};
const { view } = fixture({ frame: baseFrame });
await ready(view);
assert.ok(view.container.querySelector(".buzz-terminal-welcome"));
toggleChord();
const input = view.getByLabelText("Terminal input");
fireEvent.input(input, { target: { value: "x" } });
await waitFor(() =>
assert.equal(view.container.querySelector(".buzz-terminal-welcome"), null),
);
cleanup();
const intersecting = fixture({
frame: {
...baseFrame,
rows: [
{
line: 8,
spans: [
{
style: { fg: 0, bg: 0, flags: 0 },
clusters: [{ column: 0, text: "x", width: 1 }],
},
],
},
],
},
});
await ready(intersecting.view);
await waitFor(() =>
assert.equal(
intersecting.view.container.querySelector(".buzz-terminal-welcome"),
null,
),
);
});
test("mounted wheel path accumulates fractional lines per active session", async () => {
const { calls, view } = fixture();
await ready(view);
const substrate = view.container.querySelector(".buzz-terminal-substrate");
fireEvent.wheel(substrate, { deltaMode: 0, deltaY: 8 });
assert.deepEqual(calls.scroll, []);
fireEvent.wheel(substrate, { deltaMode: 0, deltaY: 10 });
assert.deepEqual(calls.scroll, [1]);
});
test("canvas failure atomically restores Buzz ownership", async () => {
const { props, view } = fixture();
await ready(view);
const substrate = view.container.querySelector(".buzz-terminal-substrate");
toggleChord();
await waitFor(() =>
assert.equal(substrate.dataset.terminalOwner, "terminal"),
);
dom.window.HTMLCanvasElement.prototype.getContext = () => null;
view.rerender(
createElement(
ThemeProvider,
null,
createElement("div", {
className: "buzz-huddle-app-surface",
tabIndex: -1,
}),
createElement(TerminalSubstrate, {
...props,
frame: {
cursor: { column: 0, line: 0, visible: false },
full: false,
rows: [],
viewport: { columns: 80, generation: 1, screenLines: 24 },
},
}),
),
);
await waitFor(() => assert.equal(substrate.dataset.terminalOwner, "buzz"));
toggleChord();
await waitFor(() =>
assert.equal(substrate.dataset.terminalOwner, "terminal"),
);
});
@@ -5,9 +5,12 @@ import { cn } from "@/shared/lib/cn";
import { FadeController } from "./fadeController";
import {
INITIAL_HANDOFF_STATE,
accumulateScrollLines,
encodePaste,
encodeTerminalKey,
reduceHandoff,
updateWelcomeForOutput,
type WelcomeState,
} from "./terminalState";
import { type TerminalFrame, TerminalGrid } from "./terminalRenderer";
@@ -42,6 +45,42 @@ function isToggleChord(event: KeyboardEvent): boolean {
);
}
const CELL_HEIGHT = 17;
const WELCOME_COLUMNS = 25;
const WELCOME_LINES = 4;
function welcomeRect(frame: TerminalFrame) {
const top = Math.max(
0,
Math.floor(frame.viewport.screenLines * 0.42 - WELCOME_LINES / 2),
);
const left = Math.max(
0,
Math.floor((frame.viewport.columns - WELCOME_COLUMNS) / 2),
);
return {
top,
left,
bottom: top + WELCOME_LINES,
right: left + WELCOME_COLUMNS,
};
}
function frameDamageRects(frame: TerminalFrame) {
return frame.rows
.filter((row) =>
row.spans.some((span) =>
span.clusters.some((cluster) => cluster.text.trim().length > 0),
),
)
.map((row) => ({
top: row.line,
left: 0,
bottom: row.line + 1,
right: frame.viewport.columns,
}));
}
export function TerminalSubstrate({
appSurfaceRef,
channelName,
@@ -65,7 +104,13 @@ export function TerminalSubstrate({
const paintedPaletteRef = React.useRef(terminalPalette);
const previousFocusRef = React.useRef<HTMLElement | null>(null);
const reportedFocusRef = React.useRef<boolean | null>(null);
const scrollBySessionRef = React.useRef(new Map<string, number>());
const activeSession = sessions.find((session) => session.active);
const [owner, setOwner] = React.useState<"buzz" | "terminal">("buzz");
const [welcome, setWelcome] = React.useState<WelcomeState>({
visible: true,
reserved: null,
});
const shortcutLabel = /Mac|iPhone|iPad/.test(navigator.platform)
? "⌘J"
: "CTRL+J";
@@ -103,6 +148,18 @@ export function TerminalSubstrate({
setOwner(next);
});
const forceBuzzFallback = React.useEffectEvent(() => {
handoffRef.current = { ...INITIAL_HANDOFF_STATE };
commitOwner("buzz");
fadeRef.current?.settle("conceal");
});
const sendInput = React.useEffectEvent((text: string) => {
if (!text) return;
setWelcome((current) => ({ ...current, visible: false }));
onInput(text);
});
React.useEffect(() => {
if (!focusReportingEnabled) {
reportedFocusRef.current = null;
@@ -129,6 +186,12 @@ export function TerminalSubstrate({
fadeRef.current = new FadeController(appSurface);
const handleKeyDown = (event: KeyboardEvent) => {
if (!isToggleChord(event)) return;
if (event.isComposing) {
handoffRef.current = reduceHandoff(handoffRef.current, {
type: "focus-lost",
}).state;
return;
}
event.preventDefault();
event.stopImmediatePropagation();
const result = reduceHandoff(handoffRef.current, {
@@ -173,6 +236,12 @@ export function TerminalSubstrate({
React.useEffect(() => {
if (frame) {
setWelcome((current) =>
updateWelcomeForOutput(
{ ...current, reserved: welcomeRect(frame) },
frameDamageRects(frame),
),
);
const current = gridRef.current;
if (!current) gridRef.current = new TerminalGrid(frame.viewport);
else if (
@@ -186,11 +255,14 @@ export function TerminalSubstrate({
}
const canvas = canvasRef.current;
if (!canvas || !terminalPalette) return;
if (!canvas) return;
if (!terminalPalette) {
forceBuzzFallback();
return;
}
const context = canvas.getContext("2d", { alpha: false });
if (!context) {
commitOwner("buzz");
fadeRef.current?.settle("conceal");
forceBuzzFallback();
return;
}
const dpr = window.devicePixelRatio || 1;
@@ -234,7 +306,21 @@ export function TerminalSubstrate({
style={terminalStyle}
onWheel={(event) => {
event.preventDefault();
onScroll(event.deltaY);
const sessionId = activeSession?.id;
if (!sessionId) return;
const deltaPx =
event.deltaMode === 1
? event.deltaY * CELL_HEIGHT
: event.deltaMode === 2
? event.deltaY * event.currentTarget.clientHeight
: event.deltaY;
const result = accumulateScrollLines(
{ remainderPx: scrollBySessionRef.current.get(sessionId) ?? 0 },
deltaPx,
CELL_HEIGHT,
);
scrollBySessionRef.current.set(sessionId, result.state.remainderPx);
if (result.lines !== 0) onScroll(result.lines);
}}
>
<div className="buzz-terminal-contract-bar">
@@ -289,7 +375,7 @@ export function TerminalSubstrate({
</div>
<div className="buzz-terminal-viewport">
<canvas ref={canvasRef} />
{!frame ? (
{welcome.visible ? (
<div className="buzz-terminal-welcome" aria-hidden="true">
<pre>{`┌─╲ BUZZ SUBSTRATE ╱─┐
│ B U Z Z /\\_/\\ │
@@ -302,10 +388,13 @@ export function TerminalSubstrate({
autoCapitalize="off"
autoComplete="off"
className="buzz-terminal-input"
onCompositionEnd={() => {
onCompositionEnd={(event) => {
handoffRef.current = reduceHandoff(handoffRef.current, {
type: "composition-end",
}).state;
const committed = event.currentTarget.value;
event.currentTarget.value = "";
sendInput(committed);
}}
onCompositionStart={() => {
handoffRef.current = reduceHandoff(handoffRef.current, {
@@ -313,10 +402,10 @@ export function TerminalSubstrate({
}).state;
}}
onInput={(event) => {
if (event.currentTarget.value) {
onInput(event.currentTarget.value);
event.currentTarget.value = "";
}
if (handoffRef.current.composing) return;
const committed = event.currentTarget.value;
event.currentTarget.value = "";
sendInput(committed);
}}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) {
@@ -327,12 +416,12 @@ export function TerminalSubstrate({
const encoded = encodeTerminalKey(event);
if (encoded) {
event.preventDefault();
onInput(encoded);
sendInput(encoded);
}
}}
onPaste={(event) => {
event.preventDefault();
onInput(
sendInput(
encodePaste(
event.clipboardData.getData("text/plain"),
bracketedPaste,
+6
View File
@@ -262,6 +262,9 @@ importers:
'@tauri-apps/cli':
specifier: ~2.11
version: 2.11.2
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@types/react':
specifier: ^19.1.8
version: 19.2.17
@@ -271,6 +274,9 @@ importers:
'@vitejs/plugin-react':
specifier: ^6.0.0
version: 6.0.3(vite@8.0.16(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))
jsdom:
specifier: ^27.4.0
version: 27.4.0(@noble/hashes@2.2.0)
nostr-tools:
specifier: ^2.23.3
version: 2.23.12(typescript@6.0.3)