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),