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
5 changes: 4 additions & 1 deletion src-node/ai-cli-connector.js
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,10 @@ class CliConnector {
try {
return await deadline(spec.handler(args), timeoutMs);
} finally {
if (spec.name === "askInLivePreview") { this.cancelCall(session, frame.id); }
// A question still showing when its call ends, by error or budget, must not outlive it.
if (spec.name === "askInLivePreview" || spec.name === "askRichQuestion") {
this.cancelCall(session, frame.id);
}
}
}

Expand Down
66 changes: 66 additions & 0 deletions src-node/ai-editor-tool-specs.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
const path = require("path");
const fs = require("fs");
const { z } = require("zod");
const {RICH_UI_STYLE_GUIDANCE} = require("./ai-system-prompt");

// Absolute path to the bundled API reference, mirrored from
// docs/API-Reference/ at build time by build/api-docs-generator.js.
Expand Down Expand Up @@ -52,6 +53,10 @@ const EXEC_PEER_TIMEOUT_MS = {
useImage: 90000
};

// A rich question waits until the user answers or its owner cancels it. The CLI transport needs a finite
// budget; this one is a day, and ending it cancels the question in the editor like any CLI cancellation.
const RICH_QUESTION_FAILSAFE_MS = 24 * 60 * 60 * 1000;

// Floor for caller-provided timeouts (e.g. execJsInLivePreview's
// timeoutMs). 5s minimum stops the model from spamming impatient retries
// on a preview that's just taking a beat to settle. No ceiling — the
Expand Down Expand Up @@ -97,6 +102,9 @@ function getToolTimeout(name, args = {}) {
if (name === "askInLivePreview") {
return (args.timeoutS || 300) * 1000 + 15000;
}
if (name === "askRichQuestion") {
return RICH_QUESTION_FAILSAFE_MS;
}
if (name === "execJsInEditor" || name === "execJsInLivePreview") {
return _resolveCallerTimeout(args.timeoutMs, 10000);
}
Expand Down Expand Up @@ -842,6 +850,64 @@ function getEditorToolSpecs(peerCall, options = {}) {
}
);

addTool(
"askRichQuestion",
"Ask the user a question shown as rich interactive UI " +
(options.cli ? "in your session's pane in Phoenix" : "in the chat") + ", with no live preview needed, and " +
"wait for the answer: a visual choice, a short form, or something to look at and confirm. Prefer choices: " +
"each has id, label and optional description/color; Phoenix renders the cards and a pick returns " +
"{choice:id,label}. For custom content pass html or uiFile with its own <style> or cssFile, and mark options " +
"class='ph-choice' data-id='<id>'; this wires the answer without applying card styling. " +
RICH_UI_STYLE_GUIDANCE +
"Interaction code goes in scriptFile or an inline <script> in the markup " +
"and runs sandboxed as " + (options.cli ? "function(ui, params, root), where root is the question's own " +
"DOM inside an isolated frame" : "function(ui, params), with no DOM, window or root") + ". No external " +
"scripts, libraries or network. The ui API: on(selector, event, handler, {preventDefault}), whose handler " +
"gets {value, checked, key, dataset, values}; text(selector, value), html(selector, value) and " +
"value(selector, value); attr(selector, name, value) and style(selector, name, value); show(selector, " +
"visible); getState() and setState(object); answer(value) and cancel(); interval(fn, ms), timeout(fn, ms) " +
"and cleanup(fn). params is data for the UI and its script. " +
(options.cli ? "Phoenix supplies the titlebar from summary, Close, and a reply box that returns " +
"answer:{text:string}; do not duplicate them in your markup. " : "") +
"Set acknowledgement to show an OK button for " +
"something the user only needs to review. Write reusable files in getEditorState's askInLivePreviewUiDir " +
"and pass their names. The result is {answered:true, answer, answeredIn:'richUI'}, {acknowledged:true}, " +
"{cancelled:true, by} or an error. It waits until the user answers or closes it, or the turn is stopped.",
{
summary: z.string().min(1).max(200).describe("Short title saying what you are asking"),
html: z.string().max(200000).optional().describe("Question markup with its own <style>; or use uiFile"),
uiFile: z.string().min(1).optional().describe("Markup file: an absolute path, or a file name inside " +
"askInLivePreviewUiDir; up to 200000 characters"),
cssFile: z.string().min(1).optional().describe("Stylesheet file for the markup, located like uiFile"),
scriptFile: z.string().min(1).optional().describe("Script file located like uiFile, run as the body of " +
(options.cli ? "function(ui, params, root)" : "function(ui, params)") + "; up to 100000 characters"),
params: z.object({}).passthrough().optional().describe("Data for the UI and its script"),
choices: z.array(z.object({
id: z.string().min(1).max(64),
label: z.string().min(1).max(500),
description: z.string().max(1000).optional(),
color: z.string().max(64).optional()
})).min(1).max(20).optional().describe("Options; IDs match ph-choice data-id in custom markup"),
acknowledgement: z.boolean().optional().describe("Show an OK button; the result is {acknowledged:true}")
},
async function (args) {
try {
// No timeout of its own: the chat's Stop or the CLI's cancellation ends the question.
const result = await nodeConnector.execPeer("askRichQuestion", args || {});
if (result && result.error) {
return {content: [{ type: "text", text: "Error: " + result.error }], isError: true};
}
return {content: [{ type: "text", text: JSON.stringify(result) }]};
} catch (err) {
return {content: [{ type: "text", text: "Error asking the question: " + err.message }], isError: true};
}
},
{
alwaysLoad: true,
searchHint: "ask the user with rich interactive UI in the conversation, without a live preview"
}
);

