authorgravatar for maskdotdev@gmail.comMask <maskdotdev@gmail.com> 2026-03-17 17:56:51-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-17 15:56:51-07:00
log1310045629f6613b2fd3e9b82e2e371495e0ca2a
treef7484c65f9ed21539c5815ab3446dad6c7a6ed9d
parent579a69eb7756b2169d26299d36f936838db39caa
signature Signed by PGP key B5690EEEBB952194

Give Agents access to Terminal output (#1032)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

27 files changed, 2870 insertions(+), 216 deletions(-)

.gitignore+1
...@@ -17,3 +17,4 @@ release/...@@ -17,3 +17,4 @@ release/
17apps/web/.playwright17apps/web/.playwright
18apps/web/playwright-report18apps/web/playwright-report
19apps/web/src/components/__screenshots__19apps/web/src/components/__screenshots__
20.vitest-*
\ No newline at end of file
apps/web/src/components/ChatView.browser.tsx+190
...@@ -21,6 +21,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }...@@ -21,6 +21,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
21import { render } from "vitest-browser-react";21import { render } from "vitest-browser-react";
2222
23import { useComposerDraftStore } from "../composerDraftStore";23import { useComposerDraftStore } from "../composerDraftStore";
24import {
25 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
26 type TerminalContextDraft,
27} from "../lib/terminalContext";
24import { isMacPlatform } from "../lib/utils";28import { isMacPlatform } from "../lib/utils";
25import { getRouter } from "../router";29import { getRouter } from "../router";
26import { useStore } from "../store";30import { useStore } from "../store";
...@@ -150,6 +154,25 @@ function createAssistantMessage(options: { id: MessageId; text: string; offsetSe...@@ -150,6 +154,25 @@ function createAssistantMessage(options: { id: MessageId; text: string; offsetSe
150 };154 };
151}155}
152156
157function 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
153function createSnapshotForTargetUser(options: {176function createSnapshotForTargetUser(options: {
154 targetMessageId: MessageId;177 targetMessageId: MessageId;
155 targetText: string;178 targetText: string;
...@@ -531,6 +554,13 @@ async function waitForComposerEditor(): Promise<HTMLElement> {...@@ -531,6 +554,13 @@ async function waitForComposerEditor(): Promise<HTMLElement> {
531 );554 );
532}555}
533556
557async 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
534async function waitForInteractionModeButton(564async function waitForInteractionModeButton(
535 expectedLabel: "Chat" | "Plan",565 expectedLabel: "Chat" | "Plan",
536): Promise<HTMLButtonElement> {566): Promise<HTMLButtonElement> {
...@@ -1011,6 +1041,166 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1011,6 +1041,166 @@ describe("ChatView timeline estimator parity (full app)", () => {
1011 }1041 }
1012 });1042 });
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
1014 it("shows a pointer cursor for the running stop button", async () => {1204 it("shows a pointer cursor for the running stop button", async () => {
1015 const mounted = await mountChatView({1205 const mounted = await mountChatView({
1016 viewport: DEFAULT_VIEWPORT,1206 viewport: DEFAULT_VIEWPORT,
apps/web/src/components/ChatView.logic.test.ts created+69
...@@ -0,0 +1,69 @@
1import { ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3
4import { buildExpiredTerminalContextToastCopy, deriveComposerSendState } from "./ChatView.logic";
5
6describe("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
55describe("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+46
...@@ -4,6 +4,11 @@ import { randomUUID } from "~/lib/utils";...@@ -4,6 +4,11 @@ import { randomUUID } from "~/lib/utils";
4import { getAppModelOptions } from "../appSettings";4import { getAppModelOptions } from "../appSettings";
5import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";5import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
6import { Schema } from "effect";6import { Schema } from "effect";
7import {
8 filterTerminalContextsWithText,
9 stripInlineTerminalContextPlaceholders,
10 type TerminalContextDraft,
11} from "../lib/terminalContext";
712
8export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";13export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
9const WORKTREE_BRANCH_PREFIX = "t3code";14const WORKTREE_BRANCH_PREFIX = "t3code";
...@@ -123,3 +128,44 @@ export function getCustomModelOptionsByProvider(settings: {...@@ -123,3 +128,44 @@ export function getCustomModelOptionsByProvider(settings: {
123 codex: getAppModelOptions("codex", settings.customCodexModels),128 codex: getAppModelOptions("codex", settings.customCodexModels),
124 };129 };
125}130}
131
132export 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
155export 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}
apps/web/src/components/ChatView.tsx+270-82
...@@ -127,6 +127,14 @@ import {...@@ -127,6 +127,14 @@ import {
127 useComposerDraftStore,127 useComposerDraftStore,
128 useComposerThreadDraft,128 useComposerThreadDraft,
129} from "../composerDraftStore";129} from "../composerDraftStore";
130import {
131 appendTerminalContextsToPrompt,
132 formatTerminalContextLabel,
133 insertInlineTerminalContextPlaceholder,
134 removeInlineTerminalContextPlaceholder,
135 type TerminalContextDraft,
136 type TerminalContextSelection,
137} from "../lib/terminalContext";
130import { shouldUseCompactComposerFooter } from "./composerFooterLayout";138import { shouldUseCompactComposerFooter } from "./composerFooterLayout";
131import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";139import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
132import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";140import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
...@@ -145,10 +153,12 @@ import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner";...@@ -145,10 +153,12 @@ import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner";
145import { ProviderHealthBanner } from "./chat/ProviderHealthBanner";153import { ProviderHealthBanner } from "./chat/ProviderHealthBanner";
146import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";154import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
147import {155import {
156 buildExpiredTerminalContextToastCopy,
148 buildLocalDraftThread,157 buildLocalDraftThread,
149 buildTemporaryWorktreeBranchName,158 buildTemporaryWorktreeBranchName,
150 cloneComposerImageForRetry,159 cloneComposerImageForRetry,
151 collectUserMessageBlobPreviewUrls,160 collectUserMessageBlobPreviewUrls,
161 deriveComposerSendState,
152 getCustomModelOptionsByProvider,162 getCustomModelOptionsByProvider,
153 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,163 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
154 LastInvokedScriptByProjectSchema,164 LastInvokedScriptByProjectSchema,
...@@ -185,6 +195,23 @@ const extendReplacementRangeForTrailingSpace = (...@@ -185,6 +195,23 @@ const extendReplacementRangeForTrailingSpace = (
185 return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd;195 return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd;
186};196};
187197
198const syncTerminalContextsByIds = (
199 contexts: ReadonlyArray<TerminalContextDraft>,
200 ids: ReadonlyArray<string>,
201): TerminalContextDraft[] => {
202 const contextsById = new Map(contexts.map((context) => [context.id, context]));
203 return ids.flatMap((id) => {
204 const context = contextsById.get(id);
205 return context ? [context] : [];
206 });
207};
208
209const terminalContextIdListsEqual = (
210 contexts: ReadonlyArray<TerminalContextDraft>,
211 ids: ReadonlyArray<string>,
212): boolean =>
213 contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);
214
188interface ChatViewProps {215interface ChatViewProps {
189 threadId: ThreadId;216 threadId: ThreadId;
190}217}
...@@ -209,6 +236,16 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -209,6 +236,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
209 const composerDraft = useComposerThreadDraft(threadId);236 const composerDraft = useComposerThreadDraft(threadId);
210 const prompt = composerDraft.prompt;237 const prompt = composerDraft.prompt;
211 const composerImages = composerDraft.images;238 const composerImages = composerDraft.images;
239 const composerTerminalContexts = composerDraft.terminalContexts;
240 const composerSendState = useMemo(
241 () =>
242 deriveComposerSendState({
243 prompt,
244 imageCount: composerImages.length,
245 terminalContexts: composerTerminalContexts,
246 }),
247 [composerImages.length, composerTerminalContexts, prompt],
248 );
212 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;249 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
213 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);250 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
214 const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider);251 const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider);
...@@ -222,6 +259,18 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -222,6 +259,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
222 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);259 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);
223 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);260 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
224 const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);261 const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);
262 const insertComposerDraftTerminalContext = useComposerDraftStore(
263 (store) => store.insertTerminalContext,
264 );
265 const addComposerDraftTerminalContexts = useComposerDraftStore(
266 (store) => store.addTerminalContexts,
267 );
268 const removeComposerDraftTerminalContext = useComposerDraftStore(
269 (store) => store.removeTerminalContext,
270 );
271 const setComposerDraftTerminalContexts = useComposerDraftStore(
272 (store) => store.setTerminalContexts,
273 );
225 const clearComposerDraftPersistedAttachments = useComposerDraftStore(274 const clearComposerDraftPersistedAttachments = useComposerDraftStore(
226 (store) => store.clearPersistedAttachments,275 (store) => store.clearPersistedAttachments,
227 );276 );
...@@ -248,6 +297,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -248,6 +297,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
248 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);297 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
249 const optimisticUserMessagesRef = useRef(optimisticUserMessages);298 const optimisticUserMessagesRef = useRef(optimisticUserMessages);
250 optimisticUserMessagesRef.current = optimisticUserMessages;299 optimisticUserMessagesRef.current = optimisticUserMessages;
300 const composerTerminalContextsRef = useRef<TerminalContextDraft[]>(composerTerminalContexts);
251 const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<301 const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<
252 Record<ThreadId, string | null>302 Record<ThreadId, string | null>
253 >({});303 >({});
...@@ -350,12 +400,40 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -350,12 +400,40 @@ export default function ChatView({ threadId }: ChatViewProps) {
350 },400 },
351 [addComposerDraftImages, threadId],401 [addComposerDraftImages, threadId],
352 );402 );
403 const addComposerTerminalContextsToDraft = useCallback(
404 (contexts: TerminalContextDraft[]) => {
405 addComposerDraftTerminalContexts(threadId, contexts);
406 },
407 [addComposerDraftTerminalContexts, threadId],
408 );
353 const removeComposerImageFromDraft = useCallback(409 const removeComposerImageFromDraft = useCallback(
354 (imageId: string) => {410 (imageId: string) => {
355 removeComposerDraftImage(threadId, imageId);411 removeComposerDraftImage(threadId, imageId);
356 },412 },
357 [removeComposerDraftImage, threadId],413 [removeComposerDraftImage, threadId],
358 );414 );
415 const removeComposerTerminalContextFromDraft = useCallback(
416 (contextId: string) => {
417 const contextIndex = composerTerminalContexts.findIndex(
418 (context) => context.id === contextId,
419 );
420 if (contextIndex < 0) {
421 return;
422 }
423 const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex);
424 promptRef.current = nextPrompt.prompt;
425 setPrompt(nextPrompt.prompt);
426 removeComposerDraftTerminalContext(threadId, contextId);
427 setComposerCursor(nextPrompt.cursor);
428 setComposerTrigger(
429 detectComposerTrigger(
430 nextPrompt.prompt,
431 expandCollapsedComposerCursor(nextPrompt.prompt, nextPrompt.cursor),
432 ),
433 );
434 },
435 [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId],
436 );
359437
360 const serverThread = threads.find((t) => t.id === threadId);438 const serverThread = threads.find((t) => t.id === threadId);
361 const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId);439 const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId);
...@@ -1093,6 +1171,48 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1093,6 +1171,48 @@ export default function ChatView({ threadId }: ChatViewProps) {
1093 focusComposer();1171 focusComposer();
1094 });1172 });
1095 }, [focusComposer]);1173 }, [focusComposer]);
1174 const addTerminalContextToDraft = useCallback(
1175 (selection: TerminalContextSelection) => {
1176 if (!activeThread) {
1177 return;
1178 }
1179 const snapshot = composerEditorRef.current?.readSnapshot() ?? {
1180 value: promptRef.current,
1181 cursor: composerCursor,
1182 expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor),
1183 terminalContextIds: composerTerminalContexts.map((context) => context.id),
1184 };
1185 const insertion = insertInlineTerminalContextPlaceholder(
1186 snapshot.value,
1187 snapshot.expandedCursor,
1188 );
1189 const nextCollapsedCursor = collapseExpandedComposerCursor(
1190 insertion.prompt,
1191 insertion.cursor,
1192 );
1193 const inserted = insertComposerDraftTerminalContext(
1194 activeThread.id,
1195 insertion.prompt,
1196 {
1197 id: randomUUID(),
1198 threadId: activeThread.id,
1199 createdAt: new Date().toISOString(),
1200 ...selection,
1201 },
1202 insertion.contextIndex,
1203 );
1204 if (!inserted) {
1205 return;
1206 }
1207 promptRef.current = insertion.prompt;
1208 setComposerCursor(nextCollapsedCursor);
1209 setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor));
1210 window.requestAnimationFrame(() => {
1211 composerEditorRef.current?.focusAt(nextCollapsedCursor);
1212 });
1213 },
1214 [activeThread, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext],
1215 );
1096 const setTerminalOpen = useCallback(1216 const setTerminalOpen = useCallback(
1097 (open: boolean) => {1217 (open: boolean) => {
1098 if (!activeThreadId) return;1218 if (!activeThreadId) return;
...@@ -1730,6 +1850,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1730,6 +1850,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
1730 composerImagesRef.current = composerImages;1850 composerImagesRef.current = composerImages;
1731 }, [composerImages]);1851 }, [composerImages]);
17321852
1853 useEffect(() => {
1854 composerTerminalContextsRef.current = composerTerminalContexts;
1855 }, [composerTerminalContexts]);
1856
1733 useEffect(() => {1857 useEffect(() => {
1734 if (!activeThread?.id) return;1858 if (!activeThread?.id) return;
1735 if (activeThread.messages.length === 0) {1859 if (activeThread.messages.length === 0) {
...@@ -2204,7 +2328,17 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2204,7 +2328,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
2204 onAdvanceActivePendingUserInput();2328 onAdvanceActivePendingUserInput();
2205 return;2329 return;
2206 }2330 }
2207 const trimmed = prompt.trim();2331 const promptForSend = promptRef.current;
2332 const {
2333 trimmedPrompt: trimmed,
2334 sendableTerminalContexts: sendableComposerTerminalContexts,
2335 expiredTerminalContextCount,
2336 hasSendableContent,
2337 } = deriveComposerSendState({
2338 prompt: promptForSend,
2339 imageCount: composerImages.length,
2340 terminalContexts: composerTerminalContexts,
2341 });
2208 if (showPlanFollowUpPrompt && activeProposedPlan) {2342 if (showPlanFollowUpPrompt && activeProposedPlan) {
2209 const followUp = resolvePlanFollowUpSubmission({2343 const followUp = resolvePlanFollowUpSubmission({
2210 draftText: trimmed,2344 draftText: trimmed,
...@@ -2222,7 +2356,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2222,7 +2356,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
2222 return;2356 return;
2223 }2357 }
2224 const standaloneSlashCommand =2358 const standaloneSlashCommand =
2225 composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null;2359 composerImages.length === 0 && sendableComposerTerminalContexts.length === 0
2360 ? parseStandaloneComposerSlashCommand(trimmed)
2361 : null;
2226 if (standaloneSlashCommand) {2362 if (standaloneSlashCommand) {
2227 await handleInteractionModeChange(standaloneSlashCommand);2363 await handleInteractionModeChange(standaloneSlashCommand);
2228 promptRef.current = "";2364 promptRef.current = "";
...@@ -2232,7 +2368,20 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2232,7 +2368,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
2232 setComposerTrigger(null);2368 setComposerTrigger(null);
2233 return;2369 return;
2234 }2370 }
2235 if (!trimmed && composerImages.length === 0) return;2371 if (!hasSendableContent) {
2372 if (expiredTerminalContextCount > 0) {
2373 const toastCopy = buildExpiredTerminalContextToastCopy(
2374 expiredTerminalContextCount,
2375 "empty",
2376 );
2377 toastManager.add({
2378 type: "warning",
2379 title: toastCopy.title,
2380 description: toastCopy.description,
2381 });
2382 }
2383 return;
2384 }
2236 if (!activeProject) return;2385 if (!activeProject) return;
2237 const threadIdForSend = activeThread.id;2386 const threadIdForSend = activeThread.id;
2238 const isFirstMessage = !isServerThread || activeThread.messages.length === 0;2387 const isFirstMessage = !isServerThread || activeThread.messages.length === 0;
...@@ -2257,6 +2406,11 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2257,6 +2406,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
2257 beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn");2406 beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn");
22582407
2259 const composerImagesSnapshot = [...composerImages];2408 const composerImagesSnapshot = [...composerImages];
2409 const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts];
2410 const messageTextForSend = appendTerminalContextsToPrompt(
2411 promptForSend,
2412 composerTerminalContextsSnapshot,
2413 );
2260 const messageIdForSend = newMessageId();2414 const messageIdForSend = newMessageId();
2261 const messageCreatedAt = new Date().toISOString();2415 const messageCreatedAt = new Date().toISOString();
2262 const turnAttachmentsPromise = Promise.all(2416 const turnAttachmentsPromise = Promise.all(
...@@ -2281,7 +2435,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2281,7 +2435,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2281 {2435 {
2282 id: messageIdForSend,2436 id: messageIdForSend,
2283 role: "user",2437 role: "user",
2284 text: trimmed,2438 text: messageTextForSend,
2285 ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}),2439 ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}),
2286 createdAt: messageCreatedAt,2440 createdAt: messageCreatedAt,
2287 streaming: false,2441 streaming: false,
...@@ -2292,6 +2446,17 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2292,6 +2446,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
2292 forceStickToBottom();2446 forceStickToBottom();
22932447
2294 setThreadError(threadIdForSend, null);2448 setThreadError(threadIdForSend, null);
2449 if (expiredTerminalContextCount > 0) {
2450 const toastCopy = buildExpiredTerminalContextToastCopy(
2451 expiredTerminalContextCount,
2452 "omitted",
2453 );
2454 toastManager.add({
2455 type: "warning",
2456 title: toastCopy.title,
2457 description: toastCopy.description,
2458 });
2459 }
2295 promptRef.current = "";2460 promptRef.current = "";
2296 clearComposerDraftContent(threadIdForSend);2461 clearComposerDraftContent(threadIdForSend);
2297 setComposerHighlightedItemId(null);2462 setComposerHighlightedItemId(null);
...@@ -2339,6 +2504,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2339,6 +2504,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
2339 if (!titleSeed) {2504 if (!titleSeed) {
2340 if (firstComposerImageName) {2505 if (firstComposerImageName) {
2341 titleSeed = `Image: ${firstComposerImageName}`;2506 titleSeed = `Image: ${firstComposerImageName}`;
2507 } else if (composerTerminalContextsSnapshot.length > 0) {
2508 titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!);
2342 } else {2509 } else {
2343 titleSeed = "New thread";2510 titleSeed = "New thread";
2344 }2511 }
...@@ -2419,7 +2586,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2419,7 +2586,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2419 message: {2586 message: {
2420 messageId: messageIdForSend,2587 messageId: messageIdForSend,
2421 role: "user",2588 role: "user",
2422 text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT,2589 text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT,
2423 attachments: turnAttachments,2590 attachments: turnAttachments,
2424 },2591 },
2425 model: selectedModel || undefined,2592 model: selectedModel || undefined,
...@@ -2447,7 +2614,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2447,7 +2614,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
2447 if (2614 if (
2448 !turnStartSucceeded &&2615 !turnStartSucceeded &&
2449 promptRef.current.length === 0 &&2616 promptRef.current.length === 0 &&
2450 composerImagesRef.current.length === 02617 composerImagesRef.current.length === 0 &&
2618 composerTerminalContextsRef.current.length === 0
2451 ) {2619 ) {
2452 setOptimisticUserMessages((existing) => {2620 setOptimisticUserMessages((existing) => {
2453 const removed = existing.filter((message) => message.id === messageIdForSend);2621 const removed = existing.filter((message) => message.id === messageIdForSend);
...@@ -2457,11 +2625,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2457,11 +2625,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
2457 const next = existing.filter((message) => message.id !== messageIdForSend);2625 const next = existing.filter((message) => message.id !== messageIdForSend);
2458 return next.length === existing.length ? existing : next;2626 return next.length === existing.length ? existing : next;
2459 });2627 });
2460 promptRef.current = trimmed;2628 promptRef.current = promptForSend;
2461 setPrompt(trimmed);2629 setPrompt(promptForSend);
2462 setComposerCursor(collapseExpandedComposerCursor(trimmed, trimmed.length));2630 setComposerCursor(collapseExpandedComposerCursor(promptForSend, promptForSend.length));
2463 addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry));2631 addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry));
2464 setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length));2632 addComposerTerminalContextsToDraft(composerTerminalContextsSnapshot);
2633 setComposerTrigger(detectComposerTrigger(promptForSend, promptForSend.length));
2465 }2634 }
2466 setThreadError(2635 setThreadError(
2467 threadIdForSend,2636 threadIdForSend,
...@@ -2969,6 +3138,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2969,6 +3138,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2969 value: string;3138 value: string;
2970 cursor: number;3139 cursor: number;
2971 expandedCursor: number;3140 expandedCursor: number;
3141 terminalContextIds: string[];
2972 } => {3142 } => {
2973 const editorSnapshot = composerEditorRef.current?.readSnapshot();3143 const editorSnapshot = composerEditorRef.current?.readSnapshot();
2974 if (editorSnapshot) {3144 if (editorSnapshot) {
...@@ -2978,8 +3148,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2978,8 +3148,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
2978 value: promptRef.current,3148 value: promptRef.current,
2979 cursor: composerCursor,3149 cursor: composerCursor,
2980 expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor),3150 expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor),
3151 terminalContextIds: composerTerminalContexts.map((context) => context.id),
2981 };3152 };
2982 }, [composerCursor]);3153 }, [composerCursor, composerTerminalContexts]);
29833154
2984 const resolveActiveComposerTrigger = useCallback((): {3155 const resolveActiveComposerTrigger = useCallback((): {
2985 snapshot: { value: string; cursor: number; expandedCursor: number };3156 snapshot: { value: string; cursor: number; expandedCursor: number };
...@@ -3095,6 +3266,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3095,6 +3266,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
3095 nextCursor: number,3266 nextCursor: number,
3096 expandedCursor: number,3267 expandedCursor: number,
3097 cursorAdjacentToMention: boolean,3268 cursorAdjacentToMention: boolean,
3269 terminalContextIds: string[],
3098 ) => {3270 ) => {
3099 if (activePendingProgress?.activeQuestion && activePendingUserInput) {3271 if (activePendingProgress?.activeQuestion && activePendingUserInput) {
3100 onChangeActivePendingUserInputCustomAnswer(3272 onChangeActivePendingUserInputCustomAnswer(
...@@ -3108,6 +3280,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3108,6 +3280,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
3108 }3280 }
3109 promptRef.current = nextPrompt;3281 promptRef.current = nextPrompt;
3110 setPrompt(nextPrompt);3282 setPrompt(nextPrompt);
3283 if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) {
3284 setComposerDraftTerminalContexts(
3285 threadId,
3286 syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds),
3287 );
3288 }
3111 setComposerCursor(nextCursor);3289 setComposerCursor(nextCursor);
3112 setComposerTrigger(3290 setComposerTrigger(
3113 cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),3291 cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
...@@ -3116,8 +3294,11 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3116,8 +3294,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
3116 [3294 [
3117 activePendingProgress?.activeQuestion,3295 activePendingProgress?.activeQuestion,
3118 activePendingUserInput,3296 activePendingUserInput,
3297 composerTerminalContexts,
3119 onChangeActivePendingUserInputCustomAnswer,3298 onChangeActivePendingUserInputCustomAnswer,
3120 setPrompt,3299 setPrompt,
3300 setComposerDraftTerminalContexts,
3301 threadId,
3121 ],3302 ],
3122 );3303 );
31233304
...@@ -3384,75 +3565,77 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3384,75 +3565,77 @@ export default function ChatView({ threadId }: ChatViewProps) {
3384 </div>3565 </div>
3385 )}3566 )}
33863567
3387 {!isComposerApprovalState &&3568 {!isComposerApprovalState && pendingUserInputs.length === 0 && (
3388 pendingUserInputs.length === 0 &&3569 <>
3389 composerImages.length > 0 && (3570 {composerImages.length > 0 && (
3390 <div className="mb-3 flex flex-wrap gap-2">3571 <div className="mb-3 flex flex-wrap gap-2">
3391 {composerImages.map((image) => (3572 {composerImages.map((image) => (
3392 <div3573 <div
3393 key={image.id}3574 key={image.id}
3394 className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"3575 className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"
3395 >
3396 {image.previewUrl ? (
3397 <button
3398 type="button"
3399 className="h-full w-full cursor-zoom-in"
3400 aria-label={`Preview ${image.name}`}
3401 onClick={() => {
3402 const preview = buildExpandedImagePreview(
3403 composerImages,
3404 image.id,
3405 );
3406 if (!preview) return;
3407 setExpandedImage(preview);
3408 }}
3409 >
3410 <img
3411 src={image.previewUrl}
3412 alt={image.name}
3413 className="h-full w-full object-cover"
3414 />
3415 </button>
3416 ) : (
3417 <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70">
3418 {image.name}
3419 </div>
3420 )}
3421 {nonPersistedComposerImageIdSet.has(image.id) && (
3422 <Tooltip>
3423 <TooltipTrigger
3424 render={
3425 <span
3426 role="img"
3427 aria-label="Draft attachment may not persist"
3428 className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600"
3429 >
3430 <CircleAlertIcon className="size-3" />
3431 </span>
3432 }
3433 />
3434 <TooltipPopup
3435 side="top"
3436 className="max-w-64 whitespace-normal leading-tight"
3437 >
3438 Draft attachment could not be saved locally and may be lost on
3439 navigation.
3440 </TooltipPopup>
3441 </Tooltip>
3442 )}
3443 <Button
3444 variant="ghost"
3445 size="icon-xs"
3446 className="absolute right-1 top-1 bg-background/80 hover:bg-background/90"
3447 onClick={() => removeComposerImage(image.id)}
3448 aria-label={`Remove ${image.name}`}
3449 >3576 >
3450 <XIcon />3577 {image.previewUrl ? (
3451 </Button>3578 <button
3452 </div>3579 type="button"
3453 ))}3580 className="h-full w-full cursor-zoom-in"
3454 </div>3581 aria-label={`Preview ${image.name}`}
3455 )}3582 onClick={() => {
3583 const preview = buildExpandedImagePreview(
3584 composerImages,
3585 image.id,
3586 );
3587 if (!preview) return;
3588 setExpandedImage(preview);
3589 }}
3590 >
3591 <img
3592 src={image.previewUrl}
3593 alt={image.name}
3594 className="h-full w-full object-cover"
3595 />
3596 </button>
3597 ) : (
3598 <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70">
3599 {image.name}
3600 </div>
3601 )}
3602 {nonPersistedComposerImageIdSet.has(image.id) && (
3603 <Tooltip>
3604 <TooltipTrigger
3605 render={
3606 <span
3607 role="img"
3608 aria-label="Draft attachment may not persist"
3609 className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600"
3610 >
3611 <CircleAlertIcon className="size-3" />
3612 </span>
3613 }
3614 />
3615 <TooltipPopup
3616 side="top"
3617 className="max-w-64 whitespace-normal leading-tight"
3618 >
3619 Draft attachment could not be saved locally and may be lost on
3620 navigation.
3621 </TooltipPopup>
3622 </Tooltip>
3623 )}
3624 <Button
3625 variant="ghost"
3626 size="icon-xs"
3627 className="absolute right-1 top-1 bg-background/80 hover:bg-background/90"
3628 onClick={() => removeComposerImage(image.id)}
3629 aria-label={`Remove ${image.name}`}
3630 >
3631 <XIcon />
3632 </Button>
3633 </div>
3634 ))}
3635 </div>
3636 )}
3637 </>
3638 )}
3456 <ComposerPromptEditor3639 <ComposerPromptEditor
3457 ref={composerEditorRef}3640 ref={composerEditorRef}
3458 value={3641 value={
...@@ -3463,6 +3646,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3463,6 +3646,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
3463 : prompt3646 : prompt
3464 }3647 }
3465 cursor={composerCursor}3648 cursor={composerCursor}
3649 terminalContexts={
3650 !isComposerApprovalState && pendingUserInputs.length === 0
3651 ? composerTerminalContexts
3652 : []
3653 }
3654 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
3466 onChange={onPromptChange}3655 onChange={onPromptChange}
3467 onCommandKeyDown={onComposerCommandKey}3656 onCommandKeyDown={onComposerCommandKey}
3468 onPaste={onComposerPaste}3657 onPaste={onComposerPaste}
...@@ -3740,9 +3929,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3740,9 +3929,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
3740 type="submit"3929 type="submit"
3741 className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8"3930 className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8"
3742 disabled={3931 disabled={
3743 isSendBusy ||3932 isSendBusy || isConnecting || !composerSendState.hasSendableContent
3744 isConnecting ||
3745 (!prompt.trim() && composerImages.length === 0)
3746 }3933 }
3747 aria-label={3934 aria-label={
3748 isConnecting3935 isConnecting
...@@ -3873,6 +4060,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3873,6 +4060,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
3873 onActiveTerminalChange={activateTerminal}4060 onActiveTerminalChange={activateTerminal}
3874 onCloseTerminal={closeTerminal}4061 onCloseTerminal={closeTerminal}
3875 onHeightChange={setTerminalHeight}4062 onHeightChange={setTerminalHeight}
4063 onAddTerminalContext={addTerminalContextToDraft}
3876 />4064 />
3877 );4065 );
3878 })()}4066 })()}
apps/web/src/components/ComposerPromptEditor.tsx+330-98
...@@ -26,8 +26,10 @@ import {...@@ -26,8 +26,10 @@ import {
26 COMMAND_PRIORITY_HIGH,26 COMMAND_PRIORITY_HIGH,
27 KEY_BACKSPACE_COMMAND,27 KEY_BACKSPACE_COMMAND,
28 $getRoot,28 $getRoot,
29 DecoratorNode,
29 type ElementNode,30 type ElementNode,
30 type LexicalNode,31 type LexicalNode,
32 type SerializedLexicalNode,
31 TextNode,33 TextNode,
32 type EditorConfig,34 type EditorConfig,
33 type EditorState,35 type EditorState,
...@@ -36,14 +38,17 @@ import {...@@ -36,14 +38,17 @@ import {
36 type Spread,38 type Spread,
37} from "lexical";39} from "lexical";
38import {40import {
41 createContext,
39 forwardRef,42 forwardRef,
40 useCallback,43 useCallback,
44 useContext,
41 useEffect,45 useEffect,
42 useImperativeHandle,46 useImperativeHandle,
43 useLayoutEffect,47 useLayoutEffect,
44 useMemo,48 useMemo,
45 useRef,49 useRef,
46 type ClipboardEventHandler,50 type ClipboardEventHandler,
51 type ReactElement,
47 type Ref,52 type Ref,
48} from "react";53} from "react";
4954
...@@ -51,11 +56,21 @@ import {...@@ -51,11 +56,21 @@ import {
51 clampCollapsedComposerCursor,56 clampCollapsedComposerCursor,
52 collapseExpandedComposerCursor,57 collapseExpandedComposerCursor,
53 expandCollapsedComposerCursor,58 expandCollapsedComposerCursor,
54 isCollapsedCursorAdjacentToMention,59 isCollapsedCursorAdjacentToInlineToken,
55} from "~/composer-logic";60} from "~/composer-logic";
56import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";61import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
62import {
63 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
64 type TerminalContextDraft,
65} from "~/lib/terminalContext";
57import { cn } from "~/lib/utils";66import { cn } from "~/lib/utils";
58import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons";67import { basenameOfPath, getVscodeIconUrlForEntry, inferEntryKindFromPath } from "~/vscode-icons";
68import {
69 COMPOSER_INLINE_CHIP_CLASS_NAME,
70 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
71 COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
72} from "./composerInlineChip";
73import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";
5974
60const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`;75const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`;
6176
...@@ -68,6 +83,21 @@ type SerializedComposerMentionNode = Spread<...@@ -68,6 +83,21 @@ type SerializedComposerMentionNode = Spread<
68 SerializedTextNode83 SerializedTextNode
69>;84>;
7085
86type SerializedComposerTerminalContextNode = Spread<
87 {
88 context: TerminalContextDraft;
89 type: "composer-terminal-context";
90 version: 1;
91 },
92 SerializedLexicalNode
93>;
94
95const ComposerTerminalContextActionsContext = createContext<{
96 onRemoveTerminalContext: (contextId: string) => void;
97}>({
98 onRemoveTerminalContext: () => {},
99});
100
71class ComposerMentionNode extends TextNode {101class ComposerMentionNode extends TextNode {
72 __path: string;102 __path: string;
73103
...@@ -100,8 +130,7 @@ class ComposerMentionNode extends TextNode {...@@ -100,8 +130,7 @@ class ComposerMentionNode extends TextNode {
100130
101 override createDOM(_config: EditorConfig): HTMLElement {131 override createDOM(_config: EditorConfig): HTMLElement {
102 const dom = document.createElement("span");132 const dom = document.createElement("span");
103 dom.className =133 dom.className = COMPOSER_INLINE_CHIP_CLASS_NAME;
104 "inline-flex select-none items-center gap-1 rounded-md border border-border/70 bg-accent/40 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-foreground align-middle";
105 dom.contentEditable = "false";134 dom.contentEditable = "false";
106 dom.setAttribute("spellcheck", "false");135 dom.setAttribute("spellcheck", "false");
107 renderMentionChipDom(dom, this.__path);136 renderMentionChipDom(dom, this.__path);
...@@ -141,15 +170,76 @@ function $createComposerMentionNode(path: string): ComposerMentionNode {...@@ -141,15 +170,76 @@ function $createComposerMentionNode(path: string): ComposerMentionNode {
141 return $applyNodeReplacement(new ComposerMentionNode(path));170 return $applyNodeReplacement(new ComposerMentionNode(path));
142}171}
143172
144function inferMentionPathKind(pathValue: string): "file" | "directory" {173function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) {
145 const base = basenameOfPath(pathValue);174 return <ComposerPendingTerminalContextChip context={props.context} />;
146 if (base.startsWith(".") && !base.slice(1).includes(".")) {175}
147 return "directory";176
177class ComposerTerminalContextNode extends DecoratorNode<ReactElement> {
178 __context: TerminalContextDraft;
179
180 static override getType(): string {
181 return "composer-terminal-context";
182 }
183
184 static override clone(node: ComposerTerminalContextNode): ComposerTerminalContextNode {
185 return new ComposerTerminalContextNode(node.__context, node.__key);
186 }
187
188 static override importJSON(
189 serializedNode: SerializedComposerTerminalContextNode,
190 ): ComposerTerminalContextNode {
191 return $createComposerTerminalContextNode(serializedNode.context);
192 }
193
194 constructor(context: TerminalContextDraft, key?: NodeKey) {
195 super(key);
196 this.__context = context;
197 }
198
199 override exportJSON(): SerializedComposerTerminalContextNode {
200 return {
201 ...super.exportJSON(),
202 context: this.__context,
203 type: "composer-terminal-context",
204 version: 1,
205 };
206 }
207
208 override createDOM(): HTMLElement {
209 const dom = document.createElement("span");
210 dom.className = "inline-flex align-middle leading-none";
211 return dom;
212 }
213
214 override updateDOM(): false {
215 return false;
216 }
217
218 override getTextContent(): string {
219 return INLINE_TERMINAL_CONTEXT_PLACEHOLDER;
148 }220 }
149 if (base.includes(".")) {221
150 return "file";222 override isInline(): true {
223 return true;
151 }224 }
152 return "directory";225
226 override decorate(): ReactElement {
227 return <ComposerTerminalContextDecorator context={this.__context} />;
228 }
229}
230
231function $createComposerTerminalContextNode(
232 context: TerminalContextDraft,
233): ComposerTerminalContextNode {
234 return $applyNodeReplacement(new ComposerTerminalContextNode(context));
235}
236
237type ComposerInlineTokenNode = ComposerMentionNode | ComposerTerminalContextNode;
238
239function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode {
240 return (
241 candidate instanceof ComposerMentionNode || candidate instanceof ComposerTerminalContextNode
242 );
153}243}
154244
155function resolvedThemeFromDocument(): "light" | "dark" {245function resolvedThemeFromDocument(): "light" | "dark" {
...@@ -165,25 +255,91 @@ function renderMentionChipDom(container: HTMLElement, pathValue: string): void {...@@ -165,25 +255,91 @@ function renderMentionChipDom(container: HTMLElement, pathValue: string): void {
165 const icon = document.createElement("img");255 const icon = document.createElement("img");
166 icon.alt = "";256 icon.alt = "";
167 icon.ariaHidden = "true";257 icon.ariaHidden = "true";
168 icon.className = "size-3.5 shrink-0 opacity-85";258 icon.className = COMPOSER_INLINE_CHIP_ICON_CLASS_NAME;
169 icon.loading = "lazy";259 icon.loading = "lazy";
170 icon.src = getVscodeIconUrlForEntry(pathValue, inferMentionPathKind(pathValue), theme);260 icon.src = getVscodeIconUrlForEntry(pathValue, inferEntryKindFromPath(pathValue), theme);
171261
172 const label = document.createElement("span");262 const label = document.createElement("span");
173 label.className = "truncate select-none leading-tight";263 label.className = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME;
174 label.textContent = basenameOfPath(pathValue);264 label.textContent = basenameOfPath(pathValue);
175265
176 container.append(icon, label);266 container.append(icon, label);
177}267}
178268
269function terminalContextSignature(contexts: ReadonlyArray<TerminalContextDraft>): string {
270 return contexts
271 .map((context) =>
272 [
273 context.id,
274 context.threadId,
275 context.terminalId,
276 context.terminalLabel,
277 context.lineStart,
278 context.lineEnd,
279 context.createdAt,
280 context.text,
281 ].join("\u001f"),
282 )
283 .join("\u001e");
284}
285
179function clampExpandedCursor(value: string, cursor: number): number {286function clampExpandedCursor(value: string, cursor: number): number {
180 if (!Number.isFinite(cursor)) return value.length;287 if (!Number.isFinite(cursor)) return value.length;
181 return Math.max(0, Math.min(value.length, Math.floor(cursor)));288 return Math.max(0, Math.min(value.length, Math.floor(cursor)));
182}289}
183290
291function getComposerInlineTokenTextLength(_node: ComposerInlineTokenNode): 1 {
292 return 1;
293}
294
295function getComposerInlineTokenExpandedTextLength(node: ComposerInlineTokenNode): number {
296 return node.getTextContentSize();
297}
298
299function getAbsoluteOffsetForInlineTokenPoint(
300 node: ComposerInlineTokenNode,
301 absoluteOffset: number,
302 pointOffset: number,
303): number {
304 return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenTextLength(node) : 0);
305}
306
307function getExpandedAbsoluteOffsetForInlineTokenPoint(
308 node: ComposerInlineTokenNode,
309 absoluteOffset: number,
310 pointOffset: number,
311): number {
312 return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenExpandedTextLength(node) : 0);
313}
314
315function findSelectionPointForInlineToken(
316 node: ComposerInlineTokenNode,
317 remainingRef: { value: number },
318): { key: string; offset: number; type: "element" } | null {
319 const parent = node.getParent();
320 if (!parent || !$isElementNode(parent)) return null;
321 const index = node.getIndexWithinParent();
322 if (remainingRef.value === 0) {
323 return {
324 key: parent.getKey(),
325 offset: index,
326 type: "element",
327 };
328 }
329 if (remainingRef.value === getComposerInlineTokenTextLength(node)) {
330 return {
331 key: parent.getKey(),
332 offset: index + 1,
333 type: "element",
334 };
335 }
336 remainingRef.value -= getComposerInlineTokenTextLength(node);
337 return null;
338}
339
184function getComposerNodeTextLength(node: LexicalNode): number {340function getComposerNodeTextLength(node: LexicalNode): number {
185 if (node instanceof ComposerMentionNode) {341 if (isComposerInlineTokenNode(node)) {
186 return 1;342 return getComposerInlineTokenTextLength(node);
187 }343 }
188 if ($isTextNode(node)) {344 if ($isTextNode(node)) {
189 return node.getTextContentSize();345 return node.getTextContentSize();
...@@ -198,6 +354,9 @@ function getComposerNodeTextLength(node: LexicalNode): number {...@@ -198,6 +354,9 @@ function getComposerNodeTextLength(node: LexicalNode): number {
198}354}
199355
200function getComposerNodeExpandedTextLength(node: LexicalNode): number {356function getComposerNodeExpandedTextLength(node: LexicalNode): number {
357 if (isComposerInlineTokenNode(node)) {
358 return getComposerInlineTokenExpandedTextLength(node);
359 }
201 if ($isTextNode(node)) {360 if ($isTextNode(node)) {
202 return node.getTextContentSize();361 return node.getTextContentSize();
203 }362 }
...@@ -233,10 +392,13 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb...@@ -233,10 +392,13 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb
233392
234 if ($isTextNode(node)) {393 if ($isTextNode(node)) {
235 if (node instanceof ComposerMentionNode) {394 if (node instanceof ComposerMentionNode) {
236 return offset + (pointOffset > 0 ? 1 : 0);395 return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset);
237 }396 }
238 return offset + Math.min(pointOffset, node.getTextContentSize());397 return offset + Math.min(pointOffset, node.getTextContentSize());
239 }398 }
399 if (node instanceof ComposerTerminalContextNode) {
400 return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset);
401 }
240402
241 if ($isLineBreakNode(node)) {403 if ($isLineBreakNode(node)) {
242 return offset + Math.min(pointOffset, 1);404 return offset + Math.min(pointOffset, 1);
...@@ -277,10 +439,13 @@ function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: numbe...@@ -277,10 +439,13 @@ function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: numbe
277439
278 if ($isTextNode(node)) {440 if ($isTextNode(node)) {
279 if (node instanceof ComposerMentionNode) {441 if (node instanceof ComposerMentionNode) {
280 return offset + (pointOffset > 0 ? node.getTextContentSize() : 0);442 return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset);
281 }443 }
282 return offset + Math.min(pointOffset, node.getTextContentSize());444 return offset + Math.min(pointOffset, node.getTextContentSize());
283 }445 }
446 if (node instanceof ComposerTerminalContextNode) {
447 return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset);
448 }
284449
285 if ($isLineBreakNode(node)) {450 if ($isLineBreakNode(node)) {
286 return offset + Math.min(pointOffset, 1);451 return offset + Math.min(pointOffset, 1);
...@@ -305,25 +470,10 @@ function findSelectionPointAtOffset(...@@ -305,25 +470,10 @@ function findSelectionPointAtOffset(
305 remainingRef: { value: number },470 remainingRef: { value: number },
306): { key: string; offset: number; type: "text" | "element" } | null {471): { key: string; offset: number; type: "text" | "element" } | null {
307 if (node instanceof ComposerMentionNode) {472 if (node instanceof ComposerMentionNode) {
308 const parent = node.getParent();473 return findSelectionPointForInlineToken(node, remainingRef);
309 if (!parent || !$isElementNode(parent)) return null;474 }
310 const index = node.getIndexWithinParent();475 if (node instanceof ComposerTerminalContextNode) {
311 if (remainingRef.value === 0) {476 return findSelectionPointForInlineToken(node, remainingRef);
312 return {
313 key: parent.getKey(),
314 offset: index,
315 type: "element",
316 };
317 }
318 if (remainingRef.value === 1) {
319 return {
320 key: parent.getKey(),
321 offset: index + 1,
322 type: "element",
323 };
324 }
325 remainingRef.value -= 1;
326 return null;
327 }477 }
328478
329 if ($isTextNode(node)) {479 if ($isTextNode(node)) {
...@@ -438,40 +588,67 @@ function $appendTextWithLineBreaks(parent: ElementNode, text: string): void {...@@ -438,40 +588,67 @@ function $appendTextWithLineBreaks(parent: ElementNode, text: string): void {
438 }588 }
439}589}
440590
441function $setComposerEditorPrompt(prompt: string): void {591function $setComposerEditorPrompt(
592 prompt: string,
593 terminalContexts: ReadonlyArray<TerminalContextDraft>,
594): void {
442 const root = $getRoot();595 const root = $getRoot();
443 root.clear();596 root.clear();
444 const paragraph = $createParagraphNode();597 const paragraph = $createParagraphNode();
445 root.append(paragraph);598 root.append(paragraph);
446599
447 const segments = splitPromptIntoComposerSegments(prompt);600 const segments = splitPromptIntoComposerSegments(prompt, terminalContexts);
448 for (const segment of segments) {601 for (const segment of segments) {
449 if (segment.type === "mention") {602 if (segment.type === "mention") {
450 paragraph.append($createComposerMentionNode(segment.path));603 paragraph.append($createComposerMentionNode(segment.path));
451 continue;604 continue;
452 }605 }
606 if (segment.type === "terminal-context") {
607 if (segment.context) {
608 paragraph.append($createComposerTerminalContextNode(segment.context));
609 }
610 continue;
611 }
453 $appendTextWithLineBreaks(paragraph, segment.text);612 $appendTextWithLineBreaks(paragraph, segment.text);
454 }613 }
455}614}
456615
616function collectTerminalContextIds(node: LexicalNode): string[] {
617 if (node instanceof ComposerTerminalContextNode) {
618 return [node.__context.id];
619 }
620 if ($isElementNode(node)) {
621 return node.getChildren().flatMap((child) => collectTerminalContextIds(child));
622 }
623 return [];
624}
625
457export interface ComposerPromptEditorHandle {626export interface ComposerPromptEditorHandle {
458 focus: () => void;627 focus: () => void;
459 focusAt: (cursor: number) => void;628 focusAt: (cursor: number) => void;
460 focusAtEnd: () => void;629 focusAtEnd: () => void;
461 readSnapshot: () => { value: string; cursor: number; expandedCursor: number };630 readSnapshot: () => {
631 value: string;
632 cursor: number;
633 expandedCursor: number;
634 terminalContextIds: string[];
635 };
462}636}
463637
464interface ComposerPromptEditorProps {638interface ComposerPromptEditorProps {
465 value: string;639 value: string;
466 cursor: number;640 cursor: number;
641 terminalContexts: ReadonlyArray<TerminalContextDraft>;
467 disabled: boolean;642 disabled: boolean;
468 placeholder: string;643 placeholder: string;
469 className?: string;644 className?: string;
645 onRemoveTerminalContext: (contextId: string) => void;
470 onChange: (646 onChange: (
471 nextValue: string,647 nextValue: string,
472 nextCursor: number,648 nextCursor: number,
473 expandedCursor: number,649 expandedCursor: number,
474 cursorAdjacentToMention: boolean,650 cursorAdjacentToMention: boolean,
651 terminalContextIds: string[],
475 ) => void;652 ) => void;
476 onCommandKeyDown?: (653 onCommandKeyDown?: (
477 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",654 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
...@@ -540,7 +717,7 @@ function ComposerCommandKeyPlugin(props: {...@@ -540,7 +717,7 @@ function ComposerCommandKeyPlugin(props: {
540 return null;717 return null;
541}718}
542719
543function ComposerMentionArrowPlugin() {720function ComposerInlineTokenArrowPlugin() {
544 const [editor] = useLexicalComposerContext();721 const [editor] = useLexicalComposerContext();
545722
546 useEffect(() => {723 useEffect(() => {
...@@ -554,7 +731,7 @@ function ComposerMentionArrowPlugin() {...@@ -554,7 +731,7 @@ function ComposerMentionArrowPlugin() {
554 const currentOffset = $readSelectionOffsetFromEditorState(0);731 const currentOffset = $readSelectionOffsetFromEditorState(0);
555 if (currentOffset <= 0) return;732 if (currentOffset <= 0) return;
556 const promptValue = $getRoot().getTextContent();733 const promptValue = $getRoot().getTextContent();
557 if (!isCollapsedCursorAdjacentToMention(promptValue, currentOffset, "left")) {734 if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "left")) {
558 return;735 return;
559 }736 }
560 nextOffset = currentOffset - 1;737 nextOffset = currentOffset - 1;
...@@ -581,7 +758,7 @@ function ComposerMentionArrowPlugin() {...@@ -581,7 +758,7 @@ function ComposerMentionArrowPlugin() {
581 const composerLength = $getComposerRootLength();758 const composerLength = $getComposerRootLength();
582 if (currentOffset >= composerLength) return;759 if (currentOffset >= composerLength) return;
583 const promptValue = $getRoot().getTextContent();760 const promptValue = $getRoot().getTextContent();
584 if (!isCollapsedCursorAdjacentToMention(promptValue, currentOffset, "right")) {761 if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "right")) {
585 return;762 return;
586 }763 }
587 nextOffset = currentOffset + 1;764 nextOffset = currentOffset + 1;
...@@ -606,7 +783,7 @@ function ComposerMentionArrowPlugin() {...@@ -606,7 +783,7 @@ function ComposerMentionArrowPlugin() {
606 return null;783 return null;
607}784}
608785
609function ComposerMentionSelectionNormalizePlugin() {786function ComposerInlineTokenSelectionNormalizePlugin() {
610 const [editor] = useLexicalComposerContext();787 const [editor] = useLexicalComposerContext();
611788
612 useEffect(() => {789 useEffect(() => {
...@@ -616,7 +793,7 @@ function ComposerMentionSelectionNormalizePlugin() {...@@ -616,7 +793,7 @@ function ComposerMentionSelectionNormalizePlugin() {
616 const selection = $getSelection();793 const selection = $getSelection();
617 if (!$isRangeSelection(selection) || !selection.isCollapsed()) return;794 if (!$isRangeSelection(selection) || !selection.isCollapsed()) return;
618 const anchorNode = selection.anchor.getNode();795 const anchorNode = selection.anchor.getNode();
619 if (!(anchorNode instanceof ComposerMentionNode)) return;796 if (!isComposerInlineTokenNode(anchorNode)) return;
620 if (selection.anchor.offset === 0) return;797 if (selection.anchor.offset === 0) return;
621 const beforeOffset = getAbsoluteOffsetForPoint(anchorNode, 0);798 const beforeOffset = getAbsoluteOffsetForPoint(anchorNode, 0);
622 afterOffset = beforeOffset + 1;799 afterOffset = beforeOffset + 1;
...@@ -634,8 +811,9 @@ function ComposerMentionSelectionNormalizePlugin() {...@@ -634,8 +811,9 @@ function ComposerMentionSelectionNormalizePlugin() {
634 return null;811 return null;
635}812}
636813
637function ComposerMentionBackspacePlugin() {814function ComposerInlineTokenBackspacePlugin() {
638 const [editor] = useLexicalComposerContext();815 const [editor] = useLexicalComposerContext();
816 const { onRemoveTerminalContext } = useContext(ComposerTerminalContextActionsContext);
639817
640 useEffect(() => {818 useEffect(() => {
641 return editor.registerCommand(819 return editor.registerCommand(
...@@ -647,18 +825,23 @@ function ComposerMentionBackspacePlugin() {...@@ -647,18 +825,23 @@ function ComposerMentionBackspacePlugin() {
647 }825 }
648826
649 const anchorNode = selection.anchor.getNode();827 const anchorNode = selection.anchor.getNode();
650 const removeMentionNode = (candidate: unknown): boolean => {828 const selectionOffset = $readSelectionOffsetFromEditorState(0);
651 if (!(candidate instanceof ComposerMentionNode)) {829 const removeInlineTokenNode = (candidate: unknown): boolean => {
830 if (!isComposerInlineTokenNode(candidate)) {
652 return false;831 return false;
653 }832 }
654 const mentionStart = getAbsoluteOffsetForPoint(candidate, 0);833 const tokenStart = getAbsoluteOffsetForPoint(candidate, 0);
655 candidate.remove();834 candidate.remove();
656 $setSelectionAtComposerOffset(mentionStart);835 if (candidate instanceof ComposerTerminalContextNode) {
836 onRemoveTerminalContext(candidate.__context.id);
837 $setSelectionAtComposerOffset(selectionOffset);
838 } else {
839 $setSelectionAtComposerOffset(tokenStart);
840 }
657 event?.preventDefault();841 event?.preventDefault();
658 return true;842 return true;
659 };843 };
660844 if (removeInlineTokenNode(anchorNode)) {
661 if (removeMentionNode(anchorNode)) {
662 return true;845 return true;
663 }846 }
664847
...@@ -666,13 +849,13 @@ function ComposerMentionBackspacePlugin() {...@@ -666,13 +849,13 @@ function ComposerMentionBackspacePlugin() {
666 if (selection.anchor.offset > 0) {849 if (selection.anchor.offset > 0) {
667 return false;850 return false;
668 }851 }
669 if (removeMentionNode(anchorNode.getPreviousSibling())) {852 if (removeInlineTokenNode(anchorNode.getPreviousSibling())) {
670 return true;853 return true;
671 }854 }
672 const parent = anchorNode.getParent();855 const parent = anchorNode.getParent();
673 if ($isElementNode(parent)) {856 if ($isElementNode(parent)) {
674 const index = anchorNode.getIndexWithinParent();857 const index = anchorNode.getIndexWithinParent();
675 if (index > 0 && removeMentionNode(parent.getChildAtIndex(index - 1))) {858 if (index > 0 && removeInlineTokenNode(parent.getChildAtIndex(index - 1))) {
676 return true;859 return true;
677 }860 }
678 }861 }
...@@ -681,7 +864,7 @@ function ComposerMentionBackspacePlugin() {...@@ -681,7 +864,7 @@ function ComposerMentionBackspacePlugin() {
681864
682 if ($isElementNode(anchorNode)) {865 if ($isElementNode(anchorNode)) {
683 const childIndex = selection.anchor.offset - 1;866 const childIndex = selection.anchor.offset - 1;
684 if (childIndex >= 0 && removeMentionNode(anchorNode.getChildAtIndex(childIndex))) {867 if (childIndex >= 0 && removeInlineTokenNode(anchorNode.getChildAtIndex(childIndex))) {
685 return true;868 return true;
686 }869 }
687 }870 }
...@@ -690,7 +873,7 @@ function ComposerMentionBackspacePlugin() {...@@ -690,7 +873,7 @@ function ComposerMentionBackspacePlugin() {
690 },873 },
691 COMMAND_PRIORITY_HIGH,874 COMMAND_PRIORITY_HIGH,
692 );875 );
693 }, [editor]);876 }, [editor, onRemoveTerminalContext]);
694877
695 return null;878 return null;
696}879}
...@@ -698,9 +881,11 @@ function ComposerMentionBackspacePlugin() {...@@ -698,9 +881,11 @@ function ComposerMentionBackspacePlugin() {
698function ComposerPromptEditorInner({881function ComposerPromptEditorInner({
699 value,882 value,
700 cursor,883 cursor,
884 terminalContexts,
701 disabled,885 disabled,
702 placeholder,886 placeholder,
703 className,887 className,
888 onRemoveTerminalContext,
704 onChange,889 onChange,
705 onCommandKeyDown,890 onCommandKeyDown,
706 onPaste,891 onPaste,
...@@ -709,12 +894,19 @@ function ComposerPromptEditorInner({...@@ -709,12 +894,19 @@ function ComposerPromptEditorInner({
709 const [editor] = useLexicalComposerContext();894 const [editor] = useLexicalComposerContext();
710 const onChangeRef = useRef(onChange);895 const onChangeRef = useRef(onChange);
711 const initialCursor = clampCollapsedComposerCursor(value, cursor);896 const initialCursor = clampCollapsedComposerCursor(value, cursor);
897 const terminalContextsSignature = terminalContextSignature(terminalContexts);
898 const terminalContextsSignatureRef = useRef(terminalContextsSignature);
712 const snapshotRef = useRef({899 const snapshotRef = useRef({
713 value,900 value,
714 cursor: initialCursor,901 cursor: initialCursor,
715 expandedCursor: expandCollapsedComposerCursor(value, initialCursor),902 expandedCursor: expandCollapsedComposerCursor(value, initialCursor),
903 terminalContextIds: terminalContexts.map((context) => context.id),
716 });904 });
717 const isApplyingControlledUpdateRef = useRef(false);905 const isApplyingControlledUpdateRef = useRef(false);
906 const terminalContextActions = useMemo(
907 () => ({ onRemoveTerminalContext }),
908 [onRemoveTerminalContext],
909 );
718910
719 useEffect(() => {911 useEffect(() => {
720 onChangeRef.current = onChange;912 onChangeRef.current = onChange;
...@@ -727,7 +919,12 @@ function ComposerPromptEditorInner({...@@ -727,7 +919,12 @@ function ComposerPromptEditorInner({
727 useLayoutEffect(() => {919 useLayoutEffect(() => {
728 const normalizedCursor = clampCollapsedComposerCursor(value, cursor);920 const normalizedCursor = clampCollapsedComposerCursor(value, cursor);
729 const previousSnapshot = snapshotRef.current;921 const previousSnapshot = snapshotRef.current;
730 if (previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor) {922 const contextsChanged = terminalContextsSignatureRef.current !== terminalContextsSignature;
923 if (
924 previousSnapshot.value === value &&
925 previousSnapshot.cursor === normalizedCursor &&
926 !contextsChanged
927 ) {
731 return;928 return;
732 }929 }
733930
...@@ -735,28 +932,30 @@ function ComposerPromptEditorInner({...@@ -735,28 +932,30 @@ function ComposerPromptEditorInner({
735 value,932 value,
736 cursor: normalizedCursor,933 cursor: normalizedCursor,
737 expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor),934 expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor),
935 terminalContextIds: terminalContexts.map((context) => context.id),
738 };936 };
937 terminalContextsSignatureRef.current = terminalContextsSignature;
739938
740 const rootElement = editor.getRootElement();939 const rootElement = editor.getRootElement();
741 const isFocused = Boolean(rootElement && document.activeElement === rootElement);940 const isFocused = Boolean(rootElement && document.activeElement === rootElement);
742 if (previousSnapshot.value === value && !isFocused) {941 if (previousSnapshot.value === value && !contextsChanged && !isFocused) {
743 return;942 return;
744 }943 }
745944
746 isApplyingControlledUpdateRef.current = true;945 isApplyingControlledUpdateRef.current = true;
747 editor.update(() => {946 editor.update(() => {
748 const valueChanged = previousSnapshot.value !== value;947 const shouldRewriteEditorState = previousSnapshot.value !== value || contextsChanged;
749 if (previousSnapshot.value !== value) {948 if (shouldRewriteEditorState) {
750 $setComposerEditorPrompt(value);949 $setComposerEditorPrompt(value, terminalContexts);
751 }950 }
752 if (valueChanged || isFocused) {951 if (shouldRewriteEditorState || isFocused) {
753 $setSelectionAtComposerOffset(normalizedCursor);952 $setSelectionAtComposerOffset(normalizedCursor);
754 }953 }
755 });954 });
756 queueMicrotask(() => {955 queueMicrotask(() => {
757 isApplyingControlledUpdateRef.current = false;956 isApplyingControlledUpdateRef.current = false;
758 });957 });
759 }, [cursor, editor, value]);958 }, [cursor, editor, terminalContexts, terminalContextsSignature, value]);
760959
761 const focusAt = useCallback(960 const focusAt = useCallback(
762 (nextCursor: number) => {961 (nextCursor: number) => {
...@@ -771,12 +970,14 @@ function ComposerPromptEditorInner({...@@ -771,12 +970,14 @@ function ComposerPromptEditorInner({
771 value: snapshotRef.current.value,970 value: snapshotRef.current.value,
772 cursor: boundedCursor,971 cursor: boundedCursor,
773 expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor),972 expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor),
973 terminalContextIds: snapshotRef.current.terminalContextIds,
774 };974 };
775 onChangeRef.current(975 onChangeRef.current(
776 snapshotRef.current.value,976 snapshotRef.current.value,
777 boundedCursor,977 boundedCursor,
778 snapshotRef.current.expandedCursor,978 snapshotRef.current.expandedCursor,
779 false,979 false,
980 snapshotRef.current.terminalContextIds,
780 );981 );
781 },982 },
782 [editor],983 [editor],
...@@ -786,6 +987,7 @@ function ComposerPromptEditorInner({...@@ -786,6 +987,7 @@ function ComposerPromptEditorInner({
786 value: string;987 value: string;
787 cursor: number;988 cursor: number;
788 expandedCursor: number;989 expandedCursor: number;
990 terminalContextIds: string[];
789 } => {991 } => {
790 let snapshot = snapshotRef.current;992 let snapshot = snapshotRef.current;
791 editor.getEditorState().read(() => {993 editor.getEditorState().read(() => {
...@@ -803,10 +1005,12 @@ function ComposerPromptEditorInner({...@@ -803,10 +1005,12 @@ function ComposerPromptEditorInner({
803 nextValue,1005 nextValue,
804 $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor),1006 $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor),
805 );1007 );
1008 const terminalContextIds = collectTerminalContextIds($getRoot());
806 snapshot = {1009 snapshot = {
807 value: nextValue,1010 value: nextValue,
808 cursor: nextCursor,1011 cursor: nextCursor,
809 expandedCursor: nextExpandedCursor,1012 expandedCursor: nextExpandedCursor,
1013 terminalContextIds,
810 };1014 };
811 });1015 });
812 snapshotRef.current = snapshot;1016 snapshotRef.current = snapshot;
...@@ -849,11 +1053,14 @@ function ComposerPromptEditorInner({...@@ -849,11 +1053,14 @@ function ComposerPromptEditorInner({
849 nextValue,1053 nextValue,
850 $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor),1054 $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor),
851 );1055 );
1056 const terminalContextIds = collectTerminalContextIds($getRoot());
852 const previousSnapshot = snapshotRef.current;1057 const previousSnapshot = snapshotRef.current;
853 if (1058 if (
854 previousSnapshot.value === nextValue &&1059 previousSnapshot.value === nextValue &&
855 previousSnapshot.cursor === nextCursor &&1060 previousSnapshot.cursor === nextCursor &&
856 previousSnapshot.expandedCursor === nextExpandedCursor1061 previousSnapshot.expandedCursor === nextExpandedCursor &&
1062 previousSnapshot.terminalContextIds.length === terminalContextIds.length &&
1063 previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index])
857 ) {1064 ) {
858 return;1065 return;
859 }1066 }
...@@ -864,43 +1071,54 @@ function ComposerPromptEditorInner({...@@ -864,43 +1071,54 @@ function ComposerPromptEditorInner({
864 value: nextValue,1071 value: nextValue,
865 cursor: nextCursor,1072 cursor: nextCursor,
866 expandedCursor: nextExpandedCursor,1073 expandedCursor: nextExpandedCursor,
1074 terminalContextIds,
867 };1075 };
868 const cursorAdjacentToMention =1076 const cursorAdjacentToMention =
869 isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "left") ||1077 isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") ||
870 isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "right");1078 isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right");
871 onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, cursorAdjacentToMention);1079 onChangeRef.current(
1080 nextValue,
1081 nextCursor,
1082 nextExpandedCursor,
1083 cursorAdjacentToMention,
1084 terminalContextIds,
1085 );
872 });1086 });
873 }, []);1087 }, []);
8741088
875 return (1089 return (
876 <div className="relative">1090 <ComposerTerminalContextActionsContext.Provider value={terminalContextActions}>
877 <PlainTextPlugin1091 <div className="relative">
878 contentEditable={1092 <PlainTextPlugin
879 <ContentEditable1093 contentEditable={
880 className={cn(1094 <ContentEditable
881 "block max-h-[200px] min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent text-[14px] leading-relaxed text-foreground focus:outline-none",1095 className={cn(
882 className,1096 "block max-h-[200px] min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent text-[14px] leading-relaxed text-foreground focus:outline-none",
883 )}1097 className,
884 data-testid="composer-editor"1098 )}
885 aria-placeholder={placeholder}1099 data-testid="composer-editor"
886 placeholder={<span />}1100 aria-placeholder={placeholder}
887 onPaste={onPaste}1101 placeholder={<span />}
888 />1102 onPaste={onPaste}
889 }1103 />
890 placeholder={1104 }
891 <div className="pointer-events-none absolute inset-0 text-[14px] leading-relaxed text-muted-foreground/35">1105 placeholder={
892 {placeholder}1106 terminalContexts.length > 0 ? null : (
893 </div>1107 <div className="pointer-events-none absolute inset-0 text-[14px] leading-relaxed text-muted-foreground/35">
894 }1108 {placeholder}
895 ErrorBoundary={LexicalErrorBoundary}1109 </div>
896 />1110 )
897 <OnChangePlugin onChange={handleEditorChange} />1111 }
898 <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} />1112 ErrorBoundary={LexicalErrorBoundary}
899 <ComposerMentionArrowPlugin />1113 />
900 <ComposerMentionSelectionNormalizePlugin />1114 <OnChangePlugin onChange={handleEditorChange} />
901 <ComposerMentionBackspacePlugin />1115 <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} />
902 <HistoryPlugin />1116 <ComposerInlineTokenArrowPlugin />
903 </div>1117 <ComposerInlineTokenSelectionNormalizePlugin />
1118 <ComposerInlineTokenBackspacePlugin />
1119 <HistoryPlugin />
1120 </div>
1121 </ComposerTerminalContextActionsContext.Provider>
904 );1122 );
905}1123}
9061124
...@@ -908,17 +1126,29 @@ export const ComposerPromptEditor = forwardRef<...@@ -908,17 +1126,29 @@ export const ComposerPromptEditor = forwardRef<
908 ComposerPromptEditorHandle,1126 ComposerPromptEditorHandle,
909 ComposerPromptEditorProps1127 ComposerPromptEditorProps
910>(function ComposerPromptEditor(1128>(function ComposerPromptEditor(
911 { value, cursor, disabled, placeholder, className, onChange, onCommandKeyDown, onPaste },1129 {
1130 value,
1131 cursor,
1132 terminalContexts,
1133 disabled,
1134 placeholder,
1135 className,
1136 onRemoveTerminalContext,
1137 onChange,
1138 onCommandKeyDown,
1139 onPaste,
1140 },
912 ref,1141 ref,
913) {1142) {
914 const initialValueRef = useRef(value);1143 const initialValueRef = useRef(value);
1144 const initialTerminalContextsRef = useRef(terminalContexts);
915 const initialConfig = useMemo<InitialConfigType>(1145 const initialConfig = useMemo<InitialConfigType>(
916 () => ({1146 () => ({
917 namespace: "t3tools-composer-editor",1147 namespace: "t3tools-composer-editor",
918 editable: true,1148 editable: true,
919 nodes: [ComposerMentionNode],1149 nodes: [ComposerMentionNode, ComposerTerminalContextNode],
920 editorState: () => {1150 editorState: () => {
921 $setComposerEditorPrompt(initialValueRef.current);1151 $setComposerEditorPrompt(initialValueRef.current, initialTerminalContextsRef.current);
922 },1152 },
923 onError: (error) => {1153 onError: (error) => {
924 throw error;1154 throw error;
...@@ -932,8 +1162,10 @@ export const ComposerPromptEditor = forwardRef<...@@ -932,8 +1162,10 @@ export const ComposerPromptEditor = forwardRef<
932 <ComposerPromptEditorInner1162 <ComposerPromptEditorInner
933 value={value}1163 value={value}
934 cursor={cursor}1164 cursor={cursor}
1165 terminalContexts={terminalContexts}
935 disabled={disabled}1166 disabled={disabled}
936 placeholder={placeholder}1167 placeholder={placeholder}
1168 onRemoveTerminalContext={onRemoveTerminalContext}
937 onChange={onChange}1169 onChange={onChange}
938 onPaste={onPaste}1170 onPaste={onPaste}
939 editorRef={ref}1171 editorRef={ref}
apps/web/src/components/ThreadTerminalDrawer.test.ts created+75
...@@ -0,0 +1,75 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 resolveTerminalSelectionActionPosition,
5 shouldHandleTerminalSelectionMouseUp,
6 terminalSelectionActionDelayForClickCount,
7} from "./ThreadTerminalDrawer";
8
9describe("resolveTerminalSelectionActionPosition", () => {
10 it("prefers the selection rect over the last pointer position", () => {
11 expect(
12 resolveTerminalSelectionActionPosition({
13 bounds: { left: 100, top: 50, width: 500, height: 220 },
14 selectionRect: { right: 260, bottom: 140 },
15 pointer: { x: 520, y: 200 },
16 viewport: { width: 1024, height: 768 },
17 }),
18 ).toEqual({
19 x: 260,
20 y: 144,
21 });
22 });
23
24 it("falls back to the pointer position when no selection rect is available", () => {
25 expect(
26 resolveTerminalSelectionActionPosition({
27 bounds: { left: 100, top: 50, width: 500, height: 220 },
28 selectionRect: null,
29 pointer: { x: 180, y: 130 },
30 viewport: { width: 1024, height: 768 },
31 }),
32 ).toEqual({
33 x: 180,
34 y: 130,
35 });
36 });
37
38 it("clamps the pointer fallback into the terminal drawer bounds", () => {
39 expect(
40 resolveTerminalSelectionActionPosition({
41 bounds: { left: 100, top: 50, width: 500, height: 220 },
42 selectionRect: null,
43 pointer: { x: 720, y: 340 },
44 viewport: { width: 1024, height: 768 },
45 }),
46 ).toEqual({
47 x: 600,
48 y: 270,
49 });
50
51 expect(
52 resolveTerminalSelectionActionPosition({
53 bounds: { left: 100, top: 50, width: 500, height: 220 },
54 selectionRect: null,
55 pointer: { x: 40, y: 20 },
56 viewport: { width: 1024, height: 768 },
57 }),
58 ).toEqual({
59 x: 100,
60 y: 50,
61 });
62 });
63
64 it("delays multi-click selection actions so triple-click selection can complete", () => {
65 expect(terminalSelectionActionDelayForClickCount(1)).toBe(0);
66 expect(terminalSelectionActionDelayForClickCount(2)).toBe(260);
67 expect(terminalSelectionActionDelayForClickCount(3)).toBe(260);
68 });
69
70 it("only handles mouseup when the selection gesture started in the terminal", () => {
71 expect(shouldHandleTerminalSelectionMouseUp(true, 0)).toBe(true);
72 expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false);
73 expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false);
74 });
75});
apps/web/src/components/ThreadTerminalDrawer.tsx+217-1
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 useState,12 useState,
13} from "react";13} from "react";
14import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";14import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
15import { type TerminalContextSelection } from "~/lib/terminalContext";
15import { openInPreferredEditor } from "../editorPreferences";16import { openInPreferredEditor } from "../editorPreferences";
16import {17import {
17 extractTerminalLinks,18 extractTerminalLinks,
...@@ -29,6 +30,7 @@ import { readNativeApi } from "~/nativeApi";...@@ -29,6 +30,7 @@ import { readNativeApi } from "~/nativeApi";
2930
30const MIN_DRAWER_HEIGHT = 180;31const MIN_DRAWER_HEIGHT = 180;
31const MAX_DRAWER_HEIGHT_RATIO = 0.75;32const MAX_DRAWER_HEIGHT_RATIO = 0.75;
33const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260;
3234
33function maxDrawerHeight(): number {35function maxDrawerHeight(): number {
34 if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT;36 if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT;
...@@ -107,12 +109,85 @@ function terminalThemeFromApp(): ITheme {...@@ -107,12 +109,85 @@ function terminalThemeFromApp(): ITheme {
107 };109 };
108}110}
109111
112function getTerminalSelectionRect(mountElement: HTMLElement): DOMRect | null {
113 const selection = window.getSelection();
114 if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
115 return null;
116 }
117
118 const range = selection.getRangeAt(0);
119 const commonAncestor = range.commonAncestorContainer;
120 const selectionRoot =
121 commonAncestor instanceof Element ? commonAncestor : commonAncestor.parentElement;
122 if (!(selectionRoot instanceof Element) || !mountElement.contains(selectionRoot)) {
123 return null;
124 }
125
126 const rects = Array.from(range.getClientRects()).filter(
127 (rect) => rect.width > 0 || rect.height > 0,
128 );
129 if (rects.length > 0) {
130 return rects[rects.length - 1] ?? null;
131 }
132
133 const boundingRect = range.getBoundingClientRect();
134 return boundingRect.width > 0 || boundingRect.height > 0 ? boundingRect : null;
135}
136
137export function resolveTerminalSelectionActionPosition(options: {
138 bounds: { left: number; top: number; width: number; height: number };
139 selectionRect: { right: number; bottom: number } | null;
140 pointer: { x: number; y: number } | null;
141 viewport?: { width: number; height: number } | null;
142}): { x: number; y: number } {
143 const { bounds, selectionRect, pointer, viewport } = options;
144 const viewportWidth =
145 viewport?.width ??
146 (typeof window === "undefined" ? bounds.left + bounds.width + 8 : window.innerWidth);
147 const viewportHeight =
148 viewport?.height ??
149 (typeof window === "undefined" ? bounds.top + bounds.height + 8 : window.innerHeight);
150 const drawerLeft = Math.round(bounds.left);
151 const drawerTop = Math.round(bounds.top);
152 const drawerRight = Math.round(bounds.left + bounds.width);
153 const drawerBottom = Math.round(bounds.top + bounds.height);
154 const preferredX =
155 selectionRect !== null
156 ? Math.round(selectionRect.right)
157 : pointer === null
158 ? Math.round(bounds.left + bounds.width - 140)
159 : Math.max(drawerLeft, Math.min(Math.round(pointer.x), drawerRight));
160 const preferredY =
161 selectionRect !== null
162 ? Math.round(selectionRect.bottom + 4)
163 : pointer === null
164 ? Math.round(bounds.top + 12)
165 : Math.max(drawerTop, Math.min(Math.round(pointer.y), drawerBottom));
166 return {
167 x: Math.max(8, Math.min(preferredX, Math.max(viewportWidth - 8, 8))),
168 y: Math.max(8, Math.min(preferredY, Math.max(viewportHeight - 8, 8))),
169 };
170}
171
172export function terminalSelectionActionDelayForClickCount(clickCount: number): number {
173 return clickCount >= 2 ? MULTI_CLICK_SELECTION_ACTION_DELAY_MS : 0;
174}
175
176export function shouldHandleTerminalSelectionMouseUp(
177 selectionGestureActive: boolean,
178 button: number,
179): boolean {
180 return selectionGestureActive && button === 0;
181}
182
110interface TerminalViewportProps {183interface TerminalViewportProps {
111 threadId: ThreadId;184 threadId: ThreadId;
112 terminalId: string;185 terminalId: string;
186 terminalLabel: string;
113 cwd: string;187 cwd: string;
114 runtimeEnv?: Record<string, string>;188 runtimeEnv?: Record<string, string>;
115 onSessionExited: () => void;189 onSessionExited: () => void;
190 onAddTerminalContext: (selection: TerminalContextSelection) => void;
116 focusRequestId: number;191 focusRequestId: number;
117 autoFocus: boolean;192 autoFocus: boolean;
118 resizeEpoch: number;193 resizeEpoch: number;
...@@ -122,9 +197,11 @@ interface TerminalViewportProps {...@@ -122,9 +197,11 @@ interface TerminalViewportProps {
122function TerminalViewport({197function TerminalViewport({
123 threadId,198 threadId,
124 terminalId,199 terminalId,
200 terminalLabel,
125 cwd,201 cwd,
126 runtimeEnv,202 runtimeEnv,
127 onSessionExited,203 onSessionExited,
204 onAddTerminalContext,
128 focusRequestId,205 focusRequestId,
129 autoFocus,206 autoFocus,
130 resizeEpoch,207 resizeEpoch,
...@@ -134,12 +211,27 @@ function TerminalViewport({...@@ -134,12 +211,27 @@ function TerminalViewport({
134 const terminalRef = useRef<Terminal | null>(null);211 const terminalRef = useRef<Terminal | null>(null);
135 const fitAddonRef = useRef<FitAddon | null>(null);212 const fitAddonRef = useRef<FitAddon | null>(null);
136 const onSessionExitedRef = useRef(onSessionExited);213 const onSessionExitedRef = useRef(onSessionExited);
214 const onAddTerminalContextRef = useRef(onAddTerminalContext);
215 const terminalLabelRef = useRef(terminalLabel);
137 const hasHandledExitRef = useRef(false);216 const hasHandledExitRef = useRef(false);
217 const selectionPointerRef = useRef<{ x: number; y: number } | null>(null);
218 const selectionGestureActiveRef = useRef(false);
219 const selectionActionRequestIdRef = useRef(0);
220 const selectionActionOpenRef = useRef(false);
221 const selectionActionTimerRef = useRef<number | null>(null);
138222
139 useEffect(() => {223 useEffect(() => {
140 onSessionExitedRef.current = onSessionExited;224 onSessionExitedRef.current = onSessionExited;
141 }, [onSessionExited]);225 }, [onSessionExited]);
142226
227 useEffect(() => {
228 onAddTerminalContextRef.current = onAddTerminalContext;
229 }, [onAddTerminalContext]);
230
231 useEffect(() => {
232 terminalLabelRef.current = terminalLabel;
233 }, [terminalLabel]);
234
143 useEffect(() => {235 useEffect(() => {
144 const mount = containerRef.current;236 const mount = containerRef.current;
145 if (!mount) return;237 if (!mount) return;
...@@ -165,6 +257,81 @@ function TerminalViewport({...@@ -165,6 +257,81 @@ function TerminalViewport({
165 const api = readNativeApi();257 const api = readNativeApi();
166 if (!api) return;258 if (!api) return;
167259
260 const clearSelectionAction = () => {
261 selectionActionRequestIdRef.current += 1;
262 if (selectionActionTimerRef.current !== null) {
263 window.clearTimeout(selectionActionTimerRef.current);
264 selectionActionTimerRef.current = null;
265 }
266 };
267
268 const readSelectionAction = (): {
269 position: { x: number; y: number };
270 selection: TerminalContextSelection;
271 } | null => {
272 const activeTerminal = terminalRef.current;
273 const mountElement = containerRef.current;
274 if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) {
275 return null;
276 }
277 const selectionText = activeTerminal.getSelection();
278 const selectionPosition = activeTerminal.getSelectionPosition();
279 const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, "");
280 if (!selectionPosition || normalizedText.length === 0) {
281 return null;
282 }
283 const lineStart = selectionPosition.start.y + 1;
284 const lineCount = normalizedText.split("\n").length;
285 const lineEnd = Math.max(lineStart, lineStart + lineCount - 1);
286 const bounds = mountElement.getBoundingClientRect();
287 const selectionRect = getTerminalSelectionRect(mountElement);
288 const position = resolveTerminalSelectionActionPosition({
289 bounds,
290 selectionRect:
291 selectionRect === null
292 ? null
293 : { right: selectionRect.right, bottom: selectionRect.bottom },
294 pointer: selectionPointerRef.current,
295 });
296 return {
297 position,
298 selection: {
299 terminalId,
300 terminalLabel: terminalLabelRef.current,
301 lineStart,
302 lineEnd,
303 text: normalizedText,
304 },
305 };
306 };
307
308 const showSelectionAction = async () => {
309 if (selectionActionOpenRef.current) {
310 return;
311 }
312 const nextAction = readSelectionAction();
313 if (!nextAction) {
314 clearSelectionAction();
315 return;
316 }
317 const requestId = ++selectionActionRequestIdRef.current;
318 selectionActionOpenRef.current = true;
319 try {
320 const clicked = await api.contextMenu.show(
321 [{ id: "add-to-chat", label: "Add to chat" }],
322 nextAction.position,
323 );
324 if (requestId !== selectionActionRequestIdRef.current || clicked !== "add-to-chat") {
325 return;
326 }
327 onAddTerminalContextRef.current(nextAction.selection);
328 terminalRef.current?.clearSelection();
329 terminalRef.current?.focus();
330 } finally {
331 selectionActionOpenRef.current = false;
332 }
333 };
334
168 const sendTerminalInput = async (data: string, fallbackError: string) => {335 const sendTerminalInput = async (data: string, fallbackError: string) => {
169 const activeTerminal = terminalRef.current;336 const activeTerminal = terminalRef.current;
170 if (!activeTerminal) return;337 if (!activeTerminal) return;
...@@ -259,6 +426,38 @@ function TerminalViewport({...@@ -259,6 +426,38 @@ function TerminalViewport({
259 );426 );
260 });427 });
261428
429 const selectionDisposable = terminal.onSelectionChange(() => {
430 if (terminalRef.current?.hasSelection()) {
431 return;
432 }
433 clearSelectionAction();
434 });
435
436 const handleMouseUp = (event: MouseEvent) => {
437 const shouldHandle = shouldHandleTerminalSelectionMouseUp(
438 selectionGestureActiveRef.current,
439 event.button,
440 );
441 selectionGestureActiveRef.current = false;
442 if (!shouldHandle) {
443 return;
444 }
445 selectionPointerRef.current = { x: event.clientX, y: event.clientY };
446 const delay = terminalSelectionActionDelayForClickCount(event.detail);
447 selectionActionTimerRef.current = window.setTimeout(() => {
448 selectionActionTimerRef.current = null;
449 window.requestAnimationFrame(() => {
450 void showSelectionAction();
451 });
452 }, delay);
453 };
454 const handlePointerDown = (event: PointerEvent) => {
455 clearSelectionAction();
456 selectionGestureActiveRef.current = event.button === 0;
457 };
458 window.addEventListener("mouseup", handleMouseUp);
459 mount.addEventListener("pointerdown", handlePointerDown);
460
262 const themeObserver = new MutationObserver(() => {461 const themeObserver = new MutationObserver(() => {
263 const activeTerminal = terminalRef.current;462 const activeTerminal = terminalRef.current;
264 if (!activeTerminal) return;463 if (!activeTerminal) return;
...@@ -310,11 +509,13 @@ function TerminalViewport({...@@ -310,11 +509,13 @@ function TerminalViewport({
310509
311 if (event.type === "output") {510 if (event.type === "output") {
312 activeTerminal.write(event.data);511 activeTerminal.write(event.data);
512 clearSelectionAction();
313 return;513 return;
314 }514 }
315515
316 if (event.type === "started" || event.type === "restarted") {516 if (event.type === "started" || event.type === "restarted") {
317 hasHandledExitRef.current = false;517 hasHandledExitRef.current = false;
518 clearSelectionAction();
318 activeTerminal.write("\u001bc");519 activeTerminal.write("\u001bc");
319 if (event.snapshot.history.length > 0) {520 if (event.snapshot.history.length > 0) {
320 activeTerminal.write(event.snapshot.history);521 activeTerminal.write(event.snapshot.history);
...@@ -323,6 +524,7 @@ function TerminalViewport({...@@ -323,6 +524,7 @@ function TerminalViewport({
323 }524 }
324525
325 if (event.type === "cleared") {526 if (event.type === "cleared") {
527 clearSelectionAction();
326 activeTerminal.clear();528 activeTerminal.clear();
327 activeTerminal.write("\u001bc");529 activeTerminal.write("\u001bc");
328 return;530 return;
...@@ -383,7 +585,13 @@ function TerminalViewport({...@@ -383,7 +585,13 @@ function TerminalViewport({
383 window.clearTimeout(fitTimer);585 window.clearTimeout(fitTimer);
384 unsubscribe();586 unsubscribe();
385 inputDisposable.dispose();587 inputDisposable.dispose();
588 selectionDisposable.dispose();
386 terminalLinksDisposable.dispose();589 terminalLinksDisposable.dispose();
590 if (selectionActionTimerRef.current !== null) {
591 window.clearTimeout(selectionActionTimerRef.current);
592 }
593 window.removeEventListener("mouseup", handleMouseUp);
594 mount.removeEventListener("pointerdown", handlePointerDown);
387 themeObserver.disconnect();595 themeObserver.disconnect();
388 terminalRef.current = null;596 terminalRef.current = null;
389 fitAddonRef.current = null;597 fitAddonRef.current = null;
...@@ -430,7 +638,9 @@ function TerminalViewport({...@@ -430,7 +638,9 @@ function TerminalViewport({
430 window.cancelAnimationFrame(frame);638 window.cancelAnimationFrame(frame);
431 };639 };
432 }, [drawerHeight, resizeEpoch, terminalId, threadId]);640 }, [drawerHeight, resizeEpoch, terminalId, threadId]);
433 return <div ref={containerRef} className="h-full w-full overflow-hidden rounded-[4px]" />;641 return (
642 <div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-[4px]" />
643 );
434}644}
435645
436interface ThreadTerminalDrawerProps {646interface ThreadTerminalDrawerProps {
...@@ -451,6 +661,7 @@ interface ThreadTerminalDrawerProps {...@@ -451,6 +661,7 @@ interface ThreadTerminalDrawerProps {
451 onActiveTerminalChange: (terminalId: string) => void;661 onActiveTerminalChange: (terminalId: string) => void;
452 onCloseTerminal: (terminalId: string) => void;662 onCloseTerminal: (terminalId: string) => void;
453 onHeightChange: (height: number) => void;663 onHeightChange: (height: number) => void;
664 onAddTerminalContext: (selection: TerminalContextSelection) => void;
454}665}
455666
456interface TerminalActionButtonProps {667interface TerminalActionButtonProps {
...@@ -500,6 +711,7 @@ export default function ThreadTerminalDrawer({...@@ -500,6 +711,7 @@ export default function ThreadTerminalDrawer({
500 onActiveTerminalChange,711 onActiveTerminalChange,
501 onCloseTerminal,712 onCloseTerminal,
502 onHeightChange,713 onHeightChange,
714 onAddTerminalContext,
503}: ThreadTerminalDrawerProps) {715}: ThreadTerminalDrawerProps) {
504 const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));716 const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));
505 const [resizeEpoch, setResizeEpoch] = useState(0);717 const [resizeEpoch, setResizeEpoch] = useState(0);
...@@ -796,9 +1008,11 @@ export default function ThreadTerminalDrawer({...@@ -796,9 +1008,11 @@ export default function ThreadTerminalDrawer({
796 <TerminalViewport1008 <TerminalViewport
797 threadId={threadId}1009 threadId={threadId}
798 terminalId={terminalId}1010 terminalId={terminalId}
1011 terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}
799 cwd={cwd}1012 cwd={cwd}
800 {...(runtimeEnv ? { runtimeEnv } : {})}1013 {...(runtimeEnv ? { runtimeEnv } : {})}
801 onSessionExited={() => onCloseTerminal(terminalId)}1014 onSessionExited={() => onCloseTerminal(terminalId)}
1015 onAddTerminalContext={onAddTerminalContext}
802 focusRequestId={focusRequestId}1016 focusRequestId={focusRequestId}
803 autoFocus={terminalId === resolvedActiveTerminalId}1017 autoFocus={terminalId === resolvedActiveTerminalId}
804 resizeEpoch={resizeEpoch}1018 resizeEpoch={resizeEpoch}
...@@ -814,9 +1028,11 @@ export default function ThreadTerminalDrawer({...@@ -814,9 +1028,11 @@ export default function ThreadTerminalDrawer({
814 key={resolvedActiveTerminalId}1028 key={resolvedActiveTerminalId}
815 threadId={threadId}1029 threadId={threadId}
816 terminalId={resolvedActiveTerminalId}1030 terminalId={resolvedActiveTerminalId}
1031 terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"}
817 cwd={cwd}1032 cwd={cwd}
818 {...(runtimeEnv ? { runtimeEnv } : {})}1033 {...(runtimeEnv ? { runtimeEnv } : {})}
819 onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}1034 onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}
1035 onAddTerminalContext={onAddTerminalContext}
820 focusRequestId={focusRequestId}1036 focusRequestId={focusRequestId}
821 autoFocus1037 autoFocus
822 resizeEpoch={resizeEpoch}1038 resizeEpoch={resizeEpoch}
apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx created+28
...@@ -0,0 +1,28 @@
1import { ThreadId } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it } from "vitest";
4
5import { ComposerPendingTerminalContextChip } from "./ComposerPendingTerminalContexts";
6
7describe("ComposerPendingTerminalContextChip", () => {
8 it("renders expired terminal contexts with error styling", () => {
9 const markup = renderToStaticMarkup(
10 <ComposerPendingTerminalContextChip
11 context={{
12 id: "ctx-expired",
13 threadId: ThreadId.makeUnsafe("thread-1"),
14 terminalId: "default",
15 terminalLabel: "Terminal 1",
16 lineStart: 2,
17 lineEnd: 4,
18 text: "",
19 createdAt: "2026-03-17T18:42:05.449Z",
20 }}
21 />,
22 );
23
24 expect(markup).toContain('data-terminal-context-expired="true"');
25 expect(markup).toContain("border-destructive/35");
26 expect(markup).toContain("Terminal 1 lines 2-4");
27 });
28});
apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx created+44
...@@ -0,0 +1,44 @@
1import { cn } from "~/lib/utils";
2import {
3 type TerminalContextDraft,
4 formatTerminalContextLabel,
5 isTerminalContextExpired,
6} from "~/lib/terminalContext";
7import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
8
9interface ComposerPendingTerminalContextsProps {
10 contexts: ReadonlyArray<TerminalContextDraft>;
11 className?: string;
12}
13
14interface ComposerPendingTerminalContextChipProps {
15 context: TerminalContextDraft;
16}
17
18export function ComposerPendingTerminalContextChip({
19 context,
20}: ComposerPendingTerminalContextChipProps) {
21 const label = formatTerminalContextLabel(context);
22 const expired = isTerminalContextExpired(context);
23 const tooltipText = expired
24 ? `Terminal context expired. Remove and re-add ${label} to include it in your message.`
25 : context.text;
26
27 return <TerminalContextInlineChip label={label} tooltipText={tooltipText} expired={expired} />;
28}
29
30export function ComposerPendingTerminalContexts(props: ComposerPendingTerminalContextsProps) {
31 const { contexts, className } = props;
32
33 if (contexts.length === 0) {
34 return null;
35 }
36
37 return (
38 <div className={cn("flex flex-wrap gap-1.5", className)}>
39 {contexts.map((context) => (
40 <ComposerPendingTerminalContextChip key={context.id} context={context} />
41 ))}
42 </div>
43 );
44}
apps/web/src/components/chat/MessagesTimeline.test.tsx created+99
...@@ -0,0 +1,99 @@
1import { MessageId } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";
3import { beforeAll, describe, expect, it, vi } from "vitest";
4
5function matchMedia() {
6 return {
7 matches: false,
8 addEventListener: () => {},
9 removeEventListener: () => {},
10 };
11}
12
13beforeAll(() => {
14 const classList = {
15 add: () => {},
16 remove: () => {},
17 toggle: () => {},
18 contains: () => false,
19 };
20
21 vi.stubGlobal("localStorage", {
22 getItem: () => null,
23 setItem: () => {},
24 removeItem: () => {},
25 clear: () => {},
26 });
27 vi.stubGlobal("window", {
28 matchMedia,
29 addEventListener: () => {},
30 removeEventListener: () => {},
31 desktopBridge: undefined,
32 });
33 vi.stubGlobal("document", {
34 documentElement: {
35 classList,
36 offsetHeight: 0,
37 },
38 });
39 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
40 callback(0);
41 return 0;
42 });
43});
44
45describe("MessagesTimeline", () => {
46 it("renders inline terminal labels with the composer chip UI", async () => {
47 const { MessagesTimeline } = await import("./MessagesTimeline");
48 const markup = renderToStaticMarkup(
49 <MessagesTimeline
50 hasMessages
51 isWorking={false}
52 activeTurnInProgress={false}
53 activeTurnStartedAt={null}
54 scrollContainer={null}
55 timelineEntries={[
56 {
57 id: "entry-1",
58 kind: "message",
59 createdAt: "2026-03-17T19:12:28.000Z",
60 message: {
61 id: MessageId.makeUnsafe("message-2"),
62 role: "user",
63 text: [
64 "yoo what's @terminal-1:1-5 mean",
65 "",
66 "<terminal_context>",
67 "- Terminal 1 lines 1-5:",
68 " 1 | julius@mac effect-http-ws-cli % bun i",
69 " 2 | bun install v1.3.9 (cf6cdbbb)",
70 "</terminal_context>",
71 ].join("\n"),
72 createdAt: "2026-03-17T19:12:28.000Z",
73 streaming: false,
74 },
75 },
76 ]}
77 completionDividerBeforeEntryId={null}
78 completionSummary={null}
79 turnDiffSummaryByAssistantMessageId={new Map()}
80 nowIso="2026-03-17T19:12:30.000Z"
81 expandedWorkGroups={{}}
82 onToggleWorkGroup={() => {}}
83 onOpenTurnDiff={() => {}}
84 revertTurnCountByUserMessageId={new Map()}
85 onRevertUserMessage={() => {}}
86 isRevertingCheckpoint={false}
87 onImageExpand={() => {}}
88 markdownCwd={undefined}
89 resolvedTheme="light"
90 timestampFormat="locale"
91 workspaceRoot={undefined}
92 />,
93 );
94
95 expect(markup).toContain("Terminal 1 lines 1-5");
96 expect(markup).toContain("lucide-terminal");
97 expect(markup).toContain("yoo what&#x27;s ");
98 });
99});
apps/web/src/components/chat/MessagesTimeline.tsx+135-6
...@@ -1,5 +1,14 @@...@@ -1,5 +1,14 @@
1import { type MessageId, type TurnId } from "@t3tools/contracts";1import { type MessageId, type TurnId } from "@t3tools/contracts";
2import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";2import {
3 memo,
4 useCallback,
5 useEffect,
6 useLayoutEffect,
7 useMemo,
8 useRef,
9 useState,
10 type ReactNode,
11} from "react";
3import {12import {
4 measureElement as measureVirtualElement,13 measureElement as measureVirtualElement,
5 type VirtualItem,14 type VirtualItem,
...@@ -33,9 +42,19 @@ import { ChangedFilesTree } from "./ChangedFilesTree";...@@ -33,9 +42,19 @@ import { ChangedFilesTree } from "./ChangedFilesTree";
33import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";42import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
34import { MessageCopyButton } from "./MessageCopyButton";43import { MessageCopyButton } from "./MessageCopyButton";
35import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic";44import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic";
45import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
46import {
47 deriveDisplayedUserMessageState,
48 type ParsedTerminalContextEntry,
49} from "~/lib/terminalContext";
36import { cn } from "~/lib/utils";50import { cn } from "~/lib/utils";
37import { type TimestampFormat } from "../../appSettings";51import { type TimestampFormat } from "../../appSettings";
38import { formatTimestamp } from "../../timestampFormat";52import { formatTimestamp } from "../../timestampFormat";
53import {
54 buildInlineTerminalContextText,
55 formatInlineTerminalContextLabel,
56 textContainsInlineTerminalContextLabels,
57} from "./userMessageTerminalContexts";
3958
40const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;59const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
41const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;60const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;
...@@ -337,6 +356,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -337,6 +356,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
337 row.message.role === "user" &&356 row.message.role === "user" &&
338 (() => {357 (() => {
339 const userImages = row.message.attachments ?? [];358 const userImages = row.message.attachments ?? [];
359 const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text);
360 const terminalContexts = displayedUserMessage.contexts;
340 const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id);361 const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id);
341 return (362 return (
342 <div className="flex justify-end">363 <div className="flex justify-end">
...@@ -378,14 +399,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -378,14 +399,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({
378 )}399 )}
379 </div>400 </div>
380 )}401 )}
381 {row.message.text && (402 {(displayedUserMessage.visibleText.trim().length > 0 ||
382 <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground">403 terminalContexts.length > 0) && (
383 {row.message.text}404 <UserMessageBody
384 </pre>405 text={displayedUserMessage.visibleText}
406 terminalContexts={terminalContexts}
407 />
385 )}408 )}
386 <div className="mt-1.5 flex items-center justify-end gap-2">409 <div className="mt-1.5 flex items-center justify-end gap-2">
387 <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">410 <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">
388 {row.message.text && <MessageCopyButton text={row.message.text} />}411 {displayedUserMessage.copyText && (
412 <MessageCopyButton text={displayedUserMessage.copyText} />
413 )}
389 {canRevertAgentWork && (414 {canRevertAgentWork && (
390 <Button415 <Button
391 type="button"416 type="button"
...@@ -636,6 +661,110 @@ function formatMessageMeta(...@@ -636,6 +661,110 @@ function formatMessageMeta(
636 return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`;661 return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`;
637}662}
638663
664const UserMessageTerminalContextInlineLabel = memo(
665 function UserMessageTerminalContextInlineLabel(props: { context: ParsedTerminalContextEntry }) {
666 const tooltipText =
667 props.context.body.length > 0
668 ? `${props.context.header}\n${props.context.body}`
669 : props.context.header;
670
671 return <TerminalContextInlineChip label={props.context.header} tooltipText={tooltipText} />;
672 },
673);
674
675const UserMessageBody = memo(function UserMessageBody(props: {
676 text: string;
677 terminalContexts: ParsedTerminalContextEntry[];
678}) {
679 if (props.terminalContexts.length > 0) {
680 const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels(
681 props.text,
682 props.terminalContexts,
683 );
684 const inlinePrefix = buildInlineTerminalContextText(props.terminalContexts);
685 const inlineNodes: ReactNode[] = [];
686
687 if (hasEmbeddedInlineLabels) {
688 let cursor = 0;
689
690 for (const context of props.terminalContexts) {
691 const label = formatInlineTerminalContextLabel(context.header);
692 const matchIndex = props.text.indexOf(label, cursor);
693 if (matchIndex === -1) {
694 inlineNodes.length = 0;
695 break;
696 }
697 if (matchIndex > cursor) {
698 inlineNodes.push(
699 <span key={`user-terminal-context-inline-before:${context.header}:${cursor}`}>
700 {props.text.slice(cursor, matchIndex)}
701 </span>,
702 );
703 }
704 inlineNodes.push(
705 <UserMessageTerminalContextInlineLabel
706 key={`user-terminal-context-inline:${context.header}`}
707 context={context}
708 />,
709 );
710 cursor = matchIndex + label.length;
711 }
712
713 if (inlineNodes.length > 0) {
714 if (cursor < props.text.length) {
715 inlineNodes.push(
716 <span key={`user-message-terminal-context-inline-rest:${cursor}`}>
717 {props.text.slice(cursor)}
718 </span>,
719 );
720 }
721
722 return (
723 <div className="wrap-break-word whitespace-pre-wrap font-mono text-sm leading-relaxed text-foreground">
724 {inlineNodes}
725 </div>
726 );
727 }
728 }
729
730 for (const context of props.terminalContexts) {
731 inlineNodes.push(
732 <UserMessageTerminalContextInlineLabel
733 key={`user-terminal-context-inline:${context.header}`}
734 context={context}
735 />,
736 );
737 inlineNodes.push(
738 <span key={`user-terminal-context-inline-space:${context.header}`} aria-hidden="true">
739 {" "}
740 </span>,
741 );
742 }
743
744 if (props.text.length > 0) {
745 inlineNodes.push(<span key="user-message-terminal-context-inline-text">{props.text}</span>);
746 } else if (inlinePrefix.length === 0) {
747 return null;
748 }
749
750 return (
751 <div className="wrap-break-word whitespace-pre-wrap font-mono text-sm leading-relaxed text-foreground">
752 {inlineNodes}
753 </div>
754 );
755 }
756
757 if (props.text.length === 0) {
758 return null;
759 }
760
761 return (
762 <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground">
763 {props.text}
764 </pre>
765 );
766});
767
639function workToneIcon(tone: TimelineWorkEntry["tone"]): {768function workToneIcon(tone: TimelineWorkEntry["tone"]): {
640 icon: LucideIcon;769 icon: LucideIcon;
641 className: string;770 className: string;
apps/web/src/components/chat/TerminalContextInlineChip.tsx created+47
...@@ -0,0 +1,47 @@
1import { TerminalIcon } from "lucide-react";
2
3import { cn } from "~/lib/utils";
4import {
5 COMPOSER_INLINE_CHIP_CLASS_NAME,
6 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
7 COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
8} from "../composerInlineChip";
9import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
10
11interface TerminalContextInlineChipProps {
12 label: string;
13 tooltipText: string;
14 expired?: boolean;
15}
16
17export function TerminalContextInlineChip(props: TerminalContextInlineChipProps) {
18 const { label, tooltipText, expired = false } = props;
19
20 return (
21 <Tooltip>
22 <TooltipTrigger
23 render={
24 <span
25 className={cn(
26 COMPOSER_INLINE_CHIP_CLASS_NAME,
27 expired && "border-destructive/35 bg-destructive/8 text-destructive",
28 )}
29 data-terminal-context-expired={expired ? "true" : undefined}
30 >
31 <TerminalIcon
32 className={cn(
33 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
34 "size-3.5",
35 expired && "opacity-100",
36 )}
37 />
38 <span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}>{label}</span>
39 </span>
40 }
41 />
42 <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight">
43 {tooltipText}
44 </TooltipPopup>
45 </Tooltip>
46 );
47}
apps/web/src/components/chat/userMessageTerminalContexts.test.ts created+36
...@@ -0,0 +1,36 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 buildInlineTerminalContextText,
5 formatInlineTerminalContextLabel,
6 textContainsInlineTerminalContextLabels,
7} from "./userMessageTerminalContexts";
8
9describe("userMessageTerminalContexts", () => {
10 it("builds plain inline terminal text labels", () => {
11 expect(
12 buildInlineTerminalContextText([
13 { header: "Terminal 1 lines 12-13" },
14 { header: "Terminal 2 line 4" },
15 ]),
16 ).toBe("@terminal-1:12-13 @terminal-2:4");
17 });
18
19 it("formats individual inline terminal labels compactly", () => {
20 expect(formatInlineTerminalContextLabel("Terminal 1 lines 12-13")).toBe("@terminal-1:12-13");
21 expect(formatInlineTerminalContextLabel("Terminal 2 line 4")).toBe("@terminal-2:4");
22 });
23
24 it("detects inline terminal labels embedded in user message text", () => {
25 expect(
26 textContainsInlineTerminalContextLabels("yo @terminal-1:12-13 whats up", [
27 { header: "Terminal 1 lines 12-13" },
28 ]),
29 ).toBe(true);
30 expect(
31 textContainsInlineTerminalContextLabels("yo whats up", [
32 { header: "Terminal 1 lines 12-13" },
33 ]),
34 ).toBe(false);
35 });
36});
apps/web/src/components/chat/userMessageTerminalContexts.ts created+55
...@@ -0,0 +1,55 @@
1import { formatInlineTerminalContextLabel as formatInlineTerminalContextSelectionLabel } from "~/lib/terminalContext";
2
3const TERMINAL_CONTEXT_HEADER_PATTERN = /^(.*?)\s+line(?:s)?\s+(\d+)(?:-(\d+))?$/i;
4
5export function buildInlineTerminalContextText(
6 contexts: ReadonlyArray<{
7 header: string;
8 }>,
9): string {
10 return contexts
11 .map((context) => context.header.trim())
12 .filter((header) => header.length > 0)
13 .map(formatInlineTerminalContextLabel)
14 .join(" ");
15}
16
17export function formatInlineTerminalContextLabel(header: string): string {
18 const trimmedHeader = header.trim();
19 const match = TERMINAL_CONTEXT_HEADER_PATTERN.exec(trimmedHeader);
20 if (!match) {
21 return `@${trimmedHeader.toLowerCase().replace(/\s+/g, "-")}`;
22 }
23
24 const lineStart = Number.parseInt(match[2] ?? "", 10);
25 const lineEnd = Number.parseInt(match[3] ?? match[2] ?? "", 10);
26 if (!Number.isFinite(lineStart) || !Number.isFinite(lineEnd)) {
27 return `@${trimmedHeader.toLowerCase().replace(/\s+/g, "-")}`;
28 }
29
30 return formatInlineTerminalContextSelectionLabel({
31 terminalLabel: match[1]?.trim() || "terminal",
32 lineStart,
33 lineEnd,
34 });
35}
36
37export function textContainsInlineTerminalContextLabels(
38 text: string,
39 contexts: ReadonlyArray<{
40 header: string;
41 }>,
42): boolean {
43 let searchStartIndex = 0;
44
45 for (const context of contexts) {
46 const label = formatInlineTerminalContextLabel(context.header);
47 const matchIndex = text.indexOf(label, searchStartIndex);
48 if (matchIndex === -1) {
49 return false;
50 }
51 searchStartIndex = matchIndex + label.length;
52 }
53
54 return true;
55}
apps/web/src/components/composerInlineChip.ts created+9
...@@ -0,0 +1,9 @@
1export const COMPOSER_INLINE_CHIP_CLASS_NAME =
2 "inline-flex max-w-full select-none items-center gap-1 rounded-md border border-border/70 bg-accent/40 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-foreground align-middle";
3
4export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-3.5 shrink-0 opacity-85";
5
6export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = "truncate select-none leading-tight";
7
8export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME =
9 "ml-0.5 inline-flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/72 transition-colors hover:bg-foreground/6 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
apps/web/src/components/timelineHeight.test.ts+31
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import { appendTerminalContextsToPrompt } from "../lib/terminalContext";
4import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts";
3import { estimateTimelineMessageHeight } from "./timelineHeight";5import { estimateTimelineMessageHeight } from "./timelineHeight";
46
5describe("estimateTimelineMessageHeight", () => {7describe("estimateTimelineMessageHeight", () => {
...@@ -75,6 +77,35 @@ describe("estimateTimelineMessageHeight", () => {...@@ -75,6 +77,35 @@ describe("estimateTimelineMessageHeight", () => {
75 ).toBe(162);77 ).toBe(162);
76 });78 });
7779
80 it("adds terminal context chrome without counting the hidden block as message text", () => {
81 const prompt = appendTerminalContextsToPrompt("Investigate this", [
82 {
83 terminalId: "default",
84 terminalLabel: "Terminal 1",
85 lineStart: 40,
86 lineEnd: 43,
87 text: [
88 "git status",
89 "M apps/web/src/components/chat/MessagesTimeline.tsx",
90 "?? tmp",
91 "",
92 ].join("\n"),
93 },
94 ]);
95
96 expect(
97 estimateTimelineMessageHeight({
98 role: "user",
99 text: prompt,
100 }),
101 ).toBe(
102 estimateTimelineMessageHeight({
103 role: "user",
104 text: `${buildInlineTerminalContextText([{ header: "Terminal 1 lines 40-43" }])} Investigate this`,
105 }),
106 );
107 });
108
78 it("uses narrower width to increase user line wrapping", () => {109 it("uses narrower width to increase user line wrapping", () => {
79 const message = {110 const message = {
80 role: "user" as const,111 role: "user" as const,
apps/web/src/components/timelineHeight.ts+14-1
...@@ -1,3 +1,6 @@...@@ -1,3 +1,6 @@
1import { deriveDisplayedUserMessageState } from "../lib/terminalContext";
2import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts";
3
1const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72;4const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72;
2const USER_CHARS_PER_LINE_FALLBACK = 56;5const USER_CHARS_PER_LINE_FALLBACK = 56;
3const LINE_HEIGHT_PX = 22;6const LINE_HEIGHT_PX = 22;
...@@ -75,7 +78,17 @@ export function estimateTimelineMessageHeight(...@@ -75,7 +78,17 @@ export function estimateTimelineMessageHeight(
7578
76 if (message.role === "user") {79 if (message.role === "user") {
77 const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx);80 const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx);
78 const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine);81 const displayedUserMessage = deriveDisplayedUserMessageState(message.text);
82 const renderedText =
83 displayedUserMessage.contexts.length > 0
84 ? [
85 buildInlineTerminalContextText(displayedUserMessage.contexts),
86 displayedUserMessage.visibleText,
87 ]
88 .filter((part) => part.length > 0)
89 .join(" ")
90 : displayedUserMessage.visibleText;
91 const estimatedLines = estimateWrappedLineCount(renderedText, charsPerLine);
79 const attachmentCount = message.attachments?.length ?? 0;92 const attachmentCount = message.attachments?.length ?? 0;
80 const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW);93 const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW);
81 const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX;94 const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX;
apps/web/src/composer-editor-mentions.test.ts+14
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";3import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
4import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
45
5describe("splitPromptIntoComposerSegments", () => {6describe("splitPromptIntoComposerSegments", () => {
6 it("splits mention tokens followed by whitespace into mention segments", () => {7 it("splits mention tokens followed by whitespace into mention segments", () => {
...@@ -24,4 +25,17 @@ describe("splitPromptIntoComposerSegments", () => {...@@ -24,4 +25,17 @@ describe("splitPromptIntoComposerSegments", () => {
24 { type: "text", text: " \ntwo" },25 { type: "text", text: " \ntwo" },
25 ]);26 ]);
26 });27 });
28
29 it("keeps inline terminal context placeholders at their prompt positions", () => {
30 expect(
31 splitPromptIntoComposerSegments(
32 `Inspect ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}@AGENTS.md please`,
33 ),
34 ).toEqual([
35 { type: "text", text: "Inspect " },
36 { type: "terminal-context", context: null },
37 { type: "mention", path: "AGENTS.md" },
38 { type: "text", text: " please" },
39 ]);
40 });
27});41});
apps/web/src/composer-editor-mentions.ts+51-7
...@@ -1,3 +1,8 @@...@@ -1,3 +1,8 @@
1import {
2 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
3 type TerminalContextDraft,
4} from "./lib/terminalContext";
5
1export type ComposerPromptSegment =6export type ComposerPromptSegment =
2 | {7 | {
3 type: "text";8 type: "text";
...@@ -6,6 +11,10 @@ export type ComposerPromptSegment =...@@ -6,6 +11,10 @@ export type ComposerPromptSegment =
6 | {11 | {
7 type: "mention";12 type: "mention";
8 path: string;13 path: string;
14 }
15 | {
16 type: "terminal-context";
17 context: TerminalContextDraft | null;
9 };18 };
1019
11const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g;20const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g;
...@@ -20,14 +29,14 @@ function pushTextSegment(segments: ComposerPromptSegment[], text: string): void...@@ -20,14 +29,14 @@ function pushTextSegment(segments: ComposerPromptSegment[], text: string): void
20 segments.push({ type: "text", text });29 segments.push({ type: "text", text });
21}30}
2231
23export function splitPromptIntoComposerSegments(prompt: string): ComposerPromptSegment[] {32function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegment[] {
24 const segments: ComposerPromptSegment[] = [];33 const segments: ComposerPromptSegment[] = [];
25 if (!prompt) {34 if (!text) {
26 return segments;35 return segments;
27 }36 }
2837
29 let cursor = 0;38 let cursor = 0;
30 for (const match of prompt.matchAll(MENTION_TOKEN_REGEX)) {39 for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
31 const fullMatch = match[0];40 const fullMatch = match[0];
32 const prefix = match[1] ?? "";41 const prefix = match[1] ?? "";
33 const path = match[2] ?? "";42 const path = match[2] ?? "";
...@@ -36,20 +45,55 @@ export function splitPromptIntoComposerSegments(prompt: string): ComposerPromptS...@@ -36,20 +45,55 @@ export function splitPromptIntoComposerSegments(prompt: string): ComposerPromptS
36 const mentionEnd = mentionStart + fullMatch.length - prefix.length;45 const mentionEnd = mentionStart + fullMatch.length - prefix.length;
3746
38 if (mentionStart > cursor) {47 if (mentionStart > cursor) {
39 pushTextSegment(segments, prompt.slice(cursor, mentionStart));48 pushTextSegment(segments, text.slice(cursor, mentionStart));
40 }49 }
4150
42 if (path.length > 0) {51 if (path.length > 0) {
43 segments.push({ type: "mention", path });52 segments.push({ type: "mention", path });
44 } else {53 } else {
45 pushTextSegment(segments, prompt.slice(mentionStart, mentionEnd));54 pushTextSegment(segments, text.slice(mentionStart, mentionEnd));
46 }55 }
4756
48 cursor = mentionEnd;57 cursor = mentionEnd;
49 }58 }
5059
51 if (cursor < prompt.length) {60 if (cursor < text.length) {
52 pushTextSegment(segments, prompt.slice(cursor));61 pushTextSegment(segments, text.slice(cursor));
62 }
63
64 return segments;
65}
66
67export function splitPromptIntoComposerSegments(
68 prompt: string,
69 terminalContexts: ReadonlyArray<TerminalContextDraft> = [],
70): ComposerPromptSegment[] {
71 if (!prompt) {
72 return [];
73 }
74
75 const segments: ComposerPromptSegment[] = [];
76 let textCursor = 0;
77 let terminalContextIndex = 0;
78
79 for (let index = 0; index < prompt.length; index += 1) {
80 if (prompt[index] !== INLINE_TERMINAL_CONTEXT_PLACEHOLDER) {
81 continue;
82 }
83
84 if (index > textCursor) {
85 segments.push(...splitPromptTextIntoComposerSegments(prompt.slice(textCursor, index)));
86 }
87 segments.push({
88 type: "terminal-context",
89 context: terminalContexts[terminalContextIndex] ?? null,
90 });
91 terminalContextIndex += 1;
92 textCursor = index + 1;
93 }
94
95 if (textCursor < prompt.length) {
96 segments.push(...splitPromptTextIntoComposerSegments(prompt.slice(textCursor)));
53 }97 }
5498
55 return segments;99 return segments;
apps/web/src/composer-logic.test.ts+22-12
...@@ -5,10 +5,11 @@ import {...@@ -5,10 +5,11 @@ import {
5 collapseExpandedComposerCursor,5 collapseExpandedComposerCursor,
6 detectComposerTrigger,6 detectComposerTrigger,
7 expandCollapsedComposerCursor,7 expandCollapsedComposerCursor,
8 isCollapsedCursorAdjacentToMention,8 isCollapsedCursorAdjacentToInlineToken,
9 parseStandaloneComposerSlashCommand,9 parseStandaloneComposerSlashCommand,
10 replaceTextRange,10 replaceTextRange,
11} from "./composer-logic";11} from "./composer-logic";
12import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
1213
13describe("detectComposerTrigger", () => {14describe("detectComposerTrigger", () => {
14 it("detects @path trigger at cursor", () => {15 it("detects @path trigger at cursor", () => {
...@@ -192,16 +193,16 @@ describe("replaceTextRange trailing space consumption", () => {...@@ -192,16 +193,16 @@ describe("replaceTextRange trailing space consumption", () => {
192 });193 });
193});194});
194195
195describe("isCollapsedCursorAdjacentToMention", () => {196describe("isCollapsedCursorAdjacentToInlineToken", () => {
196 it("returns false when no mention exists", () => {197 it("returns false when no mention exists", () => {
197 expect(isCollapsedCursorAdjacentToMention("plain text", 6, "left")).toBe(false);198 expect(isCollapsedCursorAdjacentToInlineToken("plain text", 6, "left")).toBe(false);
198 expect(isCollapsedCursorAdjacentToMention("plain text", 6, "right")).toBe(false);199 expect(isCollapsedCursorAdjacentToInlineToken("plain text", 6, "right")).toBe(false);
199 });200 });
200201
201 it("keeps @query typing non-adjacent while no mention pill exists", () => {202 it("keeps @query typing non-adjacent while no mention pill exists", () => {
202 const text = "hello @pac";203 const text = "hello @pac";
203 expect(isCollapsedCursorAdjacentToMention(text, text.length, "left")).toBe(false);204 expect(isCollapsedCursorAdjacentToInlineToken(text, text.length, "left")).toBe(false);
204 expect(isCollapsedCursorAdjacentToMention(text, text.length, "right")).toBe(false);205 expect(isCollapsedCursorAdjacentToInlineToken(text, text.length, "right")).toBe(false);
205 });206 });
206207
207 it("detects left adjacency only when cursor is directly after a mention", () => {208 it("detects left adjacency only when cursor is directly after a mention", () => {
...@@ -209,9 +210,9 @@ describe("isCollapsedCursorAdjacentToMention", () => {...@@ -209,9 +210,9 @@ describe("isCollapsedCursorAdjacentToMention", () => {
209 const mentionStart = "open ".length;210 const mentionStart = "open ".length;
210 const mentionEnd = mentionStart + 1;211 const mentionEnd = mentionStart + 1;
211212
212 expect(isCollapsedCursorAdjacentToMention(text, mentionEnd, "left")).toBe(true);213 expect(isCollapsedCursorAdjacentToInlineToken(text, mentionEnd, "left")).toBe(true);
213 expect(isCollapsedCursorAdjacentToMention(text, mentionStart, "left")).toBe(false);214 expect(isCollapsedCursorAdjacentToInlineToken(text, mentionStart, "left")).toBe(false);
214 expect(isCollapsedCursorAdjacentToMention(text, mentionEnd + 1, "left")).toBe(false);215 expect(isCollapsedCursorAdjacentToInlineToken(text, mentionEnd + 1, "left")).toBe(false);
215 });216 });
216217
217 it("detects right adjacency only when cursor is directly before a mention", () => {218 it("detects right adjacency only when cursor is directly before a mention", () => {
...@@ -219,9 +220,18 @@ describe("isCollapsedCursorAdjacentToMention", () => {...@@ -219,9 +220,18 @@ describe("isCollapsedCursorAdjacentToMention", () => {
219 const mentionStart = "open ".length;220 const mentionStart = "open ".length;
220 const mentionEnd = mentionStart + 1;221 const mentionEnd = mentionStart + 1;
221222
222 expect(isCollapsedCursorAdjacentToMention(text, mentionStart, "right")).toBe(true);223 expect(isCollapsedCursorAdjacentToInlineToken(text, mentionStart, "right")).toBe(true);
223 expect(isCollapsedCursorAdjacentToMention(text, mentionEnd, "right")).toBe(false);224 expect(isCollapsedCursorAdjacentToInlineToken(text, mentionEnd, "right")).toBe(false);
224 expect(isCollapsedCursorAdjacentToMention(text, mentionStart - 1, "right")).toBe(false);225 expect(isCollapsedCursorAdjacentToInlineToken(text, mentionStart - 1, "right")).toBe(false);
226 });
227
228 it("treats terminal pills as inline tokens for adjacency checks", () => {
229 const text = `open ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} next`;
230 const tokenStart = "open ".length;
231 const tokenEnd = tokenStart + 1;
232
233 expect(isCollapsedCursorAdjacentToInlineToken(text, tokenEnd, "left")).toBe(true);
234 expect(isCollapsedCursorAdjacentToInlineToken(text, tokenStart, "right")).toBe(true);
225 });235 });
226});236});
227237
apps/web/src/composer-logic.ts+40-7
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";1import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
2import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
23
3export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";4export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";
4export type ComposerSlashCommand = "model" | "plan" | "default";5export type ComposerSlashCommand = "model" | "plan" | "default";
...@@ -11,6 +12,9 @@ export interface ComposerTrigger {...@@ -11,6 +12,9 @@ export interface ComposerTrigger {
11}12}
1213
13const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"];14const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"];
15const isInlineTokenSegment = (
16 segment: { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" },
17): boolean => segment.type !== "text";
1418
15function clampCursor(text: string, cursor: number): number {19function clampCursor(text: string, cursor: number): number {
16 if (!Number.isFinite(cursor)) return text.length;20 if (!Number.isFinite(cursor)) return text.length;
...@@ -18,7 +22,13 @@ function clampCursor(text: string, cursor: number): number {...@@ -18,7 +22,13 @@ function clampCursor(text: string, cursor: number): number {
18}22}
1923
20function isWhitespace(char: string): boolean {24function isWhitespace(char: string): boolean {
21 return char === " " || char === "\n" || char === "\t" || char === "\r";25 return (
26 char === " " ||
27 char === "\n" ||
28 char === "\t" ||
29 char === "\r" ||
30 char === INLINE_TERMINAL_CONTEXT_PLACEHOLDER
31 );
22}32}
2333
24function tokenStartForCursor(text: string, cursor: number): number {34function tokenStartForCursor(text: string, cursor: number): number {
...@@ -49,6 +59,14 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)...@@ -49,6 +59,14 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)
49 expandedCursor += expandedLength;59 expandedCursor += expandedLength;
50 continue;60 continue;
51 }61 }
62 if (segment.type === "terminal-context") {
63 if (remaining <= 1) {
64 return expandedCursor + remaining;
65 }
66 remaining -= 1;
67 expandedCursor += 1;
68 continue;
69 }
5270
53 const segmentLength = segment.text.length;71 const segmentLength = segment.text.length;
54 if (remaining <= segmentLength) {72 if (remaining <= segmentLength) {
...@@ -62,13 +80,18 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)...@@ -62,13 +80,18 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)
62}80}
6381
64function collapsedSegmentLength(82function collapsedSegmentLength(
65 segment: { type: "text"; text: string } | { type: "mention" },83 segment: { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" },
66): number {84): number {
67 return segment.type === "mention" ? 1 : segment.text.length;85 if (segment.type === "text") {
86 return segment.text.length;
87 }
88 return 1;
68}89}
6990
70function clampCollapsedComposerCursorForSegments(91function clampCollapsedComposerCursorForSegments(
71 segments: ReadonlyArray<{ type: "text"; text: string } | { type: "mention" }>,92 segments: ReadonlyArray<
93 { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" }
94 >,
72 cursorInput: number,95 cursorInput: number,
73): number {96): number {
74 const collapsedLength = segments.reduce(97 const collapsedLength = segments.reduce(
...@@ -111,6 +134,14 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number...@@ -111,6 +134,14 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number
111 collapsedCursor += 1;134 collapsedCursor += 1;
112 continue;135 continue;
113 }136 }
137 if (segment.type === "terminal-context") {
138 if (remaining <= 1) {
139 return collapsedCursor + remaining;
140 }
141 remaining -= 1;
142 collapsedCursor += 1;
143 continue;
144 }
114145
115 const segmentLength = segment.text.length;146 const segmentLength = segment.text.length;
116 if (remaining <= segmentLength) {147 if (remaining <= segmentLength) {
...@@ -123,13 +154,13 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number...@@ -123,13 +154,13 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number
123 return collapsedCursor;154 return collapsedCursor;
124}155}
125156
126export function isCollapsedCursorAdjacentToMention(157export function isCollapsedCursorAdjacentToInlineToken(
127 text: string,158 text: string,
128 cursorInput: number,159 cursorInput: number,
129 direction: "left" | "right",160 direction: "left" | "right",
130): boolean {161): boolean {
131 const segments = splitPromptIntoComposerSegments(text);162 const segments = splitPromptIntoComposerSegments(text);
132 if (!segments.some((segment) => segment.type === "mention")) {163 if (!segments.some(isInlineTokenSegment)) {
133 return false;164 return false;
134 }165 }
135166
...@@ -137,7 +168,7 @@ export function isCollapsedCursorAdjacentToMention(...@@ -137,7 +168,7 @@ export function isCollapsedCursorAdjacentToMention(
137 let collapsedOffset = 0;168 let collapsedOffset = 0;
138169
139 for (const segment of segments) {170 for (const segment of segments) {
140 if (segment.type === "mention") {171 if (isInlineTokenSegment(segment)) {
141 if (direction === "left" && cursor === collapsedOffset + 1) {172 if (direction === "left" && cursor === collapsedOffset + 1) {
142 return true;173 return true;
143 }174 }
...@@ -151,6 +182,8 @@ export function isCollapsedCursorAdjacentToMention(...@@ -151,6 +182,8 @@ export function isCollapsedCursorAdjacentToMention(
151 return false;182 return false;
152}183}
153184
185export const isCollapsedCursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken;
186
154export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null {187export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null {
155 const cursor = clampCursor(text, cursorInput);188 const cursor = clampCursor(text, cursorInput);
156 const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1;189 const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1;
apps/web/src/composerDraftStore.test.ts+167
...@@ -6,6 +6,11 @@ import {...@@ -6,6 +6,11 @@ import {
6 createDebouncedStorage,6 createDebouncedStorage,
7 useComposerDraftStore,7 useComposerDraftStore,
8} from "./composerDraftStore";8} from "./composerDraftStore";
9import {
10 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
11 insertInlineTerminalContextPlaceholder,
12 type TerminalContextDraft,
13} from "./lib/terminalContext";
914
10function makeImage(input: {15function makeImage(input: {
11 id: string;16 id: string;
...@@ -34,6 +39,26 @@ function makeImage(input: {...@@ -34,6 +39,26 @@ function makeImage(input: {
34 };39 };
35}40}
3641
42function makeTerminalContext(input: {
43 id: string;
44 text?: string;
45 terminalId?: string;
46 terminalLabel?: string;
47 lineStart?: number;
48 lineEnd?: number;
49}): TerminalContextDraft {
50 return {
51 id: input.id,
52 threadId: ThreadId.makeUnsafe("thread-dedupe"),
53 terminalId: input.terminalId ?? "default",
54 terminalLabel: input.terminalLabel ?? "Terminal 1",
55 lineStart: input.lineStart ?? 4,
56 lineEnd: input.lineEnd ?? 5,
57 text: input.text ?? "git status\nOn branch main",
58 createdAt: "2026-03-13T12:00:00.000Z",
59 };
60}
61
37describe("composerDraftStore addImages", () => {62describe("composerDraftStore addImages", () => {
38 const threadId = ThreadId.makeUnsafe("thread-dedupe");63 const threadId = ThreadId.makeUnsafe("thread-dedupe");
39 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;64 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;
...@@ -158,6 +183,148 @@ describe("composerDraftStore clearComposerContent", () => {...@@ -158,6 +183,148 @@ describe("composerDraftStore clearComposerContent", () => {
158 });183 });
159});184});
160185
186describe("composerDraftStore terminal contexts", () => {
187 const threadId = ThreadId.makeUnsafe("thread-dedupe");
188
189 beforeEach(() => {
190 useComposerDraftStore.setState({
191 draftsByThreadId: {},
192 draftThreadsByThreadId: {},
193 projectDraftThreadIdByProjectId: {},
194 });
195 });
196
197 it("deduplicates identical terminal contexts by selection signature", () => {
198 const first = makeTerminalContext({ id: "ctx-1" });
199 const duplicate = makeTerminalContext({ id: "ctx-2" });
200
201 useComposerDraftStore.getState().addTerminalContexts(threadId, [first, duplicate]);
202
203 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
204 expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-1"]);
205 });
206
207 it("clears terminal contexts when clearing composer content", () => {
208 useComposerDraftStore
209 .getState()
210 .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-1" }));
211
212 useComposerDraftStore.getState().clearComposerContent(threadId);
213
214 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
215 });
216
217 it("inserts terminal contexts at the requested inline prompt position", () => {
218 const firstInsertion = insertInlineTerminalContextPlaceholder("alpha beta", 6);
219 const secondInsertion = insertInlineTerminalContextPlaceholder(firstInsertion.prompt, 0);
220
221 expect(
222 useComposerDraftStore
223 .getState()
224 .insertTerminalContext(
225 threadId,
226 firstInsertion.prompt,
227 makeTerminalContext({ id: "ctx-1" }),
228 firstInsertion.contextIndex,
229 ),
230 ).toBe(true);
231 expect(
232 useComposerDraftStore.getState().insertTerminalContext(
233 threadId,
234 secondInsertion.prompt,
235 makeTerminalContext({
236 id: "ctx-2",
237 terminalLabel: "Terminal 2",
238 lineStart: 9,
239 lineEnd: 10,
240 }),
241 secondInsertion.contextIndex,
242 ),
243 ).toBe(true);
244
245 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
246 expect(draft?.prompt).toBe(
247 `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} alpha ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} beta`,
248 );
249 expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-2", "ctx-1"]);
250 });
251
252 it("omits terminal context text from persisted drafts", () => {
253 useComposerDraftStore
254 .getState()
255 .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-persist" }));
256
257 const persistApi = useComposerDraftStore.persist as unknown as {
258 getOptions: () => {
259 partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown;
260 };
261 };
262 const persistedState = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as {
263 draftsByThreadId?: Record<string, { terminalContexts?: Array<Record<string, unknown>> }>;
264 };
265
266 expect(
267 persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0],
268 "Expected terminal context metadata to be persisted.",
269 ).toMatchObject({
270 id: "ctx-persist",
271 terminalId: "default",
272 terminalLabel: "Terminal 1",
273 lineStart: 4,
274 lineEnd: 5,
275 });
276 expect(
277 persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0]?.text,
278 ).toBeUndefined();
279 });
280
281 it("hydrates persisted terminal contexts without in-memory snapshot text", () => {
282 const persistApi = useComposerDraftStore.persist as unknown as {
283 getOptions: () => {
284 merge: (
285 persistedState: unknown,
286 currentState: ReturnType<typeof useComposerDraftStore.getState>,
287 ) => ReturnType<typeof useComposerDraftStore.getState>;
288 };
289 };
290 const mergedState = persistApi.getOptions().merge(
291 {
292 draftsByThreadId: {
293 [threadId]: {
294 prompt: INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
295 attachments: [],
296 terminalContexts: [
297 {
298 id: "ctx-rehydrated",
299 threadId,
300 createdAt: "2026-03-13T12:00:00.000Z",
301 terminalId: "default",
302 terminalLabel: "Terminal 1",
303 lineStart: 4,
304 lineEnd: 5,
305 },
306 ],
307 },
308 },
309 draftThreadsByThreadId: {},
310 projectDraftThreadIdByProjectId: {},
311 },
312 useComposerDraftStore.getInitialState(),
313 );
314
315 expect(mergedState.draftsByThreadId[threadId]?.terminalContexts).toMatchObject([
316 {
317 id: "ctx-rehydrated",
318 terminalId: "default",
319 terminalLabel: "Terminal 1",
320 lineStart: 4,
321 lineEnd: 5,
322 text: "",
323 },
324 ]);
325 });
326});
327
161describe("composerDraftStore project draft thread mapping", () => {328describe("composerDraftStore project draft thread mapping", () => {
162 const projectId = ProjectId.makeUnsafe("project-a");329 const projectId = ProjectId.makeUnsafe("project-a");
163 const otherProjectId = ProjectId.makeUnsafe("project-b");330 const otherProjectId = ProjectId.makeUnsafe("project-b");
apps/web/src/composerDraftStore.ts+303-2
...@@ -10,6 +10,11 @@ import {...@@ -10,6 +10,11 @@ import {
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import { normalizeModelSlug } from "@t3tools/shared/model";11import { normalizeModelSlug } from "@t3tools/shared/model";
12import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";12import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";
13import {
14 type TerminalContextDraft,
15 ensureInlineTerminalContextPlaceholders,
16 normalizeTerminalContextText,
17} from "./lib/terminalContext";
13import { Debouncer } from "@tanstack/react-pacer";18import { Debouncer } from "@tanstack/react-pacer";
14import { create } from "zustand";19import { create } from "zustand";
15import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";20import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
...@@ -71,9 +76,20 @@ export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "prev...@@ -71,9 +76,20 @@ export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "prev
71 file: File;76 file: File;
72}77}
7378
79interface PersistedTerminalContextDraft {
80 id: string;
81 threadId: ThreadId;
82 createdAt: string;
83 terminalId: string;
84 terminalLabel: string;
85 lineStart: number;
86 lineEnd: number;
87}
88
74interface PersistedComposerThreadDraftState {89interface PersistedComposerThreadDraftState {
75 prompt: string;90 prompt: string;
76 attachments: PersistedComposerImageAttachment[];91 attachments: PersistedComposerImageAttachment[];
92 terminalContexts?: PersistedTerminalContextDraft[];
77 provider?: ProviderKind | null;93 provider?: ProviderKind | null;
78 model?: string | null;94 model?: string | null;
79 runtimeMode?: RuntimeMode | null;95 runtimeMode?: RuntimeMode | null;
...@@ -104,6 +120,7 @@ interface ComposerThreadDraftState {...@@ -104,6 +120,7 @@ interface ComposerThreadDraftState {
104 images: ComposerImageAttachment[];120 images: ComposerImageAttachment[];
105 nonPersistedImageIds: string[];121 nonPersistedImageIds: string[];
106 persistedAttachments: PersistedComposerImageAttachment[];122 persistedAttachments: PersistedComposerImageAttachment[];
123 terminalContexts: TerminalContextDraft[];
107 provider: ProviderKind | null;124 provider: ProviderKind | null;
108 model: string | null;125 model: string | null;
109 runtimeMode: RuntimeMode | null;126 runtimeMode: RuntimeMode | null;
...@@ -160,6 +177,7 @@ interface ComposerDraftStoreState {...@@ -160,6 +177,7 @@ interface ComposerDraftStoreState {
160 clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void;177 clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void;
161 clearDraftThread: (threadId: ThreadId) => void;178 clearDraftThread: (threadId: ThreadId) => void;
162 setPrompt: (threadId: ThreadId, prompt: string) => void;179 setPrompt: (threadId: ThreadId, prompt: string) => void;
180 setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void;
163 setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void;181 setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void;
164 setModel: (threadId: ThreadId, model: string | null | undefined) => void;182 setModel: (threadId: ThreadId, model: string | null | undefined) => void;
165 setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void;183 setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void;
...@@ -172,6 +190,16 @@ interface ComposerDraftStoreState {...@@ -172,6 +190,16 @@ interface ComposerDraftStoreState {
172 addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void;190 addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void;
173 addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void;191 addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void;
174 removeImage: (threadId: ThreadId, imageId: string) => void;192 removeImage: (threadId: ThreadId, imageId: string) => void;
193 insertTerminalContext: (
194 threadId: ThreadId,
195 prompt: string,
196 context: TerminalContextDraft,
197 index: number,
198 ) => boolean;
199 addTerminalContext: (threadId: ThreadId, context: TerminalContextDraft) => void;
200 addTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void;
201 removeTerminalContext: (threadId: ThreadId, contextId: string) => void;
202 clearTerminalContexts: (threadId: ThreadId) => void;
175 clearPersistedAttachments: (threadId: ThreadId) => void;203 clearPersistedAttachments: (threadId: ThreadId) => void;
176 syncPersistedAttachments: (204 syncPersistedAttachments: (
177 threadId: ThreadId,205 threadId: ThreadId,
...@@ -190,14 +218,17 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = {...@@ -190,14 +218,17 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = {
190const EMPTY_IMAGES: ComposerImageAttachment[] = [];218const EMPTY_IMAGES: ComposerImageAttachment[] = [];
191const EMPTY_IDS: string[] = [];219const EMPTY_IDS: string[] = [];
192const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = [];220const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = [];
221const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = [];
193Object.freeze(EMPTY_IMAGES);222Object.freeze(EMPTY_IMAGES);
194Object.freeze(EMPTY_IDS);223Object.freeze(EMPTY_IDS);
195Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);224Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);
225Object.freeze(EMPTY_TERMINAL_CONTEXTS);
196const EMPTY_THREAD_DRAFT = Object.freeze({226const EMPTY_THREAD_DRAFT = Object.freeze({
197 prompt: "",227 prompt: "",
198 images: EMPTY_IMAGES,228 images: EMPTY_IMAGES,
199 nonPersistedImageIds: EMPTY_IDS,229 nonPersistedImageIds: EMPTY_IDS,
200 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,230 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,
231 terminalContexts: EMPTY_TERMINAL_CONTEXTS,
201 provider: null,232 provider: null,
202 model: null,233 model: null,
203 runtimeMode: null,234 runtimeMode: null,
...@@ -216,6 +247,7 @@ function createEmptyThreadDraft(): ComposerThreadDraftState {...@@ -216,6 +247,7 @@ function createEmptyThreadDraft(): ComposerThreadDraftState {
216 images: [],247 images: [],
217 nonPersistedImageIds: [],248 nonPersistedImageIds: [],
218 persistedAttachments: [],249 persistedAttachments: [],
250 terminalContexts: [],
219 provider: null,251 provider: null,
220 model: null,252 model: null,
221 runtimeMode: null,253 runtimeMode: null,
...@@ -231,11 +263,63 @@ function composerImageDedupKey(image: ComposerImageAttachment): string {...@@ -231,11 +263,63 @@ function composerImageDedupKey(image: ComposerImageAttachment): string {
231 return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`;263 return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`;
232}264}
233265
266function terminalContextDedupKey(context: TerminalContextDraft): string {
267 return `${context.terminalId}\u0000${context.lineStart}\u0000${context.lineEnd}`;
268}
269
270function normalizeTerminalContextForThread(
271 threadId: ThreadId,
272 context: TerminalContextDraft,
273): TerminalContextDraft | null {
274 const terminalId = context.terminalId.trim();
275 const terminalLabel = context.terminalLabel.trim();
276 if (terminalId.length === 0 || terminalLabel.length === 0) {
277 return null;
278 }
279 const lineStart = Math.max(1, Math.floor(context.lineStart));
280 const lineEnd = Math.max(lineStart, Math.floor(context.lineEnd));
281 return {
282 ...context,
283 threadId,
284 terminalId,
285 terminalLabel,
286 lineStart,
287 lineEnd,
288 text: normalizeTerminalContextText(context.text),
289 };
290}
291
292function normalizeTerminalContextsForThread(
293 threadId: ThreadId,
294 contexts: ReadonlyArray<TerminalContextDraft>,
295): TerminalContextDraft[] {
296 const existingIds = new Set<string>();
297 const existingDedupKeys = new Set<string>();
298 const normalizedContexts: TerminalContextDraft[] = [];
299
300 for (const context of contexts) {
301 const normalizedContext = normalizeTerminalContextForThread(threadId, context);
302 if (!normalizedContext) {
303 continue;
304 }
305 const dedupKey = terminalContextDedupKey(normalizedContext);
306 if (existingIds.has(normalizedContext.id) || existingDedupKeys.has(dedupKey)) {
307 continue;
308 }
309 normalizedContexts.push(normalizedContext);
310 existingIds.add(normalizedContext.id);
311 existingDedupKeys.add(dedupKey);
312 }
313
314 return normalizedContexts;
315}
316
234function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {317function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
235 return (318 return (
236 draft.prompt.length === 0 &&319 draft.prompt.length === 0 &&
237 draft.images.length === 0 &&320 draft.images.length === 0 &&
238 draft.persistedAttachments.length === 0 &&321 draft.persistedAttachments.length === 0 &&
322 draft.terminalContexts.length === 0 &&
239 draft.provider === null &&323 draft.provider === null &&
240 draft.model === null &&324 draft.model === null &&
241 draft.runtimeMode === null &&325 draft.runtimeMode === null &&
...@@ -290,6 +374,51 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt...@@ -290,6 +374,51 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt
290 };374 };
291}375}
292376
377function normalizePersistedTerminalContextDraft(
378 value: unknown,
379): PersistedTerminalContextDraft | null {
380 if (!value || typeof value !== "object") {
381 return null;
382 }
383 const candidate = value as Record<string, unknown>;
384 const id = candidate.id;
385 const threadId = candidate.threadId;
386 const createdAt = candidate.createdAt;
387 const lineStart = candidate.lineStart;
388 const lineEnd = candidate.lineEnd;
389 if (
390 typeof id !== "string" ||
391 id.length === 0 ||
392 typeof threadId !== "string" ||
393 threadId.length === 0 ||
394 typeof createdAt !== "string" ||
395 createdAt.length === 0 ||
396 typeof lineStart !== "number" ||
397 !Number.isFinite(lineStart) ||
398 typeof lineEnd !== "number" ||
399 !Number.isFinite(lineEnd)
400 ) {
401 return null;
402 }
403 const terminalId = typeof candidate.terminalId === "string" ? candidate.terminalId.trim() : "";
404 const terminalLabel =
405 typeof candidate.terminalLabel === "string" ? candidate.terminalLabel.trim() : "";
406 if (terminalId.length === 0 || terminalLabel.length === 0) {
407 return null;
408 }
409 const normalizedLineStart = Math.max(1, Math.floor(lineStart));
410 const normalizedLineEnd = Math.max(normalizedLineStart, Math.floor(lineEnd));
411 return {
412 id,
413 threadId: threadId as ThreadId,
414 createdAt,
415 terminalId,
416 terminalLabel,
417 lineStart: normalizedLineStart,
418 lineEnd: normalizedLineEnd,
419 };
420}
421
293function normalizeDraftThreadEnvMode(422function normalizeDraftThreadEnvMode(
294 value: unknown,423 value: unknown,
295 fallbackWorktreePath: string | null,424 fallbackWorktreePath: string | null,
...@@ -397,13 +526,19 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer...@@ -397,13 +526,19 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
397 continue;526 continue;
398 }527 }
399 const draftCandidate = draftValue as Record<string, unknown>;528 const draftCandidate = draftValue as Record<string, unknown>;
400 const prompt = typeof draftCandidate.prompt === "string" ? draftCandidate.prompt : "";529 const promptCandidate = typeof draftCandidate.prompt === "string" ? draftCandidate.prompt : "";
401 const attachments = Array.isArray(draftCandidate.attachments)530 const attachments = Array.isArray(draftCandidate.attachments)
402 ? draftCandidate.attachments.flatMap((entry) => {531 ? draftCandidate.attachments.flatMap((entry) => {
403 const normalized = normalizePersistedAttachment(entry);532 const normalized = normalizePersistedAttachment(entry);
404 return normalized ? [normalized] : [];533 return normalized ? [normalized] : [];
405 })534 })
406 : [];535 : [];
536 const terminalContexts = Array.isArray(draftCandidate.terminalContexts)
537 ? draftCandidate.terminalContexts.flatMap((entry) => {
538 const normalized = normalizePersistedTerminalContextDraft(entry);
539 return normalized ? [normalized] : [];
540 })
541 : [];
407 const provider = normalizeProviderKind(draftCandidate.provider);542 const provider = normalizeProviderKind(draftCandidate.provider);
408 const model =543 const model =
409 typeof draftCandidate.model === "string"544 typeof draftCandidate.model === "string"
...@@ -427,9 +562,14 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer...@@ -427,9 +562,14 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
427 const codexFastMode =562 const codexFastMode =
428 draftCandidate.codexFastMode === true ||563 draftCandidate.codexFastMode === true ||
429 (typeof draftCandidate.serviceTier === "string" && draftCandidate.serviceTier === "fast");564 (typeof draftCandidate.serviceTier === "string" && draftCandidate.serviceTier === "fast");
565 const prompt = ensureInlineTerminalContextPlaceholders(
566 promptCandidate,
567 terminalContexts.length,
568 );
430 if (569 if (
431 prompt.length === 0 &&570 promptCandidate.length === 0 &&
432 attachments.length === 0 &&571 attachments.length === 0 &&
572 terminalContexts.length === 0 &&
433 !provider &&573 !provider &&
434 !model &&574 !model &&
435 !runtimeMode &&575 !runtimeMode &&
...@@ -442,6 +582,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer...@@ -442,6 +582,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
442 nextDraftsByThreadId[threadId as ThreadId] = {582 nextDraftsByThreadId[threadId as ThreadId] = {
443 prompt,583 prompt,
444 attachments,584 attachments,
585 ...(terminalContexts.length > 0 ? { terminalContexts } : {}),
445 ...(provider ? { provider } : {}),586 ...(provider ? { provider } : {}),
446 ...(model ? { model } : {}),587 ...(model ? { model } : {}),
447 ...(runtimeMode ? { runtimeMode } : {}),588 ...(runtimeMode ? { runtimeMode } : {}),
...@@ -548,6 +689,11 @@ function toHydratedThreadDraft(...@@ -548,6 +689,11 @@ function toHydratedThreadDraft(
548 images: hydrateImagesFromPersisted(persistedDraft.attachments),689 images: hydrateImagesFromPersisted(persistedDraft.attachments),
549 nonPersistedImageIds: [],690 nonPersistedImageIds: [],
550 persistedAttachments: persistedDraft.attachments,691 persistedAttachments: persistedDraft.attachments,
692 terminalContexts:
693 persistedDraft.terminalContexts?.map((context) => ({
694 ...context,
695 text: "",
696 })) ?? [],
551 provider: persistedDraft.provider ?? null,697 provider: persistedDraft.provider ?? null,
552 model: persistedDraft.model ?? null,698 model: persistedDraft.model ?? null,
553 runtimeMode: persistedDraft.runtimeMode ?? null,699 runtimeMode: persistedDraft.runtimeMode ?? null,
...@@ -817,6 +963,30 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -817,6 +963,30 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
817 return { draftsByThreadId: nextDraftsByThreadId };963 return { draftsByThreadId: nextDraftsByThreadId };
818 });964 });
819 },965 },
966 setTerminalContexts: (threadId, contexts) => {
967 if (threadId.length === 0) {
968 return;
969 }
970 const normalizedContexts = normalizeTerminalContextsForThread(threadId, contexts);
971 set((state) => {
972 const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();
973 const nextDraft: ComposerThreadDraftState = {
974 ...existing,
975 prompt: ensureInlineTerminalContextPlaceholders(
976 existing.prompt,
977 normalizedContexts.length,
978 ),
979 terminalContexts: normalizedContexts,
980 };
981 const nextDraftsByThreadId = { ...state.draftsByThreadId };
982 if (shouldRemoveDraft(nextDraft)) {
983 delete nextDraftsByThreadId[threadId];
984 } else {
985 nextDraftsByThreadId[threadId] = nextDraft;
986 }
987 return { draftsByThreadId: nextDraftsByThreadId };
988 });
989 },
820 setProvider: (threadId, provider) => {990 setProvider: (threadId, provider) => {
821 if (threadId.length === 0) {991 if (threadId.length === 0) {
822 return;992 return;
...@@ -1066,6 +1236,124 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1066,6 +1236,124 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1066 return { draftsByThreadId: nextDraftsByThreadId };1236 return { draftsByThreadId: nextDraftsByThreadId };
1067 });1237 });
1068 },1238 },
1239 insertTerminalContext: (threadId, prompt, context, index) => {
1240 if (threadId.length === 0) {
1241 return false;
1242 }
1243 let inserted = false;
1244 set((state) => {
1245 const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();
1246 const normalizedContext = normalizeTerminalContextForThread(threadId, context);
1247 if (!normalizedContext) {
1248 return state;
1249 }
1250 const dedupKey = terminalContextDedupKey(normalizedContext);
1251 if (
1252 existing.terminalContexts.some((entry) => entry.id === normalizedContext.id) ||
1253 existing.terminalContexts.some((entry) => terminalContextDedupKey(entry) === dedupKey)
1254 ) {
1255 return state;
1256 }
1257 inserted = true;
1258 const boundedIndex = Math.max(0, Math.min(existing.terminalContexts.length, index));
1259 const nextDraft: ComposerThreadDraftState = {
1260 ...existing,
1261 prompt,
1262 terminalContexts: [
1263 ...existing.terminalContexts.slice(0, boundedIndex),
1264 normalizedContext,
1265 ...existing.terminalContexts.slice(boundedIndex),
1266 ],
1267 };
1268 return {
1269 draftsByThreadId: {
1270 ...state.draftsByThreadId,
1271 [threadId]: nextDraft,
1272 },
1273 };
1274 });
1275 return inserted;
1276 },
1277 addTerminalContext: (threadId, context) => {
1278 if (threadId.length === 0) {
1279 return;
1280 }
1281 get().addTerminalContexts(threadId, [context]);
1282 },
1283 addTerminalContexts: (threadId, contexts) => {
1284 if (threadId.length === 0 || contexts.length === 0) {
1285 return;
1286 }
1287 set((state) => {
1288 const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();
1289 const acceptedContexts = normalizeTerminalContextsForThread(threadId, [
1290 ...existing.terminalContexts,
1291 ...contexts,
1292 ]).slice(existing.terminalContexts.length);
1293 if (acceptedContexts.length === 0) {
1294 return state;
1295 }
1296 return {
1297 draftsByThreadId: {
1298 ...state.draftsByThreadId,
1299 [threadId]: {
1300 ...existing,
1301 prompt: ensureInlineTerminalContextPlaceholders(
1302 existing.prompt,
1303 existing.terminalContexts.length + acceptedContexts.length,
1304 ),
1305 terminalContexts: [...existing.terminalContexts, ...acceptedContexts],
1306 },
1307 },
1308 };
1309 });
1310 },
1311 removeTerminalContext: (threadId, contextId) => {
1312 if (threadId.length === 0 || contextId.length === 0) {
1313 return;
1314 }
1315 set((state) => {
1316 const current = state.draftsByThreadId[threadId];
1317 if (!current) {
1318 return state;
1319 }
1320 const nextDraft: ComposerThreadDraftState = {
1321 ...current,
1322 terminalContexts: current.terminalContexts.filter(
1323 (context) => context.id !== contextId,
1324 ),
1325 };
1326 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1327 if (shouldRemoveDraft(nextDraft)) {
1328 delete nextDraftsByThreadId[threadId];
1329 } else {
1330 nextDraftsByThreadId[threadId] = nextDraft;
1331 }
1332 return { draftsByThreadId: nextDraftsByThreadId };
1333 });
1334 },
1335 clearTerminalContexts: (threadId) => {
1336 if (threadId.length === 0) {
1337 return;
1338 }
1339 set((state) => {
1340 const current = state.draftsByThreadId[threadId];
1341 if (!current || current.terminalContexts.length === 0) {
1342 return state;
1343 }
1344 const nextDraft: ComposerThreadDraftState = {
1345 ...current,
1346 terminalContexts: [],
1347 };
1348 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1349 if (shouldRemoveDraft(nextDraft)) {
1350 delete nextDraftsByThreadId[threadId];
1351 } else {
1352 nextDraftsByThreadId[threadId] = nextDraft;
1353 }
1354 return { draftsByThreadId: nextDraftsByThreadId };
1355 });
1356 },
1069 clearPersistedAttachments: (threadId) => {1357 clearPersistedAttachments: (threadId) => {
1070 if (threadId.length === 0) {1358 if (threadId.length === 0) {
1071 return;1359 return;
...@@ -1159,6 +1447,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1159,6 +1447,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1159 images: [],1447 images: [],
1160 nonPersistedImageIds: [],1448 nonPersistedImageIds: [],
1161 persistedAttachments: [],1449 persistedAttachments: [],
1450 terminalContexts: [],
1162 };1451 };
1163 const nextDraftsByThreadId = { ...state.draftsByThreadId };1452 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1164 if (shouldRemoveDraft(nextDraft)) {1453 if (shouldRemoveDraft(nextDraft)) {
...@@ -1218,6 +1507,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1218,6 +1507,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1218 if (1507 if (
1219 draft.prompt.length === 0 &&1508 draft.prompt.length === 0 &&
1220 draft.persistedAttachments.length === 0 &&1509 draft.persistedAttachments.length === 0 &&
1510 draft.terminalContexts.length === 0 &&
1221 draft.provider === null &&1511 draft.provider === null &&
1222 draft.model === null &&1512 draft.model === null &&
1223 draft.runtimeMode === null &&1513 draft.runtimeMode === null &&
...@@ -1231,6 +1521,17 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1231,6 +1521,17 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1231 prompt: draft.prompt,1521 prompt: draft.prompt,
1232 attachments: draft.persistedAttachments,1522 attachments: draft.persistedAttachments,
1233 };1523 };
1524 if (draft.terminalContexts.length > 0) {
1525 persistedDraft.terminalContexts = draft.terminalContexts.map((context) => ({
1526 id: context.id,
1527 threadId: context.threadId,
1528 createdAt: context.createdAt,
1529 terminalId: context.terminalId,
1530 terminalLabel: context.terminalLabel,
1531 lineStart: context.lineStart,
1532 lineEnd: context.lineEnd,
1533 }));
1534 }
1234 if (draft.model) {1535 if (draft.model) {
1235 persistedDraft.model = draft.model;1536 persistedDraft.model = draft.model;
1236 }1537 }
apps/web/src/lib/terminalContext.test.ts created+210
...@@ -0,0 +1,210 @@
1import { ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3
4import {
5 appendTerminalContextsToPrompt,
6 buildTerminalContextPreviewTitle,
7 buildTerminalContextBlock,
8 countInlineTerminalContextPlaceholders,
9 deriveDisplayedUserMessageState,
10 ensureInlineTerminalContextPlaceholders,
11 extractTrailingTerminalContexts,
12 filterTerminalContextsWithText,
13 formatInlineTerminalContextLabel,
14 formatTerminalContextLabel,
15 hasTerminalContextText,
16 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
17 insertInlineTerminalContextPlaceholder,
18 isTerminalContextExpired,
19 materializeInlineTerminalContextPrompt,
20 removeInlineTerminalContextPlaceholder,
21 stripInlineTerminalContextPlaceholders,
22 type TerminalContextDraft,
23} from "./terminalContext";
24
25function makeContext(overrides?: Partial<TerminalContextDraft>): TerminalContextDraft {
26 return {
27 id: "context-1",
28 threadId: ThreadId.makeUnsafe("thread-1"),
29 terminalId: "default",
30 terminalLabel: "Terminal 1",
31 lineStart: 12,
32 lineEnd: 13,
33 text: "git status\nOn branch main",
34 createdAt: "2026-03-13T12:00:00.000Z",
35 ...overrides,
36 };
37}
38
39describe("terminalContext", () => {
40 it("formats terminal labels with line ranges", () => {
41 expect(formatTerminalContextLabel(makeContext())).toBe("Terminal 1 lines 12-13");
42 expect(
43 formatTerminalContextLabel(
44 makeContext({
45 lineStart: 9,
46 lineEnd: 9,
47 }),
48 ),
49 ).toBe("Terminal 1 line 9");
50 });
51
52 it("builds a numbered terminal context block", () => {
53 expect(buildTerminalContextBlock([makeContext()])).toBe(
54 [
55 "<terminal_context>",
56 "- Terminal 1 lines 12-13:",
57 " 12 | git status",
58 " 13 | On branch main",
59 "</terminal_context>",
60 ].join("\n"),
61 );
62 });
63
64 it("appends terminal context blocks after prompt text", () => {
65 expect(appendTerminalContextsToPrompt("Investigate this", [makeContext()])).toBe(
66 [
67 "Investigate this",
68 "",
69 "<terminal_context>",
70 "- Terminal 1 lines 12-13:",
71 " 12 | git status",
72 " 13 | On branch main",
73 "</terminal_context>",
74 ].join("\n"),
75 );
76 });
77
78 it("replaces inline placeholders with inline terminal labels before appending context blocks", () => {
79 expect(
80 appendTerminalContextsToPrompt(
81 `Investigate ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} carefully`,
82 [makeContext()],
83 ),
84 ).toBe(
85 [
86 "Investigate @terminal-1:12-13 carefully",
87 "",
88 "<terminal_context>",
89 "- Terminal 1 lines 12-13:",
90 " 12 | git status",
91 " 13 | On branch main",
92 "</terminal_context>",
93 ].join("\n"),
94 );
95 });
96
97 it("extracts terminal context blocks from message text", () => {
98 const prompt = appendTerminalContextsToPrompt("Investigate this", [makeContext()]);
99 expect(extractTrailingTerminalContexts(prompt)).toEqual({
100 promptText: "Investigate this",
101 contextCount: 1,
102 previewTitle: "Terminal 1 lines 12-13\n12 | git status\n13 | On branch main",
103 contexts: [
104 {
105 header: "Terminal 1 lines 12-13",
106 body: "12 | git status\n13 | On branch main",
107 },
108 ],
109 });
110 });
111
112 it("derives displayed user message state from terminal context prompts", () => {
113 const prompt = appendTerminalContextsToPrompt("Investigate this", [makeContext()]);
114 expect(deriveDisplayedUserMessageState(prompt)).toEqual({
115 visibleText: "Investigate this",
116 copyText: prompt,
117 contextCount: 1,
118 previewTitle: "Terminal 1 lines 12-13\n12 | git status\n13 | On branch main",
119 contexts: [
120 {
121 header: "Terminal 1 lines 12-13",
122 body: "12 | git status\n13 | On branch main",
123 },
124 ],
125 });
126 });
127
128 it("preserves prompt text when no trailing terminal context block exists", () => {
129 expect(extractTrailingTerminalContexts("No attached context")).toEqual({
130 promptText: "No attached context",
131 contextCount: 0,
132 previewTitle: null,
133 contexts: [],
134 });
135 });
136
137 it("returns null preview title when every context is invalid", () => {
138 expect(
139 buildTerminalContextPreviewTitle([
140 makeContext({
141 terminalId: " ",
142 }),
143 makeContext({
144 id: "context-2",
145 text: "\n\n",
146 }),
147 ]),
148 ).toBeNull();
149 });
150
151 it("tracks inline terminal context placeholders in prompt text", () => {
152 const placeholder = INLINE_TERMINAL_CONTEXT_PLACEHOLDER;
153 expect(countInlineTerminalContextPlaceholders(`a${placeholder}b${placeholder}`)).toBe(2);
154 expect(ensureInlineTerminalContextPlaceholders("Investigate this", 2)).toBe(
155 `${placeholder}${placeholder}Investigate this`,
156 );
157 expect(insertInlineTerminalContextPlaceholder("abc", 1)).toEqual({
158 prompt: `a ${placeholder} bc`,
159 cursor: 4,
160 contextIndex: 0,
161 });
162 expect(removeInlineTerminalContextPlaceholder(`a${placeholder}b${placeholder}c`, 1)).toEqual({
163 prompt: `a${placeholder}bc`,
164 cursor: 3,
165 });
166 expect(stripInlineTerminalContextPlaceholders(`a${placeholder}b`)).toBe("ab");
167 });
168
169 it("inserts a placeholder after a file mention when given the expanded prompt cursor", () => {
170 const placeholder = INLINE_TERMINAL_CONTEXT_PLACEHOLDER;
171 expect(insertInlineTerminalContextPlaceholder("Inspect @package.json ", 22)).toEqual({
172 prompt: `Inspect @package.json ${placeholder} `,
173 cursor: 24,
174 contextIndex: 0,
175 });
176 });
177
178 it("adds a trailing space and consumes an existing trailing space at the insertion point", () => {
179 const placeholder = INLINE_TERMINAL_CONTEXT_PLACEHOLDER;
180 expect(insertInlineTerminalContextPlaceholder("yo whats", 3)).toEqual({
181 prompt: `yo ${placeholder} whats`,
182 cursor: 5,
183 contextIndex: 0,
184 });
185 });
186
187 it("marks contexts without snapshot text as expired and filters them from sendable contexts", () => {
188 const liveContext = makeContext();
189 const expiredContext = makeContext({
190 id: "context-2",
191 text: "",
192 });
193
194 expect(hasTerminalContextText(liveContext)).toBe(true);
195 expect(isTerminalContextExpired(liveContext)).toBe(false);
196 expect(hasTerminalContextText(expiredContext)).toBe(false);
197 expect(isTerminalContextExpired(expiredContext)).toBe(true);
198 expect(filterTerminalContextsWithText([expiredContext, liveContext])).toEqual([liveContext]);
199 });
200
201 it("formats and materializes inline terminal labels from placeholder positions", () => {
202 expect(formatInlineTerminalContextLabel(makeContext())).toBe("@terminal-1:12-13");
203 expect(
204 materializeInlineTerminalContextPrompt(
205 `Investigate ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} carefully`,
206 [makeContext()],
207 ),
208 ).toBe("Investigate @terminal-1:12-13 carefully");
209 });
210});
apps/web/src/lib/terminalContext.ts created+356
...@@ -0,0 +1,356 @@
1import { type ThreadId } from "@t3tools/contracts";
2
3export interface TerminalContextSelection {
4 terminalId: string;
5 terminalLabel: string;
6 lineStart: number;
7 lineEnd: number;
8 text: string;
9}
10
11export interface TerminalContextDraft extends TerminalContextSelection {
12 id: string;
13 threadId: ThreadId;
14 createdAt: string;
15}
16
17export interface ExtractedTerminalContexts {
18 promptText: string;
19 contextCount: number;
20 previewTitle: string | null;
21 contexts: ParsedTerminalContextEntry[];
22}
23
24export interface DisplayedUserMessageState {
25 visibleText: string;
26 copyText: string;
27 contextCount: number;
28 previewTitle: string | null;
29 contexts: ParsedTerminalContextEntry[];
30}
31
32export interface ParsedTerminalContextEntry {
33 header: string;
34 body: string;
35}
36
37export const INLINE_TERMINAL_CONTEXT_PLACEHOLDER = "\uFFFC";
38
39const TRAILING_TERMINAL_CONTEXT_BLOCK_PATTERN =
40 /\n*<terminal_context>\n([\s\S]*?)\n<\/terminal_context>\s*$/;
41
42export function normalizeTerminalContextText(text: string): string {
43 return text.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, "");
44}
45
46export function hasTerminalContextText(context: { text: string }): boolean {
47 return normalizeTerminalContextText(context.text).length > 0;
48}
49
50export function isTerminalContextExpired(context: { text: string }): boolean {
51 return !hasTerminalContextText(context);
52}
53
54export function filterTerminalContextsWithText<T extends { text: string }>(
55 contexts: ReadonlyArray<T>,
56): T[] {
57 return contexts.filter((context) => hasTerminalContextText(context));
58}
59
60function previewTerminalContextText(text: string): string {
61 const normalized = normalizeTerminalContextText(text);
62 if (normalized.length === 0) {
63 return "";
64 }
65 const lines = normalized.split("\n");
66 const visibleLines = lines.slice(0, 3);
67 if (lines.length > 3) {
68 visibleLines.push("...");
69 }
70 const preview = visibleLines.join("\n");
71 return preview.length > 180 ? `${preview.slice(0, 177)}...` : preview;
72}
73
74export function normalizeTerminalContextSelection(
75 selection: TerminalContextSelection,
76): TerminalContextSelection | null {
77 const text = normalizeTerminalContextText(selection.text);
78 const terminalId = selection.terminalId.trim();
79 const terminalLabel = selection.terminalLabel.trim();
80 if (text.length === 0 || terminalId.length === 0 || terminalLabel.length === 0) {
81 return null;
82 }
83 const lineStart = Math.max(1, Math.floor(selection.lineStart));
84 const lineEnd = Math.max(lineStart, Math.floor(selection.lineEnd));
85 return {
86 terminalId,
87 terminalLabel,
88 lineStart,
89 lineEnd,
90 text,
91 };
92}
93
94export function formatTerminalContextRange(selection: {
95 lineStart: number;
96 lineEnd: number;
97}): string {
98 return selection.lineStart === selection.lineEnd
99 ? `line ${selection.lineStart}`
100 : `lines ${selection.lineStart}-${selection.lineEnd}`;
101}
102
103export function formatTerminalContextLabel(selection: {
104 terminalLabel: string;
105 lineStart: number;
106 lineEnd: number;
107}): string {
108 return `${selection.terminalLabel} ${formatTerminalContextRange(selection)}`;
109}
110
111export function formatInlineTerminalContextLabel(selection: {
112 terminalLabel: string;
113 lineStart: number;
114 lineEnd: number;
115}): string {
116 const terminalLabel = selection.terminalLabel.trim().toLowerCase().replace(/\s+/g, "-");
117 const range =
118 selection.lineStart === selection.lineEnd
119 ? `${selection.lineStart}`
120 : `${selection.lineStart}-${selection.lineEnd}`;
121 return `@${terminalLabel}:${range}`;
122}
123
124export function buildTerminalContextPreviewTitle(
125 contexts: ReadonlyArray<TerminalContextSelection>,
126): string | null {
127 if (contexts.length === 0) {
128 return null;
129 }
130 const previews = contexts
131 .map((context) => {
132 const normalized = normalizeTerminalContextSelection(context);
133 if (!normalized) {
134 return null;
135 }
136 const preview = previewTerminalContextText(normalized.text);
137 return preview.length > 0
138 ? `${formatTerminalContextLabel(normalized)}\n${preview}`
139 : formatTerminalContextLabel(normalized);
140 })
141 .filter((value): value is string => value !== null)
142 .join("\n\n");
143 return previews.length > 0 ? previews : null;
144}
145
146function buildTerminalContextBodyLines(selection: TerminalContextSelection): string[] {
147 return normalizeTerminalContextText(selection.text)
148 .split("\n")
149 .map((line, index) => ` ${selection.lineStart + index} | ${line}`);
150}
151
152export function buildTerminalContextBlock(
153 contexts: ReadonlyArray<TerminalContextSelection>,
154): string {
155 const normalizedContexts = contexts
156 .map((context) => normalizeTerminalContextSelection(context))
157 .filter((context): context is TerminalContextSelection => context !== null);
158 if (normalizedContexts.length === 0) {
159 return "";
160 }
161 const lines: string[] = [];
162 for (let index = 0; index < normalizedContexts.length; index += 1) {
163 const context = normalizedContexts[index]!;
164 lines.push(`- ${formatTerminalContextLabel(context)}:`);
165 lines.push(...buildTerminalContextBodyLines(context));
166 if (index < normalizedContexts.length - 1) {
167 lines.push("");
168 }
169 }
170 return ["<terminal_context>", ...lines, "</terminal_context>"].join("\n");
171}
172
173export function materializeInlineTerminalContextPrompt(
174 prompt: string,
175 contexts: ReadonlyArray<{
176 terminalLabel: string;
177 lineStart: number;
178 lineEnd: number;
179 }>,
180): string {
181 let nextContextIndex = 0;
182 let result = "";
183
184 for (const char of prompt) {
185 if (char !== INLINE_TERMINAL_CONTEXT_PLACEHOLDER) {
186 result += char;
187 continue;
188 }
189 const context = contexts[nextContextIndex] ?? null;
190 nextContextIndex += 1;
191 if (!context) {
192 continue;
193 }
194 result += formatInlineTerminalContextLabel(context);
195 }
196
197 return result;
198}
199
200export function appendTerminalContextsToPrompt(
201 prompt: string,
202 contexts: ReadonlyArray<TerminalContextSelection>,
203): string {
204 const trimmedPrompt = materializeInlineTerminalContextPrompt(prompt, contexts).trim();
205 const contextBlock = buildTerminalContextBlock(contexts);
206 if (contextBlock.length === 0) {
207 return trimmedPrompt;
208 }
209 return trimmedPrompt.length > 0 ? `${trimmedPrompt}\n\n${contextBlock}` : contextBlock;
210}
211
212export function extractTrailingTerminalContexts(prompt: string): ExtractedTerminalContexts {
213 const match = TRAILING_TERMINAL_CONTEXT_BLOCK_PATTERN.exec(prompt);
214 if (!match) {
215 return {
216 promptText: prompt,
217 contextCount: 0,
218 previewTitle: null,
219 contexts: [],
220 };
221 }
222 const promptText = prompt.slice(0, match.index).replace(/\n+$/, "");
223 const parsedContexts = parseTerminalContextEntries(match[1] ?? "");
224 return {
225 promptText,
226 contextCount: parsedContexts.length,
227 previewTitle:
228 parsedContexts.length > 0
229 ? parsedContexts
230 .map(({ header, body }) => (body.length > 0 ? `${header}\n${body}` : header))
231 .join("\n\n")
232 : null,
233 contexts: parsedContexts,
234 };
235}
236
237export function deriveDisplayedUserMessageState(prompt: string): DisplayedUserMessageState {
238 const extractedContexts = extractTrailingTerminalContexts(prompt);
239 return {
240 visibleText: extractedContexts.promptText,
241 copyText: prompt,
242 contextCount: extractedContexts.contextCount,
243 previewTitle: extractedContexts.previewTitle,
244 contexts: extractedContexts.contexts,
245 };
246}
247
248function parseTerminalContextEntries(block: string): ParsedTerminalContextEntry[] {
249 const entries: ParsedTerminalContextEntry[] = [];
250 let current: { header: string; bodyLines: string[] } | null = null;
251
252 const commitCurrent = () => {
253 if (!current) {
254 return;
255 }
256 entries.push({
257 header: current.header,
258 body: current.bodyLines.join("\n").trimEnd(),
259 });
260 current = null;
261 };
262
263 for (const rawLine of block.split("\n")) {
264 const headerMatch = /^- (.+):$/.exec(rawLine);
265 if (headerMatch) {
266 commitCurrent();
267 current = {
268 header: headerMatch[1]!,
269 bodyLines: [],
270 };
271 continue;
272 }
273 if (!current) {
274 continue;
275 }
276 if (rawLine.startsWith(" ")) {
277 current.bodyLines.push(rawLine.slice(2));
278 continue;
279 }
280 if (rawLine.length === 0) {
281 current.bodyLines.push("");
282 }
283 }
284
285 commitCurrent();
286 return entries;
287}
288
289export function countInlineTerminalContextPlaceholders(prompt: string): number {
290 let count = 0;
291 for (const char of prompt) {
292 if (char === INLINE_TERMINAL_CONTEXT_PLACEHOLDER) {
293 count += 1;
294 }
295 }
296 return count;
297}
298
299export function ensureInlineTerminalContextPlaceholders(
300 prompt: string,
301 terminalContextCount: number,
302): string {
303 const missingCount = terminalContextCount - countInlineTerminalContextPlaceholders(prompt);
304 if (missingCount <= 0) {
305 return prompt;
306 }
307 return `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER.repeat(missingCount)}${prompt}`;
308}
309
310function isInlineTerminalContextBoundaryWhitespace(char: string | undefined): boolean {
311 return char === undefined || char === " " || char === "\n" || char === "\t" || char === "\r";
312}
313
314export function insertInlineTerminalContextPlaceholder(
315 prompt: string,
316 cursorInput: number,
317): { prompt: string; cursor: number; contextIndex: number } {
318 const cursor = Math.max(0, Math.min(prompt.length, Math.floor(cursorInput)));
319 const needsLeadingSpace = !isInlineTerminalContextBoundaryWhitespace(prompt[cursor - 1]);
320 const replacement = `${needsLeadingSpace ? " " : ""}${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} `;
321 const rangeEnd = prompt[cursor] === " " ? cursor + 1 : cursor;
322 return {
323 prompt: `${prompt.slice(0, cursor)}${replacement}${prompt.slice(rangeEnd)}`,
324 cursor: cursor + replacement.length,
325 contextIndex: countInlineTerminalContextPlaceholders(prompt.slice(0, cursor)),
326 };
327}
328
329export function stripInlineTerminalContextPlaceholders(prompt: string): string {
330 return prompt.replaceAll(INLINE_TERMINAL_CONTEXT_PLACEHOLDER, "");
331}
332
333export function removeInlineTerminalContextPlaceholder(
334 prompt: string,
335 contextIndex: number,
336): { prompt: string; cursor: number } {
337 if (contextIndex < 0) {
338 return { prompt, cursor: prompt.length };
339 }
340
341 let placeholderIndex = 0;
342 for (let index = 0; index < prompt.length; index += 1) {
343 if (prompt[index] !== INLINE_TERMINAL_CONTEXT_PLACEHOLDER) {
344 continue;
345 }
346 if (placeholderIndex === contextIndex) {
347 return {
348 prompt: prompt.slice(0, index) + prompt.slice(index + 1),
349 cursor: index,
350 };
351 }
352 placeholderIndex += 1;
353 }
354
355 return { prompt, cursor: prompt.length };
356}
apps/web/src/vscode-icons.ts+11
...@@ -75,6 +75,17 @@ export function basenameOfPath(pathValue: string): string {...@@ -75,6 +75,17 @@ export function basenameOfPath(pathValue: string): string {
75 return pathValue.slice(slashIndex + 1);75 return pathValue.slice(slashIndex + 1);
76}76}
7777
78export function inferEntryKindFromPath(pathValue: string): "file" | "directory" {
79 const base = basenameOfPath(pathValue);
80 if (base.startsWith(".") && !base.slice(1).includes(".")) {
81 return "directory";
82 }
83 if (base.includes(".")) {
84 return "file";
85 }
86 return "directory";
87}
88
78function extensionCandidates(fileName: string): string[] {89function extensionCandidates(fileName: string): string[] {
79 const candidates = new Set<string>();90 const candidates = new Set<string>();
80 if (fileName.includes(".")) {91 if (fileName.includes(".")) {