Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c316149
feat(ai): style welcome carousel and update onboarding copy
abose Sep 20, 2026
4d26e2b
feat(ai): native AI start screen, CLI setup dialog and installer fixes
abose Sep 21, 2026
1a2b0ac
feat(ai): strings and styles for compact start screen and chat input …
abose Sep 21, 2026
a44e035
feat(ai): strings and styles for the recorded Surprise Me demo
abose Sep 21, 2026
52872a7
fix(ai): send streamed text before tool cards, report model activity
abose Sep 21, 2026
757f100
feat(ai): strings and styles for the demo ending card and speed control
abose Sep 21, 2026
fb50078
fix(live-preview): hide the md viewer while a splash is shown over it
abose Sep 21, 2026
71a589e
test: dismiss dialogs a suite leaves open in the shared test window
abose Sep 21, 2026
7a0a2d0
build: update pro deps
abose Sep 21, 2026
9c55291
build: update pro deps
abose Sep 22, 2026
5f50e8a
feat(layers): add the full-panel page picker layout
abose Sep 22, 2026
ff43546
fix(live-preview): preserve dependencies in minified builds
abose Sep 22, 2026
3120ad0
chore(ai): remove unused demo notice string
abose Sep 22, 2026
09c7592
build: update pro deps
abose Sep 22, 2026
f67604b
feat(ai): add image tools and gallery previews
abose Sep 22, 2026
7628ba2
build: update pro deps
abose Sep 22, 2026
6f4a1df
fix(ai): match SVG prologs and comments in linear time
abose Sep 22, 2026
b6a0cc1
feat(ai): strings and styles for the Surprise Me choice and shared de…
abose Sep 22, 2026
6154635
feat(ai): style the demo ending card's + New as its primary action
abose Sep 22, 2026
623e589
build: update pro deps
abose Sep 22, 2026
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
98 changes: 98 additions & 0 deletions src-node/ai-image-preview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* Copyright (c) 2021 - present core.ai
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

/** Bounded image reads for local file URLs and remote images without browser CORS support. */
const fs = require("fs");
const {fileURLToPath} = require("url");

const MAX_BYTES = 8 * 1024 * 1024;

/** @return {string|null} An image MIME type recognized from the bytes, never from the filename alone. */
function _imageType(bytes) {
if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) { return "image/png"; }
if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) { return "image/jpeg"; }
if (/^GIF8[79]a$/.test(bytes.toString("ascii", 0, 6))) { return "image/gif"; }
if (bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") {
return "image/webp";
}
if (bytes.toString("ascii", 4, 8) === "ftyp" && /avif|avis/.test(bytes.toString("ascii", 8, 32))) {
return "image/avif";
}
if (bytes[0] === 0 && bytes[1] === 0 && bytes[2] === 1 && bytes[3] === 0) { return "image/x-icon"; }
const head = bytes.toString("utf8", 0, Math.min(bytes.length, 2048)).trim();
// The prolog and each comment stop at their own end marker, so the match is linear
// (a lazy [\s\S]*? could span comments and backtrack exponentially on hostile bytes).
if (/^(?:<\?xml(?:(?!\?>)[\s\S])*\?>\s*)?(?:<!--(?:(?!-->)[\s\S])*-->\s*)*<svg[\s>]/i.test(head)) {
return "image/svg+xml";
}
return null;
}

