diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts
index 825bcbded3..f340fbc5e3 100644
--- a/apps/server/src/terminal/Layers/Manager.test.ts
+++ b/apps/server/src/terminal/Layers/Manager.test.ts
@@ -6,6 +6,7 @@ import {
DEFAULT_TERMINAL_ID,
type TerminalEvent,
type TerminalOpenInput,
+ type TerminalRenderedSnapshot,
type TerminalRestartInput,
} from "@t3tools/contracts";
import { afterEach, describe, expect, it } from "vitest";
@@ -363,6 +364,87 @@ describe("TerminalManager", () => {
manager.dispose();
});
+ it("reads a rendered tail snapshot from retained history", async () => {
+ const { manager, ptyAdapter } = makeManager(500);
+ await manager.open(openInput());
+ const process = ptyAdapter.processes[0];
+ expect(process).toBeDefined();
+ if (!process) return;
+
+ process.emitData("one\n");
+ process.emitData("\u001b[31mtwo\u001b[0m\n");
+ process.emitData("three\n\n");
+
+ const snapshot = await manager.read({
+ threadId: "thread-1",
+ terminalId: DEFAULT_TERMINAL_ID,
+ scope: "tail",
+ maxLines: 2,
+ });
+
+ expect(snapshot).toEqual({
+ text: "two\nthree",
+ totalLines: 3,
+ returnedLineCount: 2,
+ });
+
+ manager.dispose();
+ });
+
+ it("renders carriage-return rewrites in tail snapshots", async () => {
+ const { manager, ptyAdapter } = makeManager(500);
+ await manager.open(openInput());
+ const process = ptyAdapter.processes[0];
+ expect(process).toBeDefined();
+ if (!process) return;
+
+ process.emitData("10%");
+ process.emitData("\r25%");
+ process.emitData("\r100%\n");
+ process.emitData("done\n");
+
+ const snapshot = await manager.read({
+ threadId: "thread-1",
+ terminalId: DEFAULT_TERMINAL_ID,
+ scope: "tail",
+ maxLines: 2,
+ });
+
+ expect(snapshot).toEqual({
+ text: "100%\ndone",
+ totalLines: 2,
+ returnedLineCount: 2,
+ });
+
+ manager.dispose();
+ });
+
+ it("applies erase-in-line control sequences in tail snapshots", async () => {
+ const { manager, ptyAdapter } = makeManager(500);
+ await manager.open(openInput());
+ const process = ptyAdapter.processes[0];
+ expect(process).toBeDefined();
+ if (!process) return;
+
+ process.emitData("foobar");
+ process.emitData("\rbar\u001b[K\n");
+
+ const snapshot = await manager.read({
+ threadId: "thread-1",
+ terminalId: DEFAULT_TERMINAL_ID,
+ scope: "tail",
+ maxLines: 1,
+ });
+
+ expect(snapshot).toEqual({
+ text: "bar",
+ totalLines: 1,
+ returnedLineCount: 1,
+ });
+
+ manager.dispose();
+ });
+
it("restarts terminal with empty transcript and respawns pty", async () => {
const { manager, ptyAdapter, logsDir } = makeManager();
await manager.open(openInput());
diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts
index 8c71834e9e..12693effc9 100644
--- a/apps/server/src/terminal/Layers/Manager.ts
+++ b/apps/server/src/terminal/Layers/Manager.ts
@@ -7,6 +7,8 @@ import {
TerminalClearInput,
TerminalCloseInput,
TerminalOpenInput,
+ TerminalReadInput,
+ TerminalRenderedSnapshot,
TerminalResizeInput,
TerminalRestartInput,
TerminalWriteInput,
@@ -43,6 +45,8 @@ const decodeTerminalWriteInput = Schema.decodeUnknownSync(TerminalWriteInput);
const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput);
const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput);
const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput);
+const decodeTerminalReadInput = Schema.decodeUnknownSync(TerminalReadInput);
+const ANSI_ESCAPE = "\u001B";
type TerminalSubprocessChecker = (terminalPid: number) => Promise;
@@ -254,6 +258,150 @@ function capHistory(history: string, maxLines: number): string {
return hasTrailingNewline ? `${capped}\n` : capped;
}
+function trimTrailingEmptyRenderedLines(lines: string[]): string[] {
+ let end = lines.length;
+ while (end > 0 && lines[end - 1]?.length === 0) {
+ end -= 1;
+ }
+ return end === lines.length ? lines : lines.slice(0, end);
+}
+
+function readAnsiEscapeSequence(
+ history: string,
+ startIndex: number,
+): { length: number; finalByte: string | null; parameters: string } | null {
+ if (history[startIndex] !== ANSI_ESCAPE) {
+ return null;
+ }
+
+ const nextChar = history[startIndex + 1];
+ if (!nextChar) {
+ return { length: 1, finalByte: null, parameters: "" };
+ }
+
+ if (nextChar === "]") {
+ let index = startIndex + 2;
+ while (index < history.length) {
+ const char = history[index];
+ if (char === "\u0007") {
+ return { length: index - startIndex + 1, finalByte: null, parameters: "" };
+ }
+ if (char === ANSI_ESCAPE && history[index + 1] === "\\") {
+ return { length: index - startIndex + 2, finalByte: null, parameters: "" };
+ }
+ index += 1;
+ }
+ return { length: history.length - startIndex, finalByte: null, parameters: "" };
+ }
+
+ if (nextChar === "[") {
+ let index = startIndex + 2;
+ while (index < history.length) {
+ const char = history[index];
+ if (!char) {
+ break;
+ }
+ const code = char.charCodeAt(0);
+ if (code >= 0x40 && code <= 0x7e) {
+ return {
+ length: index - startIndex + 1,
+ finalByte: char,
+ parameters: history.slice(startIndex + 2, index),
+ };
+ }
+ index += 1;
+ }
+ return { length: history.length - startIndex, finalByte: null, parameters: "" };
+ }
+
+ return { length: 2, finalByte: null, parameters: "" };
+}
+
+function parseEraseInLineMode(parameters: string): 0 | 1 | 2 {
+ if (parameters.length === 0) {
+ return 0;
+ }
+ const mode = Number(parameters.split(";").at(-1) ?? "0");
+ if (mode === 1 || mode === 2) {
+ return mode;
+ }
+ return 0;
+}
+
+function eraseRenderedLine(line: string[], cursor: number, mode: 0 | 1 | 2): string[] {
+ if (mode === 2) {
+ return [];
+ }
+ if (mode === 1) {
+ if (line.length === 0) {
+ return line;
+ }
+ const nextLine = [...line];
+ const end = Math.min(cursor, nextLine.length - 1);
+ for (let index = 0; index <= end; index += 1) {
+ nextLine[index] = " ";
+ }
+ return nextLine;
+ }
+ return line.slice(0, cursor);
+}
+
+function renderTerminalHistoryLines(history: string): string[] {
+ const lines: string[] = [];
+ let currentLine: string[] = [];
+ let cursor = 0;
+
+ const commitLine = () => {
+ lines.push(currentLine.join(""));
+ currentLine = [];
+ cursor = 0;
+ };
+
+ const normalizedHistory = history.replace(/\r\n/g, "\n");
+
+ for (let index = 0; index < normalizedHistory.length; index += 1) {
+ const char = normalizedHistory[index];
+ if (!char) {
+ continue;
+ }
+ if (char === ANSI_ESCAPE) {
+ const escapeSequence = readAnsiEscapeSequence(normalizedHistory, index);
+ if (!escapeSequence) {
+ continue;
+ }
+ if (escapeSequence.finalByte === "K") {
+ currentLine = eraseRenderedLine(
+ currentLine,
+ cursor,
+ parseEraseInLineMode(escapeSequence.parameters),
+ );
+ }
+ index += escapeSequence.length - 1;
+ continue;
+ }
+ if (char === "\n") {
+ commitLine();
+ continue;
+ }
+ if (char === "\r") {
+ cursor = 0;
+ continue;
+ }
+ if (cursor < currentLine.length) {
+ currentLine[cursor] = char;
+ } else {
+ while (currentLine.length < cursor) {
+ currentLine.push(" ");
+ }
+ currentLine.push(char);
+ }
+ cursor += 1;
+ }
+
+ lines.push(currentLine.join(""));
+ return trimTrailingEmptyRenderedLines(lines);
+}
+
function legacySafeThreadId(threadId: string): string {
return threadId.replace(/[^a-zA-Z0-9._-]/g, "_");
}
@@ -480,6 +628,27 @@ export class TerminalManagerRuntime extends EventEmitter
});
}
+ async read(raw: TerminalReadInput): Promise {
+ const input = decodeTerminalReadInput(raw);
+ if (input.scope !== "tail") {
+ throw new Error(`Unsupported terminal read scope: ${input.scope}`);
+ }
+
+ const session = this.sessions.get(toSessionKey(input.threadId, input.terminalId)) ?? null;
+ const history = session
+ ? session.history
+ : await this.readHistory(input.threadId, input.terminalId);
+ const renderedLines = this.renderHistoryLines(history);
+ const totalLines = renderedLines.length;
+ const tailLines = renderedLines.slice(Math.max(0, totalLines - input.maxLines));
+
+ return {
+ text: tailLines.join("\n"),
+ totalLines,
+ returnedLineCount: tailLines.length,
+ };
+ }
+
async restart(raw: TerminalRestartInput): Promise {
const input = decodeTerminalRestartInput(raw);
return this.runWithThreadLock(input.threadId, async () => {
@@ -1089,6 +1258,13 @@ export class TerminalManagerRuntime extends EventEmitter
return [...this.sessions.values()].filter((session) => session.threadId === threadId);
}
+ private renderHistoryLines(history: string): string[] {
+ if (history.length === 0) {
+ return [];
+ }
+ return renderTerminalHistoryLines(history);
+ }
+
private async deleteAllHistoryForThread(threadId: string): Promise {
const threadPrefix = `${toSafeThreadId(threadId)}_`;
try {
@@ -1201,6 +1377,11 @@ export const TerminalManagerLive = Layer.effect(
try: () => runtime.clear(input),
catch: (cause) => new TerminalError({ message: "Failed to clear terminal", cause }),
}),
+ read: (input) =>
+ Effect.tryPromise({
+ try: () => runtime.read(input),
+ catch: (cause) => new TerminalError({ message: "Failed to read terminal", cause }),
+ }),
restart: (input) =>
Effect.tryPromise({
try: () => runtime.restart(input),
diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts
index 8d8398c7ad..a0a17dffa7 100644
--- a/apps/server/src/terminal/Services/Manager.ts
+++ b/apps/server/src/terminal/Services/Manager.ts
@@ -11,6 +11,8 @@ import {
TerminalCloseInput,
TerminalEvent,
TerminalOpenInput,
+ TerminalReadInput,
+ TerminalRenderedSnapshot,
TerminalResizeInput,
TerminalRestartInput,
TerminalSessionSnapshot,
@@ -83,6 +85,13 @@ export interface TerminalManagerShape {
*/
readonly clear: (input: TerminalClearInput) => Effect.Effect;
+ /**
+ * Read a rendered terminal snapshot from retained history.
+ */
+ readonly read: (
+ input: TerminalReadInput,
+ ) => Effect.Effect;
+
/**
* Restart a terminal session in place.
*
diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts
index 9c6adfeba9..5f5d8119ff 100644
--- a/apps/server/src/wsServer.test.ts
+++ b/apps/server/src/wsServer.test.ts
@@ -37,6 +37,7 @@ import type {
TerminalCloseInput,
TerminalEvent,
TerminalOpenInput,
+ TerminalRenderedSnapshot,
TerminalResizeInput,
TerminalSessionSnapshot,
TerminalWriteInput,
@@ -157,6 +158,13 @@ class MockTerminalManager implements TerminalManagerShape {
});
});
+ readonly read: TerminalManagerShape["read"] = () =>
+ Effect.succeed({
+ text: "tail line 1\ntail line 2",
+ totalLines: 42,
+ returnedLineCount: 2,
+ } satisfies TerminalRenderedSnapshot);
+
readonly restart: TerminalManagerShape["restart"] = (input: TerminalOpenInput) =>
Effect.sync(() => {
const now = new Date().toISOString();
@@ -1382,6 +1390,19 @@ describe("WebSocket Server", () => {
});
expect(clear.error).toBeUndefined();
+ const read = await sendRequest(ws, WS_METHODS.terminalRead, {
+ threadId: "thread-1",
+ terminalId: "default",
+ scope: "tail",
+ maxLines: 100,
+ });
+ expect(read.error).toBeUndefined();
+ expect(read.result).toEqual({
+ text: "tail line 1\ntail line 2",
+ totalLines: 42,
+ returnedLineCount: 2,
+ });
+
const restart = await sendRequest(ws, WS_METHODS.terminalRestart, {
threadId: "thread-1",
cwd,
diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts
index e22c23988b..d09e9c7350 100644
--- a/apps/server/src/wsServer.ts
+++ b/apps/server/src/wsServer.ts
@@ -856,6 +856,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
return yield* terminalManager.clear(body);
}
+ case WS_METHODS.terminalRead: {
+ const body = stripRequestTag(request.body);
+ return yield* terminalManager.read(body);
+ }
+
case WS_METHODS.terminalRestart: {
const body = stripRequestTag(request.body);
return yield* terminalManager.restart(body);
diff --git a/apps/web/public/mockServiceWorker.js b/apps/web/public/mockServiceWorker.js
index daa58d0f12..8fa9dca80e 100644
--- a/apps/web/public/mockServiceWorker.js
+++ b/apps/web/public/mockServiceWorker.js
@@ -7,7 +7,7 @@
* - Please do NOT modify this file.
*/
-const PACKAGE_VERSION = '2.12.10'
+const PACKAGE_VERSION = '2.12.11'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx
index 48c627747d..e1134e0156 100644
--- a/apps/web/src/components/ChatView.browser.tsx
+++ b/apps/web/src/components/ChatView.browser.tsx
@@ -36,6 +36,8 @@ const PROJECT_ID = "project-1" as ProjectId;
const NOW_ISO = "2026-03-04T12:00:00.000Z";
const BASE_TIME_MS = Date.parse(NOW_ISO);
const ATTACHMENT_SVG = "";
+const TERMINAL_TAIL_LINES = Array.from({ length: 100 }, (_, index) => `tail line ${index + 21}`);
+const USE_DEFAULT_WS_RPC = Symbol("use-default-ws-rpc");
interface WsRequestEnvelope {
id: string;
@@ -54,6 +56,9 @@ interface TestFixture {
let fixture: TestFixture;
const wsRequests: WsRequestEnvelope["body"][] = [];
const wsLink = ws.link(/ws(s)?:\/\/.*/);
+let resolveWsRpcOverride:
+ | ((body: WsRequestEnvelope["body"]) => unknown | Promise | typeof USE_DEFAULT_WS_RPC)
+ | null = null;
interface ViewportSpec {
name: string;
@@ -95,6 +100,29 @@ interface MountedChatView {
router: ReturnType;
}
+function createDeferred() {
+ let resolve!: (value: T | PromiseLike) => void;
+ const promise = new Promise((nextResolve) => {
+ resolve = nextResolve;
+ });
+ return { promise, resolve };
+}
+
+function createThreadDraft(prompt: string) {
+ return {
+ prompt,
+ images: [],
+ nonPersistedImageIds: [],
+ persistedAttachments: [],
+ terminalContexts: [],
+ provider: null,
+ model: null,
+ modelOptions: null,
+ runtimeMode: null,
+ interactionMode: null,
+ };
+}
+
function isoAt(offsetSeconds: number): string {
return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString();
}
@@ -381,7 +409,13 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
};
}
-function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
+async function resolveWsRpc(body: WsRequestEnvelope["body"]): Promise {
+ if (resolveWsRpcOverride) {
+ const overrideResult = await resolveWsRpcOverride(body);
+ if (overrideResult !== USE_DEFAULT_WS_RPC) {
+ return overrideResult;
+ }
+ }
const tag = body._tag;
if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {
return fixture.snapshot;
@@ -437,6 +471,13 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
updatedAt: NOW_ISO,
};
}
+ if (tag === WS_METHODS.terminalRead) {
+ return {
+ text: TERMINAL_TAIL_LINES.join("\n"),
+ totalLines: 120,
+ returnedLineCount: 100,
+ };
+ }
return {};
}
@@ -462,12 +503,14 @@ const worker = setupWorker(
const method = request.body?._tag;
if (typeof method !== "string") return;
wsRequests.push(request.body);
- client.send(
- JSON.stringify({
- id: request.id,
- result: resolveWsRpc(request.body),
- }),
- );
+ void (async () => {
+ client.send(
+ JSON.stringify({
+ id: request.id,
+ result: await resolveWsRpc(request.body),
+ }),
+ );
+ })();
});
}),
http.get("*/attachments/:attachmentId", () =>
@@ -748,6 +791,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
localStorage.clear();
document.body.innerHTML = "";
wsRequests.length = 0;
+ resolveWsRpcOverride = null;
useComposerDraftStore.setState({
draftsByThreadId: {},
draftThreadsByThreadId: {},
@@ -1045,6 +1089,311 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});
+ it("captures terminal tail context from the @ menu", async () => {
+ useComposerDraftStore.setState({
+ draftsByThreadId: {
+ [THREAD_ID]: createThreadDraft("@term"),
+ },
+ draftThreadsByThreadId: {},
+ projectDraftThreadIdByProjectId: {},
+ });
+
+ const mounted = await mountChatView({
+ viewport: DEFAULT_VIEWPORT,
+ snapshot: createSnapshotForTargetUser({
+ targetMessageId: "msg-user-terminal-mention" as MessageId,
+ targetText: "terminal mention target",
+ }),
+ });
+
+ try {
+ const commandItem = await waitForElement(
+ () =>
+ Array.from(document.querySelectorAll('[data-slot="command-item"]')).find(
+ (element) => element.textContent?.includes("Terminal 1"),
+ ) ?? null,
+ "Unable to find terminal command item.",
+ );
+
+ commandItem.click();
+
+ await vi.waitFor(
+ () => {
+ const readRequest = wsRequests.find(
+ (request) => request._tag === WS_METHODS.terminalRead,
+ );
+ expect(readRequest).toMatchObject({
+ _tag: WS_METHODS.terminalRead,
+ threadId: THREAD_ID,
+ terminalId: "default",
+ scope: "tail",
+ maxLines: 100,
+ });
+ expect(document.body.textContent).toContain("Terminal 1 lines 21-120");
+ const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID];
+ expect(draft?.terminalContexts).toHaveLength(1);
+ expect(draft?.terminalContexts[0]).toMatchObject({
+ terminalId: "default",
+ terminalLabel: "Terminal 1",
+ lineStart: 21,
+ lineEnd: 120,
+ });
+ expect(draft?.prompt).toContain(INLINE_TERMINAL_CONTEXT_PLACEHOLDER);
+ expect(draft?.prompt).not.toContain("@term");
+ },
+ { timeout: 8_000, interval: 16 },
+ );
+ } finally {
+ await mounted.cleanup();
+ }
+ });
+
+ it("captures terminal tail context from the @ menu with Enter", async () => {
+ useComposerDraftStore.setState({
+ draftsByThreadId: {
+ [THREAD_ID]: createThreadDraft("@term"),
+ },
+ draftThreadsByThreadId: {},
+ projectDraftThreadIdByProjectId: {},
+ });
+
+ const mounted = await mountChatView({
+ viewport: DEFAULT_VIEWPORT,
+ snapshot: createSnapshotForTargetUser({
+ targetMessageId: "msg-user-terminal-mention-enter" as MessageId,
+ targetText: "terminal mention target via enter",
+ }),
+ });
+
+ try {
+ const composerEditor = await waitForComposerEditor();
+ composerEditor.focus();
+ composerEditor.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: "Enter",
+ bubbles: true,
+ cancelable: true,
+ }),
+ );
+
+ await vi.waitFor(
+ () => {
+ const readRequest = wsRequests.find(
+ (request) => request._tag === WS_METHODS.terminalRead,
+ );
+ expect(readRequest).toMatchObject({
+ _tag: WS_METHODS.terminalRead,
+ threadId: THREAD_ID,
+ terminalId: "default",
+ scope: "tail",
+ maxLines: 100,
+ });
+ expect(document.body.textContent).toContain("Terminal 1 lines 21-120");
+ const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID];
+ expect(draft?.terminalContexts).toHaveLength(1);
+ expect(draft?.terminalContexts[0]).toMatchObject({
+ terminalId: "default",
+ terminalLabel: "Terminal 1",
+ lineStart: 21,
+ lineEnd: 120,
+ });
+ expect(draft?.prompt).toContain(INLINE_TERMINAL_CONTEXT_PLACEHOLDER);
+ expect(draft?.prompt).not.toContain("@term");
+ },
+ { timeout: 8_000, interval: 16 },
+ );
+ } finally {
+ await mounted.cleanup();
+ }
+ });
+
+ it("keeps newer composer text when terminal context resolves later", async () => {
+ const terminalReadBlocked = createDeferred();
+ resolveWsRpcOverride = (body) => {
+ if (body._tag !== WS_METHODS.terminalRead) {
+ return USE_DEFAULT_WS_RPC;
+ }
+ return terminalReadBlocked.promise.then(() => ({
+ text: TERMINAL_TAIL_LINES.join("\n"),
+ totalLines: 120,
+ returnedLineCount: 100,
+ }));
+ };
+ useComposerDraftStore.setState({
+ draftsByThreadId: {
+ [THREAD_ID]: createThreadDraft("@term"),
+ },
+ draftThreadsByThreadId: {},
+ projectDraftThreadIdByProjectId: {},
+ });
+
+ const mounted = await mountChatView({
+ viewport: DEFAULT_VIEWPORT,
+ snapshot: createSnapshotForTargetUser({
+ targetMessageId: "msg-user-terminal-mention-race" as MessageId,
+ targetText: "terminal mention target with in-flight edits",
+ }),
+ });
+
+ try {
+ const commandItem = await waitForElement(
+ () =>
+ Array.from(document.querySelectorAll('[data-slot="command-item"]')).find(
+ (element) => element.textContent?.includes("Terminal 1"),
+ ) ?? null,
+ "Unable to find terminal command item.",
+ );
+
+ commandItem.click();
+
+ await vi.waitFor(
+ () => {
+ const readRequest = wsRequests.find(
+ (request) => request._tag === WS_METHODS.terminalRead,
+ );
+ expect(readRequest).toMatchObject({
+ _tag: WS_METHODS.terminalRead,
+ threadId: THREAD_ID,
+ terminalId: "default",
+ scope: "tail",
+ maxLines: 100,
+ });
+ },
+ { timeout: 8_000, interval: 16 },
+ );
+
+ useComposerDraftStore.getState().setPrompt(THREAD_ID, "@term more context");
+ await waitForLayout();
+ terminalReadBlocked.resolve();
+
+ await vi.waitFor(
+ () => {
+ const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID];
+ expect(draft?.terminalContexts).toHaveLength(1);
+ expect(draft?.prompt).toContain(INLINE_TERMINAL_CONTEXT_PLACEHOLDER);
+ expect(draft?.prompt).toContain("more context");
+ expect(draft?.prompt).not.toContain("@term");
+ },
+ { timeout: 8_000, interval: 16 },
+ );
+ } finally {
+ await mounted.cleanup();
+ }
+ });
+
+ it("leaves the prompt unchanged when adding a duplicate terminal tail", async () => {
+ useComposerDraftStore.setState({
+ draftsByThreadId: {
+ [THREAD_ID]: {
+ ...createThreadDraft(`${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} @term`),
+ terminalContexts: [
+ {
+ id: "ctx-existing-default",
+ threadId: THREAD_ID,
+ terminalId: "default",
+ terminalLabel: "Terminal 1",
+ lineStart: 21,
+ lineEnd: 120,
+ text: TERMINAL_TAIL_LINES.join("\n"),
+ createdAt: NOW_ISO,
+ },
+ ],
+ },
+ },
+ draftThreadsByThreadId: {},
+ projectDraftThreadIdByProjectId: {},
+ });
+
+ const mounted = await mountChatView({
+ viewport: DEFAULT_VIEWPORT,
+ snapshot: createSnapshotForTargetUser({
+ targetMessageId: "msg-user-terminal-mention-dedupe" as MessageId,
+ targetText: "duplicate terminal mention target",
+ }),
+ });
+
+ try {
+ const commandItem = await waitForElement(
+ () =>
+ Array.from(document.querySelectorAll('[data-slot="command-item"]')).find(
+ (element) => element.textContent?.includes("Terminal 1"),
+ ) ?? null,
+ "Unable to find terminal command item.",
+ );
+
+ commandItem.click();
+
+ await vi.waitFor(
+ () => {
+ const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID];
+ expect(draft?.terminalContexts).toHaveLength(1);
+ expect(draft?.prompt).toBe(`${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} @term`);
+ },
+ { timeout: 8_000, interval: 16 },
+ );
+ } finally {
+ await mounted.cleanup();
+ }
+ });
+
+ it("closes the @ menu after adding terminal context after an existing mention", async () => {
+ resolveWsRpcOverride = (body) => {
+ if (body._tag !== WS_METHODS.projectsSearchEntries) {
+ return USE_DEFAULT_WS_RPC;
+ }
+ return {
+ entries: [
+ {
+ kind: "file",
+ path: "src/foo.ts",
+ parentPath: "src",
+ },
+ ],
+ truncated: false,
+ };
+ };
+ useComposerDraftStore.setState({
+ draftsByThreadId: {
+ [THREAD_ID]: createThreadDraft("@src/foo.ts @term"),
+ },
+ draftThreadsByThreadId: {},
+ projectDraftThreadIdByProjectId: {},
+ });
+
+ const mounted = await mountChatView({
+ viewport: DEFAULT_VIEWPORT,
+ snapshot: createSnapshotForTargetUser({
+ targetMessageId: "msg-user-terminal-mention-after-path" as MessageId,
+ targetText: "terminal mention after path target",
+ }),
+ });
+
+ try {
+ const commandItem = await waitForElement(
+ () =>
+ Array.from(document.querySelectorAll('[data-slot="command-item"]')).find(
+ (element) => element.textContent?.includes("Terminal 1"),
+ ) ?? null,
+ "Unable to find terminal command item.",
+ );
+
+ commandItem.click();
+
+ await vi.waitFor(
+ () => {
+ const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID];
+ expect(draft?.terminalContexts).toHaveLength(1);
+ expect(draft?.prompt).toContain(INLINE_TERMINAL_CONTEXT_PLACEHOLDER);
+ expect(draft?.prompt).not.toContain("@term");
+ expect(document.querySelectorAll('[data-slot="command-item"]')).toHaveLength(0);
+ },
+ { timeout: 8_000, interval: 16 },
+ );
+ } finally {
+ await mounted.cleanup();
+ }
+ });
+
it("keeps backspaced terminal context pills removed when a new one is added", async () => {
const removedLabel = "Terminal 1 lines 1-2";
const addedLabel = "Terminal 2 lines 9-10";
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index e628f6ea6a..bba83e7e91 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -134,7 +134,9 @@ import {
useComposerThreadDraft,
} from "../composerDraftStore";
import {
+ INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
appendTerminalContextsToPrompt,
+ countInlineTerminalContextPlaceholders,
formatTerminalContextLabel,
insertInlineTerminalContextPlaceholder,
removeInlineTerminalContextPlaceholder,
@@ -232,6 +234,10 @@ const terminalContextIdListsEqual = (
): boolean =>
contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);
+function terminalLabelForIndex(index: number): string {
+ return `Terminal ${index + 1}`;
+}
+
interface ChatViewProps {
threadId: ThreadId;
}
@@ -1024,17 +1030,44 @@ export default function ChatView({ threadId }: ChatViewProps) {
}),
);
const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES;
+ const terminalMentionEntries = useMemo(
+ () =>
+ terminalState.terminalIds.map((terminalId, index) => ({
+ terminalId,
+ label: terminalLabelForIndex(index),
+ })),
+ [terminalState.terminalIds],
+ );
const composerMenuItems = useMemo(() => {
if (!composerTrigger) return [];
if (composerTrigger.kind === "path") {
- return workspaceEntries.map((entry) => ({
+ const normalizedQuery = composerTrigger.query.trim().toLowerCase();
+ const terminalItems = terminalMentionEntries
+ .filter((entry) => {
+ if (!normalizedQuery) {
+ return true;
+ }
+ return (
+ entry.label.toLowerCase().includes(normalizedQuery) ||
+ entry.terminalId.toLowerCase().includes(normalizedQuery)
+ );
+ })
+ .map((entry) => ({
+ id: `terminal:${entry.terminalId}`,
+ type: "terminal" as const,
+ terminalId: entry.terminalId,
+ label: entry.label,
+ description: entry.terminalId,
+ }));
+ const pathItems = workspaceEntries.map((entry) => ({
id: `path:${entry.kind}:${entry.path}`,
- type: "path",
+ type: "path" as const,
path: entry.path,
pathKind: entry.kind,
label: basenameOfPath(entry.path),
description: entry.parentPath ?? "",
}));
+ return [...terminalItems, ...pathItems];
}
if (composerTrigger.kind === "slash-command") {
@@ -1086,7 +1119,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
label: name,
description: `${providerLabel} ยท ${slug}`,
}));
- }, [composerTrigger, searchableModelOptions, workspaceEntries]);
+ }, [composerTrigger, searchableModelOptions, terminalMentionEntries, workspaceEntries]);
const composerMenuOpen = Boolean(composerTrigger);
const activeComposerMenuItem = useMemo(
() =>
@@ -1193,11 +1226,103 @@ export default function ChatView({ threadId }: ChatViewProps) {
focusComposer();
});
}, [focusComposer]);
- const addTerminalContextToDraft = useCallback(
- (selection: TerminalContextSelection) => {
+ const commitComposerPromptState = useCallback(
+ (options: {
+ prompt: string;
+ cursor: number;
+ expandedCursor: number;
+ persistPrompt: boolean;
+ }) => {
+ promptRef.current = options.prompt;
+ const activePendingQuestion = activePendingProgress?.activeQuestion;
+ if (activePendingQuestion && activePendingUserInput) {
+ setPendingUserInputAnswersByRequestId((existing) => ({
+ ...existing,
+ [activePendingUserInput.requestId]: {
+ ...existing[activePendingUserInput.requestId],
+ [activePendingQuestion.id]: setPendingUserInputCustomAnswer(
+ existing[activePendingUserInput.requestId]?.[activePendingQuestion.id],
+ options.prompt,
+ ),
+ },
+ }));
+ } else if (options.persistPrompt) {
+ setPrompt(options.prompt);
+ }
+ setComposerCursor(options.cursor);
+ setComposerTrigger(detectComposerTrigger(options.prompt, options.expandedCursor));
+ window.requestAnimationFrame(() => {
+ composerEditorRef.current?.focusAt(options.cursor);
+ });
+ },
+ [
+ activePendingProgress?.activeQuestion,
+ activePendingUserInput,
+ setPendingUserInputAnswersByRequestId,
+ setPrompt,
+ ],
+ );
+ const resolvePromptReplacement = useCallback(
+ (
+ rangeStart: number,
+ rangeEnd: number,
+ replacement: string,
+ options?: { expectedText?: string },
+ ): { text: string; cursor: number; collapsedCursor: number } | null => {
+ const currentText = promptRef.current;
+ const safeStart = Math.max(0, Math.min(currentText.length, rangeStart));
+ const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd));
+ if (
+ options?.expectedText !== undefined &&
+ currentText.slice(safeStart, safeEnd) !== options.expectedText
+ ) {
+ return null;
+ }
+ const next = replaceTextRange(currentText, rangeStart, rangeEnd, replacement);
+ return {
+ ...next,
+ collapsedCursor: collapseExpandedComposerCursor(next.text, next.cursor),
+ };
+ },
+ [],
+ );
+ const insertTerminalContextIntoDraft = useCallback(
+ (options: {
+ selection: TerminalContextSelection;
+ prompt: string;
+ cursor: number;
+ expandedCursor: number;
+ contextIndex: number;
+ }): boolean => {
if (!activeThread) {
- return;
+ return false;
+ }
+ const inserted = insertComposerDraftTerminalContext(
+ activeThread.id,
+ options.prompt,
+ {
+ id: randomUUID(),
+ threadId: activeThread.id,
+ createdAt: new Date().toISOString(),
+ ...options.selection,
+ },
+ options.contextIndex,
+ );
+ if (!inserted) {
+ return false;
}
+ commitComposerPromptState({
+ prompt: options.prompt,
+ cursor: options.cursor,
+ expandedCursor: options.expandedCursor,
+ persistPrompt: false,
+ });
+ return true;
+ },
+ [activeThread, commitComposerPromptState, insertComposerDraftTerminalContext],
+ );
+ const addTerminalContextToDraft = useCallback(
+ (selection: TerminalContextSelection) => {
const snapshot = composerEditorRef.current?.readSnapshot() ?? {
value: promptRef.current,
cursor: composerCursor,
@@ -1212,28 +1337,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
insertion.prompt,
insertion.cursor,
);
- const inserted = insertComposerDraftTerminalContext(
- activeThread.id,
- insertion.prompt,
- {
- id: randomUUID(),
- threadId: activeThread.id,
- createdAt: new Date().toISOString(),
- ...selection,
- },
- insertion.contextIndex,
- );
- if (!inserted) {
- return;
- }
- promptRef.current = insertion.prompt;
- setComposerCursor(nextCollapsedCursor);
- setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor));
- window.requestAnimationFrame(() => {
- composerEditorRef.current?.focusAt(nextCollapsedCursor);
+ insertTerminalContextIntoDraft({
+ selection,
+ prompt: insertion.prompt,
+ cursor: nextCollapsedCursor,
+ expandedCursor: insertion.cursor,
+ contextIndex: insertion.contextIndex,
});
},
- [activeThread, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext],
+ [composerCursor, composerTerminalContexts, insertTerminalContextIntoDraft],
);
const setTerminalOpen = useCallback(
(open: boolean) => {
@@ -3156,44 +3268,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
rangeEnd: number,
replacement: string,
options?: { expectedText?: string },
- ): boolean => {
- const currentText = promptRef.current;
- const safeStart = Math.max(0, Math.min(currentText.length, rangeStart));
- const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd));
- if (
- options?.expectedText !== undefined &&
- currentText.slice(safeStart, safeEnd) !== options.expectedText
- ) {
- return false;
- }
- const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
- const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor);
- promptRef.current = next.text;
- const activePendingQuestion = activePendingProgress?.activeQuestion;
- if (activePendingQuestion && activePendingUserInput) {
- setPendingUserInputAnswersByRequestId((existing) => ({
- ...existing,
- [activePendingUserInput.requestId]: {
- ...existing[activePendingUserInput.requestId],
- [activePendingQuestion.id]: setPendingUserInputCustomAnswer(
- existing[activePendingUserInput.requestId]?.[activePendingQuestion.id],
- next.text,
- ),
- },
- }));
- } else {
- setPrompt(next.text);
+ ): { text: string; cursor: number } | null => {
+ const next = resolvePromptReplacement(rangeStart, rangeEnd, replacement, options);
+ if (!next) {
+ return null;
}
- setComposerCursor(nextCursor);
- setComposerTrigger(
- detectComposerTrigger(next.text, expandCollapsedComposerCursor(next.text, nextCursor)),
- );
- window.requestAnimationFrame(() => {
- composerEditorRef.current?.focusAt(nextCursor);
+ commitComposerPromptState({
+ prompt: next.text,
+ cursor: next.collapsedCursor,
+ expandedCursor: next.cursor,
+ persistPrompt: true,
});
- return true;
+ return next;
},
- [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt],
+ [commitComposerPromptState, resolvePromptReplacement],
);
const readComposerSnapshot = useCallback((): {
@@ -3234,6 +3322,80 @@ export default function ChatView({ threadId }: ChatViewProps) {
});
const { snapshot, trigger } = resolveActiveComposerTrigger();
if (!trigger) return;
+ if (item.type === "terminal") {
+ const api = readNativeApi();
+ if (!api || !activeThread) {
+ return;
+ }
+ const replacement = `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} `;
+ const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
+ snapshot.value,
+ trigger.rangeEnd,
+ replacement,
+ );
+ const expectedText = snapshot.value.slice(trigger.rangeStart, replacementRangeEnd);
+ void (async () => {
+ try {
+ const terminalSnapshot = await api.terminal.read({
+ threadId: activeThread.id,
+ terminalId: item.terminalId,
+ scope: "tail",
+ maxLines: 100,
+ });
+ if (
+ terminalSnapshot.returnedLineCount === 0 ||
+ terminalSnapshot.text.trim().length === 0
+ ) {
+ throw new Error("Selected terminal has no recent output to add.");
+ }
+ const lineEnd = Math.max(1, terminalSnapshot.totalLines);
+ const lineStart = Math.max(
+ 1,
+ lineEnd - Math.max(terminalSnapshot.returnedLineCount, 1) + 1,
+ );
+ const applied = resolvePromptReplacement(
+ trigger.rangeStart,
+ replacementRangeEnd,
+ replacement,
+ { expectedText },
+ );
+ if (!applied) {
+ return;
+ }
+ const nextCollapsedCursor = collapseExpandedComposerCursor(
+ applied.text,
+ applied.cursor,
+ );
+ const inserted = insertTerminalContextIntoDraft({
+ selection: {
+ terminalId: item.terminalId,
+ terminalLabel: item.label,
+ lineStart,
+ lineEnd,
+ text: terminalSnapshot.text,
+ },
+ prompt: applied.text,
+ cursor: nextCollapsedCursor,
+ expandedCursor: applied.cursor,
+ contextIndex: countInlineTerminalContextPlaceholders(
+ applied.text.slice(0, trigger.rangeStart),
+ ),
+ });
+ if (inserted) {
+ setComposerHighlightedItemId(null);
+ }
+ } catch (error) {
+ toastManager.add({
+ type: "error",
+ title: "Failed to capture terminal context",
+ description:
+ error instanceof Error ? error.message : "Unable to read terminal output.",
+ data: { threadId: activeThread.id },
+ });
+ }
+ })();
+ return;
+ }
if (item.type === "path") {
const replacement = `@${item.path} `;
const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
@@ -3289,9 +3451,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
}
},
[
+ activeThread,
applyPromptReplacement,
handleInteractionModeChange,
+ insertTerminalContextIntoDraft,
onProviderModelSelect,
+ resolvePromptReplacement,
resolveActiveComposerTrigger,
],
);
diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx
index 818c3c20f8..1652b558eb 100644
--- a/apps/web/src/components/chat/ComposerCommandMenu.tsx
+++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx
@@ -8,6 +8,13 @@ import { Command, CommandItem, CommandList } from "../ui/command";
import { VscodeEntryIcon } from "./VscodeEntryIcon";
export type ComposerCommandItem =
+ | {
+ id: string;
+ type: "terminal";
+ terminalId: string;
+ label: string;
+ description: string;
+ }
| {
id: string;
type: "path";
@@ -67,7 +74,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
{props.isLoading
? "Searching workspace files..."
: props.triggerKind === "path"
- ? "No matching files or folders."
+ ? "No matching terminals, files, or folders."
: "No matching command."}
)}
@@ -96,6 +103,11 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
props.onSelect(props.item);
}}
>
+ {props.item.type === "terminal" ? (
+
+ term
+
+ ) : null}
{props.item.type === "path" ? (
{
});
});
+ it("forwards terminal read requests to the websocket terminal method", async () => {
+ requestMock.mockResolvedValue({
+ text: "tail line 1\ntail line 2",
+ totalLines: 42,
+ returnedLineCount: 2,
+ });
+ const { createWsNativeApi } = await import("./wsNativeApi");
+
+ const api = createWsNativeApi();
+ await api.terminal.read({
+ threadId: ThreadId.makeUnsafe("thread-1"),
+ terminalId: "default",
+ scope: "tail",
+ maxLines: 100,
+ });
+
+ expect(requestMock).toHaveBeenCalledWith(WS_METHODS.terminalRead, {
+ threadId: "thread-1",
+ terminalId: "default",
+ scope: "tail",
+ maxLines: 100,
+ });
+ });
+
it("forwards context menu metadata to desktop bridge", async () => {
const showContextMenu = vi.fn().mockResolvedValue("delete");
Object.defineProperty(getWindowForTest(), "desktopBridge", {
diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts
index ddfffbde69..5c92afc66e 100644
--- a/apps/web/src/wsNativeApi.ts
+++ b/apps/web/src/wsNativeApi.ts
@@ -106,6 +106,7 @@ export function createWsNativeApi(): NativeApi {
write: (input) => transport.request(WS_METHODS.terminalWrite, input),
resize: (input) => transport.request(WS_METHODS.terminalResize, input),
clear: (input) => transport.request(WS_METHODS.terminalClear, input),
+ read: (input) => transport.request(WS_METHODS.terminalRead, input),
restart: (input) => transport.request(WS_METHODS.terminalRestart, input),
close: (input) => transport.request(WS_METHODS.terminalClose, input),
onEvent: (callback) =>
diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts
index b9127fb176..414250c9eb 100644
--- a/packages/contracts/src/ipc.ts
+++ b/packages/contracts/src/ipc.ts
@@ -30,6 +30,8 @@ import type {
TerminalCloseInput,
TerminalEvent,
TerminalOpenInput,
+ TerminalReadInput,
+ TerminalRenderedSnapshot,
TerminalResizeInput,
TerminalRestartInput,
TerminalSessionSnapshot,
@@ -121,6 +123,7 @@ export interface NativeApi {
write: (input: TerminalWriteInput) => Promise;
resize: (input: TerminalResizeInput) => Promise;
clear: (input: TerminalClearInput) => Promise;
+ read: (input: TerminalReadInput) => Promise;
restart: (input: TerminalRestartInput) => Promise;
close: (input: TerminalCloseInput) => Promise;
onEvent: (callback: (event: TerminalEvent) => void) => () => void;
diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts
index 614df05141..de8be04f92 100644
--- a/packages/contracts/src/terminal.test.ts
+++ b/packages/contracts/src/terminal.test.ts
@@ -7,6 +7,8 @@ import {
TerminalCloseInput,
TerminalEvent,
TerminalOpenInput,
+ TerminalReadInput,
+ TerminalRenderedSnapshot,
TerminalResizeInput,
TerminalSessionSnapshot,
TerminalThreadInput,
@@ -139,6 +141,19 @@ describe("TerminalClearInput", () => {
});
});
+describe("TerminalReadInput", () => {
+ it("accepts tail snapshot reads", () => {
+ expect(
+ decodes(TerminalReadInput, {
+ threadId: "thread-1",
+ terminalId: "default",
+ scope: "tail",
+ maxLines: 100,
+ }),
+ ).toBe(true);
+ });
+});
+
describe("TerminalCloseInput", () => {
it("accepts optional deleteHistory", () => {
expect(
@@ -168,6 +183,18 @@ describe("TerminalSessionSnapshot", () => {
});
});
+describe("TerminalRenderedSnapshot", () => {
+ it("accepts rendered snapshot payloads", () => {
+ expect(
+ decodes(TerminalRenderedSnapshot, {
+ text: "tail line 1\ntail line 2",
+ totalLines: 42,
+ returnedLineCount: 2,
+ }),
+ ).toBe(true);
+ });
+});
+
describe("TerminalEvent", () => {
it("accepts output events", () => {
expect(
diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts
index b0493d95c2..980add0aa9 100644
--- a/packages/contracts/src/terminal.ts
+++ b/packages/contracts/src/terminal.ts
@@ -59,6 +59,18 @@ export type TerminalResizeInput = Schema.Codec.Encoded;
+export const TerminalReadScope = Schema.Literal("tail");
+export type TerminalReadScope = typeof TerminalReadScope.Type;
+
+export const TerminalReadInput = Schema.Struct({
+ ...TerminalSessionInput.fields,
+ scope: TerminalReadScope,
+ maxLines: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(
+ Schema.isLessThanOrEqualTo(500),
+ ),
+});
+export type TerminalReadInput = Schema.Codec.Encoded;
+
export const TerminalRestartInput = Schema.Struct({
...TerminalSessionInput.fields,
cwd: TrimmedNonEmptyStringSchema,
@@ -91,6 +103,13 @@ export const TerminalSessionSnapshot = Schema.Struct({
});
export type TerminalSessionSnapshot = typeof TerminalSessionSnapshot.Type;
+export const TerminalRenderedSnapshot = Schema.Struct({
+ text: Schema.String,
+ totalLines: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
+ returnedLineCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
+});
+export type TerminalRenderedSnapshot = typeof TerminalRenderedSnapshot.Type;
+
const TerminalEventBaseSchema = Schema.Struct({
threadId: Schema.String.check(Schema.isNonEmpty()),
terminalId: Schema.String.check(Schema.isNonEmpty()),
diff --git a/packages/contracts/src/ws.test.ts b/packages/contracts/src/ws.test.ts
index d732242ecd..627619303c 100644
--- a/packages/contracts/src/ws.test.ts
+++ b/packages/contracts/src/ws.test.ts
@@ -73,6 +73,22 @@ it.effect("accepts git.preparePullRequestThread requests", () =>
}),
);
+it.effect("accepts terminal.read requests", () =>
+ Effect.gen(function* () {
+ const parsed = yield* decodeWebSocketRequest({
+ id: "req-terminal-read-1",
+ body: {
+ _tag: WS_METHODS.terminalRead,
+ threadId: "thread-1",
+ terminalId: "default",
+ scope: "tail",
+ maxLines: 100,
+ },
+ });
+ assert.strictEqual(parsed.body._tag, WS_METHODS.terminalRead);
+ }),
+);
+
it.effect("accepts typed websocket push envelopes with sequence", () =>
Effect.gen(function* () {
const parsed = yield* decodeWsResponse({
diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts
index ebb76138b8..84acac4626 100644
--- a/packages/contracts/src/ws.ts
+++ b/packages/contracts/src/ws.ts
@@ -29,6 +29,7 @@ import {
TerminalCloseInput,
TerminalEvent,
TerminalOpenInput,
+ TerminalReadInput,
TerminalResizeInput,
TerminalRestartInput,
TerminalWriteInput,
@@ -69,6 +70,7 @@ export const WS_METHODS = {
terminalWrite: "terminal.write",
terminalResize: "terminal.resize",
terminalClear: "terminal.clear",
+ terminalRead: "terminal.read",
terminalRestart: "terminal.restart",
terminalClose: "terminal.close",
@@ -133,6 +135,7 @@ const WebSocketRequestBody = Schema.Union([
tagRequestBody(WS_METHODS.terminalWrite, TerminalWriteInput),
tagRequestBody(WS_METHODS.terminalResize, TerminalResizeInput),
tagRequestBody(WS_METHODS.terminalClear, TerminalClearInput),
+ tagRequestBody(WS_METHODS.terminalRead, TerminalReadInput),
tagRequestBody(WS_METHODS.terminalRestart, TerminalRestartInput),
tagRequestBody(WS_METHODS.terminalClose, TerminalCloseInput),