authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-24 23:22:12+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-24 13:22:12-07:00
log66129c6fd518aa01d873ebb47060b41c17312d60
tree99887e55978a3b210b19bc0a08efc0c3b813c03b
parente759847f932c0de08261ec98a5fac3bae037120f
signature Signed by PGP key B5690EEEBB952194

feat(web): run shell commands from chat in the thread terminal (#13060)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

4 files changed, 157 insertions(+), 1 deletions(-)

apps/web/src/components/ChatMarkdown.test.tsx+69
......@@ -179,6 +179,75 @@ describe("ChatMarkdown favicon privacy", () => {
179179});
180180
181181describe("ChatMarkdown streaming", () => {
182 it("runs only a complete single-line shell block after a click", async () => {
183 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
184 const onRunShellCommand = vi.fn();
185 let renderer: ReactTestRenderer | undefined;
186 const message = (text: string, isStreaming = false) => (
187 <ChatMarkdown
188 cwd="/tmp/project"
189 text={text}
190 isStreaming={isStreaming}
191 onRunShellCommand={onRunShellCommand}
192 />
193 );
194 try {
195 await act(async () => {
196 renderer = create(message("```bash\necho hello\n```", true));
197 });
198 const mounted = renderer!;
199 expect(
200 mounted.root
201 .findAllByType(Button)
202 .some((button) => button.props["aria-label"] === "Run in terminal"),
203 ).toBe(false);
204
205 await act(async () => {
206 mounted.update(message("```bash\necho hello\n```"));
207 });
208 await act(async () => {
209 codeButton(mounted, "Run in terminal").onClick?.({} as never);
210 });
211 expect(onRunShellCommand).toHaveBeenCalledExactlyOnceWith("echo hello");
212
213 for (const text of [
214 "~~~bash\necho tilde\n~~~",
215 "> ```bash\n> echo quote\n> ```",
216 "````bash\necho four\n````",
217 ]) {
218 await act(async () => {
219 mounted.update(message(text));
220 });
221 expect(codeButton(mounted, "Run in terminal")).toBeDefined();
222 }
223
224 for (const text of [
225 "```bash\necho one\necho two\n```",
226 "```typescript\necho hello\n```",
227 "```bash\n\n```",
228 "```bash\necho hello\n\n```",
229 "```bash\necho hello\\\n```",
230 "```bash\necho safe \u202e#\n```",
231 "```bash\necho incomplete",
232 "~~~bash\necho incomplete",
233 "````bash\necho incomplete\n```",
234 '<pre><code class="language-bash">echo html</code></pre>',
235 ]) {
236 await act(async () => {
237 mounted.update(message(text));
238 });
239 expect(
240 mounted.root
241 .findAllByType(Button)
242 .some((button) => button.props["aria-label"] === "Run in terminal"),
243 ).toBe(false);
244 }
245 } finally {
246 await act(async () => renderer?.unmount());
247 vi.unstubAllGlobals();
248 }
249 });
250
182251 it("does not retokenize completed lines when streaming finishes", async () => {
183252 const highlighter = await getSyntaxHighlighterPromise("typescript");
184253 const highlight = vi.spyOn(highlighter, "codeToHast");
apps/web/src/components/ChatMarkdown.tsx+64-1
......@@ -20,6 +20,7 @@ import {
2020 MessageSquareWarningIcon,
2121 Minimize2Icon,
2222 OctagonAlertIcon,
23 PlayIcon,
2324 PresentationIcon,
2425 SparklesIcon,
2526 TriangleAlertIcon,
......@@ -215,6 +216,7 @@ interface ChatMarkdownProps {
215216 parseRawHtml?: boolean;
216217 /** Append a prompt that invokes a newly created artifact-template skill. */
217218 onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
219 onRunShellCommand?: ((command: string) => void) | undefined;
218220 /** Directory that anchors relative links and images; defaults to `cwd`. Set
219221 to the file's own directory when rendering a markdown file. */
220222 imageBaseDir?: string | undefined;
......@@ -585,6 +587,23 @@ function extractPreCodeMeta(node: unknown): string | undefined {
585587 return typeof meta === "string" && meta.trim().length > 0 ? meta.trim() : undefined;
586588}
587589
590function isClosedCodeFence(node: ReactMarkdownExtraProps["node"], text: string): boolean {
591 const start = node?.position?.start.offset;
592 const end = node?.position?.end.offset;
593 if (start === undefined || end === undefined) return false;
594 const source = text.slice(start, end);
595 const opening = /^(?:`{3,}|~{3,})/.exec(source)?.[0];
596 // One class for the blockquote prefix: nested quantifiers here backtrack
597 // exponentially on code lines that start with many `> ` markers.
598 const closing = /(?:^|\n)[ \t>]*(`{3,}|~{3,})[ \t\r]*$/.exec(source)?.[1];
599 return (
600 opening !== undefined &&
601 closing !== undefined &&
602 opening[0] === closing[0] &&
603 closing.length >= opening.length
604 );
605}
606
588607type MarkdownAstNode = {
589608 type?: string;
590609 meta?: unknown;
......@@ -920,12 +939,16 @@ function MarkdownCodeBlock({
920939 language,
921940 fenceTitle,
922941 theme,
942 onRunShellCommand,
943 isStreaming,
923944 children,
924945}: {
925946 code: string;
926947 language: string;
927948 fenceTitle: string | null;
928949 theme: "light" | "dark";
950 onRunShellCommand?: ((command: string) => void) | undefined;
951 isStreaming: boolean;
929952 children: ReactNode;
930953}) {
931954 const [copied, setCopied] = useState(false);
......@@ -933,6 +956,17 @@ function MarkdownCodeBlock({
933956 const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
934957 const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines";
935958 const copyLabel = copied ? "Copied" : "Copy code";
959 const command = code.trim();
960 const canRun =
961 onRunShellCommand !== undefined &&
962 !isStreaming &&
963 /^(?:sh|bash|zsh|fish|shell|powershell|pwsh)$/.test(language) &&
964 code.endsWith("\n") &&
965 command.length > 0 &&
966 !command.endsWith("\\") &&
967 // Control and invisible format characters (bidi overrides, zero-width) can
968 // make the rendered command differ from what the terminal would receive.
969 !/[\p{Cc}\p{Cf}]/u.test(code.slice(0, -1));
936970
937971 const handleCopy = useCallback(() => {
938972 if (typeof navigator === "undefined" || navigator.clipboard == null) {
......@@ -1004,6 +1038,24 @@ function MarkdownCodeBlock({
10041038 </TooltipTrigger>
10051039 <TooltipPopup side="top">{wrapLabel}</TooltipPopup>
10061040 </Tooltip>
1041 {canRun ? (
1042 <Tooltip>
1043 <TooltipTrigger
1044 render={
1045 <Button
1046 type="button"
1047 variant="ghost-muted"
1048 size="icon-xs"
1049 onClick={() => onRunShellCommand(command)}
1050 aria-label="Run in terminal"
1051 />
1052 }
1053 >
1054 <PlayIcon className="size-3" />
1055 </TooltipTrigger>
1056 <TooltipPopup side="top">Run in terminal</TooltipPopup>
1057 </Tooltip>
1058 ) : null}
10071059 <Tooltip>
10081060 <TooltipTrigger
10091061 render={
......@@ -2244,6 +2296,7 @@ function useChatMarkdownState({
22442296 isStreaming = false,
22452297 skills = EMPTY_MARKDOWN_SKILLS,
22462298 onUseArtifactTemplate,
2299 onRunShellCommand,
22472300 imageBaseDir,
22482301 onImageExpand,
22492302 renderContextReference,
......@@ -2650,6 +2703,7 @@ function useChatMarkdownState({
26502703 markdownFileLinkMetaByHref,
26512704 onTaskListChange,
26522705 onUseArtifactTemplate,
2706 onRunShellCommand,
26532707 openChangeRequestLink,
26542708 openDeferredMarkdownLink,
26552709 openExternalLinkInPreview,
......@@ -2680,6 +2734,7 @@ function useChatMarkdownState({
26802734 markdownFileLinkMetaByHref,
26812735 onTaskListChange,
26822736 onUseArtifactTemplate,
2737 onRunShellCommand,
26832738 openChangeRequestLink,
26842739 openDeferredMarkdownLink,
26852740 openExternalLinkInPreview,
......@@ -3211,7 +3266,9 @@ const CHAT_MARKDOWN_COMPONENTS = {
32113266 return <MarkdownDetails open={detailsOpen}>{children}</MarkdownDetails>;
32123267 },
32133268 pre: function MarkdownPre({ node, children, ...props }) {
3214 const { resolvedTheme, diffThemeName, isStreaming } = use(ChatMarkdownRendererContext);
3269 const { resolvedTheme, diffThemeName, isStreaming, onRunShellCommand, text } = use(
3270 ChatMarkdownRendererContext,
3271 );
32153272 const codeBlock = extractCodeBlock(children);
32163273 if (!codeBlock) {
32173274 return <pre {...props}>{children}</pre>;
......@@ -3225,6 +3282,12 @@ const CHAT_MARKDOWN_COMPONENTS = {
32253282 language={language}
32263283 fenceTitle={fenceTitle}
32273284 theme={resolvedTheme}
3285 onRunShellCommand={
3286 onRunShellCommand && !isStreaming && isClosedCodeFence(node, text)
3287 ? onRunShellCommand
3288 : undefined
3289 }
3290 isStreaming={isStreaming}
32283291 >
32293292 <RenderErrorBoundary
32303293 resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]}
apps/web/src/components/ChatView.tsx+18
......@@ -4313,6 +4313,23 @@ export default function ChatView(props: ChatViewProps) {
43134313 ],
43144314 );
43154315
4316 const runProjectScriptRef = useRef(runProjectScript);
4317 useLayoutEffect(() => {
4318 runProjectScriptRef.current = runProjectScript;
4319 }, [runProjectScript]);
4320 const runShellCommand = useCallback((command: string) => {
4321 void runProjectScriptRef.current(
4322 {
4323 id: "chat-code-block",
4324 name: "Chat code block",
4325 command,
4326 icon: "play",
4327 runOnWorktreeCreate: false,
4328 },
4329 { rememberAsLastInvoked: false },
4330 );
4331 }, []);
4332
43164333 const supportsProjectSettingsOverrides =
43174334 environmentById.get(environmentId)?.serverConfig?.environment.capabilities
43184335 .projectSettingsOverrides === true;
......@@ -9902,6 +9919,7 @@ export default function ChatView(props: ChatViewProps) {
99029919 agentPanelModel,
99039920 onOpenAgents: addAgentsSurface,
99049921 onUseArtifactTemplate: useArtifactTemplate,
9922 ...(activeProject ? { onRunShellCommand: runShellCommand } : {}),
99059923 }
99069924 : {})}
99079925 isWorking={!paintOnlyDisplayedTimeline && isWorking}
apps/web/src/components/chat/MessagesTimeline.tsx+6
......@@ -277,6 +277,7 @@ interface TimelineRowSharedState {
277277 activeThreadEnvironmentId: EnvironmentId;
278278 onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void;
279279 onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;
280 onRunShellCommand: ((command: string) => void) | undefined;
280281 onImageExpand: (preview: ExpandedImagePreview) => void;
281282 onFileOpen: (attachment: ChatFileAttachment) => void;
282283 onFileDownload: (attachment: ChatFileAttachment) => void;
......@@ -425,6 +426,7 @@ interface MessagesTimelineProps {
425426 supportsConversationRollback: boolean;
426427 onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void;
427428 onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;
429 onRunShellCommand?: (command: string) => void;
428430 isRevertingCheckpoint: boolean;
429431 onImageExpand: (preview: ExpandedImagePreview) => void;
430432 onFileOpen?: (attachment: ChatFileAttachment) => void;
......@@ -494,6 +496,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
494496 supportsConversationRollback,
495497 onRevertToTurnCount,
496498 onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE,
499 onRunShellCommand,
497500 isRevertingCheckpoint,
498501 onImageExpand,
499502 onFileOpen = NOOP_OPEN_ATTACHMENT,
......@@ -1138,6 +1141,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
11381141 activeThreadEnvironmentId,
11391142 onRevertToTurnCount,
11401143 onUseArtifactTemplate,
1144 onRunShellCommand,
11411145 onImageExpand,
11421146 onFileOpen,
11431147 onFileDownload,
......@@ -1173,6 +1177,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
11731177 activeThreadEnvironmentId,
11741178 onRevertToTurnCount,
11751179 onUseArtifactTemplate,
1180 onRunShellCommand,
11761181 onImageExpand,
11771182 onFileOpen,
11781183 onFileDownload,
......@@ -2372,6 +2377,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
23722377 skills={ctx.skills}
23732378 headingLevelOffset={MESSAGE_HEADING_LEVEL}
23742379 onUseArtifactTemplate={ctx.onUseArtifactTemplate}
2380 onRunShellCommand={ctx.onRunShellCommand}
23752381 onImageExpand={ctx.onImageExpand}
23762382 />
23772383 </AssistantCitationSource>