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", () => {...@@ -179,6 +179,75 @@ describe("ChatMarkdown favicon privacy", () => {
179});179});
180180
181describe("ChatMarkdown streaming", () => {181describe("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
182 it("does not retokenize completed lines when streaming finishes", async () => {251 it("does not retokenize completed lines when streaming finishes", async () => {
183 const highlighter = await getSyntaxHighlighterPromise("typescript");252 const highlighter = await getSyntaxHighlighterPromise("typescript");
184 const highlight = vi.spyOn(highlighter, "codeToHast");253 const highlight = vi.spyOn(highlighter, "codeToHast");
apps/web/src/components/ChatMarkdown.tsx+64-1
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20 MessageSquareWarningIcon,20 MessageSquareWarningIcon,
21 Minimize2Icon,21 Minimize2Icon,
22 OctagonAlertIcon,22 OctagonAlertIcon,
23 PlayIcon,
23 PresentationIcon,24 PresentationIcon,
24 SparklesIcon,25 SparklesIcon,
25 TriangleAlertIcon,26 TriangleAlertIcon,
...@@ -215,6 +216,7 @@ interface ChatMarkdownProps {...@@ -215,6 +216,7 @@ interface ChatMarkdownProps {
215 parseRawHtml?: boolean;216 parseRawHtml?: boolean;
216 /** Append a prompt that invokes a newly created artifact-template skill. */217 /** Append a prompt that invokes a newly created artifact-template skill. */
217 onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;218 onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
219 onRunShellCommand?: ((command: string) => void) | undefined;
218 /** Directory that anchors relative links and images; defaults to `cwd`. Set220 /** Directory that anchors relative links and images; defaults to `cwd`. Set
219 to the file's own directory when rendering a markdown file. */221 to the file's own directory when rendering a markdown file. */
220 imageBaseDir?: string | undefined;222 imageBaseDir?: string | undefined;
...@@ -585,6 +587,23 @@ function extractPreCodeMeta(node: unknown): string | undefined {...@@ -585,6 +587,23 @@ function extractPreCodeMeta(node: unknown): string | undefined {
585 return typeof meta === "string" && meta.trim().length > 0 ? meta.trim() : undefined;587 return typeof meta === "string" && meta.trim().length > 0 ? meta.trim() : undefined;
586}588}
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
588type MarkdownAstNode = {607type MarkdownAstNode = {
589 type?: string;608 type?: string;
590 meta?: unknown;609 meta?: unknown;
...@@ -920,12 +939,16 @@ function MarkdownCodeBlock({...@@ -920,12 +939,16 @@ function MarkdownCodeBlock({
920 language,939 language,
921 fenceTitle,940 fenceTitle,
922 theme,941 theme,
942 onRunShellCommand,
943 isStreaming,
923 children,944 children,
924}: {945}: {
925 code: string;946 code: string;
926 language: string;947 language: string;
927 fenceTitle: string | null;948 fenceTitle: string | null;
928 theme: "light" | "dark";949 theme: "light" | "dark";
950 onRunShellCommand?: ((command: string) => void) | undefined;
951 isStreaming: boolean;
929 children: ReactNode;952 children: ReactNode;
930}) {953}) {
931 const [copied, setCopied] = useState(false);954 const [copied, setCopied] = useState(false);
...@@ -933,6 +956,17 @@ function MarkdownCodeBlock({...@@ -933,6 +956,17 @@ function MarkdownCodeBlock({
933 const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);956 const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
934 const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines";957 const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines";
935 const copyLabel = copied ? "Copied" : "Copy code";958 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
937 const handleCopy = useCallback(() => {971 const handleCopy = useCallback(() => {
938 if (typeof navigator === "undefined" || navigator.clipboard == null) {972 if (typeof navigator === "undefined" || navigator.clipboard == null) {
...@@ -1004,6 +1038,24 @@ function MarkdownCodeBlock({...@@ -1004,6 +1038,24 @@ function MarkdownCodeBlock({
1004 </TooltipTrigger>1038 </TooltipTrigger>
1005 <TooltipPopup side="top">{wrapLabel}</TooltipPopup>1039 <TooltipPopup side="top">{wrapLabel}</TooltipPopup>
1006 </Tooltip>1040 </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}
1007 <Tooltip>1059 <Tooltip>
1008 <TooltipTrigger1060 <TooltipTrigger
1009 render={1061 render={
...@@ -2244,6 +2296,7 @@ function useChatMarkdownState({...@@ -2244,6 +2296,7 @@ function useChatMarkdownState({
2244 isStreaming = false,2296 isStreaming = false,
2245 skills = EMPTY_MARKDOWN_SKILLS,2297 skills = EMPTY_MARKDOWN_SKILLS,
2246 onUseArtifactTemplate,2298 onUseArtifactTemplate,
2299 onRunShellCommand,
2247 imageBaseDir,2300 imageBaseDir,
2248 onImageExpand,2301 onImageExpand,
2249 renderContextReference,2302 renderContextReference,
...@@ -2650,6 +2703,7 @@ function useChatMarkdownState({...@@ -2650,6 +2703,7 @@ function useChatMarkdownState({
2650 markdownFileLinkMetaByHref,2703 markdownFileLinkMetaByHref,
2651 onTaskListChange,2704 onTaskListChange,
2652 onUseArtifactTemplate,2705 onUseArtifactTemplate,
2706 onRunShellCommand,
2653 openChangeRequestLink,2707 openChangeRequestLink,
2654 openDeferredMarkdownLink,2708 openDeferredMarkdownLink,
2655 openExternalLinkInPreview,2709 openExternalLinkInPreview,
...@@ -2680,6 +2734,7 @@ function useChatMarkdownState({...@@ -2680,6 +2734,7 @@ function useChatMarkdownState({
2680 markdownFileLinkMetaByHref,2734 markdownFileLinkMetaByHref,
2681 onTaskListChange,2735 onTaskListChange,
2682 onUseArtifactTemplate,2736 onUseArtifactTemplate,
2737 onRunShellCommand,
2683 openChangeRequestLink,2738 openChangeRequestLink,
2684 openDeferredMarkdownLink,2739 openDeferredMarkdownLink,
2685 openExternalLinkInPreview,2740 openExternalLinkInPreview,
...@@ -3211,7 +3266,9 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -3211,7 +3266,9 @@ const CHAT_MARKDOWN_COMPONENTS = {
3211 return <MarkdownDetails open={detailsOpen}>{children}</MarkdownDetails>;3266 return <MarkdownDetails open={detailsOpen}>{children}</MarkdownDetails>;
3212 },3267 },
3213 pre: function MarkdownPre({ node, children, ...props }) {3268 pre: function MarkdownPre({ node, children, ...props }) {
3214 const { resolvedTheme, diffThemeName, isStreaming } = use(ChatMarkdownRendererContext);3269 const { resolvedTheme, diffThemeName, isStreaming, onRunShellCommand, text } = use(
3270 ChatMarkdownRendererContext,
3271 );
3215 const codeBlock = extractCodeBlock(children);3272 const codeBlock = extractCodeBlock(children);
3216 if (!codeBlock) {3273 if (!codeBlock) {
3217 return <pre {...props}>{children}</pre>;3274 return <pre {...props}>{children}</pre>;
...@@ -3225,6 +3282,12 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -3225,6 +3282,12 @@ const CHAT_MARKDOWN_COMPONENTS = {
3225 language={language}3282 language={language}
3226 fenceTitle={fenceTitle}3283 fenceTitle={fenceTitle}
3227 theme={resolvedTheme}3284 theme={resolvedTheme}
3285 onRunShellCommand={
3286 onRunShellCommand && !isStreaming && isClosedCodeFence(node, text)
3287 ? onRunShellCommand
3288 : undefined
3289 }
3290 isStreaming={isStreaming}
3228 >3291 >
3229 <RenderErrorBoundary3292 <RenderErrorBoundary
3230 resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]}3293 resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]}
apps/web/src/components/ChatView.tsx+18
...@@ -4313,6 +4313,23 @@ export default function ChatView(props: ChatViewProps) {...@@ -4313,6 +4313,23 @@ export default function ChatView(props: ChatViewProps) {
4313 ],4313 ],
4314 );4314 );
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
4316 const supportsProjectSettingsOverrides =4333 const supportsProjectSettingsOverrides =
4317 environmentById.get(environmentId)?.serverConfig?.environment.capabilities4334 environmentById.get(environmentId)?.serverConfig?.environment.capabilities
4318 .projectSettingsOverrides === true;4335 .projectSettingsOverrides === true;
...@@ -9902,6 +9919,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -9902,6 +9919,7 @@ export default function ChatView(props: ChatViewProps) {
9902 agentPanelModel,9919 agentPanelModel,
9903 onOpenAgents: addAgentsSurface,9920 onOpenAgents: addAgentsSurface,
9904 onUseArtifactTemplate: useArtifactTemplate,9921 onUseArtifactTemplate: useArtifactTemplate,
9922 ...(activeProject ? { onRunShellCommand: runShellCommand } : {}),
9905 }9923 }
9906 : {})}9924 : {})}
9907 isWorking={!paintOnlyDisplayedTimeline && isWorking}9925 isWorking={!paintOnlyDisplayedTimeline && isWorking}
apps/web/src/components/chat/MessagesTimeline.tsx+6
...@@ -277,6 +277,7 @@ interface TimelineRowSharedState {...@@ -277,6 +277,7 @@ interface TimelineRowSharedState {
277 activeThreadEnvironmentId: EnvironmentId;277 activeThreadEnvironmentId: EnvironmentId;
278 onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void;278 onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void;
279 onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;279 onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;
280 onRunShellCommand: ((command: string) => void) | undefined;
280 onImageExpand: (preview: ExpandedImagePreview) => void;281 onImageExpand: (preview: ExpandedImagePreview) => void;
281 onFileOpen: (attachment: ChatFileAttachment) => void;282 onFileOpen: (attachment: ChatFileAttachment) => void;
282 onFileDownload: (attachment: ChatFileAttachment) => void;283 onFileDownload: (attachment: ChatFileAttachment) => void;
...@@ -425,6 +426,7 @@ interface MessagesTimelineProps {...@@ -425,6 +426,7 @@ interface MessagesTimelineProps {
425 supportsConversationRollback: boolean;426 supportsConversationRollback: boolean;
426 onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void;427 onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void;
427 onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;428 onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;
429 onRunShellCommand?: (command: string) => void;
428 isRevertingCheckpoint: boolean;430 isRevertingCheckpoint: boolean;
429 onImageExpand: (preview: ExpandedImagePreview) => void;431 onImageExpand: (preview: ExpandedImagePreview) => void;
430 onFileOpen?: (attachment: ChatFileAttachment) => void;432 onFileOpen?: (attachment: ChatFileAttachment) => void;
...@@ -494,6 +496,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -494,6 +496,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
494 supportsConversationRollback,496 supportsConversationRollback,
495 onRevertToTurnCount,497 onRevertToTurnCount,
496 onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE,498 onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE,
499 onRunShellCommand,
497 isRevertingCheckpoint,500 isRevertingCheckpoint,
498 onImageExpand,501 onImageExpand,
499 onFileOpen = NOOP_OPEN_ATTACHMENT,502 onFileOpen = NOOP_OPEN_ATTACHMENT,
...@@ -1138,6 +1141,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -1138,6 +1141,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
1138 activeThreadEnvironmentId,1141 activeThreadEnvironmentId,
1139 onRevertToTurnCount,1142 onRevertToTurnCount,
1140 onUseArtifactTemplate,1143 onUseArtifactTemplate,
1144 onRunShellCommand,
1141 onImageExpand,1145 onImageExpand,
1142 onFileOpen,1146 onFileOpen,
1143 onFileDownload,1147 onFileDownload,
...@@ -1173,6 +1177,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -1173,6 +1177,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
1173 activeThreadEnvironmentId,1177 activeThreadEnvironmentId,
1174 onRevertToTurnCount,1178 onRevertToTurnCount,
1175 onUseArtifactTemplate,1179 onUseArtifactTemplate,
1180 onRunShellCommand,
1176 onImageExpand,1181 onImageExpand,
1177 onFileOpen,1182 onFileOpen,
1178 onFileDownload,1183 onFileDownload,
...@@ -2372,6 +2377,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess...@@ -2372,6 +2377,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
2372 skills={ctx.skills}2377 skills={ctx.skills}
2373 headingLevelOffset={MESSAGE_HEADING_LEVEL}2378 headingLevelOffset={MESSAGE_HEADING_LEVEL}
2374 onUseArtifactTemplate={ctx.onUseArtifactTemplate}2379 onUseArtifactTemplate={ctx.onUseArtifactTemplate}
2380 onRunShellCommand={ctx.onRunShellCommand}
2375 onImageExpand={ctx.onImageExpand}2381 onImageExpand={ctx.onImageExpand}
2376 />2382 />
2377 </AssistantCitationSource>2383 </AssistantCitationSource>