Skip to content

Commit 9a9191e

Browse files
maskdotdevjuliusmarminge
authored andcommitted
Give Agents access to Terminal output (pingdotgg#1032)
Co-authored-by: Julius Marminge <julius0216@outlook.com>
1 parent e583e13 commit 9a9191e

27 files changed

Lines changed: 2870 additions & 216 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ release/
1717
apps/web/.playwright
1818
apps/web/playwright-report
1919
apps/web/src/components/__screenshots__
20+
.vitest-*

apps/web/src/components/ChatView.browser.tsx

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
2222
import { render } from "vitest-browser-react";
2323

2424
import { useComposerDraftStore } from "../composerDraftStore";
25+
import {
26+
INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
27+
type TerminalContextDraft,
28+
} from "../lib/terminalContext";
2529
import { isMacPlatform } from "../lib/utils";
2630
import { getRouter } from "../router";
2731
import { useStore } from "../store";
@@ -157,6 +161,25 @@ function createAssistantMessage(options: { id: MessageId; text: string; offsetSe
157161
};
158162
}
159163

164+
function createTerminalContext(input: {
165+
id: string;
166+
terminalLabel: string;
167+
lineStart: number;
168+
lineEnd: number;
169+
text: string;
170+
}): TerminalContextDraft {
171+
return {
172+
id: input.id,
173+
threadId: THREAD_ID,
174+
terminalId: `terminal-${input.id}`,
175+
terminalLabel: input.terminalLabel,
176+
lineStart: input.lineStart,
177+
lineEnd: input.lineEnd,
178+
text: input.text,
179+
createdAt: NOW_ISO,
180+
};
181+
}
182+
160183
function createSnapshotForTargetUser(options: {
161184
targetMessageId: MessageId;
162185
targetText: string;
@@ -571,6 +594,13 @@ async function waitForComposerEditor(): Promise<HTMLElement> {
571594
);
572595
}
573596

597+
async function waitForSendButton(): Promise<HTMLButtonElement> {
598+
return waitForElement(
599+
() => document.querySelector<HTMLButtonElement>('button[aria-label="Send message"]'),
600+
"Unable to find send button.",
601+
);
602+
}
603+
574604
async function waitForInteractionModeButton(
575605
expectedLabel: "Chat" | "Plan",
576606
): Promise<HTMLButtonElement> {
@@ -1051,6 +1081,166 @@ describe("ChatView timeline estimator parity (full app)", () => {
10511081
}
10521082
});
10531083

1084+
it("keeps backspaced terminal context pills removed when a new one is added", async () => {
1085+
const removedLabel = "Terminal 1 lines 1-2";
1086+
const addedLabel = "Terminal 2 lines 9-10";
1087+
useComposerDraftStore.getState().addTerminalContext(
1088+
THREAD_ID,
1089+
createTerminalContext({
1090+
id: "ctx-removed",
1091+
terminalLabel: "Terminal 1",
1092+
lineStart: 1,
1093+
lineEnd: 2,
1094+
text: "bun i\nno changes",
1095+
}),
1096+
);
1097+
1098+
const mounted = await mountChatView({
1099+
viewport: DEFAULT_VIEWPORT,
1100+
snapshot: createSnapshotForTargetUser({
1101+
targetMessageId: "msg-user-terminal-pill-backspace" as MessageId,
1102+
targetText: "terminal pill backspace target",
1103+
}),
1104+
});
1105+
1106+
try {
1107+
await vi.waitFor(
1108+
() => {
1109+
expect(document.body.textContent).toContain(removedLabel);
1110+
},
1111+
{ timeout: 8_000, interval: 16 },
1112+
);
1113+
1114+
const composerEditor = await waitForComposerEditor();
1115+
composerEditor.focus();
1116+
composerEditor.dispatchEvent(
1117+
new KeyboardEvent("keydown", {
1118+
key: "Backspace",
1119+
bubbles: true,
1120+
cancelable: true,
1121+
}),
1122+
);
1123+
1124+
await vi.waitFor(
1125+
() => {
1126+
expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]).toBeUndefined();
1127+
expect(document.body.textContent).not.toContain(removedLabel);
1128+
},
1129+
{ timeout: 8_000, interval: 16 },
1130+
);
1131+
1132+
useComposerDraftStore.getState().addTerminalContext(
1133+
THREAD_ID,
1134+
createTerminalContext({
1135+
id: "ctx-added",
1136+
terminalLabel: "Terminal 2",
1137+
lineStart: 9,
1138+
lineEnd: 10,
1139+
text: "git status\nOn branch main",
1140+
}),
1141+
);
1142+
1143+
await vi.waitFor(
1144+
() => {
1145+
const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID];
1146+
expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]);
1147+
expect(document.body.textContent).toContain(addedLabel);
1148+
expect(document.body.textContent).not.toContain(removedLabel);
1149+
},
1150+
{ timeout: 8_000, interval: 16 },
1151+
);
1152+
} finally {
1153+
await mounted.cleanup();
1154+
}
1155+
});
1156+
1157+
it("disables send when the composer only contains an expired terminal pill", async () => {
1158+
const expiredLabel = "Terminal 1 line 4";
1159+
useComposerDraftStore.getState().addTerminalContext(
1160+
THREAD_ID,
1161+
createTerminalContext({
1162+
id: "ctx-expired-only",
1163+
terminalLabel: "Terminal 1",
1164+
lineStart: 4,
1165+
lineEnd: 4,
1166+
text: "",
1167+
}),
1168+
);
1169+
1170+
const mounted = await mountChatView({
1171+
viewport: DEFAULT_VIEWPORT,
1172+
snapshot: createSnapshotForTargetUser({
1173+
targetMessageId: "msg-user-expired-pill-disabled" as MessageId,
1174+
targetText: "expired pill disabled target",
1175+
}),
1176+
});
1177+
1178+
try {
1179+
await vi.waitFor(
1180+
() => {
1181+
expect(document.body.textContent).toContain(expiredLabel);
1182+
},
1183+
{ timeout: 8_000, interval: 16 },
1184+
);
1185+
1186+
const sendButton = await waitForSendButton();
1187+
expect(sendButton.disabled).toBe(true);
1188+
} finally {
1189+
await mounted.cleanup();
1190+
}
1191+
});
1192+
1193+
it("warns when sending text while omitting expired terminal pills", async () => {
1194+
const expiredLabel = "Terminal 1 line 4";
1195+
useComposerDraftStore.getState().addTerminalContext(
1196+
THREAD_ID,
1197+
createTerminalContext({
1198+
id: "ctx-expired-send-warning",
1199+
terminalLabel: "Terminal 1",
1200+
lineStart: 4,
1201+
lineEnd: 4,
1202+
text: "",
1203+
}),
1204+
);
1205+
useComposerDraftStore
1206+
.getState()
1207+
.setPrompt(THREAD_ID, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`);
1208+
1209+
const mounted = await mountChatView({
1210+
viewport: DEFAULT_VIEWPORT,
1211+
snapshot: createSnapshotForTargetUser({
1212+
targetMessageId: "msg-user-expired-pill-warning" as MessageId,
1213+
targetText: "expired pill warning target",
1214+
}),
1215+
});
1216+
1217+
try {
1218+
await vi.waitFor(
1219+
() => {
1220+
expect(document.body.textContent).toContain(expiredLabel);
1221+
},
1222+
{ timeout: 8_000, interval: 16 },
1223+
);
1224+
1225+
const sendButton = await waitForSendButton();
1226+
expect(sendButton.disabled).toBe(false);
1227+
sendButton.click();
1228+
1229+
await vi.waitFor(
1230+
() => {
1231+
expect(document.body.textContent).toContain(
1232+
"Expired terminal context omitted from message",
1233+
);
1234+
expect(document.body.textContent).not.toContain(expiredLabel);
1235+
expect(document.body.textContent).toContain("yoowaddup");
1236+
},
1237+
{ timeout: 8_000, interval: 16 },
1238+
);
1239+
} finally {
1240+
await mounted.cleanup();
1241+
}
1242+
});
1243+
10541244
it("shows a pointer cursor for the running stop button", async () => {
10551245
const mounted = await mountChatView({
10561246
viewport: DEFAULT_VIEWPORT,
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { ThreadId } from "@t3tools/contracts";
2+
import { describe, expect, it } from "vitest";
3+
4+
import { buildExpiredTerminalContextToastCopy, deriveComposerSendState } from "./ChatView.logic";
5+
6+
describe("deriveComposerSendState", () => {
7+
it("treats expired terminal pills as non-sendable content", () => {
8+
const state = deriveComposerSendState({
9+
prompt: "\uFFFC",
10+
imageCount: 0,
11+
terminalContexts: [
12+
{
13+
id: "ctx-expired",
14+
threadId: ThreadId.makeUnsafe("thread-1"),
15+
terminalId: "default",
16+
terminalLabel: "Terminal 1",
17+
lineStart: 4,
18+
lineEnd: 4,
19+
text: "",
20+
createdAt: "2026-03-17T12:52:29.000Z",
21+
},
22+
],
23+
});
24+
25+
expect(state.trimmedPrompt).toBe("");
26+
expect(state.sendableTerminalContexts).toEqual([]);
27+
expect(state.expiredTerminalContextCount).toBe(1);
28+
expect(state.hasSendableContent).toBe(false);
29+
});
30+
31+
it("keeps text sendable while excluding expired terminal pills", () => {
32+
const state = deriveComposerSendState({
33+
prompt: `yoo \uFFFC waddup`,
34+
imageCount: 0,
35+
terminalContexts: [
36+
{
37+
id: "ctx-expired",
38+
threadId: ThreadId.makeUnsafe("thread-1"),
39+
terminalId: "default",
40+
terminalLabel: "Terminal 1",
41+
lineStart: 4,
42+
lineEnd: 4,
43+
text: "",
44+
createdAt: "2026-03-17T12:52:29.000Z",
45+
},
46+
],
47+
});
48+
49+
expect(state.trimmedPrompt).toBe("yoo waddup");
50+
expect(state.expiredTerminalContextCount).toBe(1);
51+
expect(state.hasSendableContent).toBe(true);
52+
});
53+
});
54+
55+
describe("buildExpiredTerminalContextToastCopy", () => {
56+
it("formats clear empty-state guidance", () => {
57+
expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({
58+
title: "Expired terminal context won't be sent",
59+
description: "Remove it or re-add it to include terminal output.",
60+
});
61+
});
62+
63+
it("formats omission guidance for sent messages", () => {
64+
expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({
65+
title: "Expired terminal contexts omitted from message",
66+
description: "Re-add it if you want that terminal output included.",
67+
});
68+
});
69+
});

apps/web/src/components/ChatView.logic.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import { randomUUID } from "~/lib/utils";
1010
import { getAppModelOptions } from "../appSettings";
1111
import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
1212
import { Schema } from "effect";
13+
import {
14+
filterTerminalContextsWithText,
15+
stripInlineTerminalContextPlaceholders,
16+
type TerminalContextDraft,
17+
} from "../lib/terminalContext";
1318

1419
export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
1520

@@ -171,3 +176,44 @@ export function getCustomModelOptionsByProvider(settings: {
171176
cursor: getAppModelOptions("cursor", settings.customCursorModels ?? []),
172177
};
173178
}
179+
180+
export function deriveComposerSendState(options: {
181+
prompt: string;
182+
imageCount: number;
183+
terminalContexts: ReadonlyArray<TerminalContextDraft>;
184+
}): {
185+
trimmedPrompt: string;
186+
sendableTerminalContexts: TerminalContextDraft[];
187+
expiredTerminalContextCount: number;
188+
hasSendableContent: boolean;
189+
} {
190+
const trimmedPrompt = stripInlineTerminalContextPlaceholders(options.prompt).trim();
191+
const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts);
192+
const expiredTerminalContextCount =
193+
options.terminalContexts.length - sendableTerminalContexts.length;
194+
return {
195+
trimmedPrompt,
196+
sendableTerminalContexts,
197+
expiredTerminalContextCount,
198+
hasSendableContent:
199+
trimmedPrompt.length > 0 || options.imageCount > 0 || sendableTerminalContexts.length > 0,
200+
};
201+
}
202+
203+
export function buildExpiredTerminalContextToastCopy(
204+
expiredTerminalContextCount: number,
205+
variant: "omitted" | "empty",
206+
): { title: string; description: string } {
207+
const count = Math.max(1, Math.floor(expiredTerminalContextCount));
208+
const noun = count === 1 ? "Expired terminal context" : "Expired terminal contexts";
209+
if (variant === "empty") {
210+
return {
211+
title: `${noun} won't be sent`,
212+
description: "Remove it or re-add it to include terminal output.",
213+
};
214+
}
215+
return {
216+
title: `${noun} omitted from message`,
217+
description: "Re-add it if you want that terminal output included.",
218+
};
219+
}

0 commit comments

Comments
 (0)