From 2b8b247f51fa002b1b215358ff5a32a316ff6853 Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Sun, 2 Aug 2026 10:35:12 -0400 Subject: [PATCH] feat(desktop): add Buzz Term banner Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta --- .../terminal/TerminalBootstrap.test.mjs | 1 + .../terminal/TerminalSubstrate.test.mjs | 15 +- .../features/terminal/TerminalSubstrate.tsx | 139 +++---- .../features/terminal/terminalBanner.test.mjs | 60 +++ .../src/features/terminal/terminalBanner.ts | 264 ++++++++++++ .../terminal/terminalBannerColor.test.mjs | 216 ++++++++++ .../features/terminal/terminalBannerColor.ts | 379 ++++++++++++++++++ .../terminal/terminalBannerPainter.test.mjs | 109 +++++ .../terminal/terminalBannerPainter.ts | 38 ++ .../src/features/terminal/terminalRenderer.ts | 8 + .../src/shared/styles/globals/components.css | 7 +- 11 files changed, 1143 insertions(+), 93 deletions(-) create mode 100644 desktop/src/features/terminal/terminalBanner.test.mjs create mode 100644 desktop/src/features/terminal/terminalBanner.ts create mode 100644 desktop/src/features/terminal/terminalBannerColor.test.mjs create mode 100644 desktop/src/features/terminal/terminalBannerColor.ts create mode 100644 desktop/src/features/terminal/terminalBannerPainter.test.mjs create mode 100644 desktop/src/features/terminal/terminalBannerPainter.ts diff --git a/desktop/src/features/terminal/TerminalBootstrap.test.mjs b/desktop/src/features/terminal/TerminalBootstrap.test.mjs index 67c514d9e..77bcabd00 100644 --- a/desktop/src/features/terminal/TerminalBootstrap.test.mjs +++ b/desktop/src/features/terminal/TerminalBootstrap.test.mjs @@ -60,6 +60,7 @@ before(async () => { toJSON() {}, }); dom.window.HTMLCanvasElement.prototype.getContext = () => ({ + clearRect() {}, fillRect() {}, fillStyle: "", fillText() {}, diff --git a/desktop/src/features/terminal/TerminalSubstrate.test.mjs b/desktop/src/features/terminal/TerminalSubstrate.test.mjs index 3fbd70d35..793d48df4 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.test.mjs +++ b/desktop/src/features/terminal/TerminalSubstrate.test.mjs @@ -136,7 +136,7 @@ test("mounted IME paths neither toggle nor emit preedit text", async () => { assert.deepEqual(calls.input, ["か"]); }); -test("welcome survives non-intersecting frame and dismisses on input or intersecting damage", async () => { +test("first active PTY output dismisses the welcome overlay", async () => { const baseFrame = { cursor: { column: 0, line: 0, visible: false }, full: false, @@ -164,17 +164,8 @@ test("welcome survives non-intersecting frame and dismisses on input or intersec }; 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, - ), - { timeout: 1_000 }, + await waitFor(() => + assert.equal(view.container.querySelector(".buzz-terminal-welcome"), null), ); cleanup(); diff --git a/desktop/src/features/terminal/TerminalSubstrate.tsx b/desktop/src/features/terminal/TerminalSubstrate.tsx index d17722402..3822e005a 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.tsx +++ b/desktop/src/features/terminal/TerminalSubstrate.tsx @@ -9,10 +9,14 @@ import { encodePaste, encodeTerminalKey, reduceHandoff, - updateWelcomeForOutput, - type WelcomeState, } from "./terminalState"; -import { type TerminalFrame, TerminalGrid } from "./terminalRenderer"; +import { buildTerminalBanner } from "./terminalBanner"; +import { paintTerminalBanner } from "./terminalBannerPainter"; +import { + TERMINAL_CELL_METRICS, + type TerminalFrame, + TerminalGrid, +} from "./terminalRenderer"; export type TerminalViewportSize = { columns: number; @@ -56,38 +60,12 @@ function isToggleChord(event: KeyboardEvent): boolean { ); } -const CELL_HEIGHT = 17; -const WELCOME_COLUMNS = 25; -const WELCOME_LINES = 4; +const { width: CELL_WIDTH, height: CELL_HEIGHT } = TERMINAL_CELL_METRICS; -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.flatMap((row) => - row.spans.flatMap((span) => - span.clusters - .filter((cluster) => cluster.text.trim().length > 0) - .map((cluster) => ({ - top: row.line, - left: cluster.column, - bottom: row.line + 1, - right: cluster.column + cluster.width, - })), +function hasVisibleOutput(frame: TerminalFrame): boolean { + return frame.rows.some((row) => + row.spans.some((span) => + span.clusters.some((cluster) => cluster.text.trim().length > 0), ), ); } @@ -112,6 +90,7 @@ export function TerminalSubstrate({ }: TerminalSubstrateProps) { const { terminalPalette } = useTheme(); const canvasRef = React.useRef(null); + const bannerCanvasRef = React.useRef(null); const textareaRef = React.useRef(null); const fadeRef = React.useRef(null); const handoffRef = React.useRef(INITIAL_HANDOFF_STATE); @@ -131,10 +110,8 @@ export function TerminalSubstrate({ [activeSessionId, frame, sessionFrames], ); const [owner, setOwner] = React.useState<"buzz" | "terminal">("buzz"); - const [welcome, setWelcome] = React.useState({ - visible: true, - reserved: null, - }); + const [viewport, setViewport] = React.useState({ columns: 1, rows: 1 }); + const [welcomeVisible, setWelcomeVisible] = React.useState(true); const shortcutLabel = /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘J" : "CTRL+J"; @@ -144,6 +121,15 @@ export function TerminalSubstrate({ document.querySelector(".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, @@ -180,7 +166,7 @@ export function TerminalSubstrate({ const sendInput = React.useEffectEvent((text: string) => { if (!text) return; - setWelcome((current) => ({ ...current, visible: false })); + setWelcomeVisible(false); onInput(text); }); const consumeFrame = React.useEffectEvent((nextFrame: TerminalFrame) => { @@ -188,17 +174,19 @@ export function TerminalSubstrate({ }); const reportViewportSize = React.useEffectEvent(() => { const canvas = canvasRef.current; - if (!canvas || !onViewportSize) return; + 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)); - onViewportSize({ - columns: Math.max(1, Math.floor(bounds.width / 8.4)), - rows: Math.max(1, Math.floor(bounds.height / CELL_HEIGHT)), - pixelWidth, - pixelHeight, - }); + 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(() => { @@ -207,14 +195,14 @@ export function TerminalSubstrate({ React.useEffect(() => { const canvas = canvasRef.current; - if (!canvas || !onViewportSize) return; + if (!canvas) return; reportViewportSize(); const ResizeObserverConstructor = window.ResizeObserver; if (!ResizeObserverConstructor) return; const observer = new ResizeObserverConstructor(reportViewportSize); observer.observe(canvas); return () => observer.disconnect(); - }, [onViewportSize]); + }, []); React.useEffect(() => { if (!focusReportingEnabled) { @@ -290,6 +278,21 @@ export function TerminalSubstrate({ }; }, [enabled, getAppSurface]); + React.useEffect(() => { + const canvas = bannerCanvasRef.current; + if (!canvas || !banner || !terminalPalette || !welcomeVisible) return; + if ( + !paintTerminalBanner( + canvas, + banner, + terminalPalette, + window.devicePixelRatio || 1, + ) + ) { + setWelcomeVisible(false); + } + }, [banner, terminalPalette, welcomeVisible]); + React.useEffect(() => { for (const delivered of frames) { if (appliedFramesRef.current.has(delivered.frame)) continue; @@ -307,13 +310,12 @@ export function TerminalSubstrate({ } grid.apply(delivered.frame); consumeFrame(delivered.frame); - if (delivered.sessionId === activeSessionId) { - setWelcome((current) => - updateWelcomeForOutput( - { ...current, reserved: welcomeRect(delivered.frame) }, - frameDamageRects(delivered.frame), - ), - ); + if ( + delivered.sessionId === activeSessionId && + hasVisibleOutput(delivered.frame) + ) { + // Policy: first visible output from the active PTY removes the overlay. + setWelcomeVisible(false); } } gridRef.current = activeSessionId @@ -351,22 +353,12 @@ export function TerminalSubstrate({ context.fillStyle = terminalPalette.background; context.fillRect(0, 0, bounds.width, bounds.height); } - gridRef.current?.paint( - context, - { - width: 8.4, - height: 17, - baseline: 13, - font: '14px "JetBrains Mono", monospace', - boldFont: '700 14px "JetBrains Mono", monospace', - }, - terminalPalette, - ); + gridRef.current?.paint(context, TERMINAL_CELL_METRICS, terminalPalette); }, [activeSessionId, frames, terminalPalette]); return (
))}