feat: simplify tab layout to CSS-driven sizing and throttle terminal state broadcasts

This commit is contained in:
Mohamed Boudra
2026-03-03 23:29:48 +07:00
parent cb92f39e73
commit e9b57a5ac6
7 changed files with 197 additions and 114 deletions
@@ -12,11 +12,12 @@ type UseWorkspaceTabLayoutInput = {
actionsReservedWidth: number; actionsReservedWidth: number;
rowPaddingHorizontal: number; rowPaddingHorizontal: number;
tabGap: number; tabGap: number;
minTabWidth: number;
maxTabWidth: number; maxTabWidth: number;
iconOnlyTabWidth: number; tabIconWidth: number;
tabBaseWidthWithClose: number; tabHorizontalPadding: number;
minLabelChars: number; estimatedCharWidth: number;
charWidth: number; closeButtonWidth: number;
}; };
}; };
@@ -80,11 +80,12 @@ export function WorkspaceDesktopTabsRow({
actionsReservedWidth: 120, actionsReservedWidth: 120,
rowPaddingHorizontal: theme.spacing[2], rowPaddingHorizontal: theme.spacing[2],
tabGap: theme.spacing[1], tabGap: theme.spacing[1],
minTabWidth: 60,
maxTabWidth: 260, maxTabWidth: 260,
iconOnlyTabWidth: 40, tabIconWidth: 14,
tabBaseWidthWithClose: 84, tabHorizontalPadding: theme.spacing[3],
minLabelChars: 4, estimatedCharWidth: 7,
charWidth: 7, closeButtonWidth: 22,
}), }),
[theme.spacing] [theme.spacing]
); );
@@ -98,7 +99,6 @@ export function WorkspaceDesktopTabsRow({
<View style={styles.tabsContainer} testID="workspace-tabs-row"> <View style={styles.tabsContainer} testID="workspace-tabs-row">
<ScrollView <ScrollView
horizontal horizontal
scrollEnabled={layout.shouldScroll}
testID="workspace-tabs-scroll" testID="workspace-tabs-scroll"
style={styles.tabsScroll} style={styles.tabsScroll}
contentContainerStyle={styles.tabsContent} contentContainerStyle={styles.tabsContent}
@@ -110,7 +110,7 @@ export function WorkspaceDesktopTabsRow({
useDragHandle useDragHandle
disabled={tabs.length < 2} disabled={tabs.length < 2}
onDragEnd={onReorderTabs} onDragEnd={onReorderTabs}
renderItem={({ item: tab, dragHandleProps, index }) => { renderItem={({ item: tab, dragHandleProps }) => {
const isActive = tab.key === activeTabKey; const isActive = tab.key === activeTabKey;
const tabAgent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null; const tabAgent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null;
const isCloseHovered = hoveredCloseTabKey === tab.key; const isCloseHovered = hoveredCloseTabKey === tab.key;
@@ -125,7 +125,6 @@ export function WorkspaceDesktopTabsRow({
const isClosingTab = isClosingAgent || isClosingTerminal; const isClosingTab = isClosingAgent || isClosingTerminal;
const shouldShowCloseButton = layout.showCloseButtons; const shouldShowCloseButton = layout.showCloseButtons;
const iconColor = isActive ? theme.colors.foreground : theme.colors.foregroundMuted; const iconColor = isActive ? theme.colors.foreground : theme.colors.foregroundMuted;
const tabWidth = layout.tabWidths[index] ?? layoutMetrics.maxTabWidth;
const tabAgentStatusBucket = tabAgent const tabAgentStatusBucket = tabAgent
? deriveSidebarStateBucket({ ? deriveSidebarStateBucket({
status: tabAgent.status, status: tabAgent.status,
@@ -181,7 +180,6 @@ export function WorkspaceDesktopTabsRow({
enabledOnMobile={false} enabledOnMobile={false}
style={({ hovered, pressed }) => [ style={({ hovered, pressed }) => [
styles.tab, styles.tab,
{ width: tabWidth, minWidth: tabWidth, maxWidth: tabWidth },
layout.mode === "icon" && styles.tabIconOnly, layout.mode === "icon" && styles.tabIconOnly,
isActive && styles.tabActive, isActive && styles.tabActive,
(hovered || pressed || isCloseHovered) && styles.tabHovered, (hovered || pressed || isCloseHovered) && styles.tabHovered,
@@ -200,9 +198,7 @@ export function WorkspaceDesktopTabsRow({
<View <View
{...(dragHandleProps?.attributes as any)} {...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)} {...(dragHandleProps?.listeners as any)}
ref={(node: unknown) => { ref={dragHandleProps?.setActivatorNodeRef}
dragHandleProps?.setActivatorNodeRef?.(node);
}}
style={styles.tabHandle} style={styles.tabHandle}
> >
<View style={styles.tabIcon}>{icon}</View> <View style={styles.tabIcon}>{icon}</View>
@@ -386,6 +382,8 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
}, },
tab: { tab: {
minWidth: 60,
maxWidth: 260,
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
@@ -394,6 +392,9 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
tabIconOnly: { tabIconOnly: {
minWidth: 40,
width: 40,
maxWidth: 40,
justifyContent: "center", justifyContent: "center",
}, },
tabHandle: { tabHandle: {
@@ -575,6 +575,9 @@ function WorkspaceScreenContent({
if (normalized.startsWith("agent_")) { if (normalized.startsWith("agent_")) {
const agentId = normalized.slice("agent_".length).trim(); const agentId = normalized.slice("agent_".length).trim();
if (agentId) { if (agentId) {
if (areWorkspaceAgentsHydrated && !agentsById.has(agentId)) {
return;
}
const tabId = openOrFocusTab({ const tabId = openOrFocusTab({
serverId: normalizedServerId, serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId, workspaceId: normalizedWorkspaceId,
@@ -593,6 +596,9 @@ function WorkspaceScreenContent({
if (normalized.startsWith("terminal_")) { if (normalized.startsWith("terminal_")) {
const terminalId = normalized.slice("terminal_".length).trim(); const terminalId = normalized.slice("terminal_".length).trim();
if (terminalId) { if (terminalId) {
if (areWorkspaceTerminalsHydrated && !terminalIds.has(terminalId)) {
return;
}
const tabId = openOrFocusTab({ const tabId = openOrFocusTab({
serverId: normalizedServerId, serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId, workspaceId: normalizedWorkspaceId,
@@ -624,6 +630,9 @@ function WorkspaceScreenContent({
} }
}, [ }, [
focusTab, focusTab,
agentsById,
areWorkspaceAgentsHydrated,
areWorkspaceTerminalsHydrated,
openDraftTab, openDraftTab,
openOrFocusTab, openOrFocusTab,
openTabs, openTabs,
@@ -631,6 +640,7 @@ function WorkspaceScreenContent({
normalizedServerId, normalizedServerId,
normalizedWorkspaceId, normalizedWorkspaceId,
routeTabId, routeTabId,
terminalIds,
]); ]);
const activeTabId = useMemo(() => { const activeTabId = useMemo(() => {
@@ -6,11 +6,12 @@ const metrics = {
actionsReservedWidth: 120, actionsReservedWidth: 120,
rowPaddingHorizontal: 8, rowPaddingHorizontal: 8,
tabGap: 4, tabGap: 4,
minTabWidth: 60,
maxTabWidth: 260, maxTabWidth: 260,
iconOnlyTabWidth: 40, tabIconWidth: 14,
tabBaseWidthWithClose: 84, tabHorizontalPadding: 12,
minLabelChars: 4, estimatedCharWidth: 7,
charWidth: 7, closeButtonWidth: 22,
}; };
describe("computeWorkspaceTabLayout", () => { describe("computeWorkspaceTabLayout", () => {
@@ -22,23 +23,20 @@ describe("computeWorkspaceTabLayout", () => {
}); });
expect(result.mode).toBe("full"); expect(result.mode).toBe("full");
expect(result.shouldScroll).toBe(false);
expect(result.showLabels).toBe(true); expect(result.showLabels).toBe(true);
expect(result.showCloseButtons).toBe(true); expect(result.showCloseButtons).toBe(true);
}); });
it("shrinks proportionally in compact mode before icon-only", () => { it("uses compact mode before icon-only", () => {
const result = computeWorkspaceTabLayout({ const result = computeWorkspaceTabLayout({
viewportWidth: 500, viewportWidth: 570,
tabLabelLengths: [24, 12, 8], tabLabelLengths: [24, 12, 8],
metrics, metrics,
}); });
expect(result.mode).toBe("compact"); expect(result.mode).toBe("compact");
expect(result.shouldScroll).toBe(false);
expect(result.showLabels).toBe(true); expect(result.showLabels).toBe(true);
expect(result.tabWidths[0]).toBeGreaterThan(result.tabWidths[1]); expect(result.showCloseButtons).toBe(false);
expect(result.tabWidths[1]).toBeGreaterThan(result.tabWidths[2]);
}); });
it("falls back to icon mode when compact labels still cannot fit", () => { it("falls back to icon mode when compact labels still cannot fit", () => {
@@ -51,7 +49,6 @@ describe("computeWorkspaceTabLayout", () => {
expect(result.mode).toBe("icon"); expect(result.mode).toBe("icon");
expect(result.showLabels).toBe(false); expect(result.showLabels).toBe(false);
expect(result.showCloseButtons).toBe(false); expect(result.showCloseButtons).toBe(false);
expect(result.shouldScroll).toBe(true);
}); });
it("keeps icon mode without scroll when icons can fit", () => { it("keeps icon mode without scroll when icons can fit", () => {
@@ -62,6 +59,5 @@ describe("computeWorkspaceTabLayout", () => {
}); });
expect(result.mode).toBe("icon"); expect(result.mode).toBe("icon");
expect(result.shouldScroll).toBe(false);
}); });
}); });
@@ -8,18 +8,17 @@ export type WorkspaceTabLayoutInput = {
actionsReservedWidth: number; actionsReservedWidth: number;
rowPaddingHorizontal: number; rowPaddingHorizontal: number;
tabGap: number; tabGap: number;
minTabWidth: number;
maxTabWidth: number; maxTabWidth: number;
iconOnlyTabWidth: number; tabIconWidth: number;
tabBaseWidthWithClose: number; tabHorizontalPadding: number;
minLabelChars: number; estimatedCharWidth: number;
charWidth: number; closeButtonWidth: number;
}; };
}; };
export type WorkspaceTabLayoutResult = { export type WorkspaceTabLayoutResult = {
mode: WorkspaceTabLayoutMode; mode: WorkspaceTabLayoutMode;
tabWidths: number[];
shouldScroll: boolean;
showLabels: boolean; showLabels: boolean;
showCloseButtons: boolean; showCloseButtons: boolean;
}; };
@@ -42,18 +41,14 @@ function sum(values: number[]): number {
return total; return total;
} }
function computeTotalRowWidth(input: { function computeTotalRowWidth(input: { itemWidths: number[]; tabGap: number; rowPaddingHorizontal: number }): number {
tabWidths: number[]; if (input.itemWidths.length === 0) {
tabGap: number;
rowPaddingHorizontal: number;
}): number {
if (input.tabWidths.length === 0) {
return 0; return 0;
} }
return ( return (
input.rowPaddingHorizontal * 2 + input.rowPaddingHorizontal * 2 +
sum(input.tabWidths) + sum(input.itemWidths) +
Math.max(input.tabWidths.length - 1, 0) * input.tabGap Math.max(input.itemWidths.length - 1, 0) * input.tabGap
); );
} }
@@ -64,8 +59,6 @@ export function computeWorkspaceTabLayout(
if (tabCount === 0) { if (tabCount === 0) {
return { return {
mode: "full", mode: "full",
tabWidths: [],
shouldScroll: false,
showLabels: true, showLabels: true,
showCloseButtons: true, showCloseButtons: true,
}; };
@@ -75,68 +68,36 @@ export function computeWorkspaceTabLayout(
0, 0,
input.viewportWidth - input.metrics.rowHorizontalInset * 2 - input.metrics.actionsReservedWidth input.viewportWidth - input.metrics.rowHorizontalInset * 2 - input.metrics.actionsReservedWidth
); );
const minLabelWidth = const baseTabWidth = input.metrics.tabIconWidth + input.metrics.tabHorizontalPadding * 2;
input.metrics.tabBaseWidthWithClose + input.metrics.charWidth * input.metrics.minLabelChars; const estimateLabelWidth = (labelLength: number) => labelLength * input.metrics.estimatedCharWidth;
const preferredTabWidths = input.tabLabelLengths.map((rawLength) => { const fullTabWidths = input.tabLabelLengths.map((rawLength) => {
const labelLength = Math.max(rawLength, 1); const labelLength = Math.max(rawLength, 1);
const preferred = input.metrics.tabBaseWidthWithClose + labelLength * input.metrics.charWidth; const estimatedWidth = baseTabWidth + estimateLabelWidth(labelLength) + input.metrics.closeButtonWidth;
return clamp(preferred, minLabelWidth, input.metrics.maxTabWidth); return clamp(estimatedWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth);
}); });
const fullTotal = computeTotalRowWidth({
const preferredTotal = computeTotalRowWidth({ itemWidths: fullTabWidths,
tabWidths: preferredTabWidths,
tabGap: input.metrics.tabGap, tabGap: input.metrics.tabGap,
rowPaddingHorizontal: input.metrics.rowPaddingHorizontal, rowPaddingHorizontal: input.metrics.rowPaddingHorizontal,
}); });
if (fullTotal <= availableWidth) {
if (preferredTotal <= availableWidth) { return { mode: "full", showLabels: true, showCloseButtons: true };
return {
mode: "full",
tabWidths: preferredTabWidths,
shouldScroll: false,
showLabels: true,
showCloseButtons: true,
};
} }
const compactMinTabWidths = preferredTabWidths.map(() => minLabelWidth); const compactTabWidths = input.tabLabelLengths.map((rawLength) => {
const labelLength = Math.max(rawLength, 1);
const estimatedWidth = baseTabWidth + estimateLabelWidth(labelLength);
return clamp(estimatedWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth);
});
const compactTotal = computeTotalRowWidth({ const compactTotal = computeTotalRowWidth({
tabWidths: compactMinTabWidths, itemWidths: compactTabWidths,
tabGap: input.metrics.tabGap, tabGap: input.metrics.tabGap,
rowPaddingHorizontal: input.metrics.rowPaddingHorizontal, rowPaddingHorizontal: input.metrics.rowPaddingHorizontal,
}); });
if (compactTotal <= availableWidth) { if (compactTotal <= availableWidth) {
const totalShrinkCapacity = sum(preferredTabWidths.map((width) => width - minLabelWidth)); return { mode: "compact", showLabels: true, showCloseButtons: false };
const targetShrink = preferredTotal - availableWidth;
const shrinkRatio = totalShrinkCapacity > 0 ? targetShrink / totalShrinkCapacity : 1;
const tabWidths = preferredTabWidths.map((preferred) => {
const shrinkCapacity = preferred - minLabelWidth;
return preferred - shrinkCapacity * shrinkRatio;
});
return {
mode: "compact",
tabWidths,
shouldScroll: false,
showLabels: true,
showCloseButtons: true,
};
} }
const iconTabWidths = preferredTabWidths.map(() => input.metrics.iconOnlyTabWidth); return { mode: "icon", showLabels: false, showCloseButtons: false };
const iconTotal = computeTotalRowWidth({
tabWidths: iconTabWidths,
tabGap: input.metrics.tabGap,
rowPaddingHorizontal: input.metrics.rowPaddingHorizontal,
});
return {
mode: "icon",
tabWidths: iconTabWidths,
shouldScroll: iconTotal > availableWidth,
showLabels: false,
showCloseButtons: false,
};
} }
@@ -42,6 +42,14 @@ function percentile(values: number[], p: number): number {
return sorted[index]; return sorted[index];
} }
function mean(values: number[]): number {
if (values.length === 0) {
return 0;
}
const total = values.reduce((sum, value) => sum + value, 0);
return total / values.length;
}
const shouldRun = !process.env.CI; const shouldRun = !process.env.CI;
(shouldRun ? describe : describe.skip)("daemon E2E terminal", () => { (shouldRun ? describe : describe.skip)("daemon E2E terminal", () => {
@@ -634,7 +642,7 @@ const shouldRun = !process.env.CI;
); );
test( test(
"measures local terminal round-trip latency via daemon client stream", "profiles keystroke-to-echo latency via daemon terminal stream under burst load",
async () => { async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd); const created = await ctx.client.createTerminal(cwd);
@@ -649,26 +657,106 @@ const shouldRun = !process.env.CI;
output += decoder.decode(chunk.data, { stream: true }); output += decoder.decode(chunk.data, { stream: true });
}); });
const samplesMs: number[] = []; type Trial = {
const iterations = 8; phase: "stream-only" | "stream+state-sub";
for (let i = 0; i < iterations; i++) { iteration: number;
const marker = `PASEO_LAT_${i}_${Date.now()}`; payloadBytes: number;
const start = performance.now(); latencyMs: number;
ctx.client.sendTerminalStreamInput(streamId, `echo ${marker}\r`); };
await waitForCondition(() => output.includes(marker), 10000);
samplesMs.push(performance.now() - start); async function runBurstLoop(input: {
phase: Trial["phase"];
iterations: number;
burstRepeat: number;
}): Promise<Trial[]> {
const trials: Trial[] = [];
let searchStart = 0;
for (let i = 0; i < input.iterations; i += 1) {
const marker = `PASEO_LAT_${input.phase}_${i}_${Date.now()}_${Math.random()
.toString(36)
.slice(2, 8)}`;
const burstBody = "abcdefghijklmnopqrstuvwxyz0123456789".repeat(input.burstRepeat);
const payload = `${marker}_${burstBody}`;
const start = performance.now();
ctx.client.sendTerminalStreamInput(streamId, `${payload}\r`);
await waitForCondition(() => output.indexOf(marker, searchStart) !== -1, 15000);
const markerOffset = output.indexOf(marker, searchStart);
if (markerOffset !== -1) {
searchStart = markerOffset + marker.length;
}
trials.push({
phase: input.phase,
iteration: i + 1,
payloadBytes: payload.length,
latencyMs: performance.now() - start,
});
await new Promise((resolve) => setTimeout(resolve, 150));
}
return trials;
} }
const p50 = percentile(samplesMs, 50); // Warm-up: establish prompt/output activity before timed loops.
const p95 = percentile(samplesMs, 95); ctx.client.sendTerminalStreamInput(streamId, "echo PASEO_LAT_WARMUP\r");
await waitForCondition(() => output.includes("PASEO_LAT_WARMUP"), 10000);
expect(samplesMs).toHaveLength(iterations); const streamOnlyTrials = await runBurstLoop({
// Localhost budget should be tight; keep margin for test noise. phase: "stream-only",
expect(p95).toBeLessThan(350); iterations: 10,
burstRepeat: 12,
});
// Emit measurements for diagnosis when this test regresses. await ctx.client.subscribeTerminal(terminalId);
const withStateSubscriptionTrials = await runBurstLoop({
phase: "stream+state-sub",
iterations: 10,
burstRepeat: 12,
});
ctx.client.unsubscribeTerminal(terminalId);
const allTrials = [...streamOnlyTrials, ...withStateSubscriptionTrials];
const streamOnlyLatencies = streamOnlyTrials.map((trial) => trial.latencyMs);
const withStateLatencies = withStateSubscriptionTrials.map((trial) => trial.latencyMs);
const streamOnlyP50 = percentile(streamOnlyLatencies, 50);
const streamOnlyP95 = percentile(streamOnlyLatencies, 95);
const streamOnlyMax = percentile(streamOnlyLatencies, 100);
const withStateP50 = percentile(withStateLatencies, 50);
const withStateP95 = percentile(withStateLatencies, 95);
const withStateMax = percentile(withStateLatencies, 100);
expect(allTrials).toHaveLength(20);
expect(streamOnlyLatencies.length).toBe(10);
expect(withStateLatencies.length).toBe(10);
// Diagnostic output: used as verification/measurement loop in local debugging.
console.log( console.log(
`[terminal-latency] samples=${samplesMs.map((n) => n.toFixed(1)).join(",")} p50=${p50.toFixed(1)}ms p95=${p95.toFixed(1)}ms` `[terminal-latency-loop] stream-only samples=${streamOnlyLatencies
.map((n) => n.toFixed(1))
.join(",")} mean=${mean(streamOnlyLatencies).toFixed(1)}ms p50=${streamOnlyP50.toFixed(
1
)}ms p95=${streamOnlyP95.toFixed(1)}ms max=${streamOnlyMax.toFixed(1)}ms`
);
console.log(
`[terminal-latency-loop] stream+state-sub samples=${withStateLatencies
.map((n) => n.toFixed(1))
.join(",")} mean=${mean(withStateLatencies).toFixed(1)}ms p50=${withStateP50.toFixed(
1
)}ms p95=${withStateP95.toFixed(1)}ms max=${withStateMax.toFixed(1)}ms`
);
console.log(
`[terminal-latency-loop] trial-details=${allTrials
.map(
(trial) =>
`${trial.phase}#${trial.iteration}:${trial.latencyMs.toFixed(1)}ms(${trial.payloadBytes}b)`
)
.join(" ")}`
); );
const detach = await ctx.client.detachTerminalStream(streamId); const detach = await ctx.client.detachTerminalStream(streamId);
@@ -676,6 +764,6 @@ const shouldRun = !process.env.CI;
unsubscribe(); unsubscribe();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
}, },
30000 90000
); );
}); });
+31 -5
View File
@@ -8,6 +8,7 @@ import { createRequire } from "node:module";
const { Terminal } = xterm; const { Terminal } = xterm;
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
let nodePtySpawnHelperChecked = false; let nodePtySpawnHelperChecked = false;
const STATE_BROADCAST_INTERVAL_MS = 33;
export interface Cell { export interface Cell {
char: string; char: string;
@@ -275,6 +276,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
let killed = false; let killed = false;
let disposed = false; let disposed = false;
let exitEmitted = false; let exitEmitted = false;
let stateBroadcastScheduled = false;
let stateBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
// Create xterm.js headless terminal // Create xterm.js headless terminal
const terminal = new Terminal({ const terminal = new Terminal({
@@ -319,12 +322,39 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
return; return;
} }
disposed = true; disposed = true;
if (stateBroadcastTimer) {
clearTimeout(stateBroadcastTimer);
stateBroadcastTimer = null;
}
stateBroadcastScheduled = false;
terminal.dispose(); terminal.dispose();
listeners.clear(); listeners.clear();
rawListeners.clear(); rawListeners.clear();
exitListeners.clear(); exitListeners.clear();
} }
function emitStateToListeners(): void {
stateBroadcastScheduled = false;
if (disposed || killed || listeners.size === 0) {
return;
}
const state = getState();
for (const listener of listeners) {
listener({ type: "full", state });
}
}
function scheduleStateBroadcast(): void {
if (disposed || killed || listeners.size === 0 || stateBroadcastScheduled) {
return;
}
stateBroadcastScheduled = true;
stateBroadcastTimer = setTimeout(() => {
stateBroadcastTimer = null;
emitStateToListeners();
}, STATE_BROADCAST_INTERVAL_MS);
}
function appendRawOutputChunk(data: string): void { function appendRawOutputChunk(data: string): void {
const chunkBytes = Buffer.byteLength(data); const chunkBytes = Buffer.byteLength(data);
if (chunkBytes <= 0) { if (chunkBytes <= 0) {
@@ -386,11 +416,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
if (killed) return; if (killed) return;
appendRawOutputChunk(data); appendRawOutputChunk(data);
terminal.write(data, () => { terminal.write(data, () => {
// Notify listeners of changes scheduleStateBroadcast();
const state = getState();
for (const listener of listeners) {
listener({ type: "full", state });
}
}); });
}); });