Skip to content

Commit 649c243

Browse files
committed
fix terminal live event stream
1 parent 4f0f24f commit 649c243

2 files changed

Lines changed: 152 additions & 20 deletions

File tree

apps/web/src/components/ThreadTerminalDrawer.browser.tsx

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import "../index.css";
22

33
import { scopeThreadRef } from "@t3tools/client-runtime";
4-
import { ThreadId } from "@t3tools/contracts";
4+
import { ThreadId, type TerminalEvent } from "@t3tools/contracts";
55
import { afterEach, describe, expect, it, vi } from "vitest";
66
import { render } from "vitest-browser-react";
77

88
const {
99
terminalConstructorSpy,
1010
terminalDisposeSpy,
11+
terminalWriteSpy,
1112
fitAddonFitSpy,
1213
fitAddonLoadSpy,
1314
environmentApiById,
@@ -16,9 +17,19 @@ const {
1617
} = vi.hoisted(() => ({
1718
terminalConstructorSpy: vi.fn(),
1819
terminalDisposeSpy: vi.fn(),
20+
terminalWriteSpy: vi.fn(),
1921
fitAddonFitSpy: vi.fn(),
2022
fitAddonLoadSpy: vi.fn(),
21-
environmentApiById: new Map<string, { terminal: { open: ReturnType<typeof vi.fn> } }>(),
23+
environmentApiById: new Map<
24+
string,
25+
{
26+
terminal: {
27+
open: ReturnType<typeof vi.fn>;
28+
onEvent: ReturnType<typeof vi.fn>;
29+
};
30+
emitTerminalEvent: (event: TerminalEvent) => void;
31+
}
32+
>(),
2233
readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)),
2334
readLocalApiMock: vi.fn<
2435
() =>
@@ -62,7 +73,9 @@ vi.mock("@xterm/xterm", () => ({
6273

6374
open() {}
6475

65-
write() {}
76+
write(data: string) {
77+
terminalWriteSpy(data);
78+
}
6679

6780
clear() {}
6881

@@ -124,6 +137,7 @@ import { TerminalViewport } from "./ThreadTerminalDrawer";
124137
const THREAD_ID = ThreadId.make("thread-terminal-browser");
125138

126139
function createEnvironmentApi() {
140+
const terminalEventListeners = new Set<(event: TerminalEvent) => void>();
127141
return {
128142
terminal: {
129143
open: vi.fn(async () => ({
@@ -140,6 +154,19 @@ function createEnvironmentApi() {
140154
})),
141155
write: vi.fn(async () => undefined),
142156
resize: vi.fn(async () => undefined),
157+
clear: vi.fn(async () => undefined),
158+
close: vi.fn(async () => undefined),
159+
onEvent: vi.fn((listener: (event: TerminalEvent) => void) => {
160+
terminalEventListeners.add(listener);
161+
return () => {
162+
terminalEventListeners.delete(listener);
163+
};
164+
}),
165+
},
166+
emitTerminalEvent: (event: TerminalEvent) => {
167+
for (const listener of terminalEventListeners) {
168+
listener(event);
169+
}
143170
},
144171
};
145172
}
@@ -215,6 +242,7 @@ describe("TerminalViewport", () => {
215242
readLocalApiMock.mockClear();
216243
terminalConstructorSpy.mockClear();
217244
terminalDisposeSpy.mockClear();
245+
terminalWriteSpy.mockClear();
218246
fitAddonFitSpy.mockClear();
219247
fitAddonLoadSpy.mockClear();
220248
});
@@ -317,4 +345,35 @@ describe("TerminalViewport", () => {
317345
await mounted.cleanup();
318346
}
319347
});
348+
349+
it("applies output from the viewport-owned terminal event stream", async () => {
350+
const environment = createEnvironmentApi();
351+
environmentApiById.set("environment-a", environment);
352+
353+
const mounted = await mountTerminalViewport({
354+
threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
355+
});
356+
357+
try {
358+
await vi.waitFor(() => {
359+
expect(environment.terminal.open).toHaveBeenCalledTimes(1);
360+
expect(environment.terminal.onEvent).toHaveBeenCalledTimes(1);
361+
});
362+
terminalWriteSpy.mockClear();
363+
364+
environment.emitTerminalEvent({
365+
type: "output",
366+
threadId: THREAD_ID,
367+
terminalId: "default",
368+
createdAt: "2026-04-07T00:00:01.000Z",
369+
data: "ls\r\n",
370+
});
371+
372+
await vi.waitFor(() => {
373+
expect(terminalWriteSpy).toHaveBeenCalledWith("ls\r\n");
374+
});
375+
} finally {
376+
await mounted.cleanup();
377+
}
378+
});
320379
});

apps/web/src/components/ThreadTerminalDrawer.tsx

Lines changed: 90 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalSt
5252
const MIN_DRAWER_HEIGHT = 180;
5353
const MAX_DRAWER_HEIGHT_RATIO = 0.75;
5454
const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260;
55+
const MAX_APPLIED_TERMINAL_EVENT_KEYS = 500;
5556

5657
function maxDrawerHeight(): number {
5758
if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT;
@@ -89,6 +90,45 @@ export function selectPendingTerminalEventEntries(
8990
return entries.filter((entry) => entry.id > lastAppliedTerminalEventId);
9091
}
9192

93+
function terminalEventDedupeKey(event: TerminalEvent): string {
94+
switch (event.type) {
95+
case "output":
96+
return [event.threadId, event.terminalId, event.createdAt, event.type, event.data].join("\0");
97+
case "started":
98+
case "restarted":
99+
return [
100+
event.threadId,
101+
event.terminalId,
102+
event.createdAt,
103+
event.type,
104+
event.snapshot.updatedAt,
105+
].join("\0");
106+
case "activity":
107+
return [
108+
event.threadId,
109+
event.terminalId,
110+
event.createdAt,
111+
event.type,
112+
String(event.hasRunningSubprocess),
113+
].join("\0");
114+
case "error":
115+
return [event.threadId, event.terminalId, event.createdAt, event.type, event.message].join(
116+
"\0",
117+
);
118+
case "exited":
119+
return [
120+
event.threadId,
121+
event.terminalId,
122+
event.createdAt,
123+
event.type,
124+
String(event.exitCode),
125+
String(event.exitSignal),
126+
].join("\0");
127+
case "cleared":
128+
return [event.threadId, event.terminalId, event.createdAt, event.type].join("\0");
129+
}
130+
}
131+
92132
function normalizeComputedColor(value: string | null | undefined, fallback: string): string {
93133
const normalizedValue = value?.trim().toLowerCase();
94134
if (
@@ -292,6 +332,7 @@ export function TerminalViewport({
292332
const selectionActionTimerRef = useRef<number | null>(null);
293333
const keybindingsRef = useRef(keybindings);
294334
const lastAppliedTerminalEventIdRef = useRef(0);
335+
const appliedTerminalEventKeysRef = useRef<Set<string>>(new Set());
295336
const terminalHydratedRef = useRef(false);
296337
const handleSessionExited = useEffectEvent(() => {
297338
onSessionExited();
@@ -405,6 +446,40 @@ export function TerminalViewport({
405446
}
406447
};
407448

449+
const terminalOpenInput = () => {
450+
const activeTerminal = terminalRef.current;
451+
const activeFitAddon = fitAddonRef.current;
452+
if (!activeTerminal || !activeFitAddon) {
453+
return null;
454+
}
455+
activeFitAddon.fit();
456+
return {
457+
threadId,
458+
terminalId,
459+
cwd,
460+
...(worktreePath !== undefined ? { worktreePath } : {}),
461+
cols: activeTerminal.cols,
462+
rows: activeTerminal.rows,
463+
...(runtimeEnv ? { env: runtimeEnv } : {}),
464+
};
465+
};
466+
467+
const markTerminalEventApplied = (event: TerminalEvent) => {
468+
const key = terminalEventDedupeKey(event);
469+
const keys = appliedTerminalEventKeysRef.current;
470+
if (keys.has(key)) {
471+
return false;
472+
}
473+
keys.add(key);
474+
if (keys.size > MAX_APPLIED_TERMINAL_EVENT_KEYS) {
475+
const oldestKey = keys.values().next().value;
476+
if (typeof oldestKey === "string") {
477+
keys.delete(oldestKey);
478+
}
479+
}
480+
return true;
481+
};
482+
408483
const sendTerminalInput = async (data: string, fallbackError: string) => {
409484
const activeTerminal = terminalRef.current;
410485
if (!activeTerminal) return;
@@ -514,14 +589,7 @@ export function TerminalViewport({
514589
});
515590

516591
const inputDisposable = terminal.onData((data) => {
517-
void api.terminal
518-
.write({ threadId, terminalId, data })
519-
.catch((err) =>
520-
writeSystemMessage(
521-
terminal,
522-
err instanceof Error ? err.message : "Terminal write failed",
523-
),
524-
);
592+
void sendTerminalInput(data, "Terminal write failed");
525593
});
526594

527595
const selectionDisposable = terminal.onSelectionChange(() => {
@@ -572,6 +640,9 @@ export function TerminalViewport({
572640
if (!activeTerminal) {
573641
return;
574642
}
643+
if (!markTerminalEventApplied(event)) {
644+
return;
645+
}
575646

576647
if (event.type === "activity") {
577648
return;
@@ -663,22 +734,22 @@ export function TerminalViewport({
663734

664735
applyPendingTerminalEvents(nextEntries);
665736
});
737+
const unsubscribeLiveTerminalEvents = api.terminal.onEvent((event) => {
738+
if (event.threadId !== threadId || event.terminalId !== terminalId) {
739+
return;
740+
}
741+
applyTerminalEvent(event);
742+
});
666743

667744
const openTerminal = async () => {
668745
try {
669746
const activeTerminal = terminalRef.current;
670747
const activeFitAddon = fitAddonRef.current;
671748
if (!activeTerminal || !activeFitAddon) return;
672749
activeFitAddon.fit();
673-
const snapshot = await api.terminal.open({
674-
threadId,
675-
terminalId,
676-
cwd,
677-
...(worktreePath !== undefined ? { worktreePath } : {}),
678-
cols: activeTerminal.cols,
679-
rows: activeTerminal.rows,
680-
...(runtimeEnv ? { env: runtimeEnv } : {}),
681-
});
750+
const input = terminalOpenInput();
751+
if (!input) return;
752+
const snapshot = await api.terminal.open(input);
682753
if (disposed) return;
683754
writeTerminalSnapshot(activeTerminal, snapshot);
684755
const bufferedEntries = selectTerminalEventEntries(
@@ -734,7 +805,9 @@ export function TerminalViewport({
734805
disposed = true;
735806
terminalHydratedRef.current = false;
736807
lastAppliedTerminalEventIdRef.current = 0;
808+
appliedTerminalEventKeysRef.current.clear();
737809
unsubscribeTerminalEvents();
810+
unsubscribeLiveTerminalEvents();
738811
window.clearTimeout(fitTimer);
739812
inputDisposable.dispose();
740813
selectionDisposable.dispose();

0 commit comments

Comments
 (0)