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