Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 62 additions & 3 deletions apps/web/src/components/ThreadTerminalDrawer.browser.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import "../index.css";

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

const {
terminalConstructorSpy,
terminalDisposeSpy,
terminalWriteSpy,
fitAddonFitSpy,
fitAddonLoadSpy,
environmentApiById,
Expand All @@ -16,9 +17,19 @@ const {
} = vi.hoisted(() => ({
terminalConstructorSpy: vi.fn(),
terminalDisposeSpy: vi.fn(),
terminalWriteSpy: vi.fn(),
fitAddonFitSpy: vi.fn(),
fitAddonLoadSpy: vi.fn(),
environmentApiById: new Map<string, { terminal: { open: ReturnType<typeof vi.fn> } }>(),
environmentApiById: new Map<
string,
{
terminal: {
open: ReturnType<typeof vi.fn>;
onEvent: ReturnType<typeof vi.fn>;
};
emitTerminalEvent: (event: TerminalEvent) => void;
}
>(),
readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)),
readLocalApiMock: vi.fn<
() =>
Expand Down Expand Up @@ -62,7 +73,9 @@ vi.mock("@xterm/xterm", () => ({

open() {}

write() {}
write(data: string) {
terminalWriteSpy(data);
}

clear() {}

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

function createEnvironmentApi() {
const terminalEventListeners = new Set<(event: TerminalEvent) => void>();
return {
terminal: {
open: vi.fn(async () => ({
Expand All @@ -140,6 +154,19 @@ function createEnvironmentApi() {
})),
write: vi.fn(async () => undefined),
resize: vi.fn(async () => undefined),
clear: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
onEvent: vi.fn((listener: (event: TerminalEvent) => void) => {
terminalEventListeners.add(listener);
return () => {
terminalEventListeners.delete(listener);
};
}),
},
emitTerminalEvent: (event: TerminalEvent) => {
for (const listener of terminalEventListeners) {
listener(event);
}
},
};
}
Expand Down Expand Up @@ -215,6 +242,7 @@ describe("TerminalViewport", () => {
readLocalApiMock.mockClear();
terminalConstructorSpy.mockClear();
terminalDisposeSpy.mockClear();
terminalWriteSpy.mockClear();
fitAddonFitSpy.mockClear();
fitAddonLoadSpy.mockClear();
});
Expand Down Expand Up @@ -317,4 +345,35 @@ describe("TerminalViewport", () => {
await mounted.cleanup();
}
});

it("applies output from the viewport-owned terminal event stream", async () => {
const environment = createEnvironmentApi();
environmentApiById.set("environment-a", environment);

const mounted = await mountTerminalViewport({
threadRef: scopeThreadRef("environment-a" as never, THREAD_ID),
});

try {
await vi.waitFor(() => {
expect(environment.terminal.open).toHaveBeenCalledTimes(1);
expect(environment.terminal.onEvent).toHaveBeenCalledTimes(1);
});
terminalWriteSpy.mockClear();

environment.emitTerminalEvent({
type: "output",
threadId: THREAD_ID,
terminalId: "default",
createdAt: "2026-04-07T00:00:01.000Z",
data: "ls\r\n",
});

await vi.waitFor(() => {
expect(terminalWriteSpy).toHaveBeenCalledWith("ls\r\n");
});
} finally {
await mounted.cleanup();
}
});
});
107 changes: 90 additions & 17 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalSt
const MIN_DRAWER_HEIGHT = 180;
const MAX_DRAWER_HEIGHT_RATIO = 0.75;
const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260;
const MAX_APPLIED_TERMINAL_EVENT_KEYS = 500;

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

function terminalEventDedupeKey(event: TerminalEvent): string {
switch (event.type) {
case "output":
return [event.threadId, event.terminalId, event.createdAt, event.type, event.data].join("\0");
case "started":
case "restarted":
return [
event.threadId,
event.terminalId,
event.createdAt,
event.type,
event.snapshot.updatedAt,
].join("\0");
case "activity":
return [
event.threadId,
event.terminalId,
event.createdAt,
event.type,
String(event.hasRunningSubprocess),
].join("\0");
case "error":
return [event.threadId, event.terminalId, event.createdAt, event.type, event.message].join(
"\0",
);
case "exited":
return [
event.threadId,
event.terminalId,
event.createdAt,
event.type,
String(event.exitCode),
String(event.exitSignal),
].join("\0");
case "cleared":
return [event.threadId, event.terminalId, event.createdAt, event.type].join("\0");
}
}

function normalizeComputedColor(value: string | null | undefined, fallback: string): string {
const normalizedValue = value?.trim().toLowerCase();
if (
Expand Down Expand Up @@ -292,6 +332,7 @@ export function TerminalViewport({
const selectionActionTimerRef = useRef<number | null>(null);
const keybindingsRef = useRef(keybindings);
const lastAppliedTerminalEventIdRef = useRef(0);
const appliedTerminalEventKeysRef = useRef<Set<string>>(new Set());
const terminalHydratedRef = useRef(false);
const handleSessionExited = useEffectEvent(() => {
onSessionExited();
Expand Down Expand Up @@ -405,6 +446,40 @@ export function TerminalViewport({
}
};

const terminalOpenInput = () => {
const activeTerminal = terminalRef.current;
const activeFitAddon = fitAddonRef.current;
if (!activeTerminal || !activeFitAddon) {
return null;
}
activeFitAddon.fit();
return {
threadId,
terminalId,
cwd,
...(worktreePath !== undefined ? { worktreePath } : {}),
cols: activeTerminal.cols,
rows: activeTerminal.rows,
...(runtimeEnv ? { env: runtimeEnv } : {}),
};
};

const markTerminalEventApplied = (event: TerminalEvent) => {
const key = terminalEventDedupeKey(event);
const keys = appliedTerminalEventKeysRef.current;
if (keys.has(key)) {
return false;
}
keys.add(key);
if (keys.size > MAX_APPLIED_TERMINAL_EVENT_KEYS) {
const oldestKey = keys.values().next().value;
if (typeof oldestKey === "string") {
keys.delete(oldestKey);
}
}
return true;
};

const sendTerminalInput = async (data: string, fallbackError: string) => {
const activeTerminal = terminalRef.current;
if (!activeTerminal) return;
Expand Down Expand Up @@ -514,14 +589,7 @@ export function TerminalViewport({
});

const inputDisposable = terminal.onData((data) => {
void api.terminal
.write({ threadId, terminalId, data })
.catch((err) =>
writeSystemMessage(
terminal,
err instanceof Error ? err.message : "Terminal write failed",
),
);
void sendTerminalInput(data, "Terminal write failed");
});

const selectionDisposable = terminal.onSelectionChange(() => {
Expand Down Expand Up @@ -572,6 +640,9 @@ export function TerminalViewport({
if (!activeTerminal) {
return;
}
if (!markTerminalEventApplied(event)) {
return;
}

if (event.type === "activity") {
return;
Expand Down Expand Up @@ -663,22 +734,22 @@ export function TerminalViewport({

applyPendingTerminalEvents(nextEntries);
});
const unsubscribeLiveTerminalEvents = api.terminal.onEvent((event) => {
if (event.threadId !== threadId || event.terminalId !== terminalId) {
return;
}
applyTerminalEvent(event);
});

const openTerminal = async () => {
try {
const activeTerminal = terminalRef.current;
const activeFitAddon = fitAddonRef.current;
if (!activeTerminal || !activeFitAddon) return;
activeFitAddon.fit();
const snapshot = await api.terminal.open({
threadId,
terminalId,
cwd,
...(worktreePath !== undefined ? { worktreePath } : {}),
cols: activeTerminal.cols,
rows: activeTerminal.rows,
...(runtimeEnv ? { env: runtimeEnv } : {}),
});
const input = terminalOpenInput();
if (!input) return;
const snapshot = await api.terminal.open(input);
if (disposed) return;
writeTerminalSnapshot(activeTerminal, snapshot);
const bufferedEntries = selectTerminalEventEntries(
Expand Down Expand Up @@ -734,7 +805,9 @@ export function TerminalViewport({
disposed = true;
terminalHydratedRef.current = false;
lastAppliedTerminalEventIdRef.current = 0;
appliedTerminalEventKeysRef.current.clear();
unsubscribeTerminalEvents();
unsubscribeLiveTerminalEvents();
window.clearTimeout(fitTimer);
inputDisposable.dispose();
selectionDisposable.dispose();
Expand Down
Loading