Skip to content

Commit 8a84144

Browse files
committed
feat: add TodoWrite task list rendering, elapsed timer, and batch controlEditor
- Render TodoWrite tool as a visual task list with status icons (completed, in-progress, pending) that auto-expands and is collapsible - Show elapsed time counter on long-running tools after 2s of inactivity - Change controlEditor MCP tool to accept an operations array for batch file open/close/navigate actions in a single call - Wire controlEditor through to browser-side with full operation support - Add corresponding i18n strings and LESS styles
1 parent 8fde6d6 commit 8a84144

7 files changed

Lines changed: 330 additions & 6 deletions

File tree

src-node/claude-code-agent.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,8 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale)
221221
"Read", "Edit", "Write", "Glob", "Grep",
222222
"mcp__phoenix-editor__getEditorState",
223223
"mcp__phoenix-editor__takeScreenshot",
224-
"mcp__phoenix-editor__execJsInLivePreview"
224+
"mcp__phoenix-editor__execJsInLivePreview",
225+
"mcp__phoenix-editor__controlEditor"
225226
],
226227
mcpServers: { "phoenix-editor": editorMcpServer },
227228
permissionMode: "acceptEdits",

src-node/mcp-editor-tools.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,54 @@ function createEditorMcpServer(sdkModule, nodeConnector) {
123123
}
124124
);
125125

126+
const controlEditorTool = sdkModule.tool(
127+
"controlEditor",
128+
"Control the Phoenix editor: open/close files, navigate to lines, and select text ranges. " +
129+
"Accepts an array of operations to batch multiple actions in one call. " +
130+
"All line and ch (column) parameters are 1-based.\n\n" +
131+
"Operations:\n" +
132+
"- open: Open a file in the active pane. Params: filePath\n" +
133+
"- close: Close a file (force, no save prompt). Params: filePath\n" +
134+
"- openInWorkingSet: Open a file and pin it to the working set. Params: filePath\n" +
135+
"- setSelection: Open a file and select a range. Params: filePath, startLine, startCh, endLine, endCh\n" +
136+
"- setCursorPos: Open a file and set cursor position. Params: filePath, line, ch",
137+
{
138+
operations: z.array(z.object({
139+
operation: z.enum(["open", "close", "openInWorkingSet", "setSelection", "setCursorPos"]),
140+
filePath: z.string().describe("Absolute path to the file"),
141+
startLine: z.number().optional().describe("Start line (1-based) for setSelection"),
142+
startCh: z.number().optional().describe("Start column (1-based) for setSelection"),
143+
endLine: z.number().optional().describe("End line (1-based) for setSelection"),
144+
endCh: z.number().optional().describe("End column (1-based) for setSelection"),
145+
line: z.number().optional().describe("Line number (1-based) for setCursorPos"),
146+
ch: z.number().optional().describe("Column (1-based) for setCursorPos")
147+
})).describe("Array of editor operations to execute sequentially")
148+
},
149+
async function (args) {
150+
const results = [];
151+
let hasError = false;
152+
for (const op of args.operations) {
153+
try {
154+
const result = await nodeConnector.execPeer("controlEditor", op);
155+
results.push(result);
156+
if (!result.success) {
157+
hasError = true;
158+
}
159+
} catch (err) {
160+
results.push({ success: false, error: err.message });
161+
hasError = true;
162+
}
163+
}
164+
return {
165+
content: [{ type: "text", text: JSON.stringify(results) }],
166+
isError: hasError
167+
};
168+
}
169+
);
170+
126171
return sdkModule.createSdkMcpServer({
127172
name: "phoenix-editor",
128-
tools: [getEditorStateTool, takeScreenshotTool, execJsInLivePreviewTool]
173+
tools: [getEditorStateTool, takeScreenshotTool, execJsInLivePreviewTool, controlEditorTool]
129174
});
130175
}
131176

src/core-ai/AIChatPanel.js