addTool(
"notifyUser",
"Show a short notification toast in the editor window, outside the chat. Use it only when the " +
Expand Down
34 changes: 33 additions & 1 deletion src-node/ai-system-prompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ const SERVER_INSTRUCTIONS = "Phoenix Code editor tools for the session launched
"Call getEditorState for the user's current file and preview. Before native edits use flushUnsavedFiles; " +
"afterwards use refreshFilesFromDisk. Preserve unsaved typing and stop on conflicts. " +
"Prefer these tools for Phoenix screenshots, live preview and editor controls.";
// Match the default AI sidebar surface; authored rich content remains transparent unless it sets a background.
const RICH_UI_STYLE_GUIDANCE = "Rich content sits on Phoenix's dark panel (default background #252525, " +
"inherited text #e0e0e0), not a white page. Its transparent boundary resets inherited text formatting " +
"except the chat font and text colour; your CSS controls the content inside. " +
"Transparent areas show the panel. Inherit the chat font/colour, " +
"or set both foreground and background for your custom surfaces, including hover, focus and disabled states. " +
"Design for a narrow, resizable sidebar. Images can use HTTPS URLs or inline <svg>. " +
"For SVG in an <img> data URL, URL-encode the SVG (including # colours) or use base64. ";

/**
* Build the Phoenix append without replacing the CLI's own system instructions.
Expand Down Expand Up @@ -151,7 +159,7 @@ function buildSystemPrompt({projectPath, scratchDir, locale, cli = false} = {})
"user's attention and they may be away from the chat. Never use it for ordinary replies. " +
(cli ? "It is skipped while your originating CLI session is visible unless you pass alwaysShow." :
"It is skipped while the AI panel is visible unless you pass alwaysShow.") +
"\n- askInLivePreview: whenever showing beats telling and the page is in the live preview, " +
"\n- askInLivePreview: for visual requests about the actual page in the live preview, " +
"compose UI with askInLivePreview instead of prose: a choice about one element (its colour, " +
"copy, placement), a choice about the whole page (theme, palette, typography, layout direction, " +
"which of several designs to keep), or simply to present something visually (a mockup, a " +
Expand All @@ -167,6 +175,29 @@ function buildSystemPrompt({projectPath, scratchDir, locale, cli = false} = {})
"(yours to write freely, no permission is asked and the user is not shown those writes). ") +
"The tool description is the whole contract; never look for its implementation, " +
"and keep the look-at-the-page step to one screenshot or one execJsInLivePreview." +
"\n- Rich UI outside the live page: When asked to show visual examples, components or demos " +
"(for example, 'show me a carousel with images'), render a working visual by default. Use ordinary " +
"code fences when the user asks for source code or a code walkthrough. Standalone examples belong " +
"here even if an unrelated page is open; do not read or edit project files just to display them. " + (cli ?
"use askRichQuestion for a visual explanation, interactive mockup or question in your own terminal pane; " +
"set acknowledgement:true for a visual that only needs OK. Do not print rich Markdown fences in a CLI." :
"use a complete top-level fenced code block with language phoenix_interactive_preview_html for a " +
"nonblocking visual embedded naturally in your reply. Put plain HTML, <style> and optionally <script> " +
"inside it, no React or external libraries. " +
"Use askRichQuestion only when you need to wait for an answer. " +
"Inline scripts run in a shared DOM-free worker, never in the editor. Available API: " +
"ui.on(selector,event,fn,{preventDefault:true}) passes {value,checked,key,dataset,values}; " +
"ui.text/html/value(selector,value), ui.attr/style(selector,name,value), ui.show(selector,bool), " +
"ui.getState(), ui.setState(serializableObject), ui.timeout/interval(fn,ms), ui.cleanup(fn). " +
"Initialize state from ui.getState() and save changes with ui.setState() so controls restore from history. " +
"Example: <button id='inc'>+</button><output id='n'>0</output><script>" +
"let s=ui.getState();s.n=s.n||0;ui.text('#n',s.n);ui.on('#inc','click',()=>{" +
"ui.text('#n',++s.n);ui.setState(s);});</script>. " +
"There is no document/window/root, fetch, imports or filesystem API in inline scripts. " +
"Use ui.on, not HTML onclick attributes. CSS is scoped to this block; no external CSS resources. " +
"Keep display widgets self-contained and compact; no answer callback is needed.") +
" " + RICH_UI_STYLE_GUIDANCE +
"This feature does not inspect or change the actual live page: use askInLivePreview for that instead." +
"\n\nEDITS THAT LAND IN THE LIVE PREVIEW: when you edit the file getEditorState " +
"reported as livePreviewFile — or a CSS / JS / SVG file it links to — the user is " +
"watching the result render. Whether that is worth checking is your judgement call, " +
Expand Down Expand Up @@ -255,3 +286,4 @@ function buildEditorContextLine(ctx, options = {}) {
exports.buildSystemPrompt = buildSystemPrompt;
exports.buildEditorContextLine = buildEditorContextLine;
exports.SERVER_INSTRUCTIONS = SERVER_INSTRUCTIONS;
exports.RICH_UI_STYLE_GUIDANCE = RICH_UI_STYLE_GUIDANCE;
1 change: 1 addition & 0 deletions src-node/claude-code-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -1463,6 +1463,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
"mcp__phoenix-editor__getProblems",
"mcp__phoenix-editor__notifyUser",
"mcp__phoenix-editor__askInLivePreview",
"mcp__phoenix-editor__askRichQuestion",
"mcp__phoenix-editor__controlEditor",
"mcp__phoenix-editor__resizeLivePreview",
"mcp__phoenix-editor__wait",
Expand Down
1 change: 1 addition & 0 deletions src-node/test/test-ai-cli-connector.js
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ exports.exercise = async function ({scenario}) {
return {names: specs.map(spec => spec.name), sdkNames: built.map(tool => tool.name),
stateAlwaysLoaded: specs.find(spec => spec.name === "getEditorState").alwaysLoad,
askTimeout: getToolTimeout("askInLivePreview", {timeoutS: 1800}),
richQuestionTimeout: getToolTimeout("askRichQuestion"),
imageTimeout: getToolTimeout("useImage")};
}
if (scenario === "prompt") {
Expand Down
13 changes: 10 additions & 3 deletions src/brackets.js
Original file line number Diff line number Diff line change
Expand Up @@ -478,14 +478,19 @@
// changes). We can't use a simple .on("click", "a") because of http://bugs.jquery.com/ticket/3861:
// jQuery hides non-left clicks from such event handlers, yet middle-clicks still cause CEF to
// navigate. Also, a capture handler is more reliable than bubble.
window.document.body.addEventListener("click", function (e) {
/**
* Route links, including links inside Shadow DOM, through the native URL opener.
* @param {MouseEvent} e Click or middle-button activation.
*/
function handleLinkClick(e) {

Check warning on line 485 in src/brackets.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Move function 'handleLinkClick' to the outer scope.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AaEiL29uTm2diphwb9fg&open=AaEiL29uTm2diphwb9fg&pullRequest=3269
// Don't interfere with context menu clicks
if (e.button === 2 || (brackets.platform === "mac" && e.ctrlKey)) {
return;
}

// Check parents too, in case link has inline formatting tags
let node = e.target, url;
const path = e.composedPath ? e.composedPath() : [];
let node = path[0] || e.target, url;
while (node) {
if (node.tagName === "A") {
url = node.getAttribute("href");
Expand All @@ -498,7 +503,9 @@
}
node = node.parentElement;
}
}, true);
}
window.document.body.addEventListener("click", handleLinkClick, true);
window.document.body.addEventListener("auxclick", handleLinkClick, true);