/**
* Read one image without uploading it or forwarding browser credentials.
* @param {{url: string}} params - HTTP(S) or file URL supplied to the image tool.
* @return {Promise<Object>} A data URL, or a small structured error.
*/
async function readImage(params) {
let controller, timer;
try {
const url = new URL(params.url);
if (!["file:", "https:", "http:"].includes(url.protocol) || url.username || url.password) {
throw new Error("Unsupported image URL");
}
let bytes;
if (url.protocol === "file:") {
const filePath = fileURLToPath(url);
const beforeOpen = await fs.promises.stat(filePath);
if (!beforeOpen.isFile() || beforeOpen.size > MAX_BYTES) {
throw new Error("Image exceeds the 8 MB limit");
}
// Nonblocking prevents a replaced path from hanging if it becomes a pipe between stat and open.
// eslint-disable-next-line no-bitwise
const file = await fs.promises.open(filePath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
try {
const stat = await file.stat();
if (!stat.isFile() || stat.size > MAX_BYTES) { throw new Error("Image exceeds the 8 MB limit"); }
bytes = Buffer.alloc(Math.min(stat.size + 1, MAX_BYTES + 1));
let offset = 0;
while (offset < bytes.length) {
const read = await file.read(bytes, offset, bytes.length - offset, offset);
if (!read.bytesRead) { break; }
offset += read.bytesRead;
}
bytes = bytes.subarray(0, offset);
} finally {
await file.close();
}
} else {
controller = new AbortController();
timer = setTimeout(function () { controller.abort(); }, 10000);
const response = await fetch(url, {signal: controller.signal, credentials: "omit"});
if (!response.ok) { throw new Error("Image request failed (" + response.status + ")"); }
if (Number(response.headers.get("content-length")) > MAX_BYTES) {
throw new Error("Image exceeds the 8 MB limit");
}
const chunks = [];
let size = 0;
for await (const chunk of response.body) {
size += chunk.length;
if (size > MAX_BYTES) { throw new Error("Image exceeds the 8 MB limit"); }
chunks.push(Buffer.from(chunk));
}
bytes = Buffer.concat(chunks);
}
if (bytes.length > MAX_BYTES) { throw new Error("Image exceeds the 8 MB limit"); }
const mediaType = _imageType(bytes);
if (!mediaType) { throw new Error("The URL does not contain a supported image"); }
return {dataUrl: "data:" + mediaType + ";base64," + bytes.toString("base64")};
} catch (error) {
return {error: error.message};
} finally {
clearTimeout(timer);
if (controller) { controller.abort(); }
}
}

exports.readImage = readImage;
108 changes: 82 additions & 26 deletions src-node/claude-code-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@
const fs = require("fs");
const os = require("os");
const path = require("path");
const { createEditorMcpServer } = require("./mcp-editor-tools");
const { createEditorMcpServer, getImageSearchResult } = require("./mcp-editor-tools");
const CliLocator = require("./cli-locator");
const ImagePreview = require("./ai-image-preview");

exports.readImagePreview = ImagePreview.readImage;

const isWindows = process.platform === "win32";

Expand Down Expand Up @@ -795,6 +798,15 @@ exports.validateCliPath = async function (params) {
return CliLocator.validateCliPath(cliId, (params && params.path) || "");
};

/**
* Which downloader the CLI install one-liner can use on this machine.
* Called from browser via execPeer("getInstallDownloader").
* @return {Promise<{downloader: ?string}>} "curl", "wget", or null
*/
exports.getInstallDownloader = async function () {
return { downloader: CliLocator.findDownloader() };
};

/**
* How to spawn a CLI in a PTY: availability plus the command/args the
* terminal should use. Callers must not spawn `path` directly — on Windows
Expand Down Expand Up @@ -1391,6 +1403,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
"EnterPlanMode", "ExitPlanMode",
"mcp__phoenix-editor__getEditorState",
"mcp__phoenix-editor__searchEditorBuffers",
"mcp__phoenix-editor__searchImages",
"mcp__phoenix-editor__previewImages",
"mcp__phoenix-editor__useImage",
"mcp__phoenix-editor__takeScreenshot",
"mcp__phoenix-editor__execJsInLivePreview",
"mcp__phoenix-editor__execJsInEditor",
Expand All @@ -1413,6 +1428,8 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
tools: ["Read", "Glob", "Grep",
"mcp__phoenix-editor__getEditorState",
"mcp__phoenix-editor__searchEditorBuffers",
"mcp__phoenix-editor__searchImages",
"mcp__phoenix-editor__previewImages",
"mcp__phoenix-editor__takeScreenshot",
"mcp__phoenix-editor__execJsInLivePreview",
"mcp__phoenix-editor__editorDocs"]
Expand All @@ -1424,6 +1441,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
" changes using Edit for existing files and Write" +
" only for new files.",
tools: ["Read", "Edit", "Write", "Glob", "Grep",
"mcp__phoenix-editor__searchImages",
"mcp__phoenix-editor__previewImages",
"mcp__phoenix-editor__useImage",
"mcp__phoenix-editor__getEditorState",
"mcp__phoenix-editor__searchEditorBuffers",
"mcp__phoenix-editor__takeScreenshot",
Expand Down Expand Up @@ -1508,6 +1528,11 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
"\n- execJsInLivePreview: run JS inside the HTML preview iframe to read the DOM, " +
"query computed styles, click elements, or capture console output. Use it to debug " +
"behavior and to confirm an edit actually took effect." +
"\n- searchImages: find Unsplash photos for a website; includePreview=true returns a small " +
"numbered collage so you can choose visually. Reuse returned URLs and call useImage when selecting " +
"a photo for a page. Use searches judiciously, at most 100 per hour." +
"\n- previewImages: show actual images in the chat from existing URLs (including file:/// local images). " +
"Use it when presenting images or a shortlist to the user; no search is needed." +
"\n- resizeLivePreview: change the preview viewport width to test responsive " +
"breakpoints." +
"\n- controlEditor: open files, move the cursor, change selection, toggle the live " +
Expand Down Expand Up @@ -2280,6 +2305,26 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
let textDeltaCount = 0;
let textStreamSendCount = 0;

// Text is batched (TEXT_STREAM_THROTTLE_MS), and a batch only went out
// when the next text delta arrived. Everything else — a tool card
// above all — is sent the moment it happens, so the last words of a
// sentence used to reach the panel after the card that followed them
// and showed up underneath it. Whatever is not text flushes first.
let sentAnyText = false;
function flushText() {
if (!accumulatedText) {
return;
}
textStreamSendCount++;
sentAnyText = true;
lastStreamTime = Date.now();
nodeConnector.triggerPeer("aiTextStream", {
requestId: requestId,
text: accumulatedText
});
accumulatedText = "";
}

// Connection timeout — abort if no messages within 60s
let receivedFirstMessage = false;
const CONNECTION_TIMEOUT_MS = 60000;
Expand Down Expand Up @@ -2351,6 +2396,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
if (message.type === "assistant" &&
message.parent_tool_use_id &&
message.message && Array.isArray(message.message.content)) {
flushText();
const parentToolId = _toolUseIdToCounter[message.parent_tool_use_id];
for (const block of message.message.content) {
if (block && block.type === "tool_use") {
Expand Down Expand Up @@ -2449,6 +2495,17 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
const event = message.event;
const isSubagent = !!message.parent_tool_use_id;

if (event.type === "content_block_stop" ||
(event.type === "content_block_start" && event.content_block?.type === "tool_use")) {
flushText();
}
// Text blocks arrive back to back with nothing between them,
// which ran one block's last sentence into the next one's first.
if (event.type === "content_block_start" &&
event.content_block?.type === "text" && sentAnyText) {
accumulatedText += "\n\n";
}

if (isSubagent) {
// --- Sub-agent events ---

Expand Down Expand Up @@ -2535,20 +2592,28 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
event.delta?.type === "text_delta") {
accumulatedText += event.delta.text;
textDeltaCount++;
const now = Date.now();
if (now - lastStreamTime >= TEXT_STREAM_THROTTLE_MS) {
lastStreamTime = now;
textStreamSendCount++;
nodeConnector.triggerPeer("aiTextStream", {
requestId: requestId,
text: accumulatedText
});
accumulatedText = "";
if (Date.now() - lastStreamTime >= TEXT_STREAM_THROTTLE_MS) {
flushText();
}
}
} else {
// --- Parent-level events (unchanged) ---

// What the model is doing between tool calls. Nothing
// else reaches the panel in these stretches, and they can
// run to minutes — without this, "the API has not
// answered yet" and "the model is thinking" both look
// like a panel that has hung.
if (event.type === "message_start") {
_log("Model responding");
nodeConnector.triggerPeer("aiModelActivity", { requestId: requestId, state: "responding" });
}
if (event.type === "content_block_start" &&
event.content_block?.type === "thinking") {
_log("Thinking started");
nodeConnector.triggerPeer("aiModelActivity", { requestId: requestId, state: "thinking" });
}

// Tool use start — send initial indicator
if (event.type === "content_block_start" &&
event.content_block?.type === "tool_use") {
Expand Down Expand Up @@ -2636,15 +2701,8 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
event.delta?.type === "text_delta") {
accumulatedText += event.delta.text;
textDeltaCount++;
const now = Date.now();
if (now - lastStreamTime >= TEXT_STREAM_THROTTLE_MS) {
lastStreamTime = now;
textStreamSendCount++;
nodeConnector.triggerPeer("aiTextStream", {
requestId: requestId,
text: accumulatedText
});
accumulatedText = "";
if (Date.now() - lastStreamTime >= TEXT_STREAM_THROTTLE_MS) {
flushText();
}
}
}
Expand Down Expand Up @@ -2696,6 +2754,10 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
requestId: requestId,
toolId: counterId,
isError: !!block.is_error && !isAnsweredByDeny,
imageSearch: ["mcp__phoenix-editor__searchImages", "mcp__phoenix-editor__previewImages",
"mcp__phoenix-editor__useImage"]
.includes(resultToolName) ?
getImageSearchResult(block.content) : undefined,
preview: preview
});
}
Expand All @@ -2705,13 +2767,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
}

// Flush any remaining accumulated text
if (accumulatedText) {
textStreamSendCount++;
nodeConnector.triggerPeer("aiTextStream", {
requestId: requestId,
text: accumulatedText
});
}
flushText();

clearTimeout(connectionTimer);
_log("Complete: tools=" + toolCounter, "edits=" + editCount,
Expand Down
28 changes: 27 additions & 1 deletion src-node/cli-locator.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,13 @@ const CLI_REGISTRY = {
// returns an unvalidated path straight to pty.spawn.
winNative: function () {
const userHome = process.env.USERPROFILE || process.env.HOME || "";
return [path.join(userHome, ".local", "bin", "codex.exe")];
return [
// chatgpt.com/codex/install.ps1. Listed by full path because
// the User PATH entry it adds never reaches this already
// running process, so a PATH lookup misses a fresh install.
path.join(process.env.LOCALAPPDATA || "", "Programs", "OpenAI", "Codex", "bin", "codex.exe"),
path.join(userHome, ".local", "bin", "codex.exe")
];
},
winExtra: function () {
const userHome = process.env.USERPROFILE || process.env.HOME || "";
Expand Down Expand Up @@ -626,6 +632,25 @@ async function validateCliPath(cliId, cliPath) {
};
}

// Fetchers an install one-liner can pipe from, in preference order. Neither
// is universal on Linux: Debian/Ubuntu desktops ship wget without curl, Arch
// and Fedora ship curl without wget.
const DOWNLOADERS = ["curl", "wget"];

/**
* Which downloader is on PATH, so the install command offered to the user is
* one that can actually run.
* @return {?string} "curl", "wget", or null when neither is installed
*/
function findDownloader() {
for (const bin of DOWNLOADERS) {
if (_pathLookup(bin).length) {
return bin;
}
}
return null;
}

/**
* How to hand a resolved binary to node-pty.
*
Expand Down Expand Up @@ -653,6 +678,7 @@ exports.canAccess = canAccess;
exports.spawnCli = spawnCli;
exports.locateCli = locateCli;
exports.validateCliPath = validateCliPath;
exports.findDownloader = findDownloader;
exports.getSpawnProfile = getSpawnProfile;
exports.setOverrides = setOverrides;
exports.getOverride = getOverride;
Loading
Loading