| author | |
| committer | |
| log | 1310045629f6613b2fd3e9b82e2e371495e0ca2a |
| tree | f7484c65f9ed21539c5815ab3446dad6c7a6ed9d |
| parent | 579a69eb7756b2169d26299d36f936838db39caa |
| signature | Signed by PGP key B5690EEEBB952194 |
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/ |
| 17 | apps/web/.playwright | 17 | apps/web/.playwright |
| 18 | apps/web/playwright-report | 18 | apps/web/playwright-report |
| 19 | apps/web/src/components/__screenshots__ | 19 | apps/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 } |
| 21 | import { render } from "vitest-browser-react"; | 21 | import { render } from "vitest-browser-react"; |
| 22 | 22 | ||
| 23 | import { useComposerDraftStore } from "../composerDraftStore"; | 23 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 24 | import { | ||
| 25 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, | ||
| 26 | type TerminalContextDraft, | ||
| 27 | } from "../lib/terminalContext"; | ||
| 24 | import { isMacPlatform } from "../lib/utils"; | 28 | import { isMacPlatform } from "../lib/utils"; |
| 25 | import { getRouter } from "../router"; | 29 | import { getRouter } from "../router"; |
| 26 | import { useStore } from "../store"; | 30 | import { 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 | } |
| 152 | 156 | ||
| 157 | function createTerminalContext(input: { | ||
| 158 | id: string; | ||
| 159 | terminalLabel: string; | ||
| 160 | lineStart: number; | ||
| 161 | lineEnd: number; | ||
| 162 | text: string; | ||
| 163 | }): TerminalContextDraft { | ||
| 164 | return { | ||
| 165 | id: input.id, | ||
| 166 | threadId: THREAD_ID, | ||
| 167 | terminalId: `terminal-${input.id}`, | ||
| 168 | terminalLabel: input.terminalLabel, | ||
| 169 | lineStart: input.lineStart, | ||
| 170 | lineEnd: input.lineEnd, | ||
| 171 | text: input.text, | ||
| 172 | createdAt: NOW_ISO, | ||
| 173 | }; | ||
| 174 | } | ||
| 175 | |||
| 153 | function createSnapshotForTargetUser(options: { | 176 | function 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 | } |
| 533 | 556 | ||
| 557 | async function waitForSendButton(): Promise<HTMLButtonElement> { | ||
| 558 | return waitForElement( | ||
| 559 | () => document.querySelector<HTMLButtonElement>('button[aria-label="Send message"]'), | ||
| 560 | "Unable to find send button.", | ||
| 561 | ); | ||
| 562 | } | ||
| 563 | |||
| 534 | async function waitForInteractionModeButton( | 564 | async 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 | }); |
| 1013 | 1043 | ||
| 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 @@ | ||
| 1 | import { ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vitest"; | ||
| 3 | |||
| 4 | import { buildExpiredTerminalContextToastCopy, deriveComposerSendState } from "./ChatView.logic"; | ||
| 5 | |||
| 6 | describe("deriveComposerSendState", () => { | ||
| 7 | it("treats expired terminal pills as non-sendable content", () => { | ||
| 8 | const state = deriveComposerSendState({ | ||
| 9 | prompt: "\uFFFC", | ||
| 10 | imageCount: 0, | ||
| 11 | terminalContexts: [ | ||
| 12 | { | ||
| 13 | id: "ctx-expired", | ||
| 14 | threadId: ThreadId.makeUnsafe("thread-1"), | ||
| 15 | terminalId: "default", | ||
| 16 | terminalLabel: "Terminal 1", | ||
| 17 | lineStart: 4, | ||
| 18 | lineEnd: 4, | ||
| 19 | text: "", | ||
| 20 | createdAt: "2026-03-17T12:52:29.000Z", | ||
| 21 | }, | ||
| 22 | ], | ||
| 23 | }); | ||
| 24 | |||
| 25 | expect(state.trimmedPrompt).toBe(""); | ||
| 26 | expect(state.sendableTerminalContexts).toEqual([]); | ||
| 27 | expect(state.expiredTerminalContextCount).toBe(1); | ||
| 28 | expect(state.hasSendableContent).toBe(false); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it("keeps text sendable while excluding expired terminal pills", () => { | ||
| 32 | const state = deriveComposerSendState({ | ||
| 33 | prompt: `yoo \uFFFC waddup`, | ||
| 34 | imageCount: 0, | ||
| 35 | terminalContexts: [ | ||
| 36 | { | ||
| 37 | id: "ctx-expired", | ||
| 38 | threadId: ThreadId.makeUnsafe("thread-1"), | ||
| 39 | terminalId: "default", | ||
| 40 | terminalLabel: "Terminal 1", | ||
| 41 | lineStart: 4, | ||
| 42 | lineEnd: 4, | ||
| 43 | text: "", | ||
| 44 | createdAt: "2026-03-17T12:52:29.000Z", | ||
| 45 | }, | ||
| 46 | ], | ||
| 47 | }); | ||
| 48 | |||
| 49 | expect(state.trimmedPrompt).toBe("yoo waddup"); | ||
| 50 | expect(state.expiredTerminalContextCount).toBe(1); | ||
| 51 | expect(state.hasSendableContent).toBe(true); | ||
| 52 | }); | ||
| 53 | }); | ||
| 54 | |||
| 55 | describe("buildExpiredTerminalContextToastCopy", () => { | ||
| 56 | it("formats clear empty-state guidance", () => { | ||
| 57 | expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({ | ||
| 58 | title: "Expired terminal context won't be sent", | ||
| 59 | description: "Remove it or re-add it to include terminal output.", | ||
| 60 | }); | ||
| 61 | }); | ||
| 62 | |||
| 63 | it("formats omission guidance for sent messages", () => { | ||
| 64 | expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({ | ||
| 65 | title: "Expired terminal contexts omitted from message", | ||
| 66 | description: "Re-add it if you want that terminal output included.", | ||
| 67 | }); | ||
| 68 | }); | ||
| 69 | }); | ||
apps/web/src/components/ChatView.logic.ts+46| ... | @@ -4,6 +4,11 @@ import { randomUUID } from "~/lib/utils"; | ... | @@ -4,6 +4,11 @@ import { randomUUID } from "~/lib/utils"; |
| 4 | import { getAppModelOptions } from "../appSettings"; | 4 | import { getAppModelOptions } from "../appSettings"; |
| 5 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; | 5 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; |
| 6 | import { Schema } from "effect"; | 6 | import { Schema } from "effect"; |
| 7 | import { | ||
| 8 | filterTerminalContextsWithText, | ||
| 9 | stripInlineTerminalContextPlaceholders, | ||
| 10 | type TerminalContextDraft, | ||
| 11 | } from "../lib/terminalContext"; | ||
| 7 | 12 | ||
| 8 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; | 13 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; |
| 9 | const WORKTREE_BRANCH_PREFIX = "t3code"; | 14 | const 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 | |||
| 132 | export function deriveComposerSendState(options: { | ||
| 133 | prompt: string; | ||
| 134 | imageCount: number; | ||
| 135 | terminalContexts: ReadonlyArray<TerminalContextDraft>; | ||
| 136 | }): { | ||
| 137 | trimmedPrompt: string; | ||
| 138 | sendableTerminalContexts: TerminalContextDraft[]; | ||
| 139 | expiredTerminalContextCount: number; | ||
| 140 | hasSendableContent: boolean; | ||
| 141 | } { | ||
| 142 | const trimmedPrompt = stripInlineTerminalContextPlaceholders(options.prompt).trim(); | ||
| 143 | const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts); | ||
| 144 | const expiredTerminalContextCount = | ||
| 145 | options.terminalContexts.length - sendableTerminalContexts.length; | ||
| 146 | return { | ||
| 147 | trimmedPrompt, | ||
| 148 | sendableTerminalContexts, | ||
| 149 | expiredTerminalContextCount, | ||
| 150 | hasSendableContent: | ||
| 151 | trimmedPrompt.length > 0 || options.imageCount > 0 || sendableTerminalContexts.length > 0, | ||
| 152 | }; | ||
| 153 | } | ||
| 154 | |||
| 155 | export function buildExpiredTerminalContextToastCopy( | ||
| 156 | expiredTerminalContextCount: number, | ||
| 157 | variant: "omitted" | "empty", | ||
| 158 | ): { title: string; description: string } { | ||
| 159 | const count = Math.max(1, Math.floor(expiredTerminalContextCount)); | ||
| 160 | const noun = count === 1 ? "Expired terminal context" : "Expired terminal contexts"; | ||
| 161 | if (variant === "empty") { | ||
| 162 | return { | ||
| 163 | title: `${noun} won't be sent`, | ||
| 164 | description: "Remove it or re-add it to include terminal output.", | ||
| 165 | }; | ||
| 166 | } | ||
| 167 | return { | ||
| 168 | title: `${noun} omitted from message`, | ||
| 169 | description: "Re-add it if you want that terminal output included.", | ||
| 170 | }; | ||
| 171 | } |
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"; |
| 130 | import { | ||
| 131 | appendTerminalContextsToPrompt, | ||
| 132 | formatTerminalContextLabel, | ||
| 133 | insertInlineTerminalContextPlaceholder, | ||
| 134 | removeInlineTerminalContextPlaceholder, | ||
| 135 | type TerminalContextDraft, | ||
| 136 | type TerminalContextSelection, | ||
| 137 | } from "../lib/terminalContext"; | ||
| 130 | import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; | 138 | import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; |
| 131 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 139 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 132 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; | 140 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; |
| ... | @@ -145,10 +153,12 @@ import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; | ... | @@ -145,10 +153,12 @@ import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; |
| 145 | import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; | 153 | import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; |
| 146 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; | 154 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; |
| 147 | import { | 155 | import { |
| 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 | }; |
| 187 | 197 | ||
| 198 | const 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 | |||
| 209 | const 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 | |||
| 188 | interface ChatViewProps { | 215 | interface 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 | ); | ||
| 359 | 437 | ||
| 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]); |
| 1732 | 1852 | ||
| 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"); |
| 2258 | 2407 | ||
| 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(); |
| 2293 | 2447 | ||
| 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 === 0 | 2617 | 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]); |
| 2983 | 3154 | ||
| 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 | ); |
| 3123 | 3304 | ||
| ... | @@ -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 | )} |
| 3386 | 3567 | ||
| 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 | <div | 3573 | <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 | <ComposerPromptEditor | 3639 | <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 | : prompt | 3646 | : 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 | isConnecting | 3935 | 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"; |
| 38 | import { | 40 | import { |
| 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"; |
| 49 | 54 | ||
| ... | @@ -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"; |
| 56 | import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; | 61 | import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; |
| 62 | import { | ||
| 63 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, | ||
| 64 | type TerminalContextDraft, | ||
| 65 | } from "~/lib/terminalContext"; | ||
| 57 | import { cn } from "~/lib/utils"; | 66 | import { cn } from "~/lib/utils"; |
| 58 | import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons"; | 67 | import { basenameOfPath, getVscodeIconUrlForEntry, inferEntryKindFromPath } from "~/vscode-icons"; |
| 68 | import { | ||
| 69 | COMPOSER_INLINE_CHIP_CLASS_NAME, | ||
| 70 | COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, | ||
| 71 | COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, | ||
| 72 | } from "./composerInlineChip"; | ||
| 73 | import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; | ||
| 59 | 74 | ||
| 60 | const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; | 75 | const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; |
| 61 | 76 | ||
| ... | @@ -68,6 +83,21 @@ type SerializedComposerMentionNode = Spread< | ... | @@ -68,6 +83,21 @@ type SerializedComposerMentionNode = Spread< |
| 68 | SerializedTextNode | 83 | SerializedTextNode |
| 69 | >; | 84 | >; |
| 70 | 85 | ||
| 86 | type SerializedComposerTerminalContextNode = Spread< | ||
| 87 | { | ||
| 88 | context: TerminalContextDraft; | ||
| 89 | type: "composer-terminal-context"; | ||
| 90 | version: 1; | ||
| 91 | }, | ||
| 92 | SerializedLexicalNode | ||
| 93 | >; | ||
| 94 | |||
| 95 | const ComposerTerminalContextActionsContext = createContext<{ | ||
| 96 | onRemoveTerminalContext: (contextId: string) => void; | ||
| 97 | }>({ | ||
| 98 | onRemoveTerminalContext: () => {}, | ||
| 99 | }); | ||
| 100 | |||
| 71 | class ComposerMentionNode extends TextNode { | 101 | class ComposerMentionNode extends TextNode { |
| 72 | __path: string; | 102 | __path: string; |
| 73 | 103 | ||
| ... | @@ -100,8 +130,7 @@ class ComposerMentionNode extends TextNode { | ... | @@ -100,8 +130,7 @@ class ComposerMentionNode extends TextNode { |
| 100 | 130 | ||
| 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 | } |
| 143 | 172 | ||
| 144 | function inferMentionPathKind(pathValue: string): "file" | "directory" { | 173 | function 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 | |
| 177 | class 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 | |||
| 231 | function $createComposerTerminalContextNode( | ||
| 232 | context: TerminalContextDraft, | ||
| 233 | ): ComposerTerminalContextNode { | ||
| 234 | return $applyNodeReplacement(new ComposerTerminalContextNode(context)); | ||
| 235 | } | ||
| 236 | |||
| 237 | type ComposerInlineTokenNode = ComposerMentionNode | ComposerTerminalContextNode; | ||
| 238 | |||
| 239 | function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode { | ||
| 240 | return ( | ||
| 241 | candidate instanceof ComposerMentionNode || candidate instanceof ComposerTerminalContextNode | ||
| 242 | ); | ||
| 153 | } | 243 | } |
| 154 | 244 | ||
| 155 | function resolvedThemeFromDocument(): "light" | "dark" { | 245 | function 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); |
| 171 | 261 | ||
| 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); |
| 175 | 265 | ||
| 176 | container.append(icon, label); | 266 | container.append(icon, label); |
| 177 | } | 267 | } |
| 178 | 268 | ||
| 269 | function 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 | |||
| 179 | function clampExpandedCursor(value: string, cursor: number): number { | 286 | function 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 | } |
| 183 | 290 | ||
| 291 | function getComposerInlineTokenTextLength(_node: ComposerInlineTokenNode): 1 { | ||
| 292 | return 1; | ||
| 293 | } | ||
| 294 | |||
| 295 | function getComposerInlineTokenExpandedTextLength(node: ComposerInlineTokenNode): number { | ||
| 296 | return node.getTextContentSize(); | ||
| 297 | } | ||
| 298 | |||
| 299 | function getAbsoluteOffsetForInlineTokenPoint( | ||
| 300 | node: ComposerInlineTokenNode, | ||
| 301 | absoluteOffset: number, | ||
| 302 | pointOffset: number, | ||
| 303 | ): number { | ||
| 304 | return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenTextLength(node) : 0); | ||
| 305 | } | ||
| 306 | |||
| 307 | function getExpandedAbsoluteOffsetForInlineTokenPoint( | ||
| 308 | node: ComposerInlineTokenNode, | ||
| 309 | absoluteOffset: number, | ||
| 310 | pointOffset: number, | ||
| 311 | ): number { | ||
| 312 | return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenExpandedTextLength(node) : 0); | ||
| 313 | } | ||
| 314 | |||
| 315 | function 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 | |||
| 184 | function getComposerNodeTextLength(node: LexicalNode): number { | 340 | function 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 | } |
| 199 | 355 | ||
| 200 | function getComposerNodeExpandedTextLength(node: LexicalNode): number { | 356 | function 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 |
| 233 | 392 | ||
| 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 | } | ||
| 240 | 402 | ||
| 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 |
| 277 | 439 | ||
| 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 | } | ||
| 284 | 449 | ||
| 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 | } |
| 328 | 478 | ||
| 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 | } |
| 440 | 590 | ||
| 441 | function $setComposerEditorPrompt(prompt: string): void { | 591 | function $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); |
| 446 | 599 | ||
| 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 | } |
| 456 | 615 | ||
| 616 | function 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 | |||
| 457 | export interface ComposerPromptEditorHandle { | 626 | export 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 | } |
| 463 | 637 | ||
| 464 | interface ComposerPromptEditorProps { | 638 | interface 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 | } |
| 542 | 719 | ||
| 543 | function ComposerMentionArrowPlugin() { | 720 | function ComposerInlineTokenArrowPlugin() { |
| 544 | const [editor] = useLexicalComposerContext(); | 721 | const [editor] = useLexicalComposerContext(); |
| 545 | 722 | ||
| 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 | } |
| 608 | 785 | ||
| 609 | function ComposerMentionSelectionNormalizePlugin() { | 786 | function ComposerInlineTokenSelectionNormalizePlugin() { |
| 610 | const [editor] = useLexicalComposerContext(); | 787 | const [editor] = useLexicalComposerContext(); |
| 611 | 788 | ||
| 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 | } |
| 636 | 813 | ||
| 637 | function ComposerMentionBackspacePlugin() { | 814 | function ComposerInlineTokenBackspacePlugin() { |
| 638 | const [editor] = useLexicalComposerContext(); | 815 | const [editor] = useLexicalComposerContext(); |
| 816 | const { onRemoveTerminalContext } = useContext(ComposerTerminalContextActionsContext); | ||
| 639 | 817 | ||
| 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 | } |
| 648 | 826 | ||
| 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 | }; |
| 660 | 844 | if (removeInlineTokenNode(anchorNode)) { | |
| 661 | if (removeMentionNode(anchorNode)) { | ||
| 662 | return true; | 845 | return true; |
| 663 | } | 846 | } |
| 664 | 847 | ||
| ... | @@ -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() { |
| 681 | 864 | ||
| 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]); |
| 694 | 877 | ||
| 695 | return null; | 878 | return null; |
| 696 | } | 879 | } |
| ... | @@ -698,9 +881,11 @@ function ComposerMentionBackspacePlugin() { | ... | @@ -698,9 +881,11 @@ function ComposerMentionBackspacePlugin() { |
| 698 | function ComposerPromptEditorInner({ | 881 | function 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 | ); | ||
| 718 | 910 | ||
| 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 | } |
| 733 | 930 | ||
| ... | @@ -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; | ||
| 739 | 938 | ||
| 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 | } |
| 745 | 944 | ||
| 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]); |
| 760 | 959 | ||
| 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 === nextExpandedCursor | 1061 | 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 | }, []); |
| 874 | 1088 | ||
| 875 | return ( | 1089 | return ( |
| 876 | <div className="relative"> | 1090 | <ComposerTerminalContextActionsContext.Provider value={terminalContextActions}> |
| 877 | <PlainTextPlugin | 1091 | <div className="relative"> |
| 878 | contentEditable={ | 1092 | <PlainTextPlugin |
| 879 | <ContentEditable | 1093 | 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 | } |
| 906 | 1124 | ||
| ... | @@ -908,17 +1126,29 @@ export const ComposerPromptEditor = forwardRef< | ... | @@ -908,17 +1126,29 @@ export const ComposerPromptEditor = forwardRef< |
| 908 | ComposerPromptEditorHandle, | 1126 | ComposerPromptEditorHandle, |
| 909 | ComposerPromptEditorProps | 1127 | 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 | <ComposerPromptEditorInner | 1162 | <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 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | resolveTerminalSelectionActionPosition, | ||
| 5 | shouldHandleTerminalSelectionMouseUp, | ||
| 6 | terminalSelectionActionDelayForClickCount, | ||
| 7 | } from "./ThreadTerminalDrawer"; | ||
| 8 | |||
| 9 | describe("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"; |
| 14 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; | 14 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; |
| 15 | import { type TerminalContextSelection } from "~/lib/terminalContext"; | ||
| 15 | import { openInPreferredEditor } from "../editorPreferences"; | 16 | import { openInPreferredEditor } from "../editorPreferences"; |
| 16 | import { | 17 | import { |
| 17 | extractTerminalLinks, | 18 | extractTerminalLinks, |
| ... | @@ -29,6 +30,7 @@ import { readNativeApi } from "~/nativeApi"; | ... | @@ -29,6 +30,7 @@ import { readNativeApi } from "~/nativeApi"; |
| 29 | 30 | ||
| 30 | const MIN_DRAWER_HEIGHT = 180; | 31 | const MIN_DRAWER_HEIGHT = 180; |
| 31 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; | 32 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; |
| 33 | const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; | ||
| 32 | 34 | ||
| 33 | function maxDrawerHeight(): number { | 35 | function 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 | } |
| 109 | 111 | ||
| 112 | function 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 | |||
| 137 | export 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 | |||
| 172 | export function terminalSelectionActionDelayForClickCount(clickCount: number): number { | ||
| 173 | return clickCount >= 2 ? MULTI_CLICK_SELECTION_ACTION_DELAY_MS : 0; | ||
| 174 | } | ||
| 175 | |||
| 176 | export function shouldHandleTerminalSelectionMouseUp( | ||
| 177 | selectionGestureActive: boolean, | ||
| 178 | button: number, | ||
| 179 | ): boolean { | ||
| 180 | return selectionGestureActive && button === 0; | ||
| 181 | } | ||
| 182 | |||
| 110 | interface TerminalViewportProps { | 183 | interface 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 { |
| 122 | function TerminalViewport({ | 197 | function 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); | ||
| 138 | 222 | ||
| 139 | useEffect(() => { | 223 | useEffect(() => { |
| 140 | onSessionExitedRef.current = onSessionExited; | 224 | onSessionExitedRef.current = onSessionExited; |
| 141 | }, [onSessionExited]); | 225 | }, [onSessionExited]); |
| 142 | 226 | ||
| 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; |
| 167 | 259 | ||
| 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 | }); |
| 261 | 428 | ||
| 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({ |
| 310 | 509 | ||
| 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 | } |
| 315 | 515 | ||
| 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 | } |
| 324 | 525 | ||
| 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 | } |
| 435 | 645 | ||
| 436 | interface ThreadTerminalDrawerProps { | 646 | interface 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 | } |
| 455 | 666 | ||
| 456 | interface TerminalActionButtonProps { | 667 | interface 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 | <TerminalViewport | 1008 | <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 | autoFocus | 1037 | autoFocus |
| 822 | resizeEpoch={resizeEpoch} | 1038 | resizeEpoch={resizeEpoch} |
apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx created+28| ... | @@ -0,0 +1,28 @@ | ||
| 1 | import { ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | ||
| 3 | import { describe, expect, it } from "vitest"; | ||
| 4 | |||
| 5 | import { ComposerPendingTerminalContextChip } from "./ComposerPendingTerminalContexts"; | ||
| 6 | |||
| 7 | describe("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 @@ | ||
| 1 | import { cn } from "~/lib/utils"; | ||
| 2 | import { | ||
| 3 | type TerminalContextDraft, | ||
| 4 | formatTerminalContextLabel, | ||
| 5 | isTerminalContextExpired, | ||
| 6 | } from "~/lib/terminalContext"; | ||
| 7 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; | ||
| 8 | |||
| 9 | interface ComposerPendingTerminalContextsProps { | ||
| 10 | contexts: ReadonlyArray<TerminalContextDraft>; | ||
| 11 | className?: string; | ||
| 12 | } | ||
| 13 | |||
| 14 | interface ComposerPendingTerminalContextChipProps { | ||
| 15 | context: TerminalContextDraft; | ||
| 16 | } | ||
| 17 | |||
| 18 | export 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 | |||
| 30 | export 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 @@ | ||
| 1 | import { MessageId } from "@t3tools/contracts"; | ||
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | ||
| 3 | import { beforeAll, describe, expect, it, vi } from "vitest"; | ||
| 4 | |||
| 5 | function matchMedia() { | ||
| 6 | return { | ||
| 7 | matches: false, | ||
| 8 | addEventListener: () => {}, | ||
| 9 | removeEventListener: () => {}, | ||
| 10 | }; | ||
| 11 | } | ||
| 12 | |||
| 13 | beforeAll(() => { | ||
| 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 | |||
| 45 | describe("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 @@ |
| 1 | import { type MessageId, type TurnId } from "@t3tools/contracts"; | 1 | import { type MessageId, type TurnId } from "@t3tools/contracts"; |
| 2 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | 2 | import { |
| 3 | memo, | ||
| 4 | useCallback, | ||
| 5 | useEffect, | ||
| 6 | useLayoutEffect, | ||
| 7 | useMemo, | ||
| 8 | useRef, | ||
| 9 | useState, | ||
| 10 | type ReactNode, | ||
| 11 | } from "react"; | ||
| 3 | import { | 12 | import { |
| 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"; |
| 33 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; | 42 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 34 | import { MessageCopyButton } from "./MessageCopyButton"; | 43 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 35 | import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; | 44 | import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; |
| 45 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; | ||
| 46 | import { | ||
| 47 | deriveDisplayedUserMessageState, | ||
| 48 | type ParsedTerminalContextEntry, | ||
| 49 | } from "~/lib/terminalContext"; | ||
| 36 | import { cn } from "~/lib/utils"; | 50 | import { cn } from "~/lib/utils"; |
| 37 | import { type TimestampFormat } from "../../appSettings"; | 51 | import { type TimestampFormat } from "../../appSettings"; |
| 38 | import { formatTimestamp } from "../../timestampFormat"; | 52 | import { formatTimestamp } from "../../timestampFormat"; |
| 53 | import { | ||
| 54 | buildInlineTerminalContextText, | ||
| 55 | formatInlineTerminalContextLabel, | ||
| 56 | textContainsInlineTerminalContextLabels, | ||
| 57 | } from "./userMessageTerminalContexts"; | ||
| 39 | 58 | ||
| 40 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | 59 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| 41 | const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; | 60 | const 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 | <Button | 415 | <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 | } |
| 638 | 663 | ||
| 664 | const 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 | |||
| 675 | const 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 | |||
| 639 | function workToneIcon(tone: TimelineWorkEntry["tone"]): { | 768 | function 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 @@ | ||
| 1 | import { TerminalIcon } from "lucide-react"; | ||
| 2 | |||
| 3 | import { cn } from "~/lib/utils"; | ||
| 4 | import { | ||
| 5 | COMPOSER_INLINE_CHIP_CLASS_NAME, | ||
| 6 | COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, | ||
| 7 | COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, | ||
| 8 | } from "../composerInlineChip"; | ||
| 9 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 10 | |||
| 11 | interface TerminalContextInlineChipProps { | ||
| 12 | label: string; | ||
| 13 | tooltipText: string; | ||
| 14 | expired?: boolean; | ||
| 15 | } | ||
| 16 | |||
| 17 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | buildInlineTerminalContextText, | ||
| 5 | formatInlineTerminalContextLabel, | ||
| 6 | textContainsInlineTerminalContextLabels, | ||
| 7 | } from "./userMessageTerminalContexts"; | ||
| 8 | |||
| 9 | describe("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 @@ | ||
| 1 | import { formatInlineTerminalContextLabel as formatInlineTerminalContextSelectionLabel } from "~/lib/terminalContext"; | ||
| 2 | |||
| 3 | const TERMINAL_CONTEXT_HEADER_PATTERN = /^(.*?)\s+line(?:s)?\s+(\d+)(?:-(\d+))?$/i; | ||
| 4 | |||
| 5 | export 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 | |||
| 17 | export 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 | |||
| 37 | export 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 @@ | ||
| 1 | export 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 | |||
| 4 | export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-3.5 shrink-0 opacity-85"; | ||
| 5 | |||
| 6 | export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = "truncate select-none leading-tight"; | ||
| 7 | |||
| 8 | export 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 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { appendTerminalContextsToPrompt } from "../lib/terminalContext"; | ||
| 4 | import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts"; | ||
| 3 | import { estimateTimelineMessageHeight } from "./timelineHeight"; | 5 | import { estimateTimelineMessageHeight } from "./timelineHeight"; |
| 4 | 6 | ||
| 5 | describe("estimateTimelineMessageHeight", () => { | 7 | describe("estimateTimelineMessageHeight", () => { |
| ... | @@ -75,6 +77,35 @@ describe("estimateTimelineMessageHeight", () => { | ... | @@ -75,6 +77,35 @@ describe("estimateTimelineMessageHeight", () => { |
| 75 | ).toBe(162); | 77 | ).toBe(162); |
| 76 | }); | 78 | }); |
| 77 | 79 | ||
| 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 @@ |
| 1 | import { deriveDisplayedUserMessageState } from "../lib/terminalContext"; | ||
| 2 | import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts"; | ||
| 3 | |||
| 1 | const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; | 4 | const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; |
| 2 | const USER_CHARS_PER_LINE_FALLBACK = 56; | 5 | const USER_CHARS_PER_LINE_FALLBACK = 56; |
| 3 | const LINE_HEIGHT_PX = 22; | 6 | const LINE_HEIGHT_PX = 22; |
| ... | @@ -75,7 +78,17 @@ export function estimateTimelineMessageHeight( | ... | @@ -75,7 +78,17 @@ export function estimateTimelineMessageHeight( |
| 75 | 78 | ||
| 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 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; | 3 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; |
| 4 | import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; | ||
| 4 | 5 | ||
| 5 | describe("splitPromptIntoComposerSegments", () => { | 6 | describe("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 @@ |
| 1 | import { | ||
| 2 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, | ||
| 3 | type TerminalContextDraft, | ||
| 4 | } from "./lib/terminalContext"; | ||
| 5 | |||
| 1 | export type ComposerPromptSegment = | 6 | export 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 | }; |
| 10 | 19 | ||
| 11 | const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g; | 20 | const 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 | } |
| 22 | 31 | ||
| 23 | export function splitPromptIntoComposerSegments(prompt: string): ComposerPromptSegment[] { | 32 | function 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 | } |
| 28 | 37 | ||
| 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; |
| 37 | 46 | ||
| 38 | if (mentionStart > cursor) { | 47 | if (mentionStart > cursor) { |
| 39 | pushTextSegment(segments, prompt.slice(cursor, mentionStart)); | 48 | pushTextSegment(segments, text.slice(cursor, mentionStart)); |
| 40 | } | 49 | } |
| 41 | 50 | ||
| 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 | } |
| 47 | 56 | ||
| 48 | cursor = mentionEnd; | 57 | cursor = mentionEnd; |
| 49 | } | 58 | } |
| 50 | 59 | ||
| 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 | |||
| 67 | export 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 | } |
| 54 | 98 | ||
| 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"; |
| 12 | import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; | ||
| 12 | 13 | ||
| 13 | describe("detectComposerTrigger", () => { | 14 | describe("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 | }); |
| 194 | 195 | ||
| 195 | describe("isCollapsedCursorAdjacentToMention", () => { | 196 | describe("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 | }); |
| 200 | 201 | ||
| 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 | }); |
| 206 | 207 | ||
| 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; |
| 211 | 212 | ||
| 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 | }); |
| 216 | 217 | ||
| 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; |
| 221 | 222 | ||
| 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 | }); |
| 227 | 237 |
apps/web/src/composer-logic.ts+40-7| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; | 1 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; |
| 2 | import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; | ||
| 2 | 3 | ||
| 3 | export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; | 4 | export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; |
| 4 | export type ComposerSlashCommand = "model" | "plan" | "default"; | 5 | export type ComposerSlashCommand = "model" | "plan" | "default"; |
| ... | @@ -11,6 +12,9 @@ export interface ComposerTrigger { | ... | @@ -11,6 +12,9 @@ export interface ComposerTrigger { |
| 11 | } | 12 | } |
| 12 | 13 | ||
| 13 | const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"]; | 14 | const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"]; |
| 15 | const isInlineTokenSegment = ( | ||
| 16 | segment: { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" }, | ||
| 17 | ): boolean => segment.type !== "text"; | ||
| 14 | 18 | ||
| 15 | function clampCursor(text: string, cursor: number): number { | 19 | function 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 | } |
| 19 | 23 | ||
| 20 | function isWhitespace(char: string): boolean { | 24 | function 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 | } |
| 23 | 33 | ||
| 24 | function tokenStartForCursor(text: string, cursor: number): number { | 34 | function 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 | } | ||
| 52 | 70 | ||
| 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 | } |
| 63 | 81 | ||
| 64 | function collapsedSegmentLength( | 82 | function 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 | } |
| 69 | 90 | ||
| 70 | function clampCollapsedComposerCursorForSegments( | 91 | function 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 | } | ||
| 114 | 145 | ||
| 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 | } |
| 125 | 156 | ||
| 126 | export function isCollapsedCursorAdjacentToMention( | 157 | export 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 | } |
| 135 | 166 | ||
| ... | @@ -137,7 +168,7 @@ export function isCollapsedCursorAdjacentToMention( | ... | @@ -137,7 +168,7 @@ export function isCollapsedCursorAdjacentToMention( |
| 137 | let collapsedOffset = 0; | 168 | let collapsedOffset = 0; |
| 138 | 169 | ||
| 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 | } |
| 153 | 184 | ||
| 185 | export const isCollapsedCursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken; | ||
| 186 | |||
| 154 | export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { | 187 | export 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"; |
| 9 | import { | ||
| 10 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, | ||
| 11 | insertInlineTerminalContextPlaceholder, | ||
| 12 | type TerminalContextDraft, | ||
| 13 | } from "./lib/terminalContext"; | ||
| 9 | 14 | ||
| 10 | function makeImage(input: { | 15 | function 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 | } |
| 36 | 41 | ||
| 42 | function 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 | |||
| 37 | describe("composerDraftStore addImages", () => { | 62 | describe("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 | }); |
| 160 | 185 | ||
| 186 | describe("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 | |||
| 161 | describe("composerDraftStore project draft thread mapping", () => { | 328 | describe("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"; |
| 11 | import { normalizeModelSlug } from "@t3tools/shared/model"; | 11 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| 12 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; | 12 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; |
| 13 | import { | ||
| 14 | type TerminalContextDraft, | ||
| 15 | ensureInlineTerminalContextPlaceholders, | ||
| 16 | normalizeTerminalContextText, | ||
| 17 | } from "./lib/terminalContext"; | ||
| 13 | import { Debouncer } from "@tanstack/react-pacer"; | 18 | import { Debouncer } from "@tanstack/react-pacer"; |
| 14 | import { create } from "zustand"; | 19 | import { create } from "zustand"; |
| 15 | import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; | 20 | import { 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 | } |
| 73 | 78 | ||
| 79 | interface PersistedTerminalContextDraft { | ||
| 80 | id: string; | ||
| 81 | threadId: ThreadId; | ||
| 82 | createdAt: string; | ||
| 83 | terminalId: string; | ||
| 84 | terminalLabel: string; | ||
| 85 | lineStart: number; | ||
| 86 | lineEnd: number; | ||
| 87 | } | ||
| 88 | |||
| 74 | interface PersistedComposerThreadDraftState { | 89 | interface 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 = { |
| 190 | const EMPTY_IMAGES: ComposerImageAttachment[] = []; | 218 | const EMPTY_IMAGES: ComposerImageAttachment[] = []; |
| 191 | const EMPTY_IDS: string[] = []; | 219 | const EMPTY_IDS: string[] = []; |
| 192 | const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; | 220 | const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; |
| 221 | const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; | ||
| 193 | Object.freeze(EMPTY_IMAGES); | 222 | Object.freeze(EMPTY_IMAGES); |
| 194 | Object.freeze(EMPTY_IDS); | 223 | Object.freeze(EMPTY_IDS); |
| 195 | Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); | 224 | Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); |
| 225 | Object.freeze(EMPTY_TERMINAL_CONTEXTS); | ||
| 196 | const EMPTY_THREAD_DRAFT = Object.freeze({ | 226 | const 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 | } |
| 233 | 265 | ||
| 266 | function terminalContextDedupKey(context: TerminalContextDraft): string { | ||
| 267 | return `${context.terminalId}\u0000${context.lineStart}\u0000${context.lineEnd}`; | ||
| 268 | } | ||
| 269 | |||
| 270 | function 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 | |||
| 292 | function 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 | |||
| 234 | function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { | 317 | function 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 | } |
| 292 | 376 | ||
| 377 | function 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 | |||
| 293 | function normalizeDraftThreadEnvMode( | 422 | function 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 @@ | ||
| 1 | import { ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vitest"; | ||
| 3 | |||
| 4 | import { | ||
| 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 | |||
| 25 | function 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 | |||
| 39 | describe("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 @@ | ||
| 1 | import { type ThreadId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export interface TerminalContextSelection { | ||
| 4 | terminalId: string; | ||
| 5 | terminalLabel: string; | ||
| 6 | lineStart: number; | ||
| 7 | lineEnd: number; | ||
| 8 | text: string; | ||
| 9 | } | ||
| 10 | |||
| 11 | export interface TerminalContextDraft extends TerminalContextSelection { | ||
| 12 | id: string; | ||
| 13 | threadId: ThreadId; | ||
| 14 | createdAt: string; | ||
| 15 | } | ||
| 16 | |||
| 17 | export interface ExtractedTerminalContexts { | ||
| 18 | promptText: string; | ||
| 19 | contextCount: number; | ||
| 20 | previewTitle: string | null; | ||
| 21 | contexts: ParsedTerminalContextEntry[]; | ||
| 22 | } | ||
| 23 | |||
| 24 | export interface DisplayedUserMessageState { | ||
| 25 | visibleText: string; | ||
| 26 | copyText: string; | ||
| 27 | contextCount: number; | ||
| 28 | previewTitle: string | null; | ||
| 29 | contexts: ParsedTerminalContextEntry[]; | ||
| 30 | } | ||
| 31 | |||
| 32 | export interface ParsedTerminalContextEntry { | ||
| 33 | header: string; | ||
| 34 | body: string; | ||
| 35 | } | ||
| 36 | |||
| 37 | export const INLINE_TERMINAL_CONTEXT_PLACEHOLDER = "\uFFFC"; | ||
| 38 | |||
| 39 | const TRAILING_TERMINAL_CONTEXT_BLOCK_PATTERN = | ||
| 40 | /\n*<terminal_context>\n([\s\S]*?)\n<\/terminal_context>\s*$/; | ||
| 41 | |||
| 42 | export function normalizeTerminalContextText(text: string): string { | ||
| 43 | return text.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); | ||
| 44 | } | ||
| 45 | |||
| 46 | export function hasTerminalContextText(context: { text: string }): boolean { | ||
| 47 | return normalizeTerminalContextText(context.text).length > 0; | ||
| 48 | } | ||
| 49 | |||
| 50 | export function isTerminalContextExpired(context: { text: string }): boolean { | ||
| 51 | return !hasTerminalContextText(context); | ||
| 52 | } | ||
| 53 | |||
| 54 | export function filterTerminalContextsWithText<T extends { text: string }>( | ||
| 55 | contexts: ReadonlyArray<T>, | ||
| 56 | ): T[] { | ||
| 57 | return contexts.filter((context) => hasTerminalContextText(context)); | ||
| 58 | } | ||
| 59 | |||
| 60 | function 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 | |||
| 74 | export 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 | |||
| 94 | export 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 | |||
| 103 | export function formatTerminalContextLabel(selection: { | ||
| 104 | terminalLabel: string; | ||
| 105 | lineStart: number; | ||
| 106 | lineEnd: number; | ||
| 107 | }): string { | ||
| 108 | return `${selection.terminalLabel} ${formatTerminalContextRange(selection)}`; | ||
| 109 | } | ||
| 110 | |||
| 111 | export 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 | |||
| 124 | export 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 | |||
| 146 | function buildTerminalContextBodyLines(selection: TerminalContextSelection): string[] { | ||
| 147 | return normalizeTerminalContextText(selection.text) | ||
| 148 | .split("\n") | ||
| 149 | .map((line, index) => ` ${selection.lineStart + index} | ${line}`); | ||
| 150 | } | ||
| 151 | |||
| 152 | export 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 | |||
| 173 | export 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 | |||
| 200 | export 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 | |||
| 212 | export 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 | |||
| 237 | export 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 | |||
| 248 | function 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 | |||
| 289 | export 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 | |||
| 299 | export 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 | |||
| 310 | function isInlineTerminalContextBoundaryWhitespace(char: string | undefined): boolean { | ||
| 311 | return char === undefined || char === " " || char === "\n" || char === "\t" || char === "\r"; | ||
| 312 | } | ||
| 313 | |||
| 314 | export 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 | |||
| 329 | export function stripInlineTerminalContextPlaceholders(prompt: string): string { | ||
| 330 | return prompt.replaceAll(INLINE_TERMINAL_CONTEXT_PLACEHOLDER, ""); | ||
| 331 | } | ||
| 332 | |||
| 333 | export 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 | } |
| 77 | 77 | ||
| 78 | export 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 | |||
| 78 | function extensionCandidates(fileName: string): string[] { | 89 | function extensionCandidates(fileName: string): string[] { |
| 79 | const candidates = new Set<string>(); | 90 | const candidates = new Set<string>(); |
| 80 | if (fileName.includes(".")) { | 91 | if (fileName.includes(".")) { |