@jxsuite/studio 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/iframe-entry.js +622 -49
- package/dist/iframe-entry.js.map +15 -12
- package/dist/studio.css +1333 -0
- package/dist/studio.js +53069 -31746
- package/dist/studio.js.map +109 -74
- package/package.json +11 -9
- package/src/canvas/canvas-live-render.ts +21 -2
- package/src/canvas/canvas-render.ts +35 -16
- package/src/canvas/canvas-utils.ts +118 -72
- package/src/canvas/iframe-entry.ts +20 -0
- package/src/canvas/iframe-eval.ts +155 -0
- package/src/canvas/iframe-host.ts +131 -5
- package/src/canvas/iframe-inline-edit.ts +107 -1
- package/src/canvas/iframe-protocol.ts +43 -3
- package/src/canvas/iframe-render.ts +18 -0
- package/src/collab/collab-session.ts +23 -8
- package/src/collab/collab-state.ts +6 -3
- package/src/component-props.ts +104 -0
- package/src/editor/inline-edit-apply.ts +36 -1
- package/src/editor/inline-edit.ts +58 -1
- package/src/editor/shortcuts.ts +41 -12
- package/src/files/file-ops.ts +14 -0
- package/src/files/files.ts +53 -1
- package/src/grid/cell-editors.ts +288 -0
- package/src/grid/cell-popovers.ts +108 -0
- package/src/grid/csv-codec.ts +122 -0
- package/src/grid/edit-buffer.ts +490 -0
- package/src/grid/grid-controller.ts +417 -0
- package/src/grid/grid-layout.ts +83 -0
- package/src/grid/grid-open.ts +167 -0
- package/src/grid/grid-panel.ts +302 -0
- package/src/grid/grid-source.ts +210 -0
- package/src/grid/grid-view.ts +367 -0
- package/src/grid/schema-columns.ts +261 -0
- package/src/grid/sources/connector-source.ts +217 -0
- package/src/grid/sources/content-source.ts +542 -0
- package/src/grid/sources/csv-file-source.ts +247 -0
- package/src/grid/tabulator-tables.d.ts +120 -0
- package/src/panels/block-action-bar.ts +8 -3
- package/src/panels/chat-panel.ts +98 -0
- package/src/panels/data-grid.ts +9 -387
- package/src/panels/editors.ts +43 -17
- package/src/panels/events-panel.ts +137 -44
- package/src/panels/formula-workspace.ts +379 -0
- package/src/panels/frontmatter-fields.ts +231 -0
- package/src/panels/frontmatter-panel.ts +132 -0
- package/src/panels/git-panel.ts +1 -1
- package/src/panels/head-panel.ts +11 -175
- package/src/panels/properties-panel.ts +154 -144
- package/src/panels/right-panel.ts +9 -47
- package/src/panels/signals-panel.ts +115 -23
- package/src/panels/statement-editor.ts +710 -0
- package/src/panels/style-panel.ts +25 -1
- package/src/panels/stylebook-panel.ts +0 -2
- package/src/panels/tab-bar.ts +175 -6
- package/src/panels/tab-strip.ts +62 -6
- package/src/panels/toolbar.ts +37 -3
- package/src/services/ai-project-tools.ts +541 -0
- package/src/services/ai-session-store.ts +28 -0
- package/src/services/ai-system-prompt.ts +194 -24
- package/src/services/ai-tools.ts +88 -21
- package/src/services/automation.ts +16 -0
- package/src/services/document-assistant.ts +81 -11
- package/src/services/gated-registry.ts +72 -0
- package/src/services/live-preview.ts +0 -0
- package/src/services/preview-eval.ts +68 -0
- package/src/services/project-adoption.ts +31 -0
- package/src/site-context.ts +2 -0
- package/src/store.ts +4 -0
- package/src/studio.ts +83 -52
- package/src/tabs/patch-ops.ts +25 -0
- package/src/tabs/tab.ts +39 -1
- package/src/tabs/transact.ts +60 -13
- package/src/types.ts +12 -0
- package/src/ui/dynamic-slot.ts +272 -0
- package/src/ui/expression-editor.ts +423 -125
- package/src/ui/field-row.ts +5 -0
- package/src/ui/formula-catalog.ts +557 -0
- package/src/ui/formula-chips.ts +216 -0
- package/src/ui/formula-palette.ts +211 -0
- package/src/ui/layers.ts +40 -0
- package/src/ui/media-picker.ts +1 -1
- package/src/ui/panel-resize.ts +15 -1
- package/src/utils/preview-format.ts +26 -0
- package/src/view.ts +4 -4
- package/src/workspace/workspace.ts +14 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ai-project-tools.ts — project-level tools for the AI assistant.
|
|
3
|
+
*
|
|
4
|
+
* Two tiers registered here (document tools live in ai-tools.ts):
|
|
5
|
+
*
|
|
6
|
+
* - Bootstrap (no project open): `create_project` scaffolds through the platform's shared
|
|
7
|
+
* `createProject` pipeline (`@jxsuite/create` server-side) and adopts the result into this
|
|
8
|
+
* window; `list_starters` enumerates starter templates.
|
|
9
|
+
* - Cross-file (project open): `list_files` / `read_file` / `write_file` / `search_files` operate
|
|
10
|
+
* through the platform adapter without touching the tab strip, so the agent can develop across
|
|
11
|
+
* many files. `write_file` pre-validates Jx documents (no undo exists for disk writes, so
|
|
12
|
+
* validation blocks instead of optimistically applying) and reconciles with open tabs.
|
|
13
|
+
*
|
|
14
|
+
* @license MIT
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createToolDefinition } from "@jxsuite/ai/tools";
|
|
18
|
+
import type { ToolRegistry, ToolResult } from "@jxsuite/ai/tools";
|
|
19
|
+
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
20
|
+
import { getPlatform } from "../platform";
|
|
21
|
+
import { workspace } from "../workspace/workspace";
|
|
22
|
+
import type { Tab } from "../tabs/tab";
|
|
23
|
+
import { beginBatch, endBatch, isBatching } from "../tabs/transact";
|
|
24
|
+
import { translateValidationError } from "./ai-tools";
|
|
25
|
+
import { validateDoc } from "./jx-validate";
|
|
26
|
+
import { flagHardcodedTokens, formatTokenHints } from "./token-lint";
|
|
27
|
+
|
|
28
|
+
/** Directories the file tools never descend into or report. */
|
|
29
|
+
const EXCLUDED_DIRS = new Set(["node_modules", "dist", ".git", ".jx-cache"]);
|
|
30
|
+
|
|
31
|
+
/** Caps keeping tool results inside chat-context and localStorage budgets. */
|
|
32
|
+
const LIST_CAP = 200;
|
|
33
|
+
const SEARCH_CAP = 100;
|
|
34
|
+
const READ_CAP_BYTES = 48 * 1024;
|
|
35
|
+
const WRITE_CAP_BYTES = 256 * 1024;
|
|
36
|
+
|
|
37
|
+
const NOT_UNDOABLE = "(saved to disk; not undoable with Cmd+Z)";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Normalize a project-relative path, or return null when it escapes the project (absolute paths,
|
|
41
|
+
* `..` segments, drive letters). The server re-checks; this keeps the error actionable.
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeRelPath(path: unknown): string | null {
|
|
44
|
+
if (typeof path !== "string" || !path.trim()) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
let p = path.trim().replaceAll("\\", "/");
|
|
48
|
+
while (p.startsWith("./")) {
|
|
49
|
+
p = p.slice(2);
|
|
50
|
+
}
|
|
51
|
+
if (p.startsWith("/") || p.startsWith("~") || /^[A-Za-z]:/.test(p)) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
if (p.split("/").includes("..")) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return p;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function pathError(path: unknown): ToolResult {
|
|
61
|
+
return {
|
|
62
|
+
success: false,
|
|
63
|
+
error: `Invalid path ${JSON.stringify(path)} — use a path relative to the project root (no leading "/", no "..").`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Whether a project-relative path conventionally holds a Jx document. */
|
|
68
|
+
function isJxDocPath(path: string): boolean {
|
|
69
|
+
return /^(pages|layouts|components)\//.test(path) && path.endsWith(".json");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Structural sniff for Jx-document-shaped JSON values. */
|
|
73
|
+
function looksLikeJxDoc(value: unknown): boolean {
|
|
74
|
+
return (
|
|
75
|
+
typeof value === "object" &&
|
|
76
|
+
value !== null &&
|
|
77
|
+
!Array.isArray(value) &&
|
|
78
|
+
("tagName" in value || "$id" in value || "$elements" in value)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ProjectToolsCtx {
|
|
83
|
+
getTab: () => Tab | null;
|
|
84
|
+
validate?: (doc: unknown) => Promise<string[]>;
|
|
85
|
+
renderCheck?: (doc: unknown) => Promise<{ ok: true } | { ok: false; error: string }>;
|
|
86
|
+
/** Live getter — the project style appears only after a project is open/bootstrapped. */
|
|
87
|
+
getProjectStyle?: () => Record<string, string> | undefined;
|
|
88
|
+
/** The open tab whose `documentPath` equals the given project-relative path, if any. */
|
|
89
|
+
findOpenTab: (path: string) => Tab | null;
|
|
90
|
+
/** Reload an open tab's document from disk (files.ts `reloadFileInTab`). */
|
|
91
|
+
reloadTab: (path: string) => Promise<void>;
|
|
92
|
+
/** Open the project at an absolute root in this window (project-adoption.ts). */
|
|
93
|
+
adoptProject: (root: string) => Promise<void>;
|
|
94
|
+
/** Fired after adoption is verified — the session store re-keys the live chat here. */
|
|
95
|
+
onProjectAdopted?: (root: string) => void;
|
|
96
|
+
/** Fired after a successful `project.json` write so workspace/project state stay in sync. */
|
|
97
|
+
onProjectConfigWritten?: (config: object) => void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Register the project-level tools into a tool registry.
|
|
102
|
+
*
|
|
103
|
+
* @param {Pick<ToolRegistry, "register">} registry
|
|
104
|
+
* @param {ProjectToolsCtx} ctx
|
|
105
|
+
*/
|
|
106
|
+
export function registerProjectTools(
|
|
107
|
+
registry: Pick<ToolRegistry, "register">,
|
|
108
|
+
{
|
|
109
|
+
getTab,
|
|
110
|
+
validate = validateDoc,
|
|
111
|
+
renderCheck,
|
|
112
|
+
getProjectStyle,
|
|
113
|
+
findOpenTab,
|
|
114
|
+
reloadTab,
|
|
115
|
+
adoptProject,
|
|
116
|
+
onProjectAdopted,
|
|
117
|
+
onProjectConfigWritten,
|
|
118
|
+
}: ProjectToolsCtx,
|
|
119
|
+
) {
|
|
120
|
+
// ── list_files ─────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
registry.register(
|
|
123
|
+
createToolDefinition({
|
|
124
|
+
name: "list_files",
|
|
125
|
+
description:
|
|
126
|
+
"List the project's files recursively (paths relative to the project root, with type and " +
|
|
127
|
+
"size). Use this to discover pages, layouts, components, content, data, and styles before " +
|
|
128
|
+
"reading or writing files. Build folders (node_modules, dist, .git) are excluded.",
|
|
129
|
+
parameters: {
|
|
130
|
+
type: "object",
|
|
131
|
+
properties: {
|
|
132
|
+
dir: {
|
|
133
|
+
type: "string",
|
|
134
|
+
description:
|
|
135
|
+
'Directory to list, relative to the project root. Omit for the root (".").',
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
required: [],
|
|
139
|
+
},
|
|
140
|
+
async execute(args) {
|
|
141
|
+
const { dir } = args as { dir?: string };
|
|
142
|
+
const start = dir === undefined || dir === "" ? "." : normalizeRelPath(dir);
|
|
143
|
+
if (start === null) {
|
|
144
|
+
return pathError(dir);
|
|
145
|
+
}
|
|
146
|
+
const platform = getPlatform();
|
|
147
|
+
const entries: { path: string; type: string; size?: number }[] = [];
|
|
148
|
+
const queue = [start];
|
|
149
|
+
let truncated = false;
|
|
150
|
+
while (queue.length > 0 && !truncated) {
|
|
151
|
+
const current = queue.shift()!;
|
|
152
|
+
let children;
|
|
153
|
+
try {
|
|
154
|
+
children = await platform.listDirectory(current);
|
|
155
|
+
} catch {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
for (const e of children) {
|
|
159
|
+
const name = e.name || e.path.split("/").pop() || "";
|
|
160
|
+
if (EXCLUDED_DIRS.has(name) || name.startsWith(".")) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const path = e.path || (current === "." ? name : `${current}/${name}`);
|
|
164
|
+
if (entries.length >= LIST_CAP) {
|
|
165
|
+
truncated = true;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
entries.push({
|
|
169
|
+
path,
|
|
170
|
+
type: e.type,
|
|
171
|
+
...(typeof e.size === "number" ? { size: e.size } : {}),
|
|
172
|
+
});
|
|
173
|
+
if (e.type === "directory") {
|
|
174
|
+
queue.push(path);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
success: true,
|
|
180
|
+
data: { entries, truncated },
|
|
181
|
+
summary: `Listed ${entries.length} entries under "${start}"${truncated ? ` (truncated at ${LIST_CAP})` : ""}.`,
|
|
182
|
+
};
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// ── read_file ──────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
registry.register(
|
|
190
|
+
createToolDefinition({
|
|
191
|
+
name: "read_file",
|
|
192
|
+
description:
|
|
193
|
+
"Read a project file's content by its project-relative path. Works for any text file " +
|
|
194
|
+
"(Jx documents, markdown, CSS, data). Large files are truncated.",
|
|
195
|
+
parameters: {
|
|
196
|
+
type: "object",
|
|
197
|
+
properties: {
|
|
198
|
+
path: {
|
|
199
|
+
type: "string",
|
|
200
|
+
description: 'Project-relative file path, e.g. "pages/about.json".',
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
required: ["path"],
|
|
204
|
+
},
|
|
205
|
+
async execute(args) {
|
|
206
|
+
const relPath = normalizeRelPath((args as { path: string }).path);
|
|
207
|
+
if (relPath === null) {
|
|
208
|
+
return pathError((args as { path: string }).path);
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
const content = await getPlatform().readFile(relPath);
|
|
212
|
+
if (content.length > READ_CAP_BYTES) {
|
|
213
|
+
return {
|
|
214
|
+
success: true,
|
|
215
|
+
data: {
|
|
216
|
+
content: `${content.slice(0, READ_CAP_BYTES)}\n… [truncated: file is ${content.length} bytes, showing first ${READ_CAP_BYTES}]`,
|
|
217
|
+
truncated: true,
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
return { success: true, data: { content, truncated: false } };
|
|
222
|
+
} catch (error) {
|
|
223
|
+
return {
|
|
224
|
+
success: false,
|
|
225
|
+
error: `Failed to read "${relPath}": ${error instanceof Error ? error.message : String(error)}`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// ── write_file ─────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
registry.register(
|
|
235
|
+
createToolDefinition({
|
|
236
|
+
name: "write_file",
|
|
237
|
+
description:
|
|
238
|
+
"Write a project file (create or overwrite) at a project-relative path. Jx documents " +
|
|
239
|
+
"(.json under pages/, layouts/, components/) are schema-validated and render-checked " +
|
|
240
|
+
"before writing — fix reported errors and retry. Refused while the target file is open " +
|
|
241
|
+
"with unsaved changes. Disk writes are not undoable.",
|
|
242
|
+
parameters: {
|
|
243
|
+
type: "object",
|
|
244
|
+
properties: {
|
|
245
|
+
path: {
|
|
246
|
+
type: "string",
|
|
247
|
+
description: 'Project-relative file path, e.g. "components/footer.json".',
|
|
248
|
+
},
|
|
249
|
+
content: {
|
|
250
|
+
type: "string",
|
|
251
|
+
description: "The complete new file content (full file, not a diff).",
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
required: ["path", "content"],
|
|
255
|
+
},
|
|
256
|
+
async execute(args) {
|
|
257
|
+
const { path, content } = args as { path: string; content: string };
|
|
258
|
+
const relPath = normalizeRelPath(path);
|
|
259
|
+
if (relPath === null) {
|
|
260
|
+
return pathError(path);
|
|
261
|
+
}
|
|
262
|
+
if (content.length > WRITE_CAP_BYTES) {
|
|
263
|
+
return {
|
|
264
|
+
success: false,
|
|
265
|
+
error: `Content is ${content.length} bytes — the write cap is ${WRITE_CAP_BYTES}. Split the content across smaller files.`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Reconcile with an open tab BEFORE writing: a dirty tab would silently diverge from disk.
|
|
270
|
+
const openTab = findOpenTab(relPath);
|
|
271
|
+
if (openTab?.doc.dirty) {
|
|
272
|
+
return {
|
|
273
|
+
success: false,
|
|
274
|
+
error:
|
|
275
|
+
`"${relPath}" is open in the editor with unsaved changes. Use open_document plus the ` +
|
|
276
|
+
`document tools to edit it, or ask the user to save or discard their changes first.`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Pre-validate Jx documents — disk writes have no undo to lean on.
|
|
281
|
+
let parsed: unknown;
|
|
282
|
+
const isJson = relPath.endsWith(".json") && relPath !== "project.json";
|
|
283
|
+
if (isJson) {
|
|
284
|
+
try {
|
|
285
|
+
parsed = JSON.parse(content);
|
|
286
|
+
} catch (error) {
|
|
287
|
+
return {
|
|
288
|
+
success: false,
|
|
289
|
+
error: `Content is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
let tokenHints = "";
|
|
294
|
+
if (isJson && (isJxDocPath(relPath) || looksLikeJxDoc(parsed))) {
|
|
295
|
+
const errors = await validate(parsed);
|
|
296
|
+
if (errors.length > 0) {
|
|
297
|
+
const formatted = errors.map((e) => `- ${translateValidationError(e)}`).join("\n");
|
|
298
|
+
return {
|
|
299
|
+
success: false,
|
|
300
|
+
error: `Document has schema errors — nothing was written. Fix these and retry:\n${formatted}`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if (renderCheck) {
|
|
304
|
+
const renderResult = await renderCheck(parsed);
|
|
305
|
+
if (!renderResult.ok) {
|
|
306
|
+
return {
|
|
307
|
+
success: false,
|
|
308
|
+
error: `Document is schema-valid but fails to render — nothing was written. Fix and retry:\n- ${renderResult.error}`,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const projectStyle = getProjectStyle?.();
|
|
313
|
+
if (projectStyle) {
|
|
314
|
+
tokenHints = formatTokenHints(
|
|
315
|
+
flagHardcodedTokens(parsed as JxMutableNode, projectStyle),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (relPath === "project.json") {
|
|
321
|
+
try {
|
|
322
|
+
JSON.parse(content);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
return {
|
|
325
|
+
success: false,
|
|
326
|
+
error: `project.json must be valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
await getPlatform().writeFile(relPath, content);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
return {
|
|
335
|
+
success: false,
|
|
336
|
+
error: `Failed to write "${relPath}": ${error instanceof Error ? error.message : String(error)}`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (relPath === "project.json") {
|
|
341
|
+
onProjectConfigWritten?.(JSON.parse(content) as object);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let summary = `Wrote "${relPath}" ${NOT_UNDOABLE}.`;
|
|
345
|
+
if (openTab) {
|
|
346
|
+
await reloadTab(relPath);
|
|
347
|
+
summary = `Wrote "${relPath}" and refreshed its open editor tab ${NOT_UNDOABLE}.`;
|
|
348
|
+
}
|
|
349
|
+
return { success: true, summary: tokenHints ? `${summary}\n\n${tokenHints}` : summary };
|
|
350
|
+
},
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
// ── search_files ───────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
registry.register(
|
|
357
|
+
createToolDefinition({
|
|
358
|
+
name: "search_files",
|
|
359
|
+
description:
|
|
360
|
+
"Search project files by file NAME (not content). Returns matching paths. Use list_files " +
|
|
361
|
+
"for a directory overview and read_file to inspect contents.",
|
|
362
|
+
parameters: {
|
|
363
|
+
type: "object",
|
|
364
|
+
properties: {
|
|
365
|
+
query: { type: "string", description: 'Substring of the file name, e.g. "hero".' },
|
|
366
|
+
extensions: {
|
|
367
|
+
type: "array",
|
|
368
|
+
description: 'Optional extension filter, e.g. [".json", ".md"].',
|
|
369
|
+
items: { type: "string" },
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
required: ["query"],
|
|
373
|
+
},
|
|
374
|
+
async execute(args) {
|
|
375
|
+
const { query, extensions } = args as { query: string; extensions?: string[] };
|
|
376
|
+
try {
|
|
377
|
+
const results = await getPlatform().searchFiles(query, extensions);
|
|
378
|
+
const paths = results.map((r) => r.path).slice(0, SEARCH_CAP);
|
|
379
|
+
return {
|
|
380
|
+
success: true,
|
|
381
|
+
data: { paths, truncated: results.length > SEARCH_CAP },
|
|
382
|
+
summary: `Found ${paths.length} file(s) matching "${query}".`,
|
|
383
|
+
};
|
|
384
|
+
} catch (error) {
|
|
385
|
+
return {
|
|
386
|
+
success: false,
|
|
387
|
+
error: `Search failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
}),
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
// ── create_project ─────────────────────────────────────────────────────
|
|
395
|
+
|
|
396
|
+
registry.register(
|
|
397
|
+
createToolDefinition({
|
|
398
|
+
name: "create_project",
|
|
399
|
+
description:
|
|
400
|
+
"Create a new Jx project (project.json, conventional directories, starter pages) and open " +
|
|
401
|
+
"it in the studio. Only available while no project is open. After it succeeds, the file " +
|
|
402
|
+
"and document tools become available for building out the project. If the directory " +
|
|
403
|
+
"already exists, retry with a different directory slug.",
|
|
404
|
+
parameters: {
|
|
405
|
+
type: "object",
|
|
406
|
+
properties: {
|
|
407
|
+
name: { type: "string", description: 'Human project name, e.g. "Acme Bakery".' },
|
|
408
|
+
description: { type: "string", description: "Short project description." },
|
|
409
|
+
template: {
|
|
410
|
+
type: "string",
|
|
411
|
+
description:
|
|
412
|
+
'Scaffold variant: "blank" (default), "desktop-first", "mobile-first", or "mobile-app".',
|
|
413
|
+
enum: ["blank", "desktop-first", "mobile-first", "mobile-app"],
|
|
414
|
+
},
|
|
415
|
+
directory: {
|
|
416
|
+
type: "string",
|
|
417
|
+
description: "Directory slug to create the project in. Defaults to a slug of the name.",
|
|
418
|
+
},
|
|
419
|
+
design: {
|
|
420
|
+
type: "object",
|
|
421
|
+
description:
|
|
422
|
+
"Optional design quickstart: { accent, background, text, bodyFont, headingFont } — " +
|
|
423
|
+
"CSS colors and font-family names applied to the scaffold's design tokens.",
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
required: ["name"],
|
|
427
|
+
},
|
|
428
|
+
async execute(args) {
|
|
429
|
+
const { name, description, template, directory, design } = args as {
|
|
430
|
+
name: string;
|
|
431
|
+
description?: string;
|
|
432
|
+
template?: string;
|
|
433
|
+
directory?: string;
|
|
434
|
+
design?: Record<string, string>;
|
|
435
|
+
};
|
|
436
|
+
if (workspace.projectRoot) {
|
|
437
|
+
return {
|
|
438
|
+
success: false,
|
|
439
|
+
error:
|
|
440
|
+
"A project is already open in this window — create_project is only for bootstrapping.",
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const slug =
|
|
444
|
+
directory?.trim() ||
|
|
445
|
+
name
|
|
446
|
+
.toLowerCase()
|
|
447
|
+
.replaceAll(/[^a-z0-9]+/g, "-")
|
|
448
|
+
.replaceAll(/^-|-$/g, "");
|
|
449
|
+
if (!slug) {
|
|
450
|
+
return { success: false, error: 'Could not derive a directory slug — pass "directory".' };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
let result: { root: string; config: object };
|
|
454
|
+
try {
|
|
455
|
+
result = await getPlatform().createProject({
|
|
456
|
+
name,
|
|
457
|
+
directory: slug,
|
|
458
|
+
...(description ? { description } : {}),
|
|
459
|
+
...(template ? { template } : {}),
|
|
460
|
+
...(design ? { design } : {}),
|
|
461
|
+
});
|
|
462
|
+
} catch (error) {
|
|
463
|
+
return {
|
|
464
|
+
success: false,
|
|
465
|
+
error: `Failed to create project: ${error instanceof Error ? error.message : String(error)}`,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/*
|
|
470
|
+
* Adoption runs the full project-open flow (closes all tabs, opens the home page). The
|
|
471
|
+
* agent loop may hold an undo batch on the pre-adoption tab — flush it first and re-open
|
|
472
|
+
* one on whatever tab adoption leaves active, mirroring open_document's batch dance.
|
|
473
|
+
*/
|
|
474
|
+
const wasBatching = isBatching();
|
|
475
|
+
if (wasBatching) {
|
|
476
|
+
endBatch();
|
|
477
|
+
}
|
|
478
|
+
let adoptionError: string | null = null;
|
|
479
|
+
try {
|
|
480
|
+
await adoptProject(result.root);
|
|
481
|
+
} catch (error) {
|
|
482
|
+
adoptionError = error instanceof Error ? error.message : String(error);
|
|
483
|
+
}
|
|
484
|
+
if (wasBatching) {
|
|
485
|
+
beginBatch(getTab());
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/*
|
|
489
|
+
* The adopter (openRecentProject) swallows failures into a status message, so a resolved
|
|
490
|
+
* promise is not proof of adoption — verify against the workspace before re-keying the
|
|
491
|
+
* chat session.
|
|
492
|
+
*/
|
|
493
|
+
const adopted = workspace.projectRoot === result.root;
|
|
494
|
+
if (adopted) {
|
|
495
|
+
onProjectAdopted?.(result.root);
|
|
496
|
+
return {
|
|
497
|
+
success: true,
|
|
498
|
+
summary:
|
|
499
|
+
`Created project "${name}" at ${result.root} and opened it. The file tools ` +
|
|
500
|
+
`(list_files, read_file, write_file) and document tools are now available — start ` +
|
|
501
|
+
`with list_files to see the scaffolded pages, layouts, and components.`,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
success: true,
|
|
506
|
+
summary:
|
|
507
|
+
`Created project "${name}" at ${result.root}, but it was not opened in this window` +
|
|
508
|
+
`${adoptionError ? ` (${adoptionError})` : " (it may have opened in another window)"}. ` +
|
|
509
|
+
`Ask the user to open it from the welcome screen or recent projects.`,
|
|
510
|
+
};
|
|
511
|
+
},
|
|
512
|
+
}),
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
// ── list_starters ──────────────────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
registry.register(
|
|
518
|
+
createToolDefinition({
|
|
519
|
+
name: "list_starters",
|
|
520
|
+
description:
|
|
521
|
+
"List the starter templates available for new projects (id, name, description). " +
|
|
522
|
+
"Currently informational — create_project scaffolds from built-in template variants.",
|
|
523
|
+
parameters: { type: "object", properties: {}, required: [] },
|
|
524
|
+
async execute() {
|
|
525
|
+
try {
|
|
526
|
+
const starters = (await getPlatform().listStarters?.()) ?? [];
|
|
527
|
+
return {
|
|
528
|
+
success: true,
|
|
529
|
+
data: { starters },
|
|
530
|
+
summary: `${starters.length} starter(s) available.`,
|
|
531
|
+
};
|
|
532
|
+
} catch (error) {
|
|
533
|
+
return {
|
|
534
|
+
success: false,
|
|
535
|
+
error: `Failed to list starters: ${error instanceof Error ? error.message : String(error)}`,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
}),
|
|
540
|
+
);
|
|
541
|
+
}
|
|
@@ -248,3 +248,31 @@ export function deleteSession(root: string, id: string) {
|
|
|
248
248
|
}
|
|
249
249
|
writeIndex(root, index);
|
|
250
250
|
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Move a session between roots — the bootstrap handoff: a chat started with no project (unscoped
|
|
254
|
+
* root "") re-keys to the created project's root so `saveSession` keeps persisting it (it
|
|
255
|
+
* early-returns when the target index has no meta for the id). The moved session becomes the target
|
|
256
|
+
* root's active session. No-op when the session doesn't exist at `fromRoot`; delete-first ordering
|
|
257
|
+
* keeps a re-run idempotent.
|
|
258
|
+
*/
|
|
259
|
+
export function moveSession(fromRoot: string, toRoot: string, id: string) {
|
|
260
|
+
if (fromRoot === toRoot) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const msgs = loadSession(fromRoot, id);
|
|
264
|
+
const fromIndex = readIndex(fromRoot);
|
|
265
|
+
const meta = fromIndex.sessions.find((s) => s.id === id);
|
|
266
|
+
if (!msgs || !meta) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
deleteSession(fromRoot, id);
|
|
271
|
+
|
|
272
|
+
writeJson(payloadKey(toRoot, id), msgs);
|
|
273
|
+
const toIndex = readIndex(toRoot);
|
|
274
|
+
toIndex.sessions = toIndex.sessions.filter((s) => s.id !== id);
|
|
275
|
+
toIndex.sessions.unshift({ ...meta });
|
|
276
|
+
toIndex.activeId = id;
|
|
277
|
+
writeIndex(toRoot, toIndex);
|
|
278
|
+
}
|