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