diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx
index d8a5f651ff835159a88be40c67b304275d8bd502..cb25e6938fa750d74b3a9cb85535f7df96230885 100644
--- a/apps/web/src/components/ChatMarkdown.test.tsx
+++ b/apps/web/src/components/ChatMarkdown.test.tsx
@@ -179,6 +179,75 @@ describe("ChatMarkdown favicon privacy", () => {
});
describe("ChatMarkdown streaming", () => {
+ it("runs only a complete single-line shell block after a click", async () => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const onRunShellCommand = vi.fn();
+ let renderer: ReactTestRenderer | undefined;
+ const message = (text: string, isStreaming = false) => (
+
+ );
+ try {
+ await act(async () => {
+ renderer = create(message("```bash\necho hello\n```", true));
+ });
+ const mounted = renderer!;
+ expect(
+ mounted.root
+ .findAllByType(Button)
+ .some((button) => button.props["aria-label"] === "Run in terminal"),
+ ).toBe(false);
+
+ await act(async () => {
+ mounted.update(message("```bash\necho hello\n```"));
+ });
+ await act(async () => {
+ codeButton(mounted, "Run in terminal").onClick?.({} as never);
+ });
+ expect(onRunShellCommand).toHaveBeenCalledExactlyOnceWith("echo hello");
+
+ for (const text of [
+ "~~~bash\necho tilde\n~~~",
+ "> ```bash\n> echo quote\n> ```",
+ "````bash\necho four\n````",
+ ]) {
+ await act(async () => {
+ mounted.update(message(text));
+ });
+ expect(codeButton(mounted, "Run in terminal")).toBeDefined();
+ }
+
+ for (const text of [
+ "```bash\necho one\necho two\n```",
+ "```typescript\necho hello\n```",
+ "```bash\n\n```",
+ "```bash\necho hello\n\n```",
+ "```bash\necho hello\\\n```",
+ "```bash\necho safe \u202e#\n```",
+ "```bash\necho incomplete",
+ "~~~bash\necho incomplete",
+ "````bash\necho incomplete\n```",
+ '
echo html
',
+ ]) {
+ await act(async () => {
+ mounted.update(message(text));
+ });
+ expect(
+ mounted.root
+ .findAllByType(Button)
+ .some((button) => button.props["aria-label"] === "Run in terminal"),
+ ).toBe(false);
+ }
+ } finally {
+ await act(async () => renderer?.unmount());
+ vi.unstubAllGlobals();
+ }
+ });
+
it("does not retokenize completed lines when streaming finishes", async () => {
const highlighter = await getSyntaxHighlighterPromise("typescript");
const highlight = vi.spyOn(highlighter, "codeToHast");
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
index 5b6cdfde49d6b704f6f799fea4272005e06e7891..4a1f616aed3b76472709e3c34dba0ca5a52454fe 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -20,6 +20,7 @@ import {
MessageSquareWarningIcon,
Minimize2Icon,
OctagonAlertIcon,
+ PlayIcon,
PresentationIcon,
SparklesIcon,
TriangleAlertIcon,
@@ -215,6 +216,7 @@ interface ChatMarkdownProps {
parseRawHtml?: boolean;
/** Append a prompt that invokes a newly created artifact-template skill. */
onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
+ onRunShellCommand?: ((command: string) => void) | undefined;
/** Directory that anchors relative links and images; defaults to `cwd`. Set
to the file's own directory when rendering a markdown file. */
imageBaseDir?: string | undefined;
@@ -585,6 +587,23 @@ function extractPreCodeMeta(node: unknown): string | undefined {
return typeof meta === "string" && meta.trim().length > 0 ? meta.trim() : undefined;
}
+function isClosedCodeFence(node: ReactMarkdownExtraProps["node"], text: string): boolean {
+ const start = node?.position?.start.offset;
+ const end = node?.position?.end.offset;
+ if (start === undefined || end === undefined) return false;
+ const source = text.slice(start, end);
+ const opening = /^(?:`{3,}|~{3,})/.exec(source)?.[0];
+ // One class for the blockquote prefix: nested quantifiers here backtrack
+ // exponentially on code lines that start with many `> ` markers.
+ const closing = /(?:^|\n)[ \t>]*(`{3,}|~{3,})[ \t\r]*$/.exec(source)?.[1];
+ return (
+ opening !== undefined &&
+ closing !== undefined &&
+ opening[0] === closing[0] &&
+ closing.length >= opening.length
+ );
+}
+
type MarkdownAstNode = {
type?: string;
meta?: unknown;
@@ -920,12 +939,16 @@ function MarkdownCodeBlock({
language,
fenceTitle,
theme,
+ onRunShellCommand,
+ isStreaming,
children,
}: {
code: string;
language: string;
fenceTitle: string | null;
theme: "light" | "dark";
+ onRunShellCommand?: ((command: string) => void) | undefined;
+ isStreaming: boolean;
children: ReactNode;
}) {
const [copied, setCopied] = useState(false);
@@ -933,6 +956,17 @@ function MarkdownCodeBlock({
const copiedTimerRef = useRef | null>(null);
const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines";
const copyLabel = copied ? "Copied" : "Copy code";
+ const command = code.trim();
+ const canRun =
+ onRunShellCommand !== undefined &&
+ !isStreaming &&
+ /^(?:sh|bash|zsh|fish|shell|powershell|pwsh)$/.test(language) &&
+ code.endsWith("\n") &&
+ command.length > 0 &&
+ !command.endsWith("\\") &&
+ // Control and invisible format characters (bidi overrides, zero-width) can
+ // make the rendered command differ from what the terminal would receive.
+ !/[\p{Cc}\p{Cf}]/u.test(code.slice(0, -1));
const handleCopy = useCallback(() => {
if (typeof navigator === "undefined" || navigator.clipboard == null) {
@@ -1004,6 +1038,24 @@ function MarkdownCodeBlock({
{wrapLabel}
+ {canRun ? (
+
+ onRunShellCommand(command)}
+ aria-label="Run in terminal"
+ />
+ }
+ >
+
+
+ Run in terminal
+
+ ) : null}
{children};
},
pre: function MarkdownPre({ node, children, ...props }) {
- const { resolvedTheme, diffThemeName, isStreaming } = use(ChatMarkdownRendererContext);
+ const { resolvedTheme, diffThemeName, isStreaming, onRunShellCommand, text } = use(
+ ChatMarkdownRendererContext,
+ );
const codeBlock = extractCodeBlock(children);
if (!codeBlock) {
return {children};
@@ -3225,6 +3282,12 @@ const CHAT_MARKDOWN_COMPONENTS = {
language={language}
fenceTitle={fenceTitle}
theme={resolvedTheme}
+ onRunShellCommand={
+ onRunShellCommand && !isStreaming && isClosedCodeFence(node, text)
+ ? onRunShellCommand
+ : undefined
+ }
+ isStreaming={isStreaming}
>
{
+ runProjectScriptRef.current = runProjectScript;
+ }, [runProjectScript]);
+ const runShellCommand = useCallback((command: string) => {
+ void runProjectScriptRef.current(
+ {
+ id: "chat-code-block",
+ name: "Chat code block",
+ command,
+ icon: "play",
+ runOnWorktreeCreate: false,
+ },
+ { rememberAsLastInvoked: false },
+ );
+ }, []);
+
const supportsProjectSettingsOverrides =
environmentById.get(environmentId)?.serverConfig?.environment.capabilities
.projectSettingsOverrides === true;
@@ -9902,6 +9919,7 @@ export default function ChatView(props: ChatViewProps) {
agentPanelModel,
onOpenAgents: addAgentsSurface,
onUseArtifactTemplate: useArtifactTemplate,
+ ...(activeProject ? { onRunShellCommand: runShellCommand } : {}),
}
: {})}
isWorking={!paintOnlyDisplayedTimeline && isWorking}
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index 505f64af3640260720365534f9eeb34b47f8a4ce..755657e794ab43ba1d79d4c731a2c5dae61f3409 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -277,6 +277,7 @@ interface TimelineRowSharedState {
activeThreadEnvironmentId: EnvironmentId;
onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void;
onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;
+ onRunShellCommand: ((command: string) => void) | undefined;
onImageExpand: (preview: ExpandedImagePreview) => void;
onFileOpen: (attachment: ChatFileAttachment) => void;
onFileDownload: (attachment: ChatFileAttachment) => void;
@@ -425,6 +426,7 @@ interface MessagesTimelineProps {
supportsConversationRollback: boolean;
onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void;
onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;
+ onRunShellCommand?: (command: string) => void;
isRevertingCheckpoint: boolean;
onImageExpand: (preview: ExpandedImagePreview) => void;
onFileOpen?: (attachment: ChatFileAttachment) => void;
@@ -494,6 +496,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
supportsConversationRollback,
onRevertToTurnCount,
onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE,
+ onRunShellCommand,
isRevertingCheckpoint,
onImageExpand,
onFileOpen = NOOP_OPEN_ATTACHMENT,
@@ -1138,6 +1141,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
activeThreadEnvironmentId,
onRevertToTurnCount,
onUseArtifactTemplate,
+ onRunShellCommand,
onImageExpand,
onFileOpen,
onFileDownload,
@@ -1173,6 +1177,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
activeThreadEnvironmentId,
onRevertToTurnCount,
onUseArtifactTemplate,
+ onRunShellCommand,
onImageExpand,
onFileOpen,
onFileDownload,
@@ -2372,6 +2377,7 @@ function AssistantTimelineRow({ row }: { row: Extract