From f6aaef45bcba54ba2cafbb178f1e567a3bf540ac Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:14:26 -0700 Subject: [PATCH 1/4] refactor(desktop): tidy v2 terminal session dropdown (#3743) Drop the timestamp from the trigger title, widen the trigger and menu, push the chevron to the trailing edge, and unify dropdown text sizes. --- .../TerminalSessionDropdown.tsx | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx index 722fce9982a..3c9242d65ea 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx @@ -203,10 +203,6 @@ export function TerminalSessionDropdown({ }; const triggerTitle = context.pane.titleOverride ?? "Terminal"; - const currentSession = sessions.find( - (session) => session.terminalId === terminalId, - ); - const currentCreatedAtLabel = formatCreatedAt(currentSession?.createdAt); return ( @@ -215,14 +211,13 @@ export function TerminalSessionDropdown({ type="button" aria-label="Terminal sessions" title={triggerTitle} - className="@container/terminal-session flex min-w-0 max-w-full flex-1 items-center gap-1.5 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + className="flex min-w-32 max-w-96 items-center gap-1.5 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} > - {triggerTitle} - - {currentCreatedAtLabel} + + {triggerTitle} {sessionsQuery.isFetching && isOpen ? ( @@ -231,7 +226,7 @@ export function TerminalSessionDropdown({ )} - + Terminal Sessions @@ -269,7 +264,7 @@ export function TerminalSessionDropdown({ {title} - + {createdAtLabel} From 78a3eb63232b780caaa0789d3c5c098850a8421f Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sat, 25 Apr 2026 15:33:32 -0700 Subject: [PATCH 2/4] Fix automation timezone scheduling (#3738) * Fix automation timezone scheduling * Stabilize automation timezone formatting tests --- .../AutomationDetailSidebar.tsx | 13 +- .../commands/automations/create/command.ts | 6 +- .../cli/src/commands/automations/format.ts | 12 ++ .../src/commands/automations/list/command.ts | 6 +- .../commands/automations/resume/command.ts | 3 +- .../commands/automations/update/command.ts | 2 +- packages/shared/src/rrule.test.ts | 114 ++++++++++++++++++ packages/shared/src/rrule.ts | 46 ++++++- packages/trpc/src/router/automation/schema.ts | 15 ++- 9 files changed, 200 insertions(+), 17 deletions(-) create mode 100644 packages/cli/src/commands/automations/format.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/AutomationDetailSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/AutomationDetailSidebar.tsx index ae9ff26e9db..bcf42853e57 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/AutomationDetailSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/AutomationDetailSidebar.tsx @@ -2,9 +2,9 @@ import type { SelectAutomation, SelectAutomationRun, } from "@superset/db/schema"; +import { formatDateTimeInTimezone } from "@superset/shared/rrule"; import { cn } from "@superset/ui/utils"; import { useMutation } from "@tanstack/react-query"; -import { format } from "date-fns"; import { useEnabledAgents } from "renderer/hooks/useEnabledAgents"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; import { DevicePicker } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; @@ -81,13 +81,20 @@ export function AutomationDetailSidebar({ label="Next run" value={ automation.enabled && automation.nextRunAt - ? format(new Date(automation.nextRunAt), "MMM d, h:mm a") + ? formatDateTimeInTimezone( + new Date(automation.nextRunAt), + automation.timezone, + ) : "—" } /> diff --git a/packages/cli/src/commands/automations/create/command.ts b/packages/cli/src/commands/automations/create/command.ts index a63c5abe0bf..5dfd8a6faf0 100644 --- a/packages/cli/src/commands/automations/create/command.ts +++ b/packages/cli/src/commands/automations/create/command.ts @@ -7,6 +7,7 @@ import { resolveAgentConfigs, } from "@superset/shared/agent-settings"; import { command } from "../../../lib/command"; +import { formatAutomationDate } from "../format"; const DEFAULT_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; @@ -95,12 +96,9 @@ export default command({ mcpScope: [], }); - const nextRun = result.nextRunAt - ? new Date(result.nextRunAt).toISOString() - : "—"; return { data: result, - message: `Created automation "${result.name}" (${result.id})\nNext run: ${nextRun}`, + message: `Created automation "${result.name}" (${result.id})\nNext run: ${formatAutomationDate(result.nextRunAt, result.timezone)}`, }; }, }); diff --git a/packages/cli/src/commands/automations/format.ts b/packages/cli/src/commands/automations/format.ts new file mode 100644 index 00000000000..a4aab49374f --- /dev/null +++ b/packages/cli/src/commands/automations/format.ts @@ -0,0 +1,12 @@ +import { formatDateTimeInTimezone } from "@superset/shared/rrule"; + +export function formatAutomationDate( + value: Date | string | null | undefined, + timezone: string | null | undefined, +): string { + if (!value) return "—"; + const date = value instanceof Date ? value : new Date(value); + if (!Number.isFinite(date.getTime())) return "—"; + + return formatDateTimeInTimezone(date, timezone || "UTC"); +} diff --git a/packages/cli/src/commands/automations/list/command.ts b/packages/cli/src/commands/automations/list/command.ts index 77c891102e2..dc33e10eba1 100644 --- a/packages/cli/src/commands/automations/list/command.ts +++ b/packages/cli/src/commands/automations/list/command.ts @@ -1,5 +1,6 @@ import { table } from "@superset/cli-framework"; import { command } from "../../../lib/command"; +import { formatAutomationDate } from "../format"; export default command({ description: "List automations in the organization", @@ -14,7 +15,10 @@ export default command({ agent: (row.agentConfig as { id?: string } | null)?.id, schedule: row.scheduleText ?? row.rrule, enabled: row.enabled ? "yes" : "no", - nextRun: row.nextRunAt ?? "—", + nextRun: formatAutomationDate( + row.nextRunAt as Date | string | null | undefined, + row.timezone as string | null | undefined, + ), })), ["id", "name", "agent", "schedule", "enabled", "nextRun"], ["ID", "NAME", "AGENT", "SCHEDULE", "ENABLED", "NEXT RUN"], diff --git a/packages/cli/src/commands/automations/resume/command.ts b/packages/cli/src/commands/automations/resume/command.ts index 4cb36939924..408db550d8e 100644 --- a/packages/cli/src/commands/automations/resume/command.ts +++ b/packages/cli/src/commands/automations/resume/command.ts @@ -1,5 +1,6 @@ import { positional } from "@superset/cli-framework"; import { command } from "../../../lib/command"; +import { formatAutomationDate } from "../format"; export default command({ description: "Resume a paused automation", @@ -12,7 +13,7 @@ export default command({ }); return { data: result, - message: `Resumed automation ${id}. Next run: ${result.nextRunAt?.toISOString() ?? "—"}`, + message: `Resumed automation ${id}. Next run: ${formatAutomationDate(result.nextRunAt, result.timezone)}`, }; }, }); diff --git a/packages/cli/src/commands/automations/update/command.ts b/packages/cli/src/commands/automations/update/command.ts index 7dfe451d1c6..53cac30f26b 100644 --- a/packages/cli/src/commands/automations/update/command.ts +++ b/packages/cli/src/commands/automations/update/command.ts @@ -83,7 +83,7 @@ export default command({ timezone: options.timezone, dtstart: options.dtstart ? new Date(options.dtstart) : undefined, agentConfig, - targetHostId: options.device ?? null, + targetHostId: options.device, }); return { diff --git a/packages/shared/src/rrule.test.ts b/packages/shared/src/rrule.test.ts index 58089e5331b..a833c25660e 100644 --- a/packages/shared/src/rrule.test.ts +++ b/packages/shared/src/rrule.test.ts @@ -2,12 +2,32 @@ import { describe, expect, it } from "bun:test"; import { buildRrule, describeSchedule, + formatDateTimeInTimezone, matchPreset, + nextOccurrences, type PresetMatch, + parseRrule, } from "./rrule"; const US = { locale: "en-US" }; +function expectDateTimeParts( + formatted: string, + expected: { + month: string; + day: string; + year: string; + hour: string; + minute: string; + dayPeriod?: string; + timeZoneName: string; + }, +): void { + for (const value of Object.values(expected)) { + expect(formatted).toContain(value); + } +} + describe("describeSchedule / MINUTELY + HOURLY", () => { it("every minute", () => { expect(describeSchedule("FREQ=MINUTELY", US)).toBe("Every minute"); @@ -280,3 +300,97 @@ describe("matchPreset + buildRrule round-trip", () => { }); } }); + +describe("recurrence timezone math", () => { + it("computes daily wall-clock times as plain UTC Date instances", () => { + const next = parseRrule({ + rrule: "FREQ=DAILY;BYHOUR=6;BYMINUTE=0", + dtstart: new Date("2026-04-24T20:00:00.000Z"), + timezone: "America/Los_Angeles", + after: new Date("2026-04-25T00:00:00.000Z"), + }).nextRunAt; + + expect(next.constructor.name).toBe("Date"); + expect(next.toISOString()).toBe("2026-04-25T13:00:00.000Z"); + expectDateTimeParts( + formatDateTimeInTimezone(next, "America/Los_Angeles", { + locale: "en-US", + }), + { + month: "Apr", + day: "25", + year: "2026", + hour: "6", + minute: "00", + dayPeriod: "AM", + timeZoneName: "PDT", + }, + ); + }); + + it("keeps the same local time across daylight saving changes", () => { + const runs = nextOccurrences({ + rrule: "FREQ=DAILY;BYHOUR=6;BYMINUTE=0", + dtstart: new Date("2026-03-06T20:00:00.000Z"), + timezone: "America/Los_Angeles", + after: new Date("2026-03-07T00:00:00.000Z"), + count: 3, + }); + + expect(runs.map((run) => run.toISOString())).toEqual([ + "2026-03-07T14:00:00.000Z", + "2026-03-08T13:00:00.000Z", + "2026-03-09T13:00:00.000Z", + ]); + const formattedRuns = runs.map((run) => + formatDateTimeInTimezone(run, "America/Los_Angeles", { + locale: "en-US", + }), + ); + expectDateTimeParts(formattedRuns[0] ?? "", { + month: "Mar", + day: "7", + year: "2026", + hour: "6", + minute: "00", + dayPeriod: "AM", + timeZoneName: "PST", + }); + expectDateTimeParts(formattedRuns[1] ?? "", { + month: "Mar", + day: "8", + year: "2026", + hour: "6", + minute: "00", + dayPeriod: "AM", + timeZoneName: "PDT", + }); + expectDateTimeParts(formattedRuns[2] ?? "", { + month: "Mar", + day: "9", + year: "2026", + hour: "6", + minute: "00", + dayPeriod: "AM", + timeZoneName: "PDT", + }); + }); + + it("falls back to UTC formatting for invalid legacy timezone values", () => { + const formatted = formatDateTimeInTimezone( + new Date("2026-04-25T13:00:00.000Z"), + "Invalid/Timezone", + { locale: "en-US" }, + ); + + expectDateTimeParts(formatted, { + month: "Apr", + day: "25", + year: "2026", + hour: "1", + minute: "00", + dayPeriod: "PM", + timeZoneName: "UTC", + }); + }); +}); diff --git a/packages/shared/src/rrule.ts b/packages/shared/src/rrule.ts index 08246041dcb..5b809536aa1 100644 --- a/packages/shared/src/rrule.ts +++ b/packages/shared/src/rrule.ts @@ -6,10 +6,10 @@ * - compute real-UTC occurrences with correct DST behavior * (`parseRrule` / `nextOccurrenceAfter` / `nextOccurrences`) * - * rrule.js's `TZID` support returns Date objects whose UTC digits encode the - * *local wall-clock* in the rule's zone — not real UTC instants. Every call - * is wrapped by `utcToRruleDate` on the way in and `rruleDateToUtc` on the - * way out so callers outside this module never see the wall-clock-as-UTC. + * We intentionally run rrule.js on floating wall-clock dates without `TZID`. + * `TZID` output varies with the host process timezone; floating dates keep the + * recurrence calendar stable, then this module converts each occurrence to a + * real UTC instant in the automation's configured timezone. */ import { TZDate } from "@date-fns/tz"; @@ -296,7 +296,7 @@ export interface ParsedRecurrence { /** Wall-clock-as-UTC → real UTC in the given zone. */ export function rruleDateToUtc(rruleDate: Date, timezone: string): Date { - return new TZDate( + const zoned = new TZDate( rruleDate.getUTCFullYear(), rruleDate.getUTCMonth(), rruleDate.getUTCDate(), @@ -305,6 +305,7 @@ export function rruleDateToUtc(rruleDate: Date, timezone: string): Date { rruleDate.getUTCSeconds(), timezone, ); + return new Date(zoned.getTime()); } /** Real UTC → wall-clock-as-UTC in the given zone (rrule.js input space). */ @@ -348,7 +349,7 @@ function buildRuleString( dtstart: Date, timezone: string, ): string { - return `DTSTART;TZID=${timezone}:${formatRRuleLocalDtstart(dtstart, timezone)}\nRRULE:${rrule}`; + return `DTSTART:${formatRRuleLocalDtstart(dtstart, timezone)}\nRRULE:${rrule}`; } /** @@ -413,3 +414,36 @@ export function nextOccurrences(args: { } return results; } + +export interface FormatDateTimeInTimezoneOptions { + /** BCP-47 locale for date/time formatting. Defaults to runtime default. */ + locale?: string; +} + +const DATE_TIME_IN_TIMEZONE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", +}; + +/** Format a real UTC instant in the automation's configured timezone. */ +export function formatDateTimeInTimezone( + date: Date, + timezone: string, + options: FormatDateTimeInTimezoneOptions = {}, +): string { + try { + return new Intl.DateTimeFormat(options.locale, { + ...DATE_TIME_IN_TIMEZONE_FORMAT_OPTIONS, + timeZone: timezone, + }).format(date); + } catch { + return new Intl.DateTimeFormat(options.locale, { + ...DATE_TIME_IN_TIMEZONE_FORMAT_OPTIONS, + timeZone: "UTC", + }).format(date); + } +} diff --git a/packages/trpc/src/router/automation/schema.ts b/packages/trpc/src/router/automation/schema.ts index 728983cf4c5..085e5cdeb16 100644 --- a/packages/trpc/src/router/automation/schema.ts +++ b/packages/trpc/src/router/automation/schema.ts @@ -14,7 +14,20 @@ const agentConfigSchema = z }) .passthrough() as unknown as z.ZodType; -const iana = z.string().min(1).describe("IANA timezone name"); +function isValidIanaTimezone(timezone: string): boolean { + try { + new Intl.DateTimeFormat(undefined, { timeZone: timezone }); + return true; + } catch { + return false; + } +} + +const iana = z + .string() + .min(1) + .refine(isValidIanaTimezone, "Invalid IANA timezone name") + .describe("IANA timezone name"); const rruleBody = z .string() .min(1) From 984814bf5a1cd9ea54cdf74e1dec87b10288c7ec Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:05:38 -0700 Subject: [PATCH 3/4] [codex] Add terminal session titles (#3740) * Add terminal session titles * Use renderer xterm for terminal titles * Avoid terminal title echo * Address terminal title review feedback * Fix replay terminal title recovery * Avoid sending cached terminal title on reconnect * Match ConEmu OSC 9 title parsing * Clear stale title cache before replay * Revert terminal naming implementation * Implement terminal session naming * Instrument terminal title updates * Make terminal title logs unconditional * Prefer live terminal titles in dropdown * Remove obsolete terminal naming handoff * Address terminal title review comments * Reduce terminal title update overhead * Remove terminal title debug logs --- .../lib/terminal/terminal-runtime-registry.ts | 16 ++ .../lib/terminal/terminal-ws-transport.ts | 26 ++- .../TerminalSessionDropdown.tsx | 36 ++++- .../host-service/src/terminal/terminal.ts | 27 +++- packages/shared/package.json | 4 + .../shared/src/terminal-title-scanner.test.ts | 124 ++++++++++++++ packages/shared/src/terminal-title-scanner.ts | 153 ++++++++++++++++++ 7 files changed, 380 insertions(+), 6 deletions(-) create mode 100644 packages/shared/src/terminal-title-scanner.test.ts create mode 100644 packages/shared/src/terminal-title-scanner.ts diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index 67e9fab1d84..6c913234b32 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -397,6 +397,10 @@ class TerminalRuntimeRegistryImpl { ); } + getTitle(terminalId: string, instanceId?: string): string | null | undefined { + return this.getEntry(terminalId, instanceId)?.transport.title; + } + onStateChange( terminalId: string, listener: () => void, @@ -408,6 +412,18 @@ class TerminalRuntimeRegistryImpl { entry.transport.stateListeners.delete(listener); }; } + + onTitleChange( + terminalId: string, + listener: () => void, + instanceId = terminalId, + ): () => void { + const entry = this.getOrCreateEntry(terminalId, instanceId); + entry.transport.titleListeners.add(listener); + return () => { + entry.transport.titleListeners.delete(listener); + }; + } } // In dev, preserve the singleton across Vite HMR so active WebSocket diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts index 7797afea67e..45cc89079a9 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts @@ -11,7 +11,8 @@ type TerminalServerMessage = | { type: "data"; data: string } | { type: "error"; message: string } | { type: "exit"; exitCode: number; signal: number } - | { type: "replay"; data: string }; + | { type: "replay"; data: string } + | { type: "title"; title: string | null }; export interface TerminalTransport { debugId: string | null; @@ -19,8 +20,10 @@ export interface TerminalTransport { connectionState: ConnectionState; /** The URL the socket is currently connected (or connecting) to. */ currentUrl: string | null; + title: string | null | undefined; onDataDisposable: { dispose(): void } | null; stateListeners: Set<() => void>; + titleListeners: Set<() => void>; /** Internal: auto-reconnect timer. */ _reconnectTimer: ReturnType | null; /** Internal: reconnect attempt count for backoff. */ @@ -41,6 +44,17 @@ function setConnectionState( } } +function setTerminalTitle( + transport: TerminalTransport, + title: string | null | undefined, +) { + if (transport.title === title) return; + transport.title = title; + for (const listener of transport.titleListeners) { + listener(); + } +} + const MAX_RECONNECT_DELAY = 10_000; const BASE_RECONNECT_DELAY = 500; const MAX_RECONNECT_ATTEMPTS = 10; @@ -51,8 +65,10 @@ export function createTransport(debugId?: string): TerminalTransport { socket: null, connectionState: "disconnected", currentUrl: null, + title: undefined, onDataDisposable: null, stateListeners: new Set(), + titleListeners: new Set(), _reconnectTimer: null, _reconnectAttempt: 0, _terminal: null, @@ -187,6 +203,11 @@ export function connect( return; } + if (message.type === "title") { + setTerminalTitle(transport, message.title); + return; + } + if (message.type === "error") { terminalRendererDebug.warn( "ws-server-error", @@ -293,6 +314,7 @@ export function disconnect(transport: TerminalTransport) { transport.currentUrl = null; transport._terminal = null; transport._reconnectAttempt = 0; + setTerminalTitle(transport, undefined); setConnectionState(transport, "disconnected"); transport.onDataDisposable?.dispose(); transport.onDataDisposable = null; @@ -337,7 +359,9 @@ export function disposeTransport(transport: TerminalTransport) { transport.currentUrl = null; transport._terminal = null; transport._reconnectAttempt = 0; + setTerminalTitle(transport, undefined); transport.onDataDisposable?.dispose(); transport.onDataDisposable = null; transport.stateListeners.clear(); + transport.titleListeners.clear(); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx index 3c9242d65ea..25cc4229f78 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx @@ -18,8 +18,9 @@ import { TerminalSquare, Trash2, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; import { markTerminalForBackground } from "renderer/lib/terminal/terminal-background-intents"; +import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import type { PaneViewerData, TerminalPaneData, @@ -38,6 +39,7 @@ interface VisibleTerminalSession { exited: boolean; exitCode: number; attached: boolean; + title: string | null; pending?: boolean; } @@ -47,6 +49,8 @@ interface TerminalPaneLocation { titleOverride?: string; } +const EMPTY_TERMINAL_PANE_LOCATIONS = new Map(); + function formatCreatedAt(createdAt: number | undefined): string { if (!createdAt) return "Creating"; @@ -82,6 +86,7 @@ export function TerminalSessionDropdown({ const [isOpen, setIsOpen] = useState(false); const data = context.pane.data as TerminalPaneData; const { terminalId } = data; + const terminalInstanceId = context.pane.id; const utils = workspaceTrpc.useUtils(); const killTerminalSession = workspaceTrpc.terminal.killSession.useMutation(); const sessionsQuery = workspaceTrpc.terminal.listSessions.useQuery( @@ -104,12 +109,32 @@ export function TerminalSessionDropdown({ exited: false, exitCode: 0, attached: false, + title: null, pending: true, }, ...liveSessions, ]; }, [sessionsQuery.data?.sessions, terminalId, workspaceId]); - const renderTerminalPaneLocations = getTerminalPaneLocations(context); + const currentSession = sessions.find( + (session) => session.terminalId === terminalId, + ); + const subscribeTitle = useCallback( + (callback: () => void) => + terminalRuntimeRegistry.onTitleChange( + terminalId, + callback, + terminalInstanceId, + ), + [terminalId, terminalInstanceId], + ); + const getTitleSnapshot = useCallback( + () => terminalRuntimeRegistry.getTitle(terminalId, terminalInstanceId), + [terminalId, terminalInstanceId], + ); + const runtimeTitle = useSyncExternalStore(subscribeTitle, getTitleSnapshot); + const renderTerminalPaneLocations = isOpen + ? getTerminalPaneLocations(context) + : EMPTY_TERMINAL_PANE_LOCATIONS; const handleSelectSession = (nextTerminalId: string) => { if (nextTerminalId === terminalId) { @@ -202,7 +227,10 @@ export function TerminalSessionDropdown({ setIsOpen(false); }; - const triggerTitle = context.pane.titleOverride ?? "Terminal"; + const hostTitle = + runtimeTitle !== undefined ? runtimeTitle : currentSession?.title; + const titleOverride = context.pane.titleOverride; + const triggerTitle = hostTitle ?? titleOverride ?? "Terminal"; return ( @@ -248,7 +276,7 @@ export function TerminalSessionDropdown({ : "Detached"; const title = isCurrent ? triggerTitle - : (location?.titleOverride ?? "Terminal"); + : (session.title ?? location?.titleOverride ?? "Terminal"); return ( 0, + title: session.title, })); } @@ -188,6 +198,12 @@ function broadcastMessage( return sent; } +function setSessionTitle(session: TerminalSession, title: string | null) { + if (session.title === title) return; + session.title = title; + broadcastMessage(session, { type: "title", title }); +} + function bufferOutput(session: TerminalSession, data: string) { session.buffer.push(data); session.bufferBytes += data.length; @@ -433,6 +449,8 @@ export function createTerminalSessionInternal({ exitCode: 0, exitSignal: 0, listed, + title: null, + titleScanState: createTerminalTitleScanState(), shellReadyState: shellSupportsReady ? "pending" : "unsupported", shellReadyResolve, shellReadyPromise, @@ -451,6 +469,11 @@ export function createTerminalSessionInternal({ } pty.onData((rawData) => { + const titleUpdates = scanForTerminalTitle(session.titleScanState, rawData); + for (const title of titleUpdates.updates) { + setSessionTitle(session, title); + } + // Scan for OSC 133;A and strip it from output let data = rawData; if (session.shellReadyState === "pending") { @@ -610,6 +633,7 @@ export function registerWorkspaceTerminalRoute({ } result.sockets.add(ws); + sendMessage(ws, { type: "title", title: result.title }); db.update(terminalSessions) .set({ lastAttachedAt: Date.now() }) @@ -625,6 +649,7 @@ export function registerWorkspaceTerminalRoute({ .where(eq(terminalSessions.id, terminalId)) .run(); + sendMessage(ws, { type: "title", title: existing.title }); replayBuffer(existing, ws); if (existing.exited) { sendMessage(ws, { diff --git a/packages/shared/package.json b/packages/shared/package.json index 7b26ac04716..567abfc18fc 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -100,6 +100,10 @@ "types": "./src/shell-ready-scanner.ts", "default": "./src/shell-ready-scanner.ts" }, + "./terminal-title-scanner": { + "types": "./src/terminal-title-scanner.ts", + "default": "./src/terminal-title-scanner.ts" + }, "./rrule": { "types": "./src/rrule.ts", "default": "./src/rrule.ts" diff --git a/packages/shared/src/terminal-title-scanner.test.ts b/packages/shared/src/terminal-title-scanner.test.ts new file mode 100644 index 00000000000..2032e82a569 --- /dev/null +++ b/packages/shared/src/terminal-title-scanner.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "bun:test"; +import { + createTerminalTitleScanState, + normalizeTerminalTitle, + scanForTerminalTitle, +} from "./terminal-title-scanner"; + +describe("terminal title scanner", () => { + it("handles OSC 0 and OSC 2 with BEL terminators", () => { + const state = createTerminalTitleScanState(); + + expect(scanForTerminalTitle(state, "\x1b]0;Shell\x07").updates).toEqual([ + "Shell", + ]); + expect(scanForTerminalTitle(state, "\x1b]2;Editor\x07").updates).toEqual([ + "Editor", + ]); + }); + + it("handles ST terminators", () => { + const state = createTerminalTitleScanState(); + + expect( + scanForTerminalTitle(state, "\x1b]2;Workspace\x1b\\").updates, + ).toEqual(["Workspace"]); + }); + + it("handles C1 ST terminators", () => { + const state = createTerminalTitleScanState(); + + expect(scanForTerminalTitle(state, "\x1b]2;Workspace\x9c").updates).toEqual( + ["Workspace"], + ); + expect(scanForTerminalTitle(state, "\x1b]2;Changed\x9c").updates).toEqual([ + "Changed", + ]); + }); + + it("handles C1 OSC introducers", () => { + const state = createTerminalTitleScanState(); + + expect(scanForTerminalTitle(state, "\x9d2;Workspace\x9c").updates).toEqual([ + "Workspace", + ]); + expect(scanForTerminalTitle(state, "\x9d9;3;Agent\x07").updates).toEqual([ + "Agent", + ]); + }); + + it("handles fragmented OSC sequences", () => { + const state = createTerminalTitleScanState(); + + expect(scanForTerminalTitle(state, "\x1b]2;Work").updates).toEqual([]); + expect(scanForTerminalTitle(state, "space\x07").updates).toEqual([ + "Workspace", + ]); + }); + + it("handles fragmented OSC introducers and ST terminators", () => { + const state = createTerminalTitleScanState(); + + expect(scanForTerminalTitle(state, "\x1b").updates).toEqual([]); + expect(scanForTerminalTitle(state, "]0;Split\x1b").updates).toEqual([]); + expect(scanForTerminalTitle(state, "\\").updates).toEqual(["Split"]); + }); + + it("handles ConEmu tab title and reset sequences", () => { + const state = createTerminalTitleScanState(); + + expect(scanForTerminalTitle(state, "\x1b]9;3;Agent\x07").updates).toEqual([ + "Agent", + ]); + expect(scanForTerminalTitle(state, "\x1b]9;3;\x07").updates).toEqual([ + null, + ]); + }); + + it("ignores malformed and unsupported payloads", () => { + const state = createTerminalTitleScanState(); + + expect(scanForTerminalTitle(state, "\x1b]9;3\x07").updates).toEqual([]); + expect(scanForTerminalTitle(state, "\x1b]9;3a\x07").updates).toEqual([]); + expect(scanForTerminalTitle(state, "\x1b]9;4;Nope\x07").updates).toEqual( + [], + ); + expect(scanForTerminalTitle(state, "\x1b]1;Icon\x07").updates).toEqual([]); + }); + + it("returns every title update in a chunk", () => { + const state = createTerminalTitleScanState(); + + expect( + scanForTerminalTitle(state, "\x1b]0;First\x07text\x1b]2;Second\x07") + .updates, + ).toEqual(["First", "Second"]); + }); + + it("drops oversized incomplete OSC payloads by UTF-8 byte length", () => { + const state = createTerminalTitleScanState(); + + expect( + scanForTerminalTitle(state, `\x1b]2;${"🙂".repeat(1024)}`).updates, + ).toEqual([]); + expect(state.buffer).toBe(""); + }); +}); + +describe("normalizeTerminalTitle", () => { + it("strips control characters and trims whitespace", () => { + expect(normalizeTerminalTitle(" \x00Superset\x1b Terminal\t ")).toBe( + "Superset Terminal", + ); + }); + + it("returns null for empty titles", () => { + expect(normalizeTerminalTitle(" \x1b\t ")).toBeNull(); + }); + + it("truncates long titles without splitting code points", () => { + const title = `${"a".repeat(199)}🙂extra`; + + expect(Array.from(normalizeTerminalTitle(title) ?? "")).toHaveLength(200); + }); +}); diff --git a/packages/shared/src/terminal-title-scanner.ts b/packages/shared/src/terminal-title-scanner.ts new file mode 100644 index 00000000000..6125b9c172c --- /dev/null +++ b/packages/shared/src/terminal-title-scanner.ts @@ -0,0 +1,153 @@ +const ESC = "\x1b"; +const OSC = `${ESC}]`; +const C1_OSC = "\x9d"; +const BEL = "\x07"; +const ST = `${ESC}\\`; +const C1_ST = "\x9c"; + +const MAX_OSC_SEQUENCE_BYTES = 4096; +const MAX_TERMINAL_TITLE_LENGTH = 200; + +export interface TerminalTitleScanState { + buffer: string; +} + +export interface TerminalTitleScanResult { + updates: Array; +} + +export function createTerminalTitleScanState(): TerminalTitleScanState { + return { buffer: "" }; +} + +export function normalizeTerminalTitle(title: string): string | null { + const normalized = Array.from(title) + .filter((char) => { + const codePoint = char.codePointAt(0) ?? 0; + return !( + codePoint <= 0x1f || + codePoint === 0x7f || + (codePoint >= 0x80 && codePoint <= 0x9f) + ); + }) + .join("") + .trim(); + if (!normalized) return null; + + const chars = Array.from(normalized); + if (chars.length <= MAX_TERMINAL_TITLE_LENGTH) return normalized; + return chars.slice(0, MAX_TERMINAL_TITLE_LENGTH).join(""); +} + +function getUtf8ByteLength(value: string): number { + let bytes = 0; + for (const char of value) { + const codePoint = char.codePointAt(0) ?? 0; + if (codePoint <= 0x7f) { + bytes += 1; + } else if (codePoint <= 0x7ff) { + bytes += 2; + } else if (codePoint <= 0xffff) { + bytes += 3; + } else { + bytes += 4; + } + } + return bytes; +} + +function findOscTerminator( + input: string, + fromIndex: number, +): { index: number; length: number } | null { + for (let i = fromIndex; i < input.length; i++) { + const ch = input[i]; + if (ch === BEL) return { index: i, length: BEL.length }; + if (ch === C1_ST) return { index: i, length: C1_ST.length }; + if (ch === ESC && input.startsWith(ST, i)) { + return { index: i, length: ST.length }; + } + } + return null; +} + +function findOscStart( + input: string, + fromIndex: number, +): { index: number; length: number } | null { + const escOscIndex = input.indexOf(OSC, fromIndex); + const c1OscIndex = input.indexOf(C1_OSC, fromIndex); + + if (escOscIndex === -1 && c1OscIndex === -1) return null; + if (escOscIndex === -1) return { index: c1OscIndex, length: C1_OSC.length }; + if (c1OscIndex === -1 || escOscIndex < c1OscIndex) { + return { index: escOscIndex, length: OSC.length }; + } + return { index: c1OscIndex, length: C1_OSC.length }; +} + +function parseTitlePayload(payload: string): string | null | undefined { + const firstSeparator = payload.indexOf(";"); + if (firstSeparator <= 0) return undefined; + + const command = payload.slice(0, firstSeparator); + const value = payload.slice(firstSeparator + 1); + + if (command === "0" || command === "2") { + return normalizeTerminalTitle(value); + } + + if (command !== "9") return undefined; + if (value === "3;") return null; + if (!value.startsWith("3;")) return undefined; + return normalizeTerminalTitle(value.slice(2)); +} + +/** + * Scan PTY output for terminal title OSC sequences. + * + * Supported sequences: + * - OSC 0; BEL/ST + * - OSC 2;<title> BEL/ST + * - OSC 9;3;<title> BEL/ST (ConEmu tab title) + * - OSC 9;3; BEL/ST reset + * + * OSC may be encoded as ESC ] or the single-byte C1 introducer. + * ST may be encoded as ESC \ or the single-byte C1 terminator. + */ +export function scanForTerminalTitle( + state: TerminalTitleScanState, + chunk: string, +): TerminalTitleScanResult { + const input = state.buffer ? state.buffer + chunk : chunk; + const updates: Array<string | null> = []; + let searchIndex = 0; + + while (searchIndex < input.length) { + const oscStart = findOscStart(input, searchIndex); + if (!oscStart) { + state.buffer = input.endsWith(ESC) ? ESC : ""; + return { updates }; + } + + const payloadStart = oscStart.index + oscStart.length; + const terminator = findOscTerminator(input, payloadStart); + if (!terminator) { + const sequence = input.slice(oscStart.index); + state.buffer = + getUtf8ByteLength(sequence) <= MAX_OSC_SEQUENCE_BYTES ? sequence : ""; + return { updates }; + } + + const payload = input.slice(payloadStart, terminator.index); + const title = parseTitlePayload(payload); + if (title !== undefined) { + updates.push(title); + } + + searchIndex = terminator.index + terminator.length; + } + + state.buffer = ""; + return { updates }; +} From 6e625f12946affd95bb31fdd2fa4970bd62ba5bf Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:12:45 -0700 Subject: [PATCH 4/4] fix(desktop): refit v2 terminal after font settle (#3742) --- .../renderer/lib/terminal/terminal-addons.ts | 25 ++++- .../renderer/lib/terminal/terminal-runtime.ts | 103 +++++++++++++++++- ...60425-v2-terminal-rendering-divergences.md | 21 ++-- 3 files changed, 133 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts index 2fcc9ab277d..d51e1ff605b 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts @@ -11,9 +11,14 @@ import { installRectangleRendererAlphaPatch } from "./webgl-vibrancy-patch"; export interface LoadAddonsResult { searchAddon: SearchAddon; progressAddon: ProgressAddon; + clearTextureAtlas: () => void; dispose: () => void; } +interface LoadAddonsOptions { + onRendererChange?: () => void; +} + // Once WebGL fails, skip it for all subsequent runtimes (VS Code pattern). let suggestedRendererType: "webgl" | "dom" | undefined; @@ -22,7 +27,10 @@ let suggestedRendererType: "webgl" | "dom" | undefined; * function and addon instances. WebGL is deferred to rAF to avoid * racing with xterm's post-open viewport sync. */ -export function loadAddons(terminal: XTerm): LoadAddonsResult { +export function loadAddons( + terminal: XTerm, + options: LoadAddonsOptions = {}, +): LoadAddonsResult { let disposed = false; let webglAddon: WebglAddon | null = null; @@ -52,14 +60,16 @@ export function loadAddons(terminal: XTerm): LoadAddonsResult { webglAddon.onContextLoss(() => { webglAddon?.dispose(); webglAddon = null; + options.onRendererChange?.(); terminal.refresh(0, terminal.rows - 1); }); terminal.loadAddon(webglAddon); - // Make explicit-bg cells honor the alpha we put on `theme.ansi[]` - // when vibrancy is enabled — without this codex / Claude Code TUI - // blocks render as opaque black even though the rest of the terminal - // is transparent. See `webgl-vibrancy-patch.ts` for the details. + // FORK NOTE: Make explicit-bg cells honor the alpha we put on + // `theme.ansi[]` when vibrancy is enabled — without this codex / + // Claude Code TUI blocks render as opaque black even though the + // rest of the terminal is transparent. See `webgl-vibrancy-patch.ts`. installRectangleRendererAlphaPatch(webglAddon); + options.onRendererChange?.(); } catch { suggestedRendererType = "dom"; webglAddon = null; @@ -69,6 +79,11 @@ export function loadAddons(terminal: XTerm): LoadAddonsResult { return { searchAddon, progressAddon, + clearTextureAtlas: () => { + try { + webglAddon?.clearTextureAtlas(); + } catch {} + }, dispose: () => { disposed = true; cancelAnimationFrame(rafId); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index b08487ef356..79d8d29f01d 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -20,6 +20,8 @@ const DIMS_KEY_PREFIX = "terminal-dims:"; const DEFAULT_COLS = 120; const DEFAULT_ROWS = 32; const RESIZE_DEBOUNCE_MS = 75; +const FONT_SETTLE_TIMEOUT_MS = 1000; +const FONT_LOAD_SAMPLE_TEXT = "W"; // xterm's _keyDown calls stopPropagation after processing, so any chord we // want the host (react-hotkeys-hook, Electron menu accelerators) or the shell @@ -87,6 +89,10 @@ export interface TerminalRuntime { container: HTMLDivElement | null; resizeObserver: ResizeObserver | null; _disposeResizeObserver: (() => void) | null; + _disposeFontSettle: (() => void) | null; + _onResize: (() => void) | null; + _clearTextureAtlas: (() => void) | null; + _fontSettleToken: number; lastCols: number; lastRows: number; _disposeAddons: (() => void) | null; @@ -185,6 +191,53 @@ function hostIsVisible(container: HTMLDivElement | null): boolean { return container.clientWidth > 0 && container.clientHeight > 0; } +function waitForNextFrame(): Promise<void> { + if (typeof requestAnimationFrame !== "function") { + return Promise.resolve(); + } + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +function waitForTerminalFont( + terminal: XTerm, + timeoutMs = FONT_SETTLE_TIMEOUT_MS, +): Promise<void> { + const fontFamily = terminal.options.fontFamily; + const fontSize = terminal.options.fontSize; + if ( + typeof document === "undefined" || + !("fonts" in document) || + typeof fontFamily !== "string" || + typeof fontSize !== "number" + ) { + return waitForNextFrame(); + } + + const fontSpec = `${fontSize}px ${fontFamily}`; + let timeoutId: ReturnType<typeof setTimeout> | null = null; + const timeout = new Promise<void>((resolve) => { + timeoutId = setTimeout(resolve, timeoutMs); + }); + let fontLoad: Promise<void>; + try { + fontLoad = document.fonts + .load(fontSpec, FONT_LOAD_SAMPLE_TEXT) + .catch(() => document.fonts.ready) + .then(() => undefined) + .catch(() => undefined); + } catch { + fontLoad = document.fonts.ready + .then(() => undefined) + .catch(() => undefined); + } + + return Promise.race([fontLoad, timeout]) + .then(() => waitForNextFrame()) + .finally(() => { + if (timeoutId !== null) clearTimeout(timeoutId); + }); +} + // Body-level hidden container that owns wrapper divs of terminals whose // React component is currently unmounted (e.g. workspace switch). Keeps // xterm attached to the document so it survives provider remounts without @@ -240,6 +293,33 @@ function measureAndResize(runtime: TerminalRuntime): boolean { return terminal.cols !== prevCols || terminal.rows !== prevRows; } +function scheduleFontSettleRefit(runtime: TerminalRuntime) { + runtime._disposeFontSettle?.(); + + let disposed = false; + const token = runtime._fontSettleToken + 1; + runtime._fontSettleToken = token; + + runtime._disposeFontSettle = () => { + disposed = true; + if (runtime._fontSettleToken === token) { + runtime._disposeFontSettle = null; + } + }; + + void waitForTerminalFont(runtime.terminal).then(() => { + if (disposed || runtime._fontSettleToken !== token) return; + runtime._disposeFontSettle = null; + if (!hostIsVisible(runtime.container)) return; + + // A late-loading font can change cell metrics after xterm's first fit. + runtime._clearTextureAtlas?.(); + if (measureAndResize(runtime)) { + runtime._onResize?.(); + } + }); +} + function createResizeScheduler( runtime: TerminalRuntime, onResize?: () => void, @@ -303,14 +383,19 @@ export function createRuntime( // Activate Unicode 11 widths (inside loadAddons) before restoring the buffer, // else CJK/emoji/ZWJ widths get baked wrong into the replay. (#3572) - const addonsResult = loadAddons(terminal); + let runtime: TerminalRuntime | null = null; + const addonsResult = loadAddons(terminal, { + onRendererChange: () => { + if (runtime) scheduleFontSettleRefit(runtime); + }, + }); if (options.initialBuffer !== undefined) { terminal.write(options.initialBuffer); } else { restoreBuffer(terminalId, terminal); } - return { + runtime = { terminalId, terminal, fitAddon, @@ -321,10 +406,15 @@ export function createRuntime( container: null, resizeObserver: null, _disposeResizeObserver: null, + _disposeFontSettle: null, + _onResize: null, + _clearTextureAtlas: addonsResult.clearTextureAtlas, + _fontSettleToken: 0, lastCols: cols, lastRows: rows, _disposeAddons: addonsResult.dispose, }; + return runtime; } export function attachToContainer( @@ -335,6 +425,7 @@ export function attachToContainer( // If we're already attached to this exact container, do nothing. Prevents // redundant refresh/focus/fit from transient remounts during provider key // churn — VSCode setVisible() is idempotent for the same host element. + runtime._onResize = onResize ?? null; const sameContainer = runtime.container === container && runtime.wrapper.parentElement === container; @@ -350,6 +441,7 @@ export function attachToContainer( containerHeight: container.clientHeight, }); if (measureAndResize(runtime)) onResize?.(); + scheduleFontSettleRefit(runtime); // Renderer may have skipped frames while the wrapper was detached. // (refresh is now handled inside measureAndResize) @@ -394,6 +486,9 @@ export function detachFromContainer(runtime: TerminalRuntime) { ); runtime._disposeResizeObserver?.(); runtime._disposeResizeObserver = null; + runtime._disposeFontSettle?.(); + runtime._disposeFontSettle = null; + runtime._onResize = null; runtime.resizeObserver?.disconnect(); runtime.resizeObserver = null; // Park instead of .remove() so xterm survives the React unmount — @@ -418,6 +513,7 @@ export function updateRuntimeAppearance( terminal.options.fontSize = appearance.fontSize; if (hostIsVisible(runtime.container)) { measureAndResize(runtime); + scheduleFontSettleRefit(runtime); } } } @@ -435,6 +531,9 @@ export function disposeRuntime( runtime._disposeAddons = null; runtime._disposeResizeObserver?.(); runtime._disposeResizeObserver = null; + runtime._disposeFontSettle?.(); + runtime._disposeFontSettle = null; + runtime._onResize = null; runtime.resizeObserver?.disconnect(); runtime.resizeObserver = null; runtime.wrapper.remove(); diff --git a/plans/20260425-v2-terminal-rendering-divergences.md b/plans/20260425-v2-terminal-rendering-divergences.md index 29fcdfd7990..b618126085d 100644 --- a/plans/20260425-v2-terminal-rendering-divergences.md +++ b/plans/20260425-v2-terminal-rendering-divergences.md @@ -22,7 +22,7 @@ hypotheses and from items already handled by xterm internals. ## 1. Font loading race on first open -**Status:** Partially valid. +**Status:** Implemented after PR #3739. **Us:** `terminal-runtime.ts:232` — `terminal.open(wrapper)` runs immediately after construction. xterm measures cell width with whatever font is resolved at @@ -38,14 +38,17 @@ any user-selected font that is resolved through CSS font loading. Also, Tabby does not wait before `open()`; it opens first, waits a tick for font/layout settling, then configures colors/WebGL (`xtermFrontend.ts:275-306`). -**Fix:** Do not make `createRuntime()` async unless the registry lifecycle is -rewired. Prefer a bounded post-open font-settle step: -- after `terminal.open(wrapper)`, wait for `document.fonts.ready` or - `document.fonts.load(\`${size}px ${family}\`)` when available; -- then call `measureAndResize(runtime)`, `terminal.clearTextureAtlas()` if using - the built-in API or the WebGL addon is exposed, and `terminal.refresh(...)`; -- apply the same settle/refit path after font changes at - `terminal-runtime.ts:317`. +**Fix:** Implemented without making `createRuntime()` async. The runtime now +stores the active resize sender, exposes WebGL atlas clearing from +`terminal-addons.ts`, and schedules a bounded font-settle refit after attach, +after font changes, and after WebGL renderer changes: +- wait for `document.fonts.load(\`${size}px ${family}\`)` when available, capped + by `FONT_SETTLE_TIMEOUT_MS`, then wait one animation frame for layout to + settle; +- clear the WebGL texture atlas when present; +- run the existing `measureAndResize(runtime)` path, which preserves viewport + state, refreshes the terminal, and sends backend resize only when cols/rows + change. ---