Lines changed: 161 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,9 @@ define(function (require, exports, module) {
596596
Skill: { icon: "fa-solid fa-puzzle-piece", color: "#e0c060", label: Strings.AI_CHAT_TOOL_SKILL },
597597
"mcp__phoenix-editor__getEditorState": { icon: "fa-solid fa-code", color: "#6bc76b", label: Strings.AI_CHAT_TOOL_EDITOR_STATE },
598598
"mcp__phoenix-editor__takeScreenshot": { icon: "fa-solid fa-camera", color: "#c084fc", label: Strings.AI_CHAT_TOOL_SCREENSHOT },
599-
"mcp__phoenix-editor__execJsInLivePreview": { icon: "fa-solid fa-eye", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_LIVE_PREVIEW_JS }
599+
"mcp__phoenix-editor__execJsInLivePreview": { icon: "fa-solid fa-eye", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_LIVE_PREVIEW_JS },
600+
"mcp__phoenix-editor__controlEditor": { icon: "fa-solid fa-code", color: "#6bc76b", label: Strings.AI_CHAT_TOOL_CONTROL_EDITOR },
601+
TodoWrite: { icon: "fa-solid fa-list-check", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_TASKS }
600602
};
601603

602604
function _onProgress(_event, data) {
@@ -637,6 +639,42 @@ define(function (require, exports, module) {
637639
}
638640
}
639641

642+
/**
643+
* Start an elapsed-time counter on a tool indicator. Called when the tool's
644+
* stale timer fires (no streaming activity for 2s).
645+
*/
646+
function _startElapsedTimer($tool) {
647+
if ($tool.data("elapsedTimer")) {
648+
return; // already running
649+
}
650+
const startTime = $tool.data("startTime") || Date.now();
651+
const $header = $tool.find(".ai-tool-header");
652+
let $elapsed = $header.find(".ai-tool-elapsed");
653+
if (!$elapsed.length) {
654+
$elapsed = $('<span class="ai-tool-elapsed"></span>');
655+
$header.append($elapsed);
656+
}
657+
function update() {
658+
const secs = Math.floor((Date.now() - startTime) / 1000);
659+
if (secs < 60) {
660+
$elapsed.text(secs + "s");
661+
} else {
662+
const m = Math.floor(secs / 60);
663+
const s = secs % 60;
664+
$elapsed.text(m + "m " + (s < 10 ? "0" : "") + s + "s");
665+
}
666+
}
667+
update();
668+
const timerId = setInterval(function () {
669+
if ($tool.hasClass("ai-tool-done")) {
670+
clearInterval(timerId);
671+
return;
672+
}
673+
update();
674+
}, 1000);
675+
$tool.data("elapsedTimer", timerId);
676+
}
677+
640678
function _onToolStream(_event, data) {
641679
const uniqueToolId = (_currentRequestId || "") + "-" + data.toolId;
642680
_traceToolStreamCounts[uniqueToolId] = (_traceToolStreamCounts[uniqueToolId] || 0) + 1;
@@ -682,6 +720,7 @@ define(function (require, exports, module) {
682720
if ($livePreview.length && !$tool.hasClass("ai-tool-done")) {
683721
$livePreview.text(phrases[idx]);
684722
}
723+
_startElapsedTimer($tool);
685724
_toolStreamRotateTimer = setInterval(function () {
686725
idx = (idx + 1) % phrases.length;
687726
const $p = $tool.find(".ai-tool-preview");
@@ -1146,6 +1185,7 @@ define(function (require, exports, module) {
11461185
$tool.find(".ai-tool-label").text(config.label + "...");
11471186
$tool.css("--tool-color", config.color);
11481187
$tool.attr("data-tool-icon", config.icon);
1188+
$tool.data("startTime", Date.now());
11491189
$messages.append($tool);
11501190
_scrollToBottom();
11511191
}
@@ -1173,9 +1213,38 @@ define(function (require, exports, module) {
11731213
// Update label to include summary
11741214
$tool.find(".ai-tool-label").text(detail.summary);
11751215

1176-
// For screenshot tools, add a detail container that will be populated
1177-
// when the screenshot capture completes (via screenshotCaptured event)
1178-
if (toolName === "mcp__phoenix-editor__takeScreenshot") {
1216+
// For TodoWrite, render a mini task-list widget and auto-expand
1217+
if (toolName === "TodoWrite" && toolInput && toolInput.todos) {
1218+
const $detail = $('<div class="ai-tool-detail"></div>');
1219+
const $todoList = $('<div class="ai-todo-list"></div>');
1220+
toolInput.todos.forEach(function (todo) {
1221+
let iconClass, statusClass;
1222+
if (todo.status === "completed") {
1223+
iconClass = "fa-solid fa-circle-check";
1224+
statusClass = "completed";
1225+
} else if (todo.status === "in_progress") {
1226+
iconClass = "fa-solid fa-spinner fa-spin";
1227+
statusClass = "in_progress";
1228+
} else {
1229+
iconClass = "fa-regular fa-circle";
1230+
statusClass = "pending";
1231+
}
1232+
const $item = $(
1233+
'<div class="ai-todo-item">' +
1234+
'<span class="ai-todo-icon ' + statusClass + '"><i class="' + iconClass + '"></i></span>' +
1235+
'<span class="ai-todo-content ' + (todo.status === "completed" ? "completed" : "") + '"></span>' +
1236+
'</div>'
1237+
);
1238+
$item.find(".ai-todo-content").text(todo.content);
1239+
$todoList.append($item);
1240+
});
1241+
$detail.append($todoList);
1242+
$tool.append($detail);
1243+
$tool.addClass("ai-tool-expanded");
1244+
$tool.find(".ai-tool-header").on("click", function () {
1245+
$tool.toggleClass("ai-tool-expanded");
1246+
}).css("cursor", "pointer");
1247+
} else if (toolName === "mcp__phoenix-editor__takeScreenshot") {
11791248
const $detail = $('<div class="ai-tool-detail"></div>');
11801249
$tool.append($detail);
11811250
$tool.data("awaitingScreenshot", true);
@@ -1211,6 +1280,14 @@ define(function (require, exports, module) {
12111280
clearTimeout(_toolStreamStaleTimer);
12121281
clearInterval(_toolStreamRotateTimer);
12131282

1283+
// Stop the elapsed timer and remove the element
1284+
const elapsedTimer = $tool.data("elapsedTimer");
1285+
if (elapsedTimer) {
1286+
clearInterval(elapsedTimer);
1287+
$tool.removeData("elapsedTimer");
1288+
}
1289+
$tool.find(".ai-tool-elapsed").remove();
1290+
12141291
// Delay marking as done so the streaming preview stays visible briefly.
12151292
// The ai-tool-done class hides the preview via CSS; deferring it lets the
12161293
// browser paint the preview before it disappears.
@@ -1289,6 +1366,79 @@ define(function (require, exports, module) {
12891366
summary: Strings.AI_CHAT_TOOL_LIVE_PREVIEW_JS,
12901367
lines: input.code ? input.code.split("\n").slice(0, 20) : []
12911368
};
1369+
case "TodoWrite": {
1370+
const todos = input.todos || [];
1371+
const completed = todos.filter(function (t) { return t.status === "completed"; }).length;
1372+
return {
1373+
summary: StringUtils.format(Strings.AI_CHAT_TOOL_TASKS_SUMMARY, completed, todos.length),
1374+
lines: []
1375+
};
1376+
}
1377+
case "mcp__phoenix-editor__controlEditor": {
1378+
// Multi-operation batch format
1379+
if (input.operations && input.operations.length) {
1380+
if (input.operations.length === 1) {
1381+
// Single operation — show its detail
1382+
const op = input.operations[0];
1383+
const fn = (op.filePath || "").split("/").pop();
1384+
let opSummary;
1385+
switch (op.operation) {
1386+
case "open":
1387+
case "openInWorkingSet":
1388+
opSummary = "Open " + fn;
1389+
break;
1390+
case "close":
1391+
opSummary = "Close " + fn;
1392+
break;
1393+
case "setCursorPos":
1394+
opSummary = "Go to L" + (op.line || "?") + " in " + fn;
1395+
break;
1396+
case "setSelection":
1397+
opSummary = "Select L" + (op.startLine || "?") + "-L" + (op.endLine || "?") + " in " + fn;
1398+
break;
1399+
default:
1400+
opSummary = Strings.AI_CHAT_TOOL_CONTROL_EDITOR;
1401+
}
1402+
return { summary: opSummary, lines: [op.filePath || ""] };
1403+
}
1404+
// Multiple operations — summarize
1405+
const count = input.operations.length;
1406+
const opTypes = {};
1407+
input.operations.forEach(function (op) {
1408+
const t = op.operation || "open";
1409+
opTypes[t] = (opTypes[t] || 0) + 1;
1410+
});
1411+
const parts = Object.keys(opTypes).map(function (t) {
1412+
const label = (t === "open" || t === "openInWorkingSet") ? "Open" :
1413+
t === "close" ? "Close" :
1414+
t === "setCursorPos" ? "Navigate" :
1415+
t === "setSelection" ? "Select" : t;
1416+
return label + " " + opTypes[t];
1417+
});
1418+
return { summary: parts.join(", ") + " files", lines: [] };
1419+
}
1420+
// Legacy single-operation format
1421+
const fileName = (input.filePath || "").split("/").pop();
1422+
let opSummary;
1423+
switch (input.operation) {
1424+
case "open":
1425+
case "openInWorkingSet":
1426+
opSummary = "Open " + fileName;
1427+
break;
1428+
case "close":
1429+
opSummary = "Close " + fileName;
1430+
break;
1431+
case "setCursorPos":
1432+
opSummary = "Go to L" + (input.line || "?") + " in " + fileName;
1433+
break;
1434+
case "setSelection":
1435+
opSummary = "Select L" + (input.startLine || "?") + "-L" + (input.endLine || "?") + " in " + fileName;
1436+
break;
1437+
default:
1438+
opSummary = Strings.AI_CHAT_TOOL_CONTROL_EDITOR;
1439+
}
1440+
return { summary: opSummary, lines: [input.filePath || ""] };
1441+
}
12921442
default: {
12931443
// Fallback: use TOOL_CONFIG label if available
12941444
const cfg = TOOL_CONFIG[toolName];
@@ -1306,6 +1456,13 @@ define(function (require, exports, module) {
13061456
function _finishActiveTools() {
13071457
$messages.find(".ai-msg-tool:not(.ai-tool-done)").each(function () {
13081458
const $prev = $(this);
1459+
// Clear any running elapsed timer
1460+
const et = $prev.data("elapsedTimer");
1461+
if (et) {
1462+
clearInterval(et);
1463+
$prev.removeData("elapsedTimer");
1464+
}
1465+
$prev.find(".ai-tool-elapsed").remove();
13091466
// _updateToolIndicator already ran — let the delayed timeout handle it
13101467
if ($prev.find(".ai-tool-icon").length) {
13111468
return;

src/core-ai/aiPhoenixConnectors.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,78 @@ define(function (require, exports, module) {
392392
return deferred.promise();
393393
}
394394

395+
// --- Editor control ---
396+
397+
/**
398+
* Control the editor: open/close files, set cursor, set selection.
399+
* Called from the node-side MCP server via execPeer.
400+
* @param {Object} params - { operation, filePath, startLine, startCh, endLine, endCh, line, ch }
401+
* @return {$.Promise} resolves with { success: true } or { success: false, error: "..." }
402+
*/
403+
function controlEditor(params) {
404+
const deferred = new $.Deferred();
405+
const vfsPath = SnapshotStore.realToVfsPath(params.filePath);
406+
407+
switch (params.operation) {
408+
case "open":
409+
CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath })
410+
.done(function () { deferred.resolve({ success: true }); })
411+
.fail(function (err) { deferred.resolve({ success: false, error: String(err) }); });
412+
break;
413+
414+
case "close": {
415+
const file = FileSystem.getFileForPath(vfsPath);
416+
CommandManager.execute(Commands.FILE_CLOSE, { file: file, _forceClose: true })
417+
.done(function () { deferred.resolve({ success: true }); })
418+
.fail(function (err) { deferred.resolve({ success: false, error: String(err) }); });
419+
break;
420+
}
421+
422+
case "openInWorkingSet":
423+
CommandManager.execute(Commands.CMD_ADD_TO_WORKINGSET_AND_OPEN, { fullPath: vfsPath })
424+
.done(function () { deferred.resolve({ success: true }); })
425+
.fail(function (err) { deferred.resolve({ success: false, error: String(err) }); });
426+
break;
427+
428+
case "setSelection":
429+
CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath })
430+
.done(function () {
431+
const editor = EditorManager.getActiveEditor();
432+
if (editor) {
433+
editor.setSelection(
434+
{ line: params.startLine - 1, ch: params.startCh - 1 },
435+
{ line: params.endLine - 1, ch: params.endCh - 1 },
436+
true
437+
);
438+
deferred.resolve({ success: true });
439+
} else {
440+
deferred.resolve({ success: false, error: "No active editor after opening file" });
441+
}
442+
})
443+
.fail(function (err) { deferred.resolve({ success: false, error: String(err) }); });
444+
break;
445+
446+
case "setCursorPos":
447+
CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath })
448+
.done(function () {
449+
const editor = EditorManager.getActiveEditor();
450+
if (editor) {
451+
editor.setCursorPos(params.line - 1, params.ch - 1, true);
452+
deferred.resolve({ success: true });
453+
} else {
454+
deferred.resolve({ success: false, error: "No active editor after opening file" });
455+
}
456+
})
457+
.fail(function (err) { deferred.resolve({ success: false, error: String(err) }); });
458+
break;
459+
460+
default:
461+
deferred.resolve({ success: false, error: "Unknown operation: " + params.operation });
462+
}
463+
464+
return deferred.promise();
465+
}
466+
395467
exports.getEditorState = getEditorState;
396468
exports.takeScreenshot = takeScreenshot;
397469
exports.getFileContent = getFileContent;
@@ -400,6 +472,7 @@ define(function (require, exports, module) {
400472
exports.clearPreviousContentMap = clearPreviousContentMap;
401473
exports.getLastScreenshot = getLastScreenshot;
402474
exports.execJsInLivePreview = execJsInLivePreview;
475+
exports.controlEditor = controlEditor;
403476

404477
EventDispatcher.makeEventDispatcher(exports);
405478
});

src/core-ai/main.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ define(function (require, exports, module) {
5454
return PhoenixConnectors.execJsInLivePreview(params);
5555
};
5656

57+
exports.controlEditor = async function (params) {
58+
return PhoenixConnectors.controlEditor(params);
59+
};
60+
5761
AppInit.appReady(function () {
5862
SidebarTabs.addTab("ai", "AI", "fa-solid fa-wand-magic-sparkles", { priority: 200 });
5963

src/nls/root/strings.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1826,6 +1826,9 @@ define({
18261826
"AI_CHAT_TOOL_SCREENSHOT_LIVE_PREVIEW": "live preview",
18271827
"AI_CHAT_TOOL_SCREENSHOT_FULL_PAGE": "full page",
18281828
"AI_CHAT_TOOL_LIVE_PREVIEW_JS": "Live Preview JS",
1829+
"AI_CHAT_TOOL_CONTROL_EDITOR": "Editor",
1830+
"AI_CHAT_TOOL_TASKS": "Tasks",
1831+
"AI_CHAT_TOOL_TASKS_SUMMARY": "{0} of {1} tasks done",
18291832
"AI_CHAT_TOOL_SEARCHED": "Searched: {0}",
18301833
"AI_CHAT_TOOL_GREP": "Grep: {0}",
18311834
"AI_CHAT_TOOL_READ_FILE": "Read {0}",

0 commit comments

Comments
 (0)