mirror of
https://github.com/block/buzz.git
synced 2026-11-03 16:01:35 +00:00
## Summary - ship **Buzz Term** end to end: the terminal engine/runtime, mounted desktop substrate, and user-visible naming - add Quinn's tape-deck-inspired banner: a beveled chassis filled by the `buzz term` wordmark, surrounded by a complete-hex field - derive the wordmark's three-stop sweep from each theme's terminal palette so primary, secondary, and accent roles remain visibly distinct across all 62 shipped themes, including light themes - paint the banner once on its own pointer-transparent canvas; PTY rendering beneath it remains unchanged ## Banner behavior - uses the renderer's shared `8.4 × 17` cell metrics and production aspect ratio `2.0238` - regenerates only for viewport/theme changes; palette switches repaint correctly while the banner is visible - dismisses on non-empty output from the active terminal session; empty output and inactive sessions do not dismiss it - fails closed below **70 columns** rather than squeezing or clipping the wordmark - adds **8 lines** to `terminalRenderer.ts` for shared cell metrics and **zero lines inside `paint()`** ## Screenshots | Buzz (light) | Buzz Dark | |---|---| |  |  | | Kanagawa Lotus (light) | Red | |---|---| |  |  | Additional production-aspect finals: [Vesper](https://buzz.block.builderlab.xyz/media/9ca6514b63f8cfb2107a85ca46f16a940c0883848e6fbc718e411af94aa13100.png), [Min Dark](https://buzz.block.builderlab.xyz/media/f67bd2970e5d64ffb07b1ae78ab58c847e6ebc23e7e7a48e067eb024dba64ec8.png), and [Dark Plus](https://buzz.block.builderlab.xyz/media/290fee08924f37d064abc687ecf3e9526ab05b87e8e56d610f23048949793dbe.png). The screenshot harness was checked against the shipped painter at this exact head: all **2,541 draw calls** matched on color, glyph, x, and y; four deliberate divergence controls fired. ## Verification at `98ebc8f9048bd5f0ceb7e843b67874d642f0b7fd` - desktop tests: **3,946 / 3,946** - TypeScript: clean - checks: pass (two pre-existing informational `useTemplate` notices only) - integration/e2e: PASS (independent exact-SHA lane; artifacts recorded in the originating Buzz thread) - artifact/dead-path sweep: clean - redteam G1–G7: PASS - all six named banner emitter-deletion mutants die - independent handwritten five-row full-wordmark fixture kills Quinn's seven-mutant battery, including a one-pixel glyph change - real `112 × 46` canvas-rect dismissal tests separately cover active non-empty, active empty, and inactive non-empty output - layer-drop and zero-draw painter mutants die; z-order and pointer-events verified - CI's `tsc && vite build` includes all three banner modules - performance at DPR 2 (worst-case measured envelope): - one-time content paint: **~0.7–0.8 ms**, paid only when the banner is built or its palette changes - busy compositor, CSS `1277 × 697`, backing `2554 × 1394`: **470–497 µs/frame** for the full banner (**2.82–2.98%** of a 60 Hz frame) - busy compositor, CSS `1920 × 1080`, backing `3840 × 2160`: **1,139–1,212 µs/frame** (**6.83–7.27%**) - empty, one-glyph, and full-banner controls converge: compositor cost follows backing-layer area and DPR rather than painted-cell count - in the actual idle welcome state, cost is below both vsync-clamped rigs' resolution; it is not claimed as zero - **Pane cross-rig spread: resolved at matched loop rate.** Two independent rigs initially differed 2.3× (58–68 vs 136 µs/Mpx of backing store; pane, CSS 1277×697 / backing 2554×1394, DPR 2). The cause of *that* spread is rAF loop rate: the higher figure came from a free-running loop at ~1600fps. Throttled to ~200–236fps, both rigs read 58–68 µs/Mpx (1.25–1.44% of a 60Hz frame). The busy-composite figures quoted above remain the **unthrottled worst case** and are conservative by ~2.3× at the pane. Not established: the mechanism and sign of free-running distortion (one rig under-charges ~15%, the other over-charges 2.3×), and the 1080p figure has not been re-measured throttled. - the layer paints only on generation/theme/resize and dismisses on first non-empty active-session output, so the measurable busy cost is a short-lived worst case rather than a persistent PTY paint-path tax ## Follow-ups in this PR These are intentionally subsequent commits after the certified static-banner head, not claims about `98ebc8f90`: 1. close the compositor metrology: remeasure the 1080p point throttled and characterize the opposite-sign free-running rAF distortion, with each measurement regime stated 2. add Tyler's animated honeycomb color waves, gated by `prefers-reduced-motion`, a full 62-theme phase-sweep contrast check, and DPR-2 per-tick performance certification 3. land the already-proven mounted theme-switch regression probe from `RESEARCH/BUZZ_TERM_G3A_PROBE/` 4. bound the slow/hang-shaped G1-c mutant `waitFor` 5. optionally trim the generator to its ink bounding box, reducing the minimum viewport from 70 to 62 columns --------- Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz> Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@buzz.block.builderlab.xyz> Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz> Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
628 lines
23 KiB
TypeScript
628 lines
23 KiB
TypeScript
import * as React from "react";
|
||
|
||
import { useTheme } from "@/shared/theme/ThemeProvider";
|
||
import { cn } from "@/shared/lib/cn";
|
||
import { isMacPlatform } from "@/shared/lib/platform";
|
||
import { FadeController } from "./fadeController";
|
||
import {
|
||
INITIAL_HANDOFF_STATE,
|
||
accumulateScrollLines,
|
||
encodePaste,
|
||
encodeTerminalKey,
|
||
matchTabChord,
|
||
reduceHandoff,
|
||
stepSession,
|
||
} from "./terminalState";
|
||
import { buildTerminalBanner } from "./terminalBanner";
|
||
import { paintTerminalBanner } from "./terminalBannerPainter";
|
||
import { buildBannerColorTable, phaseAt } from "./terminalBannerWave";
|
||
import {
|
||
TERMINAL_CELL_METRICS,
|
||
type TerminalFrame,
|
||
TerminalGrid,
|
||
} from "./terminalRenderer";
|
||
|
||
export type TerminalViewportSize = {
|
||
columns: number;
|
||
rows: number;
|
||
pixelWidth: number;
|
||
pixelHeight: number;
|
||
};
|
||
|
||
export type TerminalSessionTab = {
|
||
id: string;
|
||
title: string;
|
||
closing: boolean;
|
||
active: boolean;
|
||
};
|
||
|
||
type TerminalSubstrateProps = {
|
||
appSurfaceRef?: React.RefObject<HTMLDivElement | null>;
|
||
channelName: string | null;
|
||
frame?: TerminalFrame;
|
||
sessionFrames?: readonly { sessionId: string; frame: TerminalFrame }[];
|
||
sessions: readonly TerminalSessionTab[];
|
||
bracketedPaste: boolean;
|
||
focusReportingEnabled: boolean;
|
||
enabled?: boolean;
|
||
onFrameConsumed?: (frame: TerminalFrame) => void;
|
||
onViewportSize?: (size: TerminalViewportSize) => void;
|
||
onInput: (text: string) => void;
|
||
/** Whole cells scrolled, keeping the DOM's sign: negative goes back. */
|
||
onScroll: (lines: number) => void;
|
||
onTerminalFocusChange: (focused: boolean) => void;
|
||
onSelectSession: (id: string) => void;
|
||
onCloseSession: (id: string) => void;
|
||
onNewSession: () => void;
|
||
};
|
||
|
||
function isToggleChord(event: KeyboardEvent): boolean {
|
||
return (
|
||
event.code === "KeyJ" &&
|
||
(event.metaKey || event.ctrlKey) &&
|
||
!event.altKey &&
|
||
!event.shiftKey
|
||
);
|
||
}
|
||
|
||
const { width: CELL_WIDTH, height: CELL_HEIGHT } = TERMINAL_CELL_METRICS;
|
||
|
||
function hasVisibleOutput(frame: TerminalFrame): boolean {
|
||
return frame.rows.some((row) =>
|
||
row.spans.some((span) =>
|
||
span.clusters.some((cluster) => cluster.text.trim().length > 0),
|
||
),
|
||
);
|
||
}
|
||
|
||
export function TerminalSubstrate({
|
||
appSurfaceRef,
|
||
channelName,
|
||
frame,
|
||
sessionFrames,
|
||
sessions,
|
||
bracketedPaste,
|
||
focusReportingEnabled,
|
||
enabled = true,
|
||
onFrameConsumed,
|
||
onViewportSize,
|
||
onInput,
|
||
onScroll,
|
||
onTerminalFocusChange,
|
||
onSelectSession,
|
||
onCloseSession,
|
||
onNewSession,
|
||
}: TerminalSubstrateProps) {
|
||
const { terminalPalette } = useTheme();
|
||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||
const bannerCanvasRef = React.useRef<HTMLCanvasElement>(null);
|
||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||
const fadeRef = React.useRef<FadeController | null>(null);
|
||
const handoffRef = React.useRef(INITIAL_HANDOFF_STATE);
|
||
const gridsRef = React.useRef(new Map<string, TerminalGrid>());
|
||
const appliedFramesRef = React.useRef(new WeakSet<TerminalFrame>());
|
||
const gridRef = React.useRef<TerminalGrid | null>(null);
|
||
const paintedPaletteRef = React.useRef(terminalPalette);
|
||
const paintedSessionRef = React.useRef<string | null>(null);
|
||
const previousFocusRef = React.useRef<HTMLElement | null>(null);
|
||
const reportedFocusRef = React.useRef<boolean | null>(null);
|
||
const scrollBySessionRef = React.useRef(new Map<string, number>());
|
||
const revealedRef = React.useRef(false);
|
||
const activeSession = sessions.find((session) => session.active);
|
||
const activeSessionId = activeSession?.id ?? null;
|
||
const frames = React.useMemo(
|
||
() =>
|
||
sessionFrames ??
|
||
(activeSessionId && frame ? [{ sessionId: activeSessionId, frame }] : []),
|
||
[activeSessionId, frame, sessionFrames],
|
||
);
|
||
const [owner, setOwner] = React.useState<"buzz" | "terminal">("buzz");
|
||
const [viewport, setViewport] = React.useState({ columns: 1, rows: 1 });
|
||
const [welcomeVisible, setWelcomeVisible] = React.useState(true);
|
||
const [cursorPainted, setCursorPainted] = React.useState(true);
|
||
const [cursorReset, setCursorReset] = React.useState(0);
|
||
const [reducedMotion, setReducedMotion] = React.useState(
|
||
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||
);
|
||
const shortcutLabel = /Mac|iPhone|iPad/.test(navigator.platform)
|
||
? "⌘J"
|
||
: "CTRL+J";
|
||
const getAppSurface = React.useCallback(
|
||
() =>
|
||
appSurfaceRef?.current ??
|
||
document.querySelector<HTMLDivElement>(".buzz-huddle-app-surface"),
|
||
[appSurfaceRef],
|
||
);
|
||
const banner = React.useMemo(
|
||
() =>
|
||
buildTerminalBanner(
|
||
viewport.columns,
|
||
viewport.rows,
|
||
CELL_HEIGHT / CELL_WIDTH,
|
||
),
|
||
[viewport.columns, viewport.rows],
|
||
);
|
||
const terminalStyle = terminalPalette
|
||
? ({
|
||
"--buzz-terminal-background": terminalPalette.background,
|
||
"--buzz-terminal-foreground": terminalPalette.foreground,
|
||
} as React.CSSProperties)
|
||
: undefined;
|
||
|
||
const commitOwner = React.useEffectEvent((next: "buzz" | "terminal") => {
|
||
const appSurface = getAppSurface();
|
||
if (!appSurface) return;
|
||
if (next === "terminal") {
|
||
revealedRef.current = true;
|
||
previousFocusRef.current =
|
||
document.activeElement instanceof HTMLElement
|
||
? document.activeElement
|
||
: null;
|
||
appSurface.inert = true;
|
||
appSurface.setAttribute("aria-hidden", "true");
|
||
textareaRef.current?.focus({ preventScroll: true });
|
||
} else {
|
||
appSurface.inert = false;
|
||
appSurface.removeAttribute("aria-hidden");
|
||
const previous = previousFocusRef.current;
|
||
if (previous?.isConnected) previous.focus({ preventScroll: true });
|
||
else appSurface.focus({ preventScroll: true });
|
||
}
|
||
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;
|
||
setWelcomeVisible(false);
|
||
setCursorPainted(true);
|
||
setCursorReset((current) => current + 1);
|
||
onInput(text);
|
||
});
|
||
const consumeFrame = React.useEffectEvent((nextFrame: TerminalFrame) => {
|
||
onFrameConsumed?.(nextFrame);
|
||
});
|
||
/**
|
||
* Tab chords are handled at the window in capture phase, like the ⌘J
|
||
* handoff, so they win over the focused textarea. Gated on terminal
|
||
* ownership: in Buzz mode these keys belong to the rest of the app.
|
||
*/
|
||
const runTabChord = React.useEffectEvent((event: KeyboardEvent): boolean => {
|
||
if (owner !== "terminal" || event.isComposing) return false;
|
||
const chord = matchTabChord(event, isMacPlatform());
|
||
if (!chord) return false;
|
||
if (chord === "new") {
|
||
onNewSession();
|
||
return true;
|
||
}
|
||
if (chord === "close") {
|
||
// A tab already closing has a disabled × in the tab bar; re-firing close
|
||
// on it would send a second shutdown for a session on its way out.
|
||
if (!activeSession || activeSession.closing) return true;
|
||
onCloseSession(activeSession.id);
|
||
return true;
|
||
}
|
||
const next = stepSession(sessions, chord === "next" ? 1 : -1);
|
||
if (next) onSelectSession(next);
|
||
return true;
|
||
});
|
||
const reportViewportSize = React.useEffectEvent(() => {
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return;
|
||
const bounds = canvas.getBoundingClientRect();
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const pixelWidth = Math.max(1, Math.round(bounds.width * dpr));
|
||
const pixelHeight = Math.max(1, Math.round(bounds.height * dpr));
|
||
const columns = Math.max(1, Math.floor(bounds.width / CELL_WIDTH));
|
||
const rows = Math.max(1, Math.floor(bounds.height / CELL_HEIGHT));
|
||
setViewport((current) =>
|
||
current.columns === columns && current.rows === rows
|
||
? current
|
||
: { columns, rows },
|
||
);
|
||
onViewportSize?.({ columns, rows, pixelWidth, pixelHeight });
|
||
});
|
||
|
||
React.useEffect(() => {
|
||
if (!enabled) forceBuzzFallback();
|
||
}, [enabled]);
|
||
|
||
React.useEffect(() => {
|
||
const preference = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||
const update = () => setReducedMotion(preference.matches);
|
||
preference.addEventListener("change", update);
|
||
return () => preference.removeEventListener("change", update);
|
||
}, []);
|
||
|
||
// Input activity is intentionally an effect trigger: restarting this timer is
|
||
// what resets the blink phase even when the cursor is already painted.
|
||
// biome-ignore lint/correctness/useExhaustiveDependencies: cursorReset restarts the timer by design.
|
||
React.useEffect(() => {
|
||
setCursorPainted(true);
|
||
if (owner !== "terminal" || reducedMotion) return;
|
||
const timer = window.setInterval(
|
||
() => setCursorPainted((painted) => !painted),
|
||
500,
|
||
);
|
||
return () => window.clearInterval(timer);
|
||
}, [cursorReset, owner, reducedMotion]);
|
||
|
||
React.useEffect(() => {
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return;
|
||
reportViewportSize();
|
||
const ResizeObserverConstructor = window.ResizeObserver;
|
||
if (!ResizeObserverConstructor) return;
|
||
const observer = new ResizeObserverConstructor(reportViewportSize);
|
||
observer.observe(canvas);
|
||
return () => observer.disconnect();
|
||
}, []);
|
||
|
||
React.useEffect(() => {
|
||
if (!focusReportingEnabled) {
|
||
reportedFocusRef.current = null;
|
||
return;
|
||
}
|
||
const reportCompositeFocus = () => {
|
||
const focused = owner === "terminal" && document.hasFocus();
|
||
if (reportedFocusRef.current === focused) return;
|
||
reportedFocusRef.current = focused;
|
||
onTerminalFocusChange(focused);
|
||
};
|
||
reportCompositeFocus();
|
||
window.addEventListener("focus", reportCompositeFocus);
|
||
window.addEventListener("blur", reportCompositeFocus);
|
||
return () => {
|
||
window.removeEventListener("focus", reportCompositeFocus);
|
||
window.removeEventListener("blur", reportCompositeFocus);
|
||
};
|
||
}, [focusReportingEnabled, onTerminalFocusChange, owner]);
|
||
|
||
React.useLayoutEffect(() => {
|
||
const appSurface = getAppSurface();
|
||
if (!appSurface) return;
|
||
fadeRef.current = new FadeController(appSurface);
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (!enabled) return;
|
||
if (runTabChord(event)) {
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
return;
|
||
}
|
||
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, {
|
||
type: "chord-down",
|
||
repeat: event.repeat,
|
||
});
|
||
handoffRef.current = result.state;
|
||
};
|
||
const handleKeyUp = (event: KeyboardEvent) => {
|
||
if (!enabled || !isToggleChord(event)) return;
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
if (event.isComposing) return;
|
||
const result = reduceHandoff(handoffRef.current, { type: "chord-up" });
|
||
handoffRef.current = result.state;
|
||
if (!result.toggled) return;
|
||
commitOwner(result.state.owner);
|
||
fadeRef.current?.toggle(
|
||
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||
);
|
||
};
|
||
const cancelChord = () => {
|
||
handoffRef.current = reduceHandoff(handoffRef.current, {
|
||
type: "focus-lost",
|
||
}).state;
|
||
};
|
||
window.addEventListener("keydown", handleKeyDown, true);
|
||
window.addEventListener("keyup", handleKeyUp, true);
|
||
window.addEventListener("blur", cancelChord);
|
||
document.addEventListener("visibilitychange", cancelChord);
|
||
return () => {
|
||
fadeRef.current?.settle("conceal");
|
||
fadeRef.current = null;
|
||
appSurface.inert = false;
|
||
appSurface.removeAttribute("aria-hidden");
|
||
window.removeEventListener("keydown", handleKeyDown, true);
|
||
window.removeEventListener("keyup", handleKeyUp, true);
|
||
window.removeEventListener("blur", cancelChord);
|
||
document.removeEventListener("visibilitychange", cancelChord);
|
||
};
|
||
}, [enabled, getAppSurface]);
|
||
|
||
// The banner's animation loop. It runs only while the splash is ON SCREEN,
|
||
// which needs BOTH conditions below — they are different questions:
|
||
// - `welcomeVisible`: the splash has not been dismissed by terminal output.
|
||
// - `owner === "terminal"`: the terminal layer is revealed at all.
|
||
//
|
||
// `owner` is the load-bearing one and it is not optional. This substrate is
|
||
// mounted unconditionally by AppShell on every route and merely CSS-concealed
|
||
// in Buzz mode (`.buzz-terminal-substrate` is `position:absolute; inset:0`),
|
||
// and `welcomeVisible` starts `true` and only clears on terminal INPUT. So a
|
||
// loop gated on `welcomeVisible` alone runs forever behind the whole app for
|
||
// anyone who never opens the terminal — measured at 120 rAF/s in the channel
|
||
// view, repainting a canvas nobody can see and slowing every other paint.
|
||
//
|
||
// Deliberately NOT gated on `enabled`: that is `available && Boolean(active)`
|
||
// where `available` is `isTauri()`, and a session is auto-created on channel
|
||
// open (TerminalBootstrap), so `enabled` is true while still concealed in the
|
||
// app and permanently false in the browser — it would gate the tests green
|
||
// and leave real users paying the cost. `owner` is user-gestured in both.
|
||
//
|
||
// `prefers-reduced-motion` takes the STATIC path (no motion argument), which
|
||
// is the shipped painter call, not a paused animation. Those are different:
|
||
// a stopped loop still parks on whatever phase it halted at.
|
||
React.useEffect(() => {
|
||
const canvas = bannerCanvasRef.current;
|
||
if (!canvas || !banner || !terminalPalette || !welcomeVisible) return;
|
||
if (owner !== "terminal") return;
|
||
|
||
const dpr = window.devicePixelRatio || 1;
|
||
if (reducedMotion) {
|
||
if (!paintTerminalBanner(canvas, banner, terminalPalette, dpr))
|
||
setWelcomeVisible(false);
|
||
return;
|
||
}
|
||
|
||
// Built once per palette, not per frame: the table is phase-independent.
|
||
const table = buildBannerColorTable(terminalPalette);
|
||
const start = performance.now();
|
||
let frame = 0;
|
||
const tick = (now: number) => {
|
||
if (
|
||
!paintTerminalBanner(canvas, banner, terminalPalette, dpr, {
|
||
phase: phaseAt((now - start) / 1000),
|
||
table,
|
||
})
|
||
) {
|
||
setWelcomeVisible(false);
|
||
return;
|
||
}
|
||
frame = window.requestAnimationFrame(tick);
|
||
};
|
||
frame = window.requestAnimationFrame(tick);
|
||
return () => window.cancelAnimationFrame(frame);
|
||
}, [banner, owner, reducedMotion, terminalPalette, welcomeVisible]);
|
||
|
||
React.useEffect(() => {
|
||
for (const delivered of frames) {
|
||
if (appliedFramesRef.current.has(delivered.frame)) continue;
|
||
appliedFramesRef.current.add(delivered.frame);
|
||
let grid = gridsRef.current.get(delivered.sessionId);
|
||
if (!grid) {
|
||
grid = new TerminalGrid(delivered.frame.viewport);
|
||
gridsRef.current.set(delivered.sessionId, grid);
|
||
} else if (
|
||
grid.viewport.generation !== delivered.frame.viewport.generation ||
|
||
grid.viewport.columns !== delivered.frame.viewport.columns ||
|
||
grid.viewport.screenLines !== delivered.frame.viewport.screenLines
|
||
) {
|
||
grid.resize(delivered.frame.viewport);
|
||
}
|
||
grid.apply(delivered.frame);
|
||
consumeFrame(delivered.frame);
|
||
if (
|
||
delivered.sessionId === activeSessionId &&
|
||
revealedRef.current &&
|
||
hasVisibleOutput(delivered.frame)
|
||
) {
|
||
// Policy: the banner is a splash for the reveal, so spawn-time shell
|
||
// output must not dismiss it. Only visible output from the active PTY
|
||
// that arrives after the terminal has been revealed (or the first
|
||
// keystroke, see sendInput) removes the overlay.
|
||
setWelcomeVisible(false);
|
||
}
|
||
}
|
||
gridRef.current = activeSessionId
|
||
? (gridsRef.current.get(activeSessionId) ?? null)
|
||
: null;
|
||
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return;
|
||
if (!terminalPalette) {
|
||
forceBuzzFallback();
|
||
return;
|
||
}
|
||
const context = canvas.getContext("2d", { alpha: false });
|
||
if (!context) {
|
||
forceBuzzFallback();
|
||
return;
|
||
}
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const bounds = canvas.getBoundingClientRect();
|
||
const pixelWidth = Math.round(bounds.width * dpr);
|
||
const pixelHeight = Math.round(bounds.height * dpr);
|
||
const resized =
|
||
canvas.width !== pixelWidth || canvas.height !== pixelHeight;
|
||
const paletteChanged = paintedPaletteRef.current !== terminalPalette;
|
||
paintedPaletteRef.current = terminalPalette;
|
||
// A grid drains its dirty set in paint(), so a session that was painted and
|
||
// then deactivated comes back holding rows with nothing marked dirty. Both
|
||
// refs are written after the early returns above, so a pass that bails
|
||
// keeps the switch pending instead of swallowing it.
|
||
const sessionChanged = paintedSessionRef.current !== activeSessionId;
|
||
paintedSessionRef.current = activeSessionId;
|
||
const repaintAll = resized || paletteChanged || sessionChanged;
|
||
if (resized) {
|
||
canvas.width = pixelWidth;
|
||
canvas.height = pixelHeight;
|
||
}
|
||
if (repaintAll) {
|
||
gridRef.current?.markAllDirty();
|
||
}
|
||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
if (repaintAll) {
|
||
// Not grid-guarded: switching to a session that has delivered no frame
|
||
// yet has no grid to mark or paint, so this fill is the only thing that
|
||
// erases the outgoing session's pixels.
|
||
context.fillStyle = terminalPalette.background;
|
||
context.fillRect(0, 0, bounds.width, bounds.height);
|
||
}
|
||
gridRef.current?.setCursorPainted(cursorPainted);
|
||
gridRef.current?.paint(context, TERMINAL_CELL_METRICS, terminalPalette);
|
||
}, [activeSessionId, cursorPainted, frames, terminalPalette]);
|
||
|
||
const runTabAction = (action: () => void) => {
|
||
action();
|
||
if (owner === "terminal") {
|
||
textareaRef.current?.focus({ preventScroll: true });
|
||
}
|
||
};
|
||
|
||
return (
|
||
<section
|
||
aria-label="Buzz Term"
|
||
className="buzz-terminal-substrate"
|
||
data-terminal-owner={owner}
|
||
style={terminalStyle}
|
||
onWheel={(event) => {
|
||
event.preventDefault();
|
||
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">
|
||
<div className="buzz-terminal-tabs" role="tablist">
|
||
{sessions.map((session, index) => (
|
||
<div
|
||
className={cn(
|
||
"buzz-terminal-tab",
|
||
session.active && "buzz-terminal-tab-active",
|
||
)}
|
||
key={session.id}
|
||
role="presentation"
|
||
>
|
||
<button
|
||
aria-selected={session.active}
|
||
className="buzz-terminal-tab-select"
|
||
disabled={session.closing}
|
||
onClick={() => runTabAction(() => onSelectSession(session.id))}
|
||
role="tab"
|
||
type="button"
|
||
>
|
||
<span className="buzz-terminal-designator">
|
||
SYS.{String(index + 1).padStart(2, "0")}
|
||
</span>
|
||
<span>{session.closing ? "CLOSING" : session.title}</span>
|
||
</button>
|
||
<button
|
||
aria-label={`Close ${session.title}`}
|
||
className="buzz-terminal-close"
|
||
disabled={session.closing}
|
||
onClick={() => runTabAction(() => onCloseSession(session.id))}
|
||
type="button"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
<button
|
||
aria-label="New Buzz Term tab"
|
||
className="buzz-terminal-new-tab"
|
||
onClick={() => runTabAction(onNewSession)}
|
||
type="button"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
<div className="buzz-terminal-readout">
|
||
<span>{channelName ? `#${channelName}` : "BUZZ"}</span>
|
||
<span>LOCAL PTY · PRIVATE</span>
|
||
<span>{shortcutLabel} BUZZ</span>
|
||
</div>
|
||
</div>
|
||
{/* biome-ignore lint/a11y/noStaticElementInteractions: the hidden textarea owns keyboard semantics; this only preserves its focus across canvas clicks. */}
|
||
<div
|
||
className="buzz-terminal-viewport"
|
||
onMouseDown={(event) => {
|
||
if (owner !== "terminal") return;
|
||
// Preventing the canvas mousedown also suppresses selection. Revisit
|
||
// this when the terminal gains mouse selection support.
|
||
event.preventDefault();
|
||
textareaRef.current?.focus({ preventScroll: true });
|
||
}}
|
||
>
|
||
<canvas ref={canvasRef} />
|
||
{welcomeVisible && banner ? (
|
||
<canvas className="buzz-terminal-welcome" ref={bannerCanvasRef} />
|
||
) : null}
|
||
<textarea
|
||
aria-label="Terminal input"
|
||
autoCapitalize="off"
|
||
autoComplete="off"
|
||
className="buzz-terminal-input"
|
||
onCompositionEnd={() => {
|
||
handoffRef.current = reduceHandoff(handoffRef.current, {
|
||
type: "composition-end",
|
||
}).state;
|
||
}}
|
||
onCompositionStart={() => {
|
||
handoffRef.current = reduceHandoff(handoffRef.current, {
|
||
type: "composition-start",
|
||
}).state;
|
||
}}
|
||
onInput={(event) => {
|
||
if (handoffRef.current.composing) return;
|
||
const committed = event.currentTarget.value;
|
||
event.currentTarget.value = "";
|
||
sendInput(committed);
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (event.nativeEvent.isComposing) {
|
||
event.stopPropagation();
|
||
return;
|
||
}
|
||
if (isToggleChord(event.nativeEvent)) return;
|
||
const encoded = encodeTerminalKey(event);
|
||
if (encoded) {
|
||
event.preventDefault();
|
||
sendInput(encoded);
|
||
}
|
||
}}
|
||
onPaste={(event) => {
|
||
event.preventDefault();
|
||
sendInput(
|
||
encodePaste(
|
||
event.clipboardData.getData("text/plain"),
|
||
bracketedPaste,
|
||
),
|
||
);
|
||
}}
|
||
ref={textareaRef}
|
||
spellCheck={false}
|
||
tabIndex={owner === "terminal" ? 0 : -1}
|
||
/>
|
||
</div>
|
||
<div aria-live="polite" className="sr-only">
|
||
{owner === "terminal" ? "Buzz Term mode" : "Buzz mode"}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|