Skip to content

Commit 1310045

Browse files
Give Agents access to Terminal output (pingdotgg#1032)
Co-authored-by: Julius Marminge <julius0216@outlook.com>
1 parent 579a69e commit 1310045

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
@@ -21,6 +21,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
2121
import { render } from "vitest-browser-react";
2222

2323
import { useComposerDraftStore } from "../composerDraftStore";
24+
import {
25+
INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
26+
type TerminalContextDraft,
27+
} from "../lib/terminalContext";
2428
import { isMacPlatform } from "../lib/utils";
2529
import { getRouter } from "../router";
2630
import { useStore } from "../store";
@@ -150,6 +154,25 @@ function createAssistantMessage(options: { id: MessageId; text: string; offsetSe
150154
};
151155
}
152156

157+
function createTerminalContext(input: {
158+
id: string;
159+
terminalLabel: string;
160+
lineStart: number;
161+
lineEnd: number;
162+
text: string;
163+
}): TerminalContextDraft {
164+
return {
165+
id: input.id,
166+
threadId: THREAD_ID,
167+
terminalId: `terminal-${input.id}`,
168+
terminalLabel: input.terminalLabel,
169+
lineStart: input.lineStart,
170+
lineEnd: input.lineEnd,
171+
text: input.text,
172+
createdAt: NOW_ISO,
173+
};
174+
}
175+
153176
function createSnapshotForTargetUser(options: {
154177
targetMessageId: MessageId;
155178
targetText: string;
@@ -531,6 +554,13 @@ async function waitForComposerEditor(): Promise<HTMLElement> {
531554
);
532555
}
533556

557+
async function waitForSendButton(): Promise<HTMLButtonElement> {
558+
return waitForElement(
559+
() => document.querySelector<HTMLButtonElement>('button[aria-label="Send message"]'),
560+
"Unable to find send button.",
561+
);
562+
}
563+
534564
async function waitForInteractionModeButton(
535565
expectedLabel: "Chat" | "Plan",
536566
): Promise<HTMLButtonElement> {
@@ -1011,6 +1041,166 @@ describe("ChatView timeline estimator parity (full app)", () => {
10111041
}
10121042
});
10131043

1044+
it("keeps backspaced terminal context pills removed when a new one is added", async () => {
1045+
const removedLabel = "Terminal 1 lines 1-2";
1046+
const addedLabel = "Terminal 2 lines 9-10";
1047+
useComposerDraftStore.getState().addTerminalContext(
1048+
THREAD_ID,
1049+
createTerminalContext({
1050+
id: "ctx-removed",
1051+
terminalLabel: "Terminal 1",
1052+
lineStart: 1,
1053+
lineEnd: 2,
1054+
text: "bun i\nno changes",
1055+
}),
1056+
);
1057+
1058+
const mounted = await mountChatView({
1059+
viewport: DEFAULT_VIEWPORT,
1060+
snapshot: createSnapshotForTargetUser({
1061+
targetMessageId: "msg-user-terminal-pill-backspace" as MessageId,
1062+
targetText: "terminal pill backspace target",
1063+
}),
1064+
});
1065+
1066+
try {
1067+
await vi.waitFor(
1068+
() => {
1069+
expect(document.body.textContent).toContain(removedLabel);
1070+
},
1071+
{ timeout: 8_000, interval: 16 },
1072+
);
1073+
1074+
const composerEditor = await waitForComposerEditor();
1075+
composerEditor.focus();
1076+
composerEditor.dispatchEvent(
1077+
new KeyboardEvent("keydown", {
1078+
key: "Backspace",
1079+
bubbles: true,
1080+
cancelable: true,
1081+
}),
1082+
);
1083+
1084+
await vi.waitFor(
1085+
() => {
1086+
expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]).toBeUndefined();
1087+
expect(document.body.textContent).not.toContain(removedLabel);
1088+
},
1089+
{ timeout: 8_000, interval: 16 },
1090+
);
1091+
1092+
useComposerDraftStore.getState().addTerminalContext(
1093+
THREAD_ID,
1094+
createTerminalContext({
1095+
id: "ctx-added",
1096+
terminalLabel: "Terminal 2",
1097+
lineStart: 9,
1098+
lineEnd: 10,
1099+
text: "git status\nOn branch main",
1100+
}),
1101+
);
1102+
1103+
await vi.waitFor(
1104+
() => {
1105+
const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID];
1106+
expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]);
1107+
expect(document.body.textContent).toContain(addedLabel);
1108+
expect(document.body.textContent).not.toContain(removedLabel);
1109+
},
1110+
{ timeout: 8_000, interval: 16 },
1111+
);
1112+
} finally {
1113+
await mounted.cleanup();
1114+
}
1115+
});
1116+
1117+
it("disables send when the composer only contains an expired terminal pill", async () => {
1118+
const expiredLabel = "Terminal 1 line 4";
1119+
useComposerDraftStore.getState().addTerminalContext(
1120+
THREAD_ID,
1121+
createTerminalContext({
1122+
id: "ctx-expired-only",
1123+
terminalLabel: "Terminal 1",
1124+
lineStart: 4,
1125+
lineEnd: 4,
1126+
text: "",
1127+
}),
1128+
);
1129+
1130+
const mounted = await mountChatView({
1131+
viewport: DEFAULT_VIEWPORT,
1132+
snapshot: createSnapshotForTargetUser({
1133+
targetMessageId: "msg-user-expired-pill-disabled" as MessageId,
1134+
targetText: "expired pill disabled target",
1135+
}),
1136+
});
1137+
1138+
try {
1139+
await vi.waitFor(
1140+
() => {
1141+
expect(document.body.textContent).toContain(expiredLabel);
1142+
},
1143+
{ timeout: 8_000, interval: 16 },
1144+
);
1145+
1146+
const sendButton = await waitForSendButton();
1147+
expect(sendButton.disabled).toBe(true);
1148+
} finally {
1149+
await mounted.cleanup();
1150+
}
1151+
});
1152+
1153+
it("warns when sending text while omitting expired terminal pills", async () => {
1154+
const expiredLabel = "Terminal 1 line 4";
1155+
useComposerDraftStore.getState().addTerminalContext(
1156+
THREAD_ID,
1157+
createTerminalContext({
1158+
id: "ctx-expired-send-warning",
1159+
terminalLabel: "Terminal 1",
1160+
lineStart: 4,
1161+
lineEnd: 4,
1162+
text: "",
1163+
}),
1164+
);
1165+
useComposerDraftStore
1166+
.getState()
1167+
.setPrompt(THREAD_ID, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`);
1168+
1169+
const mounted = await mountChatView({
1170+
viewport: DEFAULT_VIEWPORT,
1171+
snapshot: createSnapshotForTargetUser({
1172+
targetMessageId: "msg-user-expired-pill-warning" as MessageId,
1173+
targetText: "expired pill warning target",
1174+
}),
1175+
});
1176+
1177+
try {
1178+
await vi.waitFor(
1179+
() => {
1180+
expect(document.body.textContent).toContain(expiredLabel);
1181+
},
1182+
{ timeout: 8_000, interval: 16 },
1183+
);
1184+
1185+
const sendButton = await waitForSendButton();
1186+
expect(sendButton.disabled).toBe(false);
1187+
sendButton.click();
1188+
1189+
await vi.waitFor(
1190+
() => {
1191+
expect(document.body.textContent).toContain(
1192+
"Expired terminal context omitted from message",
1193+
);
1194+
expect(document.body.textContent).not.toContain(expiredLabel);
1195+
expect(document.body.textContent).toContain("yoowaddup");
1196+
},
1197+
{ timeout: 8_000, interval: 16 },
1198+
);
1199+
} finally {
1200+
await mounted.cleanup();
1201+
}
1202+
});
1203+
10141204
it("shows a pointer cursor for the running stop button", async () => {
10151205
const mounted = await mountChatView({
10161206
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
@@ -4,6 +4,11 @@ import { randomUUID } from "~/lib/utils";
44
import { getAppModelOptions } from "../appSettings";
55
import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
66
import { Schema } from "effect";
7+
import {
8+
filterTerminalContextsWithText,
9+
stripInlineTerminalContextPlaceholders,
10+
type TerminalContextDraft,
11+
} from "../lib/terminalContext";
712

813
export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
914
const WORKTREE_BRANCH_PREFIX = "t3code";
@@ -123,3 +128,44 @@ export function getCustomModelOptionsByProvider(settings: {
123128
codex: getAppModelOptions("codex", settings.customCodexModels),
124129
};
125130
}
131+
132+
export function deriveComposerSendState(options: {
133+
prompt: string;
134+
imageCount: number;
135+
terminalContexts: ReadonlyArray<TerminalContextDraft>;
136+
}): {
137+
trimmedPrompt: string;
138+
sendableTerminalContexts: TerminalContextDraft[];
139+
expiredTerminalContextCount: number;
140+
hasSendableContent: boolean;
141+
} {
142+
const trimmedPrompt = stripInlineTerminalContextPlaceholders(options.prompt).trim();
143+
const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts);
144+
const expiredTerminalContextCount =
145+
options.terminalContexts.length - sendableTerminalContexts.length;
146+
return {
147+
trimmedPrompt,
148+
sendableTerminalContexts,
149+
expiredTerminalContextCount,
150+
hasSendableContent:
151+
trimmedPrompt.length > 0 || options.imageCount > 0 || sendableTerminalContexts.length > 0,
152+
};
153+
}
154+
155+
export function buildExpiredTerminalContextToastCopy(
156+
expiredTerminalContextCount: number,
157+
variant: "omitted" | "empty",
158+
): { title: string; description: string } {
159+
const count = Math.max(1, Math.floor(expiredTerminalContextCount));
160+
const noun = count === 1 ? "Expired terminal context" : "Expired terminal contexts";
161+
if (variant === "empty") {
162+
return {
163+
title: `${noun} won't be sent`,
164+
description: "Remove it or re-add it to include terminal output.",
165+
};
166+
}
167+
return {
168+
title: `${noun} omitted from message`,
169+
description: "Re-add it if you want that terminal output included.",
170+
};
171+
}

0 commit comments

Comments
 (0)