// jQuery patch to shim deprecated usage of $() on EventDispatchers
const DefaultCtor = jQuery.fn.init;
Expand Down
14 changes: 14 additions & 0 deletions src/nls/root/strings.js
Original file line number Diff line number Diff line change
Expand Up @@ -2776,6 +2776,20 @@ define({
"AI_CHAT_SETUP_NEED_HELP": "Need Help?",
"AI_CHAT_SETUP_LEARN_MORE": "Learn More",
"AI_CHAT_RETRY": "Retry",
"AI_RICH_TITLE": "Interactive content",
"AI_RICH_CLI_ASKING": "{0} is asking a question",
"AI_RICH_SCRIPT_ERROR": "This interactive content encountered an error.",
"AI_RICH_SCRIPT_STOPPED": "This interactive content stopped responding.",
"AI_RICH_DISMISSED": "Dismissed",
"AI_RICH_ANSWERED": "Answered",
"AI_RICH_SEE_CODE": "See code",
"AI_RICH_REPLY_PLACEHOLDER": "Write a reply…",
"AI_RICH_SOURCE_TITLE": "Source code",
"AI_RICH_COPY_CODE": "Copy code",
"AI_RICH_SOURCE_READ_ONLY": "Read-only",
"AI_RICH_SOURCE_WRAP": "Wrap lines",
"AI_RICH_PREVIEW_GENERATING": "Building preview…",
"AI_RICH_PREVIEW_INCOMPLETE": "Incomplete preview",
"AI_CHAT_DESKTOP_ONLY": "AI features require the {APP_NAME} desktop app. Download it to get started.",
"AI_CHAT_DOWNLOAD_BTN": "Download Desktop App",
"AI_CHAT_PLACEHOLDER_TITLE": "Design, build, and fix faster with AI",
Expand Down
Loading
Loading