Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<ChatMarkdown
cwd="/tmp/project"
text={text}
isStreaming={isStreaming}
onRunShellCommand={onRunShellCommand}
/>
);
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```",
'<pre><code class="language-bash">echo html</code></pre>',
]) {
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");
Expand Down
65 changes: 64 additions & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
MessageSquareWarningIcon,
Minimize2Icon,
OctagonAlertIcon,
PlayIcon,
PresentationIcon,
SparklesIcon,
TriangleAlertIcon,
Expand Down Expand Up @@ -214,6 +215,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;
Expand Down Expand Up @@ -583,6 +585,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;
Expand Down Expand Up @@ -919,19 +938,34 @@ 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);
const [wrapped, setWrapped] = useState(readInitialWordWrapSetting);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | 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) {
Expand Down Expand Up @@ -1004,6 +1038,24 @@ function MarkdownCodeBlock({
</TooltipTrigger>
<TooltipPopup side="top">{wrapLabel}</TooltipPopup>
</Tooltip>
{canRun ? (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost-muted"
size="icon-xs"
onClick={() => onRunShellCommand(command)}
aria-label="Run in terminal"
/>
}
>
<PlayIcon className="size-3" />
</TooltipTrigger>
<TooltipPopup side="top">Run in terminal</TooltipPopup>
</Tooltip>
) : null}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<Tooltip>
<TooltipTrigger
render={
Expand Down Expand Up @@ -2259,6 +2311,7 @@ function useChatMarkdownState({
isStreaming = false,
skills = EMPTY_MARKDOWN_SKILLS,
onUseArtifactTemplate,
onRunShellCommand,
imageBaseDir,
onImageExpand,
renderContextReference,
Expand Down Expand Up @@ -2671,6 +2724,7 @@ function useChatMarkdownState({
markdownFileLinkMetaByHref,
onTaskListChange,
onUseArtifactTemplate,
onRunShellCommand,
openChangeRequestLink,
openDeferredMarkdownLink,
openExternalLinkInPreview,
Expand Down Expand Up @@ -2701,6 +2755,7 @@ function useChatMarkdownState({
markdownFileLinkMetaByHref,
onTaskListChange,
onUseArtifactTemplate,
onRunShellCommand,
openChangeRequestLink,
openDeferredMarkdownLink,
openExternalLinkInPreview,
Expand Down Expand Up @@ -3239,7 +3294,9 @@ const CHAT_MARKDOWN_COMPONENTS = {
return <MarkdownDetails open={detailsOpen}>{children}</MarkdownDetails>;
},
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 <pre {...props}>{children}</pre>;
Expand All @@ -3253,6 +3310,12 @@ const CHAT_MARKDOWN_COMPONENTS = {
language={language}
fenceTitle={fenceTitle}
theme={resolvedTheme}
onRunShellCommand={
onRunShellCommand && !isStreaming && isClosedCodeFence(node, text)
? onRunShellCommand
: undefined
}
isStreaming={isStreaming}
>
<RenderErrorBoundary
resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]}
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4309,6 +4309,23 @@ export default function ChatView(props: ChatViewProps) {
],
);

const runProjectScriptRef = useRef(runProjectScript);
useLayoutEffect(() => {
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;
Expand Down Expand Up @@ -9886,6 +9903,7 @@ export default function ChatView(props: ChatViewProps) {
agentPanelModel,
onOpenAgents: addAgentsSurface,
onUseArtifactTemplate: useArtifactTemplate,
...(activeProject ? { onRunShellCommand: runShellCommand } : {}),
}
: {})}
isWorking={!paintOnlyDisplayedTimeline && isWorking}
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,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;
Expand Down Expand Up @@ -432,6 +433,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;
Expand Down Expand Up @@ -501,6 +503,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
supportsConversationRollback,
onRevertToTurnCount,
onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE,
onRunShellCommand,
isRevertingCheckpoint,
onImageExpand,
onFileOpen = NOOP_OPEN_ATTACHMENT,
Expand Down Expand Up @@ -1145,6 +1148,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
activeThreadEnvironmentId,
onRevertToTurnCount,
onUseArtifactTemplate,
onRunShellCommand,
onImageExpand,
onFileOpen,
onFileDownload,
Expand Down Expand Up @@ -1180,6 +1184,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
activeThreadEnvironmentId,
onRevertToTurnCount,
onUseArtifactTemplate,
onRunShellCommand,
onImageExpand,
onFileOpen,
onFileDownload,
Expand Down Expand Up @@ -2381,6 +2386,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
skills={ctx.skills}
headingLevelOffset={MESSAGE_HEADING_LEVEL}
onUseArtifactTemplate={ctx.onUseArtifactTemplate}
onRunShellCommand={ctx.onRunShellCommand}
onImageExpand={ctx.onImageExpand}
/>
</AssistantCitationSource>
Expand Down
Loading