From 7ca087f8e08c80528387684364a65bf4ccd6315f Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 4 Aug 2026 08:21:40 -0600 Subject: [PATCH 1/6] Dock Buzz Term within channel workspace Replace the full-app terminal takeover with a resizable bottom panel that preserves channel navigation and chat. Add channel-header discovery, maximize/restore controls, lazy PTY creation, and channel-scoped session workspaces with immutable spawn context. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src/app/AppHuddleShell.tsx | 8 - desktop/src/app/AppShell.tsx | 2 +- desktop/src/app/AppShellChannelSurface.tsx | 4 +- desktop/src/app/BuzzThemeSurfaces.tsx | 9 +- .../channels/ui/ChannelScreenHeader.tsx | 33 ++- .../terminal/TerminalBootstrap.test.mjs | 14 +- .../features/terminal/TerminalBootstrap.tsx | 82 +++++- .../terminal/TerminalSubstrate.test.mjs | 210 -------------- .../features/terminal/TerminalSubstrate.tsx | 256 +++++++++--------- .../terminal/terminalPanelStore.test.mjs | 33 +++ .../features/terminal/terminalPanelStore.ts | 53 ++++ .../src/shared/styles/globals/components.css | 64 +++++ 12 files changed, 399 insertions(+), 369 deletions(-) create mode 100644 desktop/src/features/terminal/terminalPanelStore.test.mjs create mode 100644 desktop/src/features/terminal/terminalPanelStore.ts diff --git a/desktop/src/app/AppHuddleShell.tsx b/desktop/src/app/AppHuddleShell.tsx index 736dad1f6a..8370e8efd4 100644 --- a/desktop/src/app/AppHuddleShell.tsx +++ b/desktop/src/app/AppHuddleShell.tsx @@ -17,12 +17,6 @@ type AppHuddleShellProps = { onShowHuddleInMainApp: (ephemeralChannelId: string) => void; onViewHuddleChannel: (ephemeralChannelId: string) => void; onVisibilityChange: (visible: boolean) => void; - /** - * Terminal substrate layer. Rendered behind the app surface (which carries - * z-10) so the ⌘J handoff can reveal it by fading the surface above. Not - * mounted in the dedicated Huddle room window. - */ - terminal?: React.ReactNode; }; export function AppHuddleShell({ @@ -37,7 +31,6 @@ export function AppHuddleShell({ onShowHuddleInMainApp, onViewHuddleChannel, onVisibilityChange, - terminal, }: AppHuddleShellProps) { return ( - {isRoom ? null : terminal}
} > {hasCommunityRail && !isHuddleRoom ? ( } > diff --git a/desktop/src/app/AppShellChannelSurface.tsx b/desktop/src/app/AppShellChannelSurface.tsx index 4ab2ea1df3..37be3448ee 100644 --- a/desktop/src/app/AppShellChannelSurface.tsx +++ b/desktop/src/app/AppShellChannelSurface.tsx @@ -11,6 +11,7 @@ type AppShellChannelSurfaceProps = { isHuddleRoom: boolean; isHuddleRoomStarting: boolean; mainInsetRef: React.RefObject; + terminal?: React.ReactNode; }; export function AppShellChannelSurface({ @@ -18,6 +19,7 @@ export function AppShellChannelSurface({ isHuddleRoom, isHuddleRoomStarting, mainInsetRef, + terminal, }: AppShellChannelSurfaceProps) { return ( @@ -34,7 +36,7 @@ export function AppShellChannelSurface({ style={chromeCssVarDefaults as React.CSSProperties} > {isHuddleRoom && !isHuddleRoomStarting ? : null} - + {isHuddleRoomStarting ? : children} diff --git a/desktop/src/app/BuzzThemeSurfaces.tsx b/desktop/src/app/BuzzThemeSurfaces.tsx index 4976fc2ed8..b7912c98bc 100644 --- a/desktop/src/app/BuzzThemeSurfaces.tsx +++ b/desktop/src/app/BuzzThemeSurfaces.tsx @@ -23,8 +23,10 @@ export function GradientLayer() { export function ContentSurface({ children, unframed = false, + terminal, }: { children: ReactNode; + terminal?: ReactNode; /** Used by dedicated huddle windows, which should not resemble app cards. */ unframed?: boolean; }) { @@ -38,7 +40,12 @@ export function ContentSurface({ data-buzz-content-surface data-buzz-content-unframed={unframed ? true : undefined} > - {children} +
+ {children} +
+
+ {terminal} +
); } diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index 4c545baf68..b0ebec8081 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -1,4 +1,4 @@ -import { LogIn } from "lucide-react"; +import { LogIn, SquareTerminal } from "lucide-react"; import type * as React from "react"; import { ChatHeader } from "@/features/chat/ui/ChatHeader"; @@ -17,6 +17,10 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { Button } from "@/shared/ui/button"; import type { Channel, PresenceStatus } from "@/shared/api/types"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { + toggleTerminalPanel, + useTerminalPanel, +} from "@/features/terminal/terminalPanelStore"; const DM_HEADER_AVATAR_SIZE = 32; const DM_HEADER_AVATAR_STATUS_GEOMETRY = scaleProfileAvatarStatusGeometry( @@ -74,7 +78,26 @@ export function ChannelScreenHeader({ !activeChannel.archivedAt && onJoinChannel; - const actions = activeChannel ? ( + const terminalPanel = useTerminalPanel(); + const terminalButton = activeChannel ? ( + + ) : null; + const channelActions = activeChannel ? ( showJoinButton ? ( + {/* biome-ignore lint/a11y/noStaticElementInteractions: the hidden textarea owns keyboard semantics; this only preserves its focus across canvas clicks. */}
{ - if (owner !== "terminal") return; // Preventing the canvas mousedown also suppresses selection. Revisit // this when the terminal gains mouse selection support. event.preventDefault(); @@ -616,7 +608,7 @@ export function TerminalSubstrate({ }} ref={textareaRef} spellCheck={false} - tabIndex={owner === "terminal" ? 0 : -1} + tabIndex={0} />
diff --git a/desktop/src/features/terminal/terminalPanelStore.test.mjs b/desktop/src/features/terminal/terminalPanelStore.test.mjs new file mode 100644 index 0000000000..8a3020f0cb --- /dev/null +++ b/desktop/src/features/terminal/terminalPanelStore.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { beforeEach, test } from "node:test"; + +import { + resetTerminalPanelForTests, + setTerminalPanelMode, + setTerminalSessionChannels, + toggleTerminalPanel, + getTerminalPanelSnapshotForTests, +} from "./terminalPanelStore.ts"; + +beforeEach(resetTerminalPanelForTests); + +test("panel toggles between closed and the docked default", () => { + toggleTerminalPanel(); + assert.equal(getTerminalPanelSnapshotForTests().mode, "docked"); + toggleTerminalPanel(); + assert.equal(getTerminalPanelSnapshotForTests().mode, "closed"); + setTerminalPanelMode("maximized"); + toggleTerminalPanel(); + assert.equal(getTerminalPanelSnapshotForTests().mode, "closed"); +}); + +test("session channel identities are de-duplicated", () => { + setTerminalSessionChannels(["one", "one", "two"]); + // Regression guard: accepting an iterable (rather than Session objects) keeps + // this store UI-only and prevents mutable PTYs from leaking into header state. + setTerminalSessionChannels(new Set(["one", "two"])); + assert.deepEqual( + [...getTerminalPanelSnapshotForTests().sessionChannelIds], + ["one", "two"], + ); +}); diff --git a/desktop/src/features/terminal/terminalPanelStore.ts b/desktop/src/features/terminal/terminalPanelStore.ts new file mode 100644 index 0000000000..3c8fa778a7 --- /dev/null +++ b/desktop/src/features/terminal/terminalPanelStore.ts @@ -0,0 +1,53 @@ +import * as React from "react"; + +export type TerminalPanelMode = "closed" | "docked" | "maximized"; + +type Snapshot = { + mode: TerminalPanelMode; + sessionChannelIds: ReadonlySet; +}; + +let snapshot: Snapshot = { mode: "closed", sessionChannelIds: new Set() }; +const listeners = new Set<() => void>(); + +function publish(next: Snapshot) { + snapshot = next; + for (const listener of listeners) listener(); +} + +export function setTerminalPanelMode(mode: TerminalPanelMode) { + if (snapshot.mode === mode) return; + publish({ ...snapshot, mode }); +} + +export function toggleTerminalPanel() { + setTerminalPanelMode(snapshot.mode === "closed" ? "docked" : "closed"); +} + +export function setTerminalSessionChannels(channelIds: Iterable) { + const next = new Set(channelIds); + if ( + next.size === snapshot.sessionChannelIds.size && + [...next].every((id) => snapshot.sessionChannelIds.has(id)) + ) + return; + publish({ ...snapshot, sessionChannelIds: next }); +} + +export function useTerminalPanel() { + return React.useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + () => snapshot, + ); +} + +export function resetTerminalPanelForTests() { + snapshot = { mode: "closed", sessionChannelIds: new Set() }; +} + +export function getTerminalPanelSnapshotForTests() { + return snapshot; +} diff --git a/desktop/src/shared/styles/globals/components.css b/desktop/src/shared/styles/globals/components.css index fe4001a876..ab3b868951 100644 --- a/desktop/src/shared/styles/globals/components.css +++ b/desktop/src/shared/styles/globals/components.css @@ -867,3 +867,67 @@ width: 1px; } } + +@layer components { + .buzz-terminal-dock-host:has(.buzz-terminal-substrate) { + display: flex; + flex: 0 0 auto; + min-height: 0; + } + + .buzz-terminal-dock-host:has([data-terminal-mode="maximized"]) { + flex: 1 1 auto; + } + + .buzz-content-primary:has( + + .buzz-terminal-dock-host [data-terminal-mode="maximized"] + ) { + display: none; + } + + .buzz-terminal-substrate { + border-top: 1px solid hsl(var(--border)); + inset: auto; + min-height: 180px; + position: relative; + width: 100%; + z-index: 20; + } + + .buzz-terminal-dock-host [data-terminal-mode="maximized"] { + flex: 1 1 auto; + height: 100%; + } + + .buzz-terminal-resize-handle { + cursor: ns-resize; + height: 5px; + left: 0; + position: absolute; + right: 0; + top: -3px; + z-index: 2; + } + + .buzz-terminal-window-action { + align-items: center; + background: transparent; + border: 0; + color: inherit; + display: inline-flex; + height: 24px; + justify-content: center; + opacity: 0.62; + width: 24px; + } + + .buzz-terminal-window-action:hover, + .buzz-terminal-window-action:focus-visible { + opacity: 1; + } + + .buzz-terminal-window-action svg { + height: 13px; + width: 13px; + } +} From b7f9d628a3ab810c7e80ec0895ec6a02dd8cef5a Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 4 Aug 2026 10:15:26 -0600 Subject: [PATCH 2/6] Polish docked terminal lifecycle and resizing Keep the original welcome composition while making its first reveal bounded and deterministic. Restrict terminal entry to channel routes, preserve the bottom anchor during maximize transitions, and defer expensive terminal viewport work until drag resizing settles. Cover repeated reveals, route changes, pending attachments, restored session viewports, and adversarial pointer lifecycles. Co-authored-by: Carl Signed-off-by: Wes --- .../channels/ui/ChannelScreenHeader.tsx | 12 +- .../terminal/TerminalBootstrap.test.mjs | 177 +++++++++++- .../features/terminal/TerminalBootstrap.tsx | 100 ++++++- .../terminal/TerminalSubstrate.test.mjs | 144 ++++++---- .../features/terminal/TerminalSubstrate.tsx | 205 ++++++++++---- desktop/src/shared/styles/globals.css | 1 + .../src/shared/styles/globals/components.css | 180 ------------ .../src/shared/styles/globals/terminal.css | 259 ++++++++++++++++++ 8 files changed, 783 insertions(+), 295 deletions(-) create mode 100644 desktop/src/shared/styles/globals/terminal.css diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index b0ebec8081..358a0e637b 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -85,16 +85,12 @@ export function ChannelScreenHeader({ terminalPanel.mode === "closed" ? "Open Buzz Term" : "Hide Buzz Term" } onClick={toggleTerminalPanel} - size="icon-xs" + size="icon" title="Buzz Term (⌘J)" - variant={terminalPanel.mode === "closed" ? "ghost" : "secondary"} + type="button" + variant={terminalPanel.mode === "closed" ? "outline" : "secondary"} > - - - {terminalPanel.sessionChannelIds.has(activeChannel.id) ? ( - - ) : null} - + ) : null; const channelActions = activeChannel ? ( diff --git a/desktop/src/features/terminal/TerminalBootstrap.test.mjs b/desktop/src/features/terminal/TerminalBootstrap.test.mjs index c8adf4745c..0ed954b448 100644 --- a/desktop/src/features/terminal/TerminalBootstrap.test.mjs +++ b/desktop/src/features/terminal/TerminalBootstrap.test.mjs @@ -80,9 +80,12 @@ before(async () => { calls.push({ command, args }); if (command === "terminal_attach") { channel = args.onFrame; + const sessionNumber = calls.filter( + ({ command }) => command === "terminal_attach", + ).length; const response = { - sessionId: "session-1", - subscriptionId: "subscription-1", + sessionId: `session-${sessionNumber}`, + subscriptionId: `subscription-${sessionNumber}`, viewport: { columns: 100, generation: 0, screenLines: 24 }, }; return attachResolver @@ -177,6 +180,9 @@ test("mounted bootstrap passes GUI context and ACKs only after consuming a frame threadId: "thread-1", }); + const sessionNumber = calls.filter( + ({ command }) => command === "terminal_attach", + ).length; const frameMessage = { type: "frame", payload: { @@ -186,7 +192,7 @@ test("mounted bootstrap passes GUI context and ACKs only after consuming a frame full: true, rows: [], sequence: 7, - subscriptionId: "subscription-1", + subscriptionId: `subscription-${sessionNumber}`, viewport: { columns: 100, generation: 0, screenLines: 24 }, }, }; @@ -200,8 +206,8 @@ test("mounted bootstrap passes GUI context and ACKs only after consuming a frame calls.find(({ command }) => command === "terminal_ack").args, { sequence: 7, - sessionId: "session-1", - subscriptionId: "subscription-1", + sessionId: `session-${sessionNumber}`, + subscriptionId: `subscription-${sessionNumber}`, }, ); @@ -323,6 +329,123 @@ test("opening a tab keeps terminal ownership while its attachment is pending", a view.unmount(); }); +test("restoring a channel resizes its PTY to the current dock viewport", async () => { + const { createElement } = await import("react"); + const { act, render, waitFor } = await import("@testing-library/react"); + const { ThemeProvider } = await import("@/shared/theme/ThemeProvider"); + const { TerminalBootstrap } = await import("./TerminalBootstrap.tsx"); + + const props = (channelId, channelName) => ({ + channelId, + channelName, + npub: "npub1owner", + relayUrl: "wss://relay.example", + threadId: null, + }); + const tree = (channelId, channelName) => + createElement( + ThemeProvider, + null, + createElement(TerminalBootstrap, props(channelId, channelName)), + ); + const view = render(tree("channel-a", "alpha")); + await waitFor(() => + assert.ok( + calls.some( + ({ command, args }) => + command === "terminal_attach" && + args.request.channelId === "channel-a", + ), + ), + ); + + view.rerender(tree("channel-b", "beta")); + await waitFor(() => + assert.ok( + calls.some( + ({ command, args }) => + command === "terminal_attach" && + args.request.channelId === "channel-b", + ), + ), + ); + canvasWidth = 1_680; + await act(async () => resizeCallback()); + await waitFor(() => + assert.ok( + calls.some( + ({ command, args }) => + command === "terminal_resize" && + args.sessionId === "session-2" && + args.columns === 200, + ), + ), + ); + + view.rerender(tree("channel-a", "alpha")); + await waitFor(() => + assert.ok( + calls.some( + ({ command, args }) => + command === "terminal_resize" && + args.sessionId === "session-1" && + args.columns === 200, + ), + ), + ); + view.unmount(); +}); + +test("closing a tab while attach is pending closes the eventual session", async () => { + const { createElement } = await import("react"); + const { act, fireEvent, render, waitFor } = await import( + "@testing-library/react" + ); + const { ThemeProvider } = await import("@/shared/theme/ThemeProvider"); + const { TerminalBootstrap } = await import("./TerminalBootstrap.tsx"); + + attachResolver = () => {}; + const view = render( + createElement( + ThemeProvider, + null, + createElement(TerminalBootstrap, { + channelId: "channel-1", + channelName: "general", + npub: "npub1owner", + relayUrl: "wss://relay.example", + threadId: null, + }), + ), + ); + await waitFor(() => assert.equal(typeof attachResolver, "function")); + await waitFor(() => + assert.ok(view.queryByRole("tab", { name: /Terminal 1/ })), + ); + + await act(async () => { + fireEvent.click(view.getByLabelText("Close SHELL")); + setTerminalPanelMode("closed"); + }); + await waitFor(() => assert.equal(view.queryByRole("tab"), null)); + assert.equal( + calls.some(({ command }) => command === "terminal_close"), + false, + "a not-yet-attached session cannot be closed by backend id", + ); + + await act(async () => attachResolver()); + await waitFor(() => + assert.ok( + calls.some( + ({ command, args }) => + command === "terminal_close" && args.sessionId === "session-1", + ), + ), + ); + view.unmount(); +}); + test("a successful close removes the tab even if the exit event is lost", async () => { const { createElement } = await import("react"); const { fireEvent, render, waitFor } = await import("@testing-library/react"); @@ -349,7 +472,9 @@ test("a successful close removes the tab even if the exit event is lost", async await waitFor(() => assert.ok(calls.some(({ command }) => command === "terminal_attach")), ); - await waitFor(() => assert.ok(view.queryByRole("tab", { name: /SHELL/ }))); + await waitFor(() => + assert.ok(view.queryByRole("tab", { name: /Terminal 1/ })), + ); fireEvent.click(view.getByLabelText("Close SHELL")); @@ -423,3 +548,43 @@ test("wheel deltas reach terminal_scroll with the DOM sign intact", async () => view.unmount(); }); + +test("a non-channel route closes the panel and ignores the terminal shortcut", async () => { + const { createElement } = await import("react"); + const { act, render, waitFor } = await import("@testing-library/react"); + const { ThemeProvider } = await import("@/shared/theme/ThemeProvider"); + const { TerminalBootstrap } = await import("./TerminalBootstrap.tsx"); + const { getTerminalPanelSnapshotForTests } = await import( + "./terminalPanelStore.ts" + ); + + setTerminalPanelMode("docked"); + const view = render( + createElement( + ThemeProvider, + null, + createElement(TerminalBootstrap, { + channelId: null, + channelName: null, + npub: "npub1owner", + relayUrl: "wss://relay.example", + threadId: null, + }), + ), + ); + await waitFor(() => + assert.equal(getTerminalPanelSnapshotForTests().mode, "closed"), + ); + + const chord = { + bubbles: true, + code: "KeyJ", + metaKey: true, + }; + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", chord)); + window.dispatchEvent(new KeyboardEvent("keyup", chord)); + }); + assert.equal(getTerminalPanelSnapshotForTests().mode, "closed"); + view.unmount(); +}); diff --git a/desktop/src/features/terminal/TerminalBootstrap.tsx b/desktop/src/features/terminal/TerminalBootstrap.tsx index e770b65ce7..f086c6e84a 100644 --- a/desktop/src/features/terminal/TerminalBootstrap.tsx +++ b/desktop/src/features/terminal/TerminalBootstrap.tsx @@ -75,17 +75,78 @@ export function TerminalBootstrap({ const mountedRef = React.useRef(true); const sizeRef = React.useRef(INITIAL_SIZE); const resizeChainRef = React.useRef(Promise.resolve()); + const connectionSizesRef = React.useRef( + new WeakMap(), + ); + const closedSessionKeysRef = React.useRef(new Set()); const [sessions, setSessions] = React.useState([]); const [activeKey, setActiveKey] = React.useState(null); const [available, setAvailable] = React.useState(() => isTauri()); const panel = useTerminalPanel(); + const [renderedMode, setRenderedMode] = React.useState< + "docked" | "maximized" + >(panel.mode === "maximized" ? "maximized" : "docked"); + const [panelVisible, setPanelVisible] = React.useState( + panel.mode !== "closed", + ); + const [panelMounted, setPanelMounted] = React.useState( + panel.mode !== "closed", + ); + const [splashPending, setSplashPending] = React.useState(true); + const [viewportReportingEnabled, setViewportReportingEnabled] = + React.useState(panel.mode !== "closed"); + const previousPanelModeRef = React.useRef(panel.mode); const acknowledgedSequenceRef = React.useRef(new Map()); const sessionsRef = React.useRef(sessions); sessionsRef.current = sessions; + React.useEffect(() => { + const previousMode = previousPanelModeRef.current; + if (previousMode === panel.mode) return; + previousPanelModeRef.current = panel.mode; + setViewportReportingEnabled(false); + + let firstFrame = 0; + let secondFrame = 0; + let timeout = 0; + if (panel.mode !== "closed") { + setRenderedMode(panel.mode); + setPanelMounted(true); + if (previousMode === "closed") { + // Give the collapsed substrate a painted frame before expanding it. + // A single rAF can still be batched into the mount commit by React. + setPanelVisible(false); + firstFrame = window.requestAnimationFrame(() => { + secondFrame = window.requestAnimationFrame(() => + setPanelVisible(true), + ); + }); + } else { + setPanelVisible(true); + } + // Resizing the PTY through every animation frame causes shell reflow and + // leaves transient filler rows in the scrollback. Publish only the final + // settled viewport. + timeout = window.setTimeout(() => setViewportReportingEnabled(true), 200); + } else { + setPanelVisible(false); + timeout = window.setTimeout(() => setPanelMounted(false), 180); + } + return () => { + window.cancelAnimationFrame(firstFrame); + window.cancelAnimationFrame(secondFrame); + window.clearTimeout(timeout); + }; + }, [panel.mode]); + + React.useEffect(() => { + if (!context && panel.mode !== "closed") setTerminalPanelMode("closed"); + }, [context, panel.mode]); + React.useEffect(() => { const toggle = (event: KeyboardEvent) => { if ( + !context || panel.mode !== "closed" || event.code !== "KeyJ" || (!event.metaKey && !event.ctrlKey) || @@ -104,7 +165,7 @@ export function TerminalBootstrap({ window.removeEventListener("keydown", toggle, true); window.removeEventListener("keyup", toggle, true); }; - }, [panel.mode]); + }, [context, panel.mode]); const fail = React.useCallback((error: unknown) => { report(error); @@ -176,6 +237,8 @@ export function TerminalBootstrap({ ) .then((connection) => { if (!mountedRef.current) return connection.detach(); + if (closedSessionKeysRef.current.delete(key)) return connection.close(); + connectionSizesRef.current.set(connection, size); update((session) => ({ ...session, connection })); if (sizeRef.current !== size) { const currentSize = sizeRef.current; @@ -187,12 +250,14 @@ export function TerminalBootstrap({ currentSize.pixelWidth, currentSize.pixelHeight, ); + connectionSizesRef.current.set(connection, currentSize); await connection.viewportReady(viewport); }) .catch(fail); } }) .catch((error) => { + if (closedSessionKeysRef.current.delete(key)) return; removeSession(key); fail(error); }); @@ -243,7 +308,30 @@ export function TerminalBootstrap({ channelSessions.find((session) => session.key === activeKey) ?? channelSessions.at(-1) ?? null; + + React.useEffect(() => { + const connection = active?.connection; + if (!connection) return; + const size = sizeRef.current; + if (connectionSizesRef.current.get(connection) === size) return; + resizeChainRef.current = resizeChainRef.current + .then(async () => { + const viewport = await connection.resize( + size.columns, + size.rows, + size.pixelWidth, + size.pixelHeight, + ); + connectionSizesRef.current.set(connection, size); + await connection.viewportReady(viewport); + }) + .catch(fail); + }, [active?.connection, fail]); + const send = (operation: Promise | undefined) => operation?.catch(fail); + const handleSplashStarted = React.useCallback(() => { + setSplashPending(false); + }, []); const handleSize = React.useCallback( (size: TerminalViewportSize) => { @@ -260,6 +348,7 @@ export function TerminalBootstrap({ size.pixelWidth, size.pixelHeight, ); + connectionSizesRef.current.set(connection, size); await connection.viewportReady(viewport); }) .catch(fail); @@ -267,19 +356,23 @@ export function TerminalBootstrap({ [activeKey, fail], ); - if (panel.mode === "closed") return null; + if (!panelMounted) return null; return ( setTerminalPanelMode("closed")} onModeChange={setTerminalPanelMode} onToggle={toggleTerminalPanel} focusReportingEnabled={active?.frame?.focusReporting ?? false} frame={active?.frame} + viewportReportingEnabled={viewportReportingEnabled} + showSplash={splashPending} + onSplashStarted={handleSplashStarted} sessionFrames={channelSessions.flatMap((session) => session.frame ? [{ sessionId: session.key, frame: session.frame }] : [], )} @@ -293,6 +386,7 @@ export function TerminalBootstrap({ (session) => session.key === key, )?.connection; if (!connection) { + closedSessionKeysRef.current.add(key); removeSession(key); return; } diff --git a/desktop/src/features/terminal/TerminalSubstrate.test.mjs b/desktop/src/features/terminal/TerminalSubstrate.test.mjs index 5d2adc974d..d31775e409 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.test.mjs +++ b/desktop/src/features/terminal/TerminalSubstrate.test.mjs @@ -57,6 +57,7 @@ before(async () => { playbackRate: 1, reverse() {}, }); + dom.window.HTMLElement.prototype.setPointerCapture = () => {}; ({ act, cleanup, fireEvent, render, waitFor } = await import( "@testing-library/react" )); @@ -232,6 +233,46 @@ test("tab actions restore terminal input focus", async () => { } }); +test("drag resize batches visual updates and commits state only on release", async () => { + const { view } = fixture({ mode: "docked" }); + await ready(view); + const substrate = view.container.querySelector(".buzz-terminal-substrate"); + const handle = view.getByLabelText("Resize Buzz Term"); + + fireEvent.pointerDown(handle, { clientY: 500, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientY: 400, pointerId: 2 }); + fireEvent.pointerUp(handle, { clientY: 400, pointerId: 2 }); + assert.equal(substrate.dataset.terminalResizing, "true"); + fireEvent.pointerMove(handle, { clientY: 460, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientY: 440, pointerId: 1 }); + assert.equal(substrate.dataset.terminalResizing, "true"); + assert.equal(window.localStorage.getItem("buzz-terminal-dock-height"), null); + + await waitFor(() => assert.equal(substrate.style.height, "380px")); + fireEvent.pointerUp(handle, { clientY: 440, pointerId: 1 }); + assert.equal(substrate.dataset.terminalResizing, undefined); + assert.equal(window.localStorage.getItem("buzz-terminal-dock-height"), "380"); +}); + +test("unmount cancels a queued drag update", async () => { + const { view } = fixture({ mode: "docked" }); + await ready(view); + const handle = view.getByLabelText("Resize Buzz Term"); + const previousHeight = handle.closest(".buzz-terminal-substrate").style + .height; + + fireEvent.pointerDown(handle, { clientY: 500, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientY: 440, pointerId: 1 }); + view.unmount(); + await new Promise((resolve) => window.requestAnimationFrame(resolve)); + + assert.equal(window.localStorage.getItem("buzz-terminal-dock-height"), null); + assert.equal( + handle.closest(".buzz-terminal-substrate").style.height, + previousHeight, + ); +}); + const EMPTY_FRAME = { cursor: { column: 0, line: 0, visible: false }, full: false, @@ -275,74 +316,83 @@ async function reveal(view) { ); } -test("spawn-time output before the first reveal keeps the welcome overlay", async () => { - const subject = fixture({ frame: EMPTY_FRAME }); - await ready(subject.view); - await expectWelcome(subject.view, true); - - subject.rerender({ frame: VISIBLE_FRAME }); - await expectWelcome(subject.view, true); - - await reveal(subject.view); - await expectWelcome(subject.view, true); -}); - -test("the first keystroke dismisses the welcome overlay", async () => { - const subject = fixture({ frame: VISIBLE_FRAME }); +test("the first settled reveal runs one bounded splash", async () => { + let starts = 0; + const subject = fixture({ + frame: EMPTY_FRAME, + onSplashStarted() { + starts += 1; + }, + showSplash: true, + viewportReportingEnabled: false, + visible: true, + }); await ready(subject.view); - await reveal(subject.view); - await expectWelcome(subject.view, true); + await expectWelcome(subject.view, false); - fireEvent.input(subject.view.getByLabelText("Terminal input"), { - target: { value: "l" }, + subject.rerender({ + frame: EMPTY_FRAME, + onSplashStarted() { + starts += 1; + }, + showSplash: true, + viewportReportingEnabled: true, + visible: true, }); + await expectWelcome(subject.view, true); + assert.equal(starts, 1); + await act(async () => new Promise((resolve) => setTimeout(resolve, 2_550))); await expectWelcome(subject.view, false); }); -test("non-empty output after the reveal dismisses the welcome overlay", async () => { - const subject = fixture({ frame: EMPTY_FRAME }); +test("later reveals do not replay a consumed splash", async () => { + const subject = fixture({ + frame: EMPTY_FRAME, + showSplash: true, + viewportReportingEnabled: true, + visible: true, + }); await ready(subject.view); - await reveal(subject.view); await expectWelcome(subject.view, true); - subject.rerender({ frame: VISIBLE_FRAME }); + subject.rerender({ frame: EMPTY_FRAME, showSplash: false, visible: false }); + await expectWelcome(subject.view, false); + subject.rerender({ frame: EMPTY_FRAME, showSplash: false, visible: true }); await expectWelcome(subject.view, false); }); -test("empty active output keeps the welcome overlay", async () => { - const subject = fixture({ frame: EMPTY_FRAME }); - await ready(subject.view); - await reveal(subject.view); - await expectWelcome(subject.view, true); - - subject.rerender({ - frame: { - ...EMPTY_FRAME, - viewport: { ...EMPTY_FRAME.viewport, generation: 2 }, - }, +test("a consumed splash stays absent after substrate remount", async () => { + const first = fixture({ + frame: EMPTY_FRAME, + showSplash: true, + visible: true, }); - await expectWelcome(subject.view, true); + await ready(first.view); + await expectWelcome(first.view, true); + first.view.unmount(); + + const second = fixture({ + frame: EMPTY_FRAME, + showSplash: false, + visible: true, + }); + await ready(second.view); + await expectWelcome(second.view, false); }); -test("non-empty output from an inactive PTY keeps the welcome overlay", async () => { +test("the first keystroke dismisses the welcome overlay early", async () => { const subject = fixture({ - sessionFrames: [{ frame: EMPTY_FRAME, sessionId: "one" }], - sessions: [ - { active: true, closing: false, id: "one", title: "SHELL" }, - { active: false, closing: false, id: "two", title: "LOG" }, - ], + frame: EMPTY_FRAME, + showSplash: true, + visible: true, }); await ready(subject.view); - await reveal(subject.view); await expectWelcome(subject.view, true); - subject.rerender({ - sessionFrames: [ - { frame: EMPTY_FRAME, sessionId: "one" }, - { frame: VISIBLE_FRAME, sessionId: "two" }, - ], + fireEvent.input(subject.view.getByLabelText("Terminal input"), { + target: { value: "l" }, }); - await expectWelcome(subject.view, true); + await expectWelcome(subject.view, false); }); test("mounted wheel path accumulates fractional lines per active session", async () => { diff --git a/desktop/src/features/terminal/TerminalSubstrate.tsx b/desktop/src/features/terminal/TerminalSubstrate.tsx index bac859e670..4a4432d1d9 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.tsx +++ b/desktop/src/features/terminal/TerminalSubstrate.tsx @@ -15,6 +15,7 @@ import { } from "./terminalState"; import { buildTerminalBanner } from "./terminalBanner"; import { paintTerminalBanner } from "./terminalBannerPainter"; +import { buildBannerColorTable, phaseAt } from "./terminalBannerWave"; import { TERMINAL_CELL_METRICS, type TerminalFrame, @@ -44,11 +45,15 @@ type TerminalSubstrateProps = { focusReportingEnabled: boolean; enabled?: boolean; mode?: "docked" | "maximized"; + visible?: boolean; onHide?: () => void; onModeChange?: (mode: "docked" | "maximized") => void; onToggle?: () => void; onFrameConsumed?: (frame: TerminalFrame) => void; onViewportSize?: (size: TerminalViewportSize) => void; + viewportReportingEnabled?: boolean; + showSplash?: boolean; + onSplashStarted?: () => void; onInput: (text: string) => void; /** Whole cells scrolled, keeping the DOM's sign: negative goes back. */ onScroll: (lines: number) => void; @@ -69,17 +74,9 @@ function isToggleChord(event: KeyboardEvent): boolean { const { width: CELL_WIDTH, height: CELL_HEIGHT } = TERMINAL_CELL_METRICS; const NOOP = () => {}; - -function hasVisibleOutput(frame: TerminalFrame): boolean { - return frame.rows.some((row) => - row.spans.some((span) => - span.clusters.some((cluster) => cluster.text.trim().length > 0), - ), - ); -} +const SPLASH_DURATION_MS = 2_500; export function TerminalSubstrate({ - channelName, frame, sessionFrames, sessions, @@ -87,11 +84,15 @@ export function TerminalSubstrate({ focusReportingEnabled, enabled = true, mode = "docked", + visible = true, onHide = NOOP, onModeChange = NOOP, onToggle, onFrameConsumed, onViewportSize, + viewportReportingEnabled = true, + showSplash = true, + onSplashStarted, onInput, onScroll, onTerminalFocusChange, @@ -110,8 +111,13 @@ export function TerminalSubstrate({ const paintedPaletteRef = React.useRef(terminalPalette); const paintedSessionRef = React.useRef(null); const reportedFocusRef = React.useRef(null); + const reportedViewportSizeRef = React.useRef( + null, + ); + const dragCleanupRef = React.useRef<(() => void) | null>(null); + const resizeReportFrameRef = React.useRef(0); + const resizingRef = React.useRef(false); const scrollBySessionRef = React.useRef(new Map()); - const revealedRef = React.useRef(false); const activeSession = sessions.find((session) => session.active); const activeSessionId = activeSession?.id ?? null; const frames = React.useMemo( @@ -122,15 +128,12 @@ export function TerminalSubstrate({ ); const [owner, setOwner] = React.useState<"buzz" | "terminal">("buzz"); const [viewport, setViewport] = React.useState({ columns: 1, rows: 1 }); - const [welcomeVisible, setWelcomeVisible] = React.useState(true); + const [welcomeVisible, setWelcomeVisible] = React.useState(false); 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 [dockHeight, setDockHeight] = React.useState(() => { const stored = Number.parseInt( window.localStorage.getItem("buzz-terminal-dock-height") ?? "", @@ -169,6 +172,12 @@ export function TerminalSubstrate({ const consumeFrame = React.useEffectEvent((nextFrame: TerminalFrame) => { onFrameConsumed?.(nextFrame); }); + const beginSplash = React.useEffectEvent(() => { + if (!showSplash) return false; + onSplashStarted?.(); + setWelcomeVisible(true); + return true; + }); /** * Tab chords are handled at the window in capture phase, like the ⌘J * handoff, so they win over the focused textarea. Gated on terminal @@ -194,6 +203,7 @@ export function TerminalSubstrate({ return true; }); const reportViewportSize = React.useEffectEvent(() => { + if (!viewportReportingEnabled) return; const canvas = canvasRef.current; if (!canvas) return; const bounds = canvas.getBoundingClientRect(); @@ -202,14 +212,33 @@ export function TerminalSubstrate({ 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)); + const size = { columns, rows, pixelWidth, pixelHeight }; + if (resizingRef.current) return; setViewport((current) => current.columns === columns && current.rows === rows ? current : { columns, rows }, ); - onViewportSize?.({ columns, rows, pixelWidth, pixelHeight }); + const reported = reportedViewportSizeRef.current; + if ( + reported?.columns === columns && + reported.rows === rows && + reported.pixelWidth === pixelWidth && + reported.pixelHeight === pixelHeight + ) + return; + reportedViewportSizeRef.current = size; + onViewportSize?.(size); }); + React.useEffect( + () => () => { + dragCleanupRef.current?.(); + window.cancelAnimationFrame(resizeReportFrameRef.current); + }, + [], + ); + React.useEffect(() => { if (!enabled) forceBuzzFallback(); }, [enabled]); @@ -240,11 +269,15 @@ export function TerminalSubstrate({ reportViewportSize(); const ResizeObserverConstructor = window.ResizeObserver; if (!ResizeObserverConstructor) return; - const observer = new ResizeObserverConstructor(reportViewportSize); + const observer = new ResizeObserverConstructor(() => reportViewportSize()); observer.observe(canvas); return () => observer.disconnect(); }, []); + React.useLayoutEffect(() => { + if (viewportReportingEnabled) reportViewportSize(); + }, [viewportReportingEnabled]); + React.useEffect(() => { if (!focusReportingEnabled) { reportedFocusRef.current = null; @@ -289,7 +322,6 @@ export function TerminalSubstrate({ setOwner((current) => { const next = current === "terminal" ? "buzz" : "terminal"; if (next === "terminal") { - revealedRef.current = true; textareaRef.current?.focus({ preventScroll: true }); } return next; @@ -299,7 +331,6 @@ export function TerminalSubstrate({ window.addEventListener("keydown", handleKeyDown, true); window.addEventListener("keyup", handleKeyUp, true); if (onToggle) { - revealedRef.current = true; setOwner("terminal"); textareaRef.current?.focus({ preventScroll: true }); } @@ -309,15 +340,55 @@ export function TerminalSubstrate({ }; }, [enabled, onToggle]); - // The panel can remain open beside chat indefinitely. Paint the welcome - // banner once instead of running a permanent animation loop in the dock. + React.useEffect(() => { + if (!visible) { + setWelcomeVisible(false); + return; + } + if (!viewportReportingEnabled || !banner || !beginSplash()) return; + }, [banner, viewportReportingEnabled, visible]); + + React.useEffect(() => { + if (!welcomeVisible) return; + const timeout = window.setTimeout( + () => setWelcomeVisible(false), + SPLASH_DURATION_MS, + ); + return () => window.clearTimeout(timeout); + }, [welcomeVisible]); + + // The splash is a bounded decoration, never a PTY-readiness gate. Each open + // gets one animation epoch; input may dismiss it early and the deadline ends + // it unconditionally even when an idle shell emits no new frames. React.useEffect(() => { const canvas = bannerCanvasRef.current; - if (!canvas || !banner || !terminalPalette || !welcomeVisible) return; + if (!canvas || !banner || !terminalPalette || !welcomeVisible || !visible) + return; const dpr = window.devicePixelRatio || 1; - if (!paintTerminalBanner(canvas, banner, terminalPalette, dpr)) - setWelcomeVisible(false); - }, [banner, terminalPalette, welcomeVisible]); + if (reducedMotion) { + if (!paintTerminalBanner(canvas, banner, terminalPalette, dpr)) + setWelcomeVisible(false); + return; + } + + 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, reducedMotion, terminalPalette, visible, welcomeVisible]); React.useEffect(() => { for (const delivered of frames) { @@ -336,17 +407,6 @@ export function TerminalSubstrate({ } 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) @@ -407,6 +467,7 @@ export function TerminalSubstrate({ className="buzz-terminal-substrate" data-terminal-mode={mode} data-terminal-owner={owner} + data-terminal-visible={visible ? "true" : "false"} style={{ ...terminalStyle, ...(mode === "docked" ? { height: dockHeight } : undefined), @@ -453,29 +514,71 @@ export function TerminalSubstrate({ ); }} onPointerDown={(event) => { - event.currentTarget.setPointerCapture(event.pointerId); + event.preventDefault(); + dragCleanupRef.current?.(); + window.cancelAnimationFrame(resizeReportFrameRef.current); + const handle = event.currentTarget; + const substrate = handle.closest( + ".buzz-terminal-substrate", + ); + if (!substrate) return; + const pointerId = event.pointerId; + handle.setPointerCapture(pointerId); + resizingRef.current = true; + substrate.dataset.terminalResizing = "true"; const startY = event.clientY; const startHeight = dockHeight; + let nextHeight = startHeight; + let frame = 0; + const applyHeight = () => { + frame = 0; + substrate.style.height = `${nextHeight}px`; + }; + const cleanup = () => { + window.cancelAnimationFrame(frame); + frame = 0; + handle.removeEventListener("pointermove", move); + handle.removeEventListener("pointerup", finish); + handle.removeEventListener("pointercancel", finish); + if (dragCleanupRef.current === cleanup) + dragCleanupRef.current = null; + }; const move = (moveEvent: PointerEvent) => { - const next = Math.max( + if (moveEvent.pointerId !== pointerId) return; + nextHeight = Math.max( 180, Math.min( window.innerHeight * 0.7, startHeight + startY - moveEvent.clientY, ), ); - setDockHeight(next); + if (!frame) frame = window.requestAnimationFrame(applyHeight); + }; + const finish = (finishEvent: PointerEvent) => { + if (finishEvent.pointerId !== pointerId) return; + if (frame) { + window.cancelAnimationFrame(frame); + applyHeight(); + } + cleanup(); + resizingRef.current = false; + delete substrate.dataset.terminalResizing; + setDockHeight(nextHeight); window.localStorage.setItem( "buzz-terminal-dock-height", - String(Math.round(next)), + String(Math.round(nextHeight)), + ); + resizeReportFrameRef.current = window.requestAnimationFrame( + () => { + resizeReportFrameRef.current = 0; + if (!resizingRef.current) reportViewportSize(); + }, ); }; - const up = () => { - window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", up); - }; - window.addEventListener("pointermove", move); - window.addEventListener("pointerup", up, { once: true }); + dragCleanupRef.current = cleanup; + handle.addEventListener("pointermove", move); + handle.addEventListener("pointerup", finish); + handle.addEventListener("pointercancel", finish); }} tabIndex={0} /> @@ -492,6 +595,7 @@ export function TerminalSubstrate({ role="presentation" >
- {channelName ? `#${channelName}` : "BUZZ"} - LOCAL PTY · PRIVATE - {shortcutLabel} HIDE -
))}
@@ -666,7 +673,7 @@ export function TerminalSubstrate({
{/* biome-ignore lint/a11y/noStaticElementInteractions: the hidden textarea owns keyboard semantics; this only preserves its focus across canvas clicks. */}
{ // Preventing the canvas mousedown also suppresses selection. Revisit // this when the terminal gains mouse selection support. diff --git a/desktop/src/shared/styles/globals/terminal.css b/desktop/src/shared/styles/globals/terminal.css index 81b2e0a39d..26b5f42ba8 100644 --- a/desktop/src/shared/styles/globals/terminal.css +++ b/desktop/src/shared/styles/globals/terminal.css @@ -14,9 +14,9 @@ .buzz-terminal-contract-bar { align-items: center; - background: hsl(var(--background)); + background: hsl(var(--secondary)); border-bottom: 1px solid hsl(var(--border)); - color: hsl(var(--foreground)); + color: hsl(var(--secondary-foreground)); display: flex; flex: 0 0 40px; font-family: inherit; @@ -24,7 +24,7 @@ gap: 8px; justify-content: space-between; min-width: 0; - padding: 4px 8px; + padding: 4px 1.25rem; } .buzz-terminal-tabs, @@ -55,6 +55,7 @@ .buzz-terminal-tabs { flex: 1 1 auto; + gap: 4px; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; @@ -65,7 +66,7 @@ } .buzz-terminal-readout { - background: hsl(var(--background)); + background: hsl(var(--secondary)); flex: 0 0 auto; gap: 4px; padding: 0; @@ -74,50 +75,92 @@ } .buzz-terminal-tab { + background: hsl(var(--background)); + border-radius: 4px; flex: 0 0 auto; + padding: 0; + } + + .buzz-terminal-tab-active { + background: hsl(var(--background)); } - .buzz-terminal-tab + .buzz-terminal-tab { - border-left: 1px solid hsl(var(--border) / 0.55); + .buzz-terminal-tab:hover, + .buzz-terminal-tab:focus-within { + background: hsl(var(--foreground) / 0.06); } .buzz-terminal-tab-select { + border-radius: inherit; max-width: 12rem; min-width: 0; - } - - .buzz-terminal-tab-title { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + padding-left: 32px; } .buzz-terminal-tab-active .buzz-terminal-tab-select { - background: hsl(var(--secondary)); - color: hsl(var(--secondary-foreground)); + color: hsl(var(--foreground)); } - .buzz-terminal-tab-select:hover, .buzz-terminal-new-tab:hover { - background: hsl(var(--accent)); - color: hsl(var(--accent-foreground)); + background: hsl(var(--foreground) / 0.06); + color: hsl(var(--foreground)); + } + + .buzz-terminal-new-tab { + align-items: center; + background: hsl(var(--background)); + border-radius: 4px; + height: 30px; + justify-content: center; + padding: 7px; + width: 30px; + } + + .buzz-terminal-new-tab svg { + height: 16px; + width: 16px; + } + + .buzz-terminal-tab-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .buzz-terminal-designator { + align-items: center; + display: inline-flex; font-size: 0.75rem; + gap: 2px; letter-spacing: 0; } + .buzz-terminal-designator svg { + height: 16px; + width: 16px; + } + .buzz-terminal-close { - height: 24px; + height: 16px; + left: 8px; opacity: 0; padding: 0; - width: 24px; + pointer-events: none; + position: absolute; + width: 16px; + z-index: 1; } .buzz-terminal-tab:hover .buzz-terminal-close, - .buzz-terminal-close:focus { + .buzz-terminal-tab:focus-within .buzz-terminal-close { + color: hsl(var(--foreground)); opacity: 1; + pointer-events: auto; + } + + .buzz-terminal-close svg { + height: 16px; + width: 16px; } .buzz-terminal-readout { @@ -240,13 +283,13 @@ .buzz-terminal-window-action:hover, .buzz-terminal-window-action:focus-visible { - background: hsl(var(--accent)); - color: hsl(var(--accent-foreground)); + background: hsl(var(--foreground) / 0.06); + color: hsl(var(--foreground)); } .buzz-terminal-window-action svg { - height: 15px; - width: 15px; + height: 16px; + width: 16px; } @media (prefers-reduced-motion: reduce) {