From d44decab45fae8e4387b09aada1e021fcafc8296 Mon Sep 17 00:00:00 2001 From: mask Date: Wed, 18 Mar 2026 16:37:03 -0500 Subject: [PATCH 1/8] add terminal read snapshot API --- .../src/terminal/Layers/Manager.test.ts | 28 ++++++++++ apps/server/src/terminal/Layers/Manager.ts | 56 +++++++++++++++++++ apps/server/src/terminal/Services/Manager.ts | 9 +++ apps/server/src/wsServer.test.ts | 21 +++++++ apps/server/src/wsServer.ts | 5 ++ apps/web/src/wsNativeApi.test.ts | 24 ++++++++ apps/web/src/wsNativeApi.ts | 1 + packages/contracts/src/ipc.ts | 3 + packages/contracts/src/terminal.test.ts | 27 +++++++++ packages/contracts/src/terminal.ts | 19 +++++++ packages/contracts/src/ws.test.ts | 16 ++++++ packages/contracts/src/ws.ts | 3 + 12 files changed, 212 insertions(+) diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 825bcbded3..c787da8060 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,33 @@ 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("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 f84e7b5930..0d73c92eae 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,12 @@ 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_OSC_SEQUENCE_PATTERN = new RegExp(String.raw`\u001B\][^\u0007]*(?:\u0007|\u001B\\)`, "g"); +const ANSI_ESCAPE_SEQUENCE_PATTERN = new RegExp( + String.raw`\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])`, + "g", +); type TerminalSubprocessChecker = (terminalPid: number) => Promise; @@ -254,6 +262,20 @@ function capHistory(history: string, maxLines: number): string { return hasTrailingNewline ? `${capped}\n` : capped; } +function stripAnsiSequences(text: string): string { + return text + .replace(ANSI_OSC_SEQUENCE_PATTERN, "") + .replace(ANSI_ESCAPE_SEQUENCE_PATTERN, ""); +} + +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 legacySafeThreadId(threadId: string): string { return threadId.replace(/[^a-zA-Z0-9._-]/g, "_"); } @@ -480,6 +502,25 @@ 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 +1130,16 @@ export class TerminalManagerRuntime extends EventEmitter return [...this.sessions.values()].filter((session) => session.threadId === threadId); } + private renderHistoryLines(history: string): string[] { + const strippedHistory = stripAnsiSequences(history) + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + if (strippedHistory.length === 0) { + return []; + } + return trimTrailingEmptyRenderedLines(strippedHistory.split("\n")); + } + private async deleteAllHistoryForThread(threadId: string): Promise { const threadPrefix = `${toSafeThreadId(threadId)}_`; try { @@ -1203,6 +1254,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 f12792a318..f79fcd76ae 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(); @@ -1362,6 +1370,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 2e6ac51b7f..7b7bf74d1e 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/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 2323380da0..022441e12c 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -336,6 +336,30 @@ describe("wsNativeApi", () => { }); }); + 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), From 362f47898424fb7a57054e9a2b1d63802885c534 Mon Sep 17 00:00:00 2001 From: mask Date: Wed, 18 Mar 2026 16:37:23 -0500 Subject: [PATCH 2/8] add terminal mentions to the composer --- apps/web/src/components/ChatView.browser.tsx | 146 +++++++++++++++ apps/web/src/components/ChatView.tsx | 167 +++++++++++++++--- .../components/chat/ComposerCommandMenu.tsx | 14 +- 3 files changed, 300 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 6cbef09bd6..a102296ce8 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -36,6 +36,7 @@ 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}`); interface WsRequestEnvelope { id: string; @@ -437,6 +438,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 {}; } @@ -1043,6 +1051,144 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("captures terminal tail context from the @ menu", async () => { + useComposerDraftStore.setState({ + draftsByThreadId: { + [THREAD_ID]: { + prompt: "@term", + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + provider: null, + model: null, + runtimeMode: null, + interactionMode: null, + effort: null, + codexFastMode: false, + }, + }, + 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]: { + prompt: "@term", + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + provider: null, + model: null, + runtimeMode: null, + interactionMode: null, + effort: null, + codexFastMode: false, + }, + }, + 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 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 9ebb4ec9e8..0470f4f6f1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -129,7 +129,9 @@ import { useComposerThreadDraft, } from "../composerDraftStore"; import { + INLINE_TERMINAL_CONTEXT_PLACEHOLDER, appendTerminalContextsToPrompt, + countInlineTerminalContextPlaceholders, formatTerminalContextLabel, insertInlineTerminalContextPlaceholder, removeInlineTerminalContextPlaceholder, @@ -213,6 +215,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; } @@ -1002,17 +1008,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") { @@ -1064,7 +1097,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( () => @@ -1172,11 +1205,42 @@ export default function ChatView({ threadId }: ChatViewProps) { focusComposer(); }); }, [focusComposer]); - const addTerminalContextToDraft = useCallback( - (selection: TerminalContextSelection) => { + const insertTerminalContextIntoDraft = useCallback( + (options: { + selection: TerminalContextSelection; + prompt: string; + cursor: 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; + } + promptRef.current = options.prompt; + setComposerCursor(options.cursor); + setComposerTrigger(detectComposerTrigger(options.prompt, options.cursor)); + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAt(options.cursor); + }); + return true; + }, + [activeThread, insertComposerDraftTerminalContext], + ); + const addTerminalContextToDraft = useCallback( + (selection: TerminalContextSelection) => { const snapshot = composerEditorRef.current?.readSnapshot() ?? { value: promptRef.current, cursor: composerCursor, @@ -1191,28 +1255,14 @@ 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, + contextIndex: insertion.contextIndex, }); }, - [activeThread, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext], + [composerCursor, composerTerminalContexts, insertTerminalContextIntoDraft], ); const setTerminalOpen = useCallback( (open: boolean) => { @@ -3182,6 +3232,69 @@ 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, + ); + 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 = replaceTextRange( + snapshot.value, + trigger.rangeStart, + replacementRangeEnd, + replacement, + ); + 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, + contextIndex: countInlineTerminalContextPlaceholders( + snapshot.value.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( @@ -3237,8 +3350,10 @@ export default function ChatView({ threadId }: ChatViewProps) { } }, [ + activeThread, applyPromptReplacement, handleInteractionModeChange, + insertTerminalContextIntoDraft, onProviderModelSelect, 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" ? ( Date: Wed, 18 Mar 2026 16:49:32 -0500 Subject: [PATCH 3/8] Fix stale terminal mention insertion --- apps/web/src/components/ChatView.browser.tsx | 129 +++++++++++++++++-- apps/web/src/components/ChatView.tsx | 26 ++-- 2 files changed, 138 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index a102296ce8..31c106307f 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -37,6 +37,7 @@ 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; @@ -55,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; @@ -96,6 +100,14 @@ interface MountedChatView { router: ReturnType; } +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + function isoAt(offsetSeconds: number): string { return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString(); } @@ -382,7 +394,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; @@ -470,12 +488,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", () => @@ -756,6 +776,7 @@ describe("ChatView timeline estimator parity (full app)", () => { localStorage.clear(); document.body.innerHTML = ""; wsRequests.length = 0; + resolveWsRpcOverride = null; useComposerDraftStore.setState({ draftsByThreadId: {}, draftThreadsByThreadId: {}, @@ -1093,7 +1114,9 @@ describe("ChatView timeline estimator parity (full app)", () => { await vi.waitFor( () => { - const readRequest = wsRequests.find((request) => request._tag === WS_METHODS.terminalRead); + const readRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.terminalRead, + ); expect(readRequest).toMatchObject({ _tag: WS_METHODS.terminalRead, threadId: THREAD_ID, @@ -1162,7 +1185,9 @@ describe("ChatView timeline estimator parity (full app)", () => { await vi.waitFor( () => { - const readRequest = wsRequests.find((request) => request._tag === WS_METHODS.terminalRead); + const readRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.terminalRead, + ); expect(readRequest).toMatchObject({ _tag: WS_METHODS.terminalRead, threadId: THREAD_ID, @@ -1189,6 +1214,92 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + 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]: { + prompt: "@term", + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + provider: null, + model: null, + runtimeMode: null, + interactionMode: null, + effort: null, + codexFastMode: false, + }, + }, + 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("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 0470f4f6f1..37d8bfed3b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3154,7 +3154,7 @@ export default function ChatView({ threadId }: ChatViewProps) { rangeEnd: number, replacement: string, options?: { expectedText?: string }, - ): boolean => { + ): { text: string; cursor: 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)); @@ -3162,7 +3162,7 @@ export default function ChatView({ threadId }: ChatViewProps) { options?.expectedText !== undefined && currentText.slice(safeStart, safeEnd) !== options.expectedText ) { - return false; + return null; } const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); @@ -3189,7 +3189,7 @@ export default function ChatView({ threadId }: ChatViewProps) { window.requestAnimationFrame(() => { composerEditorRef.current?.focusAt(nextCursor); }); - return true; + return next; }, [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt], ); @@ -3243,6 +3243,7 @@ export default function ChatView({ threadId }: ChatViewProps) { trigger.rangeEnd, replacement, ); + const expectedText = snapshot.value.slice(trigger.rangeStart, replacementRangeEnd); void (async () => { try { const terminalSnapshot = await api.terminal.read({ @@ -3251,7 +3252,10 @@ export default function ChatView({ threadId }: ChatViewProps) { scope: "tail", maxLines: 100, }); - if (terminalSnapshot.returnedLineCount === 0 || terminalSnapshot.text.trim().length === 0) { + 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); @@ -3259,13 +3263,19 @@ export default function ChatView({ threadId }: ChatViewProps) { 1, lineEnd - Math.max(terminalSnapshot.returnedLineCount, 1) + 1, ); - const applied = replaceTextRange( - snapshot.value, + const applied = applyPromptReplacement( trigger.rangeStart, replacementRangeEnd, replacement, + { expectedText }, + ); + if (!applied) { + return; + } + const nextCollapsedCursor = collapseExpandedComposerCursor( + applied.text, + applied.cursor, ); - const nextCollapsedCursor = collapseExpandedComposerCursor(applied.text, applied.cursor); const inserted = insertTerminalContextIntoDraft({ selection: { terminalId: item.terminalId, @@ -3277,7 +3287,7 @@ export default function ChatView({ threadId }: ChatViewProps) { prompt: applied.text, cursor: nextCollapsedCursor, contextIndex: countInlineTerminalContextPlaceholders( - snapshot.value.slice(0, trigger.rangeStart), + applied.text.slice(0, trigger.rangeStart), ), }); if (inserted) { From d9876bd207b77170e069819c12f246f6336b8d3c Mon Sep 17 00:00:00 2001 From: mask Date: Wed, 18 Mar 2026 16:49:36 -0500 Subject: [PATCH 4/8] Fix terminal tail carriage-return rendering --- .../src/terminal/Layers/Manager.test.ts | 28 ++++++++++ apps/server/src/terminal/Layers/Manager.ts | 54 +++++++++++++++---- 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index c787da8060..52cc1de842 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -391,6 +391,34 @@ describe("TerminalManager", () => { 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("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 0d73c92eae..6ce7e54e9d 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -46,7 +46,10 @@ const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput); const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput); const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput); const decodeTerminalReadInput = Schema.decodeUnknownSync(TerminalReadInput); -const ANSI_OSC_SEQUENCE_PATTERN = new RegExp(String.raw`\u001B\][^\u0007]*(?:\u0007|\u001B\\)`, "g"); +const ANSI_OSC_SEQUENCE_PATTERN = new RegExp( + String.raw`\u001B\][^\u0007]*(?:\u0007|\u001B\\)`, + "g", +); const ANSI_ESCAPE_SEQUENCE_PATTERN = new RegExp( String.raw`\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])`, "g", @@ -263,9 +266,7 @@ function capHistory(history: string, maxLines: number): string { } function stripAnsiSequences(text: string): string { - return text - .replace(ANSI_OSC_SEQUENCE_PATTERN, "") - .replace(ANSI_ESCAPE_SEQUENCE_PATTERN, ""); + return text.replace(ANSI_OSC_SEQUENCE_PATTERN, "").replace(ANSI_ESCAPE_SEQUENCE_PATTERN, ""); } function trimTrailingEmptyRenderedLines(lines: string[]): string[] { @@ -276,6 +277,41 @@ function trimTrailingEmptyRenderedLines(lines: string[]): string[] { return end === lines.length ? lines : lines.slice(0, end); } +function renderStrippedHistoryLines(history: string): string[] { + const lines: string[] = []; + let currentLine: string[] = []; + let cursor = 0; + + const commitLine = () => { + lines.push(currentLine.join("")); + currentLine = []; + cursor = 0; + }; + + for (const char of history) { + 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, "_"); } @@ -509,7 +545,9 @@ export class TerminalManagerRuntime extends EventEmitter } const session = this.sessions.get(toSessionKey(input.threadId, input.terminalId)) ?? null; - const history = session ? session.history : await this.readHistory(input.threadId, input.terminalId); + 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)); @@ -1131,13 +1169,11 @@ export class TerminalManagerRuntime extends EventEmitter } private renderHistoryLines(history: string): string[] { - const strippedHistory = stripAnsiSequences(history) - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); + const strippedHistory = stripAnsiSequences(history).replace(/\r\n/g, "\n"); if (strippedHistory.length === 0) { return []; } - return trimTrailingEmptyRenderedLines(strippedHistory.split("\n")); + return renderStrippedHistoryLines(strippedHistory); } private async deleteAllHistoryForThread(threadId: string): Promise { From 296fa97f12d3283c1de581247b8742eef08d4536 Mon Sep 17 00:00:00 2001 From: mask Date: Sat, 21 Mar 2026 18:54:54 -0500 Subject: [PATCH 5/8] Fix chat view typecheck and stabilize fmt --- apps/web/public/mockServiceWorker.js | 2 +- apps/web/src/components/ChatView.browser.tsx | 57 +++++++------------- package.json | 4 +- 3 files changed, 21 insertions(+), 42 deletions(-) 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 086595545e..98383e3d16 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -108,6 +108,21 @@ function createDeferred() { 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(); } @@ -1077,19 +1092,7 @@ describe("ChatView timeline estimator parity (full app)", () => { it("captures terminal tail context from the @ menu", async () => { useComposerDraftStore.setState({ draftsByThreadId: { - [THREAD_ID]: { - prompt: "@term", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - provider: null, - model: null, - runtimeMode: null, - interactionMode: null, - effort: null, - codexFastMode: false, - }, + [THREAD_ID]: createThreadDraft("@term"), }, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, @@ -1148,19 +1151,7 @@ describe("ChatView timeline estimator parity (full app)", () => { it("captures terminal tail context from the @ menu with Enter", async () => { useComposerDraftStore.setState({ draftsByThreadId: { - [THREAD_ID]: { - prompt: "@term", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - provider: null, - model: null, - runtimeMode: null, - interactionMode: null, - effort: null, - codexFastMode: false, - }, + [THREAD_ID]: createThreadDraft("@term"), }, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, @@ -1230,19 +1221,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }; useComposerDraftStore.setState({ draftsByThreadId: { - [THREAD_ID]: { - prompt: "@term", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - provider: null, - model: null, - runtimeMode: null, - interactionMode: null, - effort: null, - codexFastMode: false, - }, + [THREAD_ID]: createThreadDraft("@term"), }, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, diff --git a/package.json b/package.json index 02e71cf097..bde612532d 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,8 @@ "lint": "oxlint --report-unused-disable-directives", "test": "turbo run test", "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", - "fmt": "oxfmt", - "fmt:check": "oxfmt --check", + "fmt": "oxfmt --threads=1 \"apps/**/*.{ts,tsx,js,jsx,mjs,cjs,d.ts}\" \"packages/**/*.{ts,tsx,js,jsx,mjs,cjs,d.ts}\" \"scripts/**/*.{ts,tsx,js,jsx,mjs,cjs,d.ts}\"", + "fmt:check": "oxfmt --check --threads=1 \"apps/**/*.{ts,tsx,js,jsx,mjs,cjs,d.ts}\" \"packages/**/*.{ts,tsx,js,jsx,mjs,cjs,d.ts}\" \"scripts/**/*.{ts,tsx,js,jsx,mjs,cjs,d.ts}\"", "build:contracts": "turbo run build --filter=@t3tools/contracts", "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts", "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg", From 4c35ab229c01c5d60aa472138380394192fb4674 Mon Sep 17 00:00:00 2001 From: mask Date: Sat, 21 Mar 2026 18:58:14 -0500 Subject: [PATCH 6/8] Revert fmt script change --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index bde612532d..02e71cf097 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,8 @@ "lint": "oxlint --report-unused-disable-directives", "test": "turbo run test", "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", - "fmt": "oxfmt --threads=1 \"apps/**/*.{ts,tsx,js,jsx,mjs,cjs,d.ts}\" \"packages/**/*.{ts,tsx,js,jsx,mjs,cjs,d.ts}\" \"scripts/**/*.{ts,tsx,js,jsx,mjs,cjs,d.ts}\"", - "fmt:check": "oxfmt --check --threads=1 \"apps/**/*.{ts,tsx,js,jsx,mjs,cjs,d.ts}\" \"packages/**/*.{ts,tsx,js,jsx,mjs,cjs,d.ts}\" \"scripts/**/*.{ts,tsx,js,jsx,mjs,cjs,d.ts}\"", + "fmt": "oxfmt", + "fmt:check": "oxfmt --check", "build:contracts": "turbo run build --filter=@t3tools/contracts", "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts", "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg", From 7335afe919efffffc109f23d8a08415a5c5c2526 Mon Sep 17 00:00:00 2001 From: mask Date: Sat, 21 Mar 2026 19:28:04 -0500 Subject: [PATCH 7/8] Fix terminal context capture regressions --- .../src/terminal/Layers/Manager.test.ts | 26 ++++ apps/server/src/terminal/Layers/Manager.ts | 123 +++++++++++++++--- apps/web/src/components/ChatView.browser.tsx | 113 ++++++++++++++++ apps/web/src/components/ChatView.tsx | 118 +++++++++++------ package.json | 4 +- 5 files changed, 326 insertions(+), 58 deletions(-) diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 52cc1de842..f340fbc5e3 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -419,6 +419,32 @@ describe("TerminalManager", () => { 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 cb3dec5ff3..12693effc9 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -46,14 +46,7 @@ const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput); const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput); const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput); const decodeTerminalReadInput = Schema.decodeUnknownSync(TerminalReadInput); -const ANSI_OSC_SEQUENCE_PATTERN = new RegExp( - String.raw`\u001B\][^\u0007]*(?:\u0007|\u001B\\)`, - "g", -); -const ANSI_ESCAPE_SEQUENCE_PATTERN = new RegExp( - String.raw`\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])`, - "g", -); +const ANSI_ESCAPE = "\u001B"; type TerminalSubprocessChecker = (terminalPid: number) => Promise; @@ -265,10 +258,6 @@ function capHistory(history: string, maxLines: number): string { return hasTrailingNewline ? `${capped}\n` : capped; } -function stripAnsiSequences(text: string): string { - return text.replace(ANSI_OSC_SEQUENCE_PATTERN, "").replace(ANSI_ESCAPE_SEQUENCE_PATTERN, ""); -} - function trimTrailingEmptyRenderedLines(lines: string[]): string[] { let end = lines.length; while (end > 0 && lines[end - 1]?.length === 0) { @@ -277,7 +266,87 @@ function trimTrailingEmptyRenderedLines(lines: string[]): string[] { return end === lines.length ? lines : lines.slice(0, end); } -function renderStrippedHistoryLines(history: string): string[] { +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; @@ -288,7 +357,28 @@ function renderStrippedHistoryLines(history: string): string[] { cursor = 0; }; - for (const char of history) { + 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; @@ -1169,11 +1259,10 @@ export class TerminalManagerRuntime extends EventEmitter } private renderHistoryLines(history: string): string[] { - const strippedHistory = stripAnsiSequences(history).replace(/\r\n/g, "\n"); - if (strippedHistory.length === 0) { + if (history.length === 0) { return []; } - return renderStrippedHistoryLines(strippedHistory); + return renderTerminalHistoryLines(history); } private async deleteAllHistoryForThread(threadId: string): Promise { diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 98383e3d16..e1134e0156 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1281,6 +1281,119 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + 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 7c2e40c6a4..bba83e7e91 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1226,11 +1226,72 @@ export default function ChatView({ threadId }: ChatViewProps) { focusComposer(); }); }, [focusComposer]); + 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) { @@ -1250,15 +1311,15 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!inserted) { return false; } - promptRef.current = options.prompt; - setComposerCursor(options.cursor); - setComposerTrigger(detectComposerTrigger(options.prompt, options.cursor)); - window.requestAnimationFrame(() => { - composerEditorRef.current?.focusAt(options.cursor); + commitComposerPromptState({ + prompt: options.prompt, + cursor: options.cursor, + expandedCursor: options.expandedCursor, + persistPrompt: false, }); return true; }, - [activeThread, insertComposerDraftTerminalContext], + [activeThread, commitComposerPromptState, insertComposerDraftTerminalContext], ); const addTerminalContextToDraft = useCallback( (selection: TerminalContextSelection) => { @@ -1280,6 +1341,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selection, prompt: insertion.prompt, cursor: nextCollapsedCursor, + expandedCursor: insertion.cursor, contextIndex: insertion.contextIndex, }); }, @@ -3207,43 +3269,19 @@ export default function ChatView({ threadId }: ChatViewProps) { replacement: string, options?: { expectedText?: string }, ): { text: string; cursor: 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 - ) { + const next = resolvePromptReplacement(rangeStart, rangeEnd, replacement, options); + if (!next) { return null; } - 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); - } - 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 next; }, - [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt], + [commitComposerPromptState, resolvePromptReplacement], ); const readComposerSnapshot = useCallback((): { @@ -3315,7 +3353,7 @@ export default function ChatView({ threadId }: ChatViewProps) { 1, lineEnd - Math.max(terminalSnapshot.returnedLineCount, 1) + 1, ); - const applied = applyPromptReplacement( + const applied = resolvePromptReplacement( trigger.rangeStart, replacementRangeEnd, replacement, @@ -3338,6 +3376,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, prompt: applied.text, cursor: nextCollapsedCursor, + expandedCursor: applied.cursor, contextIndex: countInlineTerminalContextPlaceholders( applied.text.slice(0, trigger.rangeStart), ), @@ -3417,6 +3456,7 @@ export default function ChatView({ threadId }: ChatViewProps) { handleInteractionModeChange, insertTerminalContextIntoDraft, onProviderModelSelect, + resolvePromptReplacement, resolveActiveComposerTrigger, ], ); diff --git a/package.json b/package.json index 02e71cf097..ef29947ee2 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,8 @@ "lint": "oxlint --report-unused-disable-directives", "test": "turbo run test", "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", - "fmt": "oxfmt", - "fmt:check": "oxfmt --check", + "fmt": "oxfmt --no-error-on-unmatched-pattern \"apps/**/*.{ts,tsx,js,jsx,mjs,cjs}\" \"packages/**/*.{ts,tsx,js,jsx,mjs,cjs}\" \"scripts/**/*.{ts,tsx,js,jsx,mjs,cjs}\"", + "fmt:check": "oxfmt --check --no-error-on-unmatched-pattern \"apps/**/*.{ts,tsx,js,jsx,mjs,cjs}\" \"packages/**/*.{ts,tsx,js,jsx,mjs,cjs}\" \"scripts/**/*.{ts,tsx,js,jsx,mjs,cjs}\"", "build:contracts": "turbo run build --filter=@t3tools/contracts", "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts", "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg", From 8fb575ad5705b83fe2b3b33c37d2988804613a71 Mon Sep 17 00:00:00 2001 From: mask Date: Sat, 21 Mar 2026 19:28:43 -0500 Subject: [PATCH 8/8] Revert formatter script change --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index ef29947ee2..02e71cf097 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,8 @@ "lint": "oxlint --report-unused-disable-directives", "test": "turbo run test", "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", - "fmt": "oxfmt --no-error-on-unmatched-pattern \"apps/**/*.{ts,tsx,js,jsx,mjs,cjs}\" \"packages/**/*.{ts,tsx,js,jsx,mjs,cjs}\" \"scripts/**/*.{ts,tsx,js,jsx,mjs,cjs}\"", - "fmt:check": "oxfmt --check --no-error-on-unmatched-pattern \"apps/**/*.{ts,tsx,js,jsx,mjs,cjs}\" \"packages/**/*.{ts,tsx,js,jsx,mjs,cjs}\" \"scripts/**/*.{ts,tsx,js,jsx,mjs,cjs}\"", + "fmt": "oxfmt", + "fmt:check": "oxfmt --check", "build:contracts": "turbo run build --filter=@t3tools/contracts", "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts", "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg",