@office-kit/pptx-dev 0.5.0 → 0.7.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/README.md +63 -2
- package/dist/cli.mjs +360 -24
- package/dist/cli.mjs.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-CShdm4G1.mjs → src-CdlqA8pS.mjs} +7 -5
- package/dist/src-CdlqA8pS.mjs.map +1 -0
- package/dist/terminal-client.js +21 -17
- package/package.json +6 -6
- package/dist/src-CShdm4G1.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -16,11 +16,24 @@ npm install
|
|
|
16
16
|
npm run dev
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
For a single-command first launch (macOS/Linux):
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npx --yes @office-kit/pptx-dev@latest init my-slides && cd my-slides && npm install && npm run dev
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The built-in `init` is the starter generator: no separate `degit` checkout is
|
|
26
|
+
needed, and it selects compatible package versions. For an existing project,
|
|
27
|
+
start development with `npm run dev` or `npx office-pptx dev deck.tsx`.
|
|
28
|
+
If you prefer the bare `office-pptx dev deck.tsx` command, install the CLI once
|
|
29
|
+
with `npm install --global @office-kit/pptx-dev@latest`. The local npm script
|
|
30
|
+
continues to use the project's installed version.
|
|
31
|
+
|
|
19
32
|
Open the local URL printed by the server. Save a slide file, `theme.ts` or `deck.tsx` to rebuild. The viewer
|
|
20
33
|
has a vertical thumbnail strip, a large slide canvas and an AI chat panel on the right. Click a thumbnail or use
|
|
21
34
|
arrow keys, Page Up/Down, Home/End to navigate. Fit/zoom and Present (Escape to
|
|
22
|
-
exit) are viewing controls
|
|
23
|
-
|
|
35
|
+
exit) are viewing controls. Select an area for an AI instruction or use **Edit text**
|
|
36
|
+
to save a literal directly. All edits are persisted in TSX.
|
|
24
37
|
Keep the server running throughout the edit/review loop. Saving updates only the
|
|
25
38
|
changed thumbnails and slide view, preserving zoom, scroll position and presentation
|
|
26
39
|
mode. The previous slide stays visible until its replacement is ready. Rapid edits
|
|
@@ -75,6 +88,20 @@ CLI. Navigation while Claude is replying does not change that turn's context.
|
|
|
75
88
|
Stale input is rejected with a retry message. If your Claude settings or managed
|
|
76
89
|
policy disable hooks, automatic context attachment is unavailable; enable the
|
|
77
90
|
preview hook in Claude Code to use it. No project settings file is rewritten.
|
|
91
|
+
|
|
92
|
+
Use **Shift+Enter** to insert a newline in the embedded Claude prompt. On completion,
|
|
93
|
+
the preview checks the actual deck build. Build errors are returned to Claude's
|
|
94
|
+
Stop hook or to the Codex conversation for up to three repair attempts per user
|
|
95
|
+
request. If repair fails, the error stays visible and the last successful preview
|
|
96
|
+
is retained. Stopping the agent cancels further repair attempts.
|
|
97
|
+
|
|
98
|
+
Both agents receive guidance to check text against its actual background, including
|
|
99
|
+
inherited theme colors, and to use native `Text` paragraphs or mapped rows for lists
|
|
100
|
+
and agendas. `Bullets` and `TableOfContents` are not built-in DSL components; define
|
|
101
|
+
or import project helpers if using those names. See the
|
|
102
|
+
[TSX authoring reference](../../skill/references/tsx.md#lists-and-tables-of-contents)
|
|
103
|
+
for a complete agenda example. Contrast guidance still requires visual review;
|
|
104
|
+
a successful build is not a visual accessibility check.
|
|
78
105
|
Dependency paths are source candidates, not an exact slide-to-file mapping.
|
|
79
106
|
“This slide” requests a focused patch; requests about another slide or the
|
|
80
107
|
whole deck can edit other files or shared styling.
|
|
@@ -126,6 +153,40 @@ References: [Claude Code commands](https://code.claude.com/docs/en/commands),
|
|
|
126
153
|
[Claude Code hooks](https://code.claude.com/docs/en/hooks), and
|
|
127
154
|
[Codex non-interactive mode](https://developers.openai.com/codex/noninteractive).
|
|
128
155
|
|
|
156
|
+
## Edit from the slide
|
|
157
|
+
|
|
158
|
+
Use **Select area** and drag a rectangle, then enter an instruction such as
|
|
159
|
+
“Move this down a little.” Choose an agent pane and click **Apply with AI**.
|
|
160
|
+
The request includes the slide, preview revision, relative bounds and intersecting
|
|
161
|
+
text. Start Claude Code in that pane first; Codex starts when you send.
|
|
162
|
+
Selection is cleared when the preview or selected slide changes. Shift+Enter
|
|
163
|
+
inserts a newline; Cmd/Ctrl+Enter sends the request.
|
|
164
|
+
|
|
165
|
+
Use **Edit text**, click a paragraph, and **Save text** to update its TSX source
|
|
166
|
+
without waiting for an agent. Direct saves require a unique source literal within
|
|
167
|
+
the entry directory and verify that other slides did not change. Computed text,
|
|
168
|
+
shared values and ambiguous matches use **Apply with AI** instead. **Undo text**
|
|
169
|
+
restores the last direct edit only if its file has not changed since. **Undo with
|
|
170
|
+
AI** asks the agent to reverse its previous selection edit while preserving newer
|
|
171
|
+
work; it is not a source snapshot restore.
|
|
172
|
+
|
|
173
|
+
After each successful agent edit or direct text save/undo, the dev server captures
|
|
174
|
+
all changed slides, including slides offscreen. Codex receives PNG attachments;
|
|
175
|
+
Claude Code receives local PNG paths through its Stop hook and is instructed to
|
|
176
|
+
read them. The agent checks overlap, clipping, alignment, spacing and contrast,
|
|
177
|
+
and can make up to two correction passes followed by a final inspection. This is
|
|
178
|
+
AI-assisted review, not a guarantee of pixel-perfect rendering. The preview updates
|
|
179
|
+
as soon as the build finishes, independently of screenshot review.
|
|
180
|
+
|
|
181
|
+
Screenshot capture uses an installed Chrome, or falls back to Playwright Chromium.
|
|
182
|
+
Set `PLAYWRIGHT_CHANNEL` to use another installed Chromium channel. If neither is
|
|
183
|
+
available, install Chrome and restart the dev server. Capture failures are shown
|
|
184
|
+
explicitly; saved edits remain available. If the chosen agent is busy or not started,
|
|
185
|
+
use **Retry visual review** after making it available. Screenshots and review prompts
|
|
186
|
+
are stored locally under `.office-kit/reviews/`; new projects ignore this directory.
|
|
187
|
+
Add `.office-kit/` to `.gitignore` in existing projects. Images are provided to the
|
|
188
|
+
selected AI agent using its existing login and provider settings.
|
|
189
|
+
|
|
129
190
|
## Edit only what changed
|
|
130
191
|
|
|
131
192
|
New projects separate slide order, content and shared styling:
|
package/dist/cli.mjs
CHANGED
|
@@ -1,13 +1,167 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as createDeckBuilder, i as initProject, n as exportDeck, r as inspectTemplate } from "./src-
|
|
2
|
+
import { a as createDeckBuilder, i as initProject, n as exportDeck, r as inspectTemplate } from "./src-CdlqA8pS.mjs";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
-
import { chmod, readFile, stat } from "node:fs/promises";
|
|
5
|
-
import { dirname, join, resolve, sep } from "node:path";
|
|
4
|
+
import { chmod, mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { watch } from "node:fs";
|
|
7
|
+
import ts from "typescript";
|
|
8
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
9
|
+
import { chromium } from "playwright";
|
|
7
10
|
import { createServer } from "node:http";
|
|
8
|
-
import { randomBytes } from "node:crypto";
|
|
9
11
|
import MarkdownIt from "markdown-it";
|
|
10
12
|
import { spawn } from "node:child_process";
|
|
13
|
+
//#region src/text-edit.ts
|
|
14
|
+
/** Literal edits are deliberately conservative: computed and ambiguous text goes to the agent. */
|
|
15
|
+
function createTextEditor(entry, preview, verify) {
|
|
16
|
+
let busy = false;
|
|
17
|
+
let undo;
|
|
18
|
+
async function restore(change) {
|
|
19
|
+
if (await realpath(change.file) !== change.file || await readFile(change.file, "utf8") !== change.after) throw new Error("Source changed during editing. Your newer changes were preserved.");
|
|
20
|
+
await writeFile(change.file, change.before);
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
async edit(value) {
|
|
24
|
+
if (busy) throw new Error("A text edit is already running.");
|
|
25
|
+
busy = true;
|
|
26
|
+
try {
|
|
27
|
+
const current = preview();
|
|
28
|
+
if (value.revision !== current.revision) throw new Error("Preview changed. Select the text again.");
|
|
29
|
+
if (!Number.isInteger(value.slide) || !current.slides[value.slide] || typeof value.before !== "string" || !value.before.trim() || value.before.length > 12e3 || typeof value.after !== "string" || value.after.length > 12e3 || value.after === value.before) throw new Error("Invalid text edit.");
|
|
30
|
+
if (current.slideTexts[value.slide].split(value.before).length !== 2) throw new Error("Text cannot be located uniquely on this slide. Use Apply with AI.");
|
|
31
|
+
const root = await realpath(dirname(entry));
|
|
32
|
+
const files = await Promise.all([...new Set(current.dependencies)].filter((file) => /\.[cm]?[jt]sx?$/.test(file)).map(async (file) => {
|
|
33
|
+
const path = await realpath(file), local = relative(root, path);
|
|
34
|
+
if (local.startsWith("..") || isAbsolute(local) || local.split(/[\\/]/).includes("node_modules")) return null;
|
|
35
|
+
return {
|
|
36
|
+
file: path,
|
|
37
|
+
before: await readFile(path, "utf8")
|
|
38
|
+
};
|
|
39
|
+
}));
|
|
40
|
+
const candidates = [];
|
|
41
|
+
for (const item of files) {
|
|
42
|
+
if (!item) continue;
|
|
43
|
+
const source = ts.createSourceFile(item.file, item.before, ts.ScriptTarget.Latest, true, /[jt]sx$/.test(extname(item.file)) ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
44
|
+
const visit = (node) => {
|
|
45
|
+
if ((ts.isJsxText(node) ? node.text.split(/\r\n|\n|\r/).map((line, index, lines) => {
|
|
46
|
+
let value = line.replaceAll(" ", " ");
|
|
47
|
+
if (index) value = value.trimStart();
|
|
48
|
+
if (index < lines.length - 1) value = value.trimEnd();
|
|
49
|
+
return value;
|
|
50
|
+
}).filter(Boolean).join(" ") : ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) ? node.text : void 0) === value.before) {
|
|
51
|
+
const replacement = ts.isJsxText(node) || ts.isJsxAttribute(node.parent) ? "{" + JSON.stringify(value.after) + "}" : JSON.stringify(value.after);
|
|
52
|
+
const start = ts.isJsxText(node) ? node.pos : node.getStart(source);
|
|
53
|
+
candidates.push({
|
|
54
|
+
...item,
|
|
55
|
+
after: item.before.slice(0, start) + replacement + item.before.slice(node.end)
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
ts.forEachChild(node, visit);
|
|
59
|
+
};
|
|
60
|
+
visit(source);
|
|
61
|
+
}
|
|
62
|
+
if (candidates.length !== 1) throw new Error("Text is computed or has multiple source matches. Use Apply with AI.");
|
|
63
|
+
const change = candidates[0];
|
|
64
|
+
if (preview().revision !== current.revision || await realpath(change.file) !== change.file || await readFile(change.file, "utf8") !== change.before) throw new Error("Source changed. Select the text again.");
|
|
65
|
+
await writeFile(change.file, change.after);
|
|
66
|
+
try {
|
|
67
|
+
if (await verify()) throw new Error("The text change did not build.");
|
|
68
|
+
const next = preview();
|
|
69
|
+
if (next.slides.length !== current.slides.length || next.slides.some((svg, index) => index !== value.slide && svg !== current.slides[index]) || next.slideTexts[value.slide] !== current.slideTexts[value.slide].replace(value.before, value.after)) throw new Error("This source affects other text or slides. Use Apply with AI for a local edit.");
|
|
70
|
+
if (await realpath(change.file) !== change.file || await readFile(change.file, "utf8") !== change.after) throw new Error("Source changed during editing.");
|
|
71
|
+
undo = change;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
await restore(change);
|
|
74
|
+
await verify();
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
} finally {
|
|
78
|
+
busy = false;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
async undo() {
|
|
82
|
+
if (busy) throw new Error("A text edit is already running.");
|
|
83
|
+
if (!undo) throw new Error("No direct text edit to undo.");
|
|
84
|
+
busy = true;
|
|
85
|
+
try {
|
|
86
|
+
await restore(undo);
|
|
87
|
+
undo = void 0;
|
|
88
|
+
if (await verify()) throw new Error("Text restored, but the project has build errors.");
|
|
89
|
+
} finally {
|
|
90
|
+
busy = false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/visual-review.ts
|
|
97
|
+
/** Each session compares against its own turn's preview, including offscreen slides. */
|
|
98
|
+
function createVisualReviewer(entry, slides) {
|
|
99
|
+
let before = [];
|
|
100
|
+
let rounds = 0;
|
|
101
|
+
return {
|
|
102
|
+
begin() {
|
|
103
|
+
before = slides().slice();
|
|
104
|
+
rounds = 0;
|
|
105
|
+
},
|
|
106
|
+
async next() {
|
|
107
|
+
const current = slides().slice();
|
|
108
|
+
const changed = current.flatMap((svg, index) => svg !== before[index] ? [{
|
|
109
|
+
svg,
|
|
110
|
+
index
|
|
111
|
+
}] : []);
|
|
112
|
+
if (!changed.length) return;
|
|
113
|
+
if (rounds >= 3) throw new Error("Visual review reached its correction limit. Review the latest slide manually before continuing.");
|
|
114
|
+
const round = rounds + 1;
|
|
115
|
+
let browser;
|
|
116
|
+
try {
|
|
117
|
+
browser = await chromium.launch({
|
|
118
|
+
channel: process.env.PLAYWRIGHT_CHANNEL || "chrome",
|
|
119
|
+
headless: true
|
|
120
|
+
});
|
|
121
|
+
} catch {
|
|
122
|
+
try {
|
|
123
|
+
browser = await chromium.launch({ headless: true });
|
|
124
|
+
} catch {
|
|
125
|
+
throw new Error("Visual review needs Chrome or a Playwright Chromium installation. Install Chrome and retry. The source edit was saved; visual review was not completed.");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const directory = join(dirname(entry), ".office-kit", "reviews", randomUUID());
|
|
130
|
+
await mkdir(directory, { recursive: true });
|
|
131
|
+
const page = await browser.newPage({
|
|
132
|
+
viewport: {
|
|
133
|
+
width: 1600,
|
|
134
|
+
height: 1e3
|
|
135
|
+
},
|
|
136
|
+
deviceScaleFactor: 1
|
|
137
|
+
});
|
|
138
|
+
await page.route("**/*", (route) => route.abort());
|
|
139
|
+
const images = [];
|
|
140
|
+
for (const { svg, index } of changed) {
|
|
141
|
+
await page.setContent("<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:\"><style>html,body{margin:0}svg{display:block;width:1600px;height:auto}</style>" + svg);
|
|
142
|
+
await page.evaluate(() => document.fonts.ready.then(() => void 0));
|
|
143
|
+
const file = join(directory, `slide-${index + 1}.png`);
|
|
144
|
+
await page.locator("svg").first().screenshot({
|
|
145
|
+
path: file,
|
|
146
|
+
timeout: 15e3
|
|
147
|
+
});
|
|
148
|
+
images.push(file);
|
|
149
|
+
}
|
|
150
|
+
const prompt = `Visual review after the edit (round ${round}/3). Open every attached screenshot (or read each local PNG path below) before finishing. Check clipping, overlap, alignment, spacing, legibility and foreground/background contrast. ${round === 3 ? "This is the final review: do not edit further; report any remaining defects to the user." : "Fix concrete design defects only on these affected slides;"} preserve the user's exact text and unrelated work. Do not claim visual verification unless you inspected the images. If there are no defects, finish without edits. Screenshots: ${JSON.stringify(images)}`;
|
|
151
|
+
await writeFile(join(directory, "review.txt"), prompt);
|
|
152
|
+
before = current;
|
|
153
|
+
rounds = round;
|
|
154
|
+
return {
|
|
155
|
+
prompt,
|
|
156
|
+
images
|
|
157
|
+
};
|
|
158
|
+
} finally {
|
|
159
|
+
await browser.close();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
//#endregion
|
|
11
165
|
//#region src/styles.ts
|
|
12
166
|
const previewStyles = `*{box-sizing:border-box}
|
|
13
167
|
body{margin:0;height:100dvh;overflow:hidden;background:#e9eaf4;color:#dce1f1;font:13px system-ui;display:grid;grid-template-rows:60px minmax(0,1fr) 42px}
|
|
@@ -77,6 +231,8 @@ const page = `<!doctype html>
|
|
|
77
231
|
<link rel="stylesheet" href="/terminal.css">
|
|
78
232
|
<style>
|
|
79
233
|
${previewStyles}
|
|
234
|
+
.slide-edit-tools{display:flex;gap:6px;padding:8px 14px;flex-shrink:0}.slide-edit-tools button[aria-pressed=true]{background:#6850b8;border-color:#b8a1ff}.slide-selection{position:fixed;border:2px solid #b094ff;background:#a080ff18;pointer-events:none;z-index:20;box-shadow:0 0 0 1px #201632}.slide-edit-panel{position:fixed;z-index:21;width:min(380px,calc(100vw - 16px));padding:14px;background:#202438;border:1px solid #776591;border-radius:14px;box-shadow:0 16px 50px #0008;display:flex;flex-direction:column;gap:12px}.slide-edit-panel[hidden],.slide-selection[hidden]{display:none}.selection-heading,.selection-actions{display:flex;align-items:center;gap:8px}.selection-heading strong{flex:1}.slide-edit-panel textarea{width:100%;min-height:90px;max-height:35vh;resize:vertical;background:#141a2b;color:#f4efff;border:1px solid #514969;border-radius:8px;padding:10px;font:inherit}.selection-actions{flex-wrap:wrap}.selection-actions select{min-width:80px;flex:1}.slide-edit-panel small{color:#d1c9e3;line-height:1.5}.presenting .slide-edit-tools{display:none}
|
|
235
|
+
|
|
80
236
|
#agent-workspace{position:relative;flex:1;min-height:0;min-width:0;display:flex;overflow:auto}.agent-pane{position:absolute;padding:3px 5px;display:flex;flex-direction:column;flex:1;min-width:0;min-height:0;overflow:hidden}.agent-pane iframe{border:0;width:100%;flex:1;min-height:0;border-radius:0 0 9px 9px}.agent-tools{display:flex;gap:4px;align-items:center;flex-wrap:wrap;padding:6px;background:#20253a;border:1px solid #34334e;border-radius:9px 9px 0 0}.agent-tools span{flex:1;font-size:10px;letter-spacing:.04em;color:#b3abc9}.agent-tools svg{width:14px;height:14px}.agent-pane:focus-within .agent-tools{border-color:#8b6ed577;background:#29243e}.agent-tools button{font-size:11px;padding:5px;background:transparent;border:0;display:grid;place-items:center}.agent-divider{position:absolute;z-index:2;background:#101422;cursor:col-resize;touch-action:none}.agent-divider.vertical{cursor:row-resize}.agent-divider:hover,.agent-divider:focus-visible{background:#9b87ff}.splitting-agents iframe,.resizing-chat iframe{pointer-events:none}
|
|
81
237
|
</style>
|
|
82
238
|
<header><span class="brand"><svg viewBox="0 0 36 36" aria-hidden="true"><defs><linearGradient id="brand-gradient" x2="1" y2="1"><stop stop-color="#c39aff"/><stop offset="1" stop-color="#7165f4"/></linearGradient></defs><rect x="2" y="2" width="32" height="32" rx="10" fill="url(#brand-gradient)"/><path d="M11 10h9l6 6v10H11z" fill="none" stroke="white" stroke-width="1.8" stroke-linejoin="round"/><path d="M20 10v7h6M15 21h7M15 25h4" fill="none" stroke="white" stroke-width="1.8" stroke-linecap="round"/><path d="m28 3 1.2 3.8L33 8l-3.8 1.2L28 13l-1.2-3.8L23 8l3.8-1.2z" fill="#e7dbff"/></svg>Office <em>Kit</em></span><span class="badge">Studio</span><span id="status" role="status">Building…</span><button id="toggle-chat" aria-expanded="true" aria-controls="chat">✦ Agents</button><button id="present" disabled>Present</button><a class="download" href="/deck.pptx">Download PPTX</a></header>
|
|
@@ -87,7 +243,7 @@ ${previewStyles}
|
|
|
87
243
|
<div id="chat-resizer" role="separator" tabindex="0" aria-label="Chat width" aria-orientation="vertical" aria-controls="chat" title="Drag to resize · Double-click to reset"></div>
|
|
88
244
|
<div class="workspace-heading"><span>✦ AI WORKSPACE</span><b>LOCAL</b></div><div id="agent-workspace"></div><div id="chat-context" hidden></div></aside>
|
|
89
245
|
</div>
|
|
90
|
-
<footer><span id="count" aria-live="polite">No slides</span><span class="hint">
|
|
246
|
+
<footer><span id="count" aria-live="polite">No slides</span><span class="hint">Select an area to ask AI · Edit text directly</span><button id="prev" aria-label="Previous slide" disabled>‹</button><button id="next" aria-label="Next slide" disabled>›</button><label for="zoom">Zoom</label><select id="zoom"><option value="fit">Fit</option><option value="0.5">50%</option><option value="0.75">75%</option><option value="1">100%</option><option value="1.25">125%</option><option value="1.5">150%</option><option value="2">200%</option></select></footer>
|
|
91
247
|
<div id="presentation-controls"><button id="present-prev" aria-label="Previous slide">‹</button><span id="present-count"></span><button id="present-next" aria-label="Next slide">›</button><button id="exit-present">Exit · Esc</button></div>
|
|
92
248
|
<script>
|
|
93
249
|
let state={slides:[],error:null,aspectRatio:16/9},index=0,urls=[],presenting=false;
|
|
@@ -257,6 +413,10 @@ window.addEventListener('message',event=>{
|
|
|
257
413
|
if(event.origin!==location.origin||event.source!==parent)return;
|
|
258
414
|
if(event.data?.type==='focus'){focus=event.data.focus;byId('chat-context').dataset.focus=JSON.stringify(focus);byId('chat-context').textContent=event.data.label;}
|
|
259
415
|
if(event.data?.type==='chat')void refreshChat();
|
|
416
|
+
if(event.data?.type==='inline-edit'&&byId('chat-provider').value==='codex'){
|
|
417
|
+
void (async()=>{try{if(chatBusy)throw new Error('This agent is working. Wait for it to finish.');await chatAction('/chat',{message:event.data.message,provider:'codex',...event.data.focus});parent.postMessage({type:'inline-result',id:event.data.id},location.origin);}catch(error){parent.postMessage({type:'inline-result',id:event.data.id,error:error.message},location.origin);}})();
|
|
418
|
+
}
|
|
419
|
+
|
|
260
420
|
});
|
|
261
421
|
parent.postMessage({type:'agent-ready'},location.origin);
|
|
262
422
|
let chatRequest=0,chatBusy=false,lastMessages='';
|
|
@@ -308,10 +468,17 @@ byId('chat-provider').onchange();
|
|
|
308
468
|
void refreshChat();
|
|
309
469
|
<\/script><script type="module" src="/terminal.js"><\/script></html>`;
|
|
310
470
|
//#endregion
|
|
471
|
+
//#region src/authoring-guidance.ts
|
|
472
|
+
/** Shared guidance for agents editing a deck through the preview. */
|
|
473
|
+
const authoringGuidance = `For lists and tables of contents, use Text paragraphs/bullets or mapped Text rows. Bullets and TableOfContents are not built-in DSL components: inspect installed exports, import existing local helpers or define them. Do not use Raw for these layouts.
|
|
474
|
+
Choose foreground and actual background colors together. Explicitly set readable text colors on slides, cards, tables, chart labels and footnotes; check inherited theme styles. Aim for 4.5:1 contrast for normal text and 3:1 for large text (18pt or 14pt bold). Use a solid backing over images or gradients if needed. Inspect rendered slides; compilation alone cannot verify contrast.`;
|
|
475
|
+
//#endregion
|
|
311
476
|
//#region src/terminal.ts
|
|
312
477
|
const REQUEST_LIMIT = 128e3;
|
|
313
478
|
/** A real interactive Claude session, shared across reloads of its owning tab. */
|
|
314
|
-
function createTerminal(entry, busy = () => false, base = "") {
|
|
479
|
+
function createTerminal(entry, busy = () => false, base = "", verify = async () => null, review) {
|
|
480
|
+
let repairs = 0;
|
|
481
|
+
let working = false;
|
|
315
482
|
let process;
|
|
316
483
|
let owner = "";
|
|
317
484
|
let output = "";
|
|
@@ -352,17 +519,69 @@ function createTerminal(entry, busy = () => false, base = "") {
|
|
|
352
519
|
response.writeHead(code, { "Content-Type": "application/json" });
|
|
353
520
|
response.end(JSON.stringify(value));
|
|
354
521
|
};
|
|
355
|
-
if (request.url === "/terminal/context") {
|
|
522
|
+
if (request.url === "/terminal/context" || request.url === "/terminal/verify") {
|
|
356
523
|
if (request.method !== "POST" || request.headers.authorization !== `Bearer ${token}`) {
|
|
357
524
|
json(403, { error: "Invalid hook credentials" });
|
|
358
525
|
return;
|
|
359
526
|
}
|
|
360
527
|
request.resume();
|
|
528
|
+
if (request.url === "/terminal/verify") {
|
|
529
|
+
if (closing || stopping || !process) {
|
|
530
|
+
json(200, {});
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
let error;
|
|
534
|
+
try {
|
|
535
|
+
error = await verify();
|
|
536
|
+
} catch (cause) {
|
|
537
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
538
|
+
}
|
|
539
|
+
if (closing || stopping || !process) {
|
|
540
|
+
json(200, {});
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (error && repairs < 3) {
|
|
544
|
+
repairs++;
|
|
545
|
+
status = `Repairing preview (${repairs}/3)…`;
|
|
546
|
+
json(200, {
|
|
547
|
+
decision: "block",
|
|
548
|
+
reason: `The actual preview build failed. Repair the source before finishing (attempt ${repairs}/3). Preserve unrelated edits, inspect the installed DSL exports and local components before changing imports. Do not hide the error or remove requested content. No Raw is needed for lists or an agenda: use Text paragraphs/bullets or mapped Text rows. Build error:\n${error.slice(0, 16e3)}`
|
|
549
|
+
});
|
|
550
|
+
} else {
|
|
551
|
+
if (!error && review) try {
|
|
552
|
+
const visual = await review.next();
|
|
553
|
+
if (closing || stopping || !process) {
|
|
554
|
+
json(200, {});
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (visual) {
|
|
558
|
+
status = "Reviewing slide screenshots…";
|
|
559
|
+
json(200, {
|
|
560
|
+
decision: "block",
|
|
561
|
+
reason: visual.prompt
|
|
562
|
+
});
|
|
563
|
+
emit("state", snapshot());
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
} catch (cause) {
|
|
567
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
568
|
+
}
|
|
569
|
+
working = false;
|
|
570
|
+
status = error ? "Preview verification incomplete: " + error : "Preview build verified";
|
|
571
|
+
json(200, {});
|
|
572
|
+
}
|
|
573
|
+
emit("state", snapshot());
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
repairs = 0;
|
|
577
|
+
working = true;
|
|
578
|
+
review?.begin();
|
|
361
579
|
json(200, { hookSpecificOutput: {
|
|
362
580
|
hookEventName: "UserPromptSubmit",
|
|
363
581
|
additionalContext: `You are editing the local PowerPoint TSX project at ${resolve(entry)}.
|
|
364
582
|
Read the project's authoring instructions. Make focused source patches and preserve unrelated work.
|
|
365
|
-
The preview rebuilds on save; do not start another dev server.
|
|
583
|
+
The preview rebuilds on save; do not start another dev server. A Stop hook checks the actual build and returns errors for repair.
|
|
584
|
+
${authoringGuidance}
|
|
366
585
|
The focused slide is context, NOT a restriction. For "this slide", edit only its relevant source. For other slides or deck-wide requests, locate the relevant source or shared theme.
|
|
367
586
|
Slide numbers are 1-based. Verify source before editing; dependency paths are candidates, not an exact slide-to-file mapping.
|
|
368
587
|
Preview context captured with the latest terminal input: ${JSON.stringify(context)}`
|
|
@@ -423,12 +642,20 @@ Preview context captured with the latest terminal input: ${JSON.stringify(contex
|
|
|
423
642
|
}
|
|
424
643
|
const pty = await import("node-pty");
|
|
425
644
|
if (process || closing) throw new Error("Terminal state changed. Try again.");
|
|
426
|
-
const settings = { hooks: {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
645
|
+
const settings = { hooks: {
|
|
646
|
+
Stop: [{ hooks: [{
|
|
647
|
+
type: "http",
|
|
648
|
+
url: `${origin}${base}/terminal/verify`,
|
|
649
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
650
|
+
timeout: 120
|
|
651
|
+
}] }],
|
|
652
|
+
UserPromptSubmit: [{ hooks: [{
|
|
653
|
+
type: "http",
|
|
654
|
+
url: `${origin}${base}/terminal/context`,
|
|
655
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
656
|
+
timeout: 5
|
|
657
|
+
}] }]
|
|
658
|
+
} };
|
|
432
659
|
const env = { ...globalThis.process.env };
|
|
433
660
|
delete env.CLAUDECODE;
|
|
434
661
|
process = pty.spawn("claude", ["--settings", JSON.stringify(settings)], {
|
|
@@ -451,16 +678,31 @@ Preview context captured with the latest terminal input: ${JSON.stringify(contex
|
|
|
451
678
|
});
|
|
452
679
|
completion = new Promise((done) => process.onExit(({ exitCode }) => {
|
|
453
680
|
process = void 0;
|
|
681
|
+
working = false;
|
|
454
682
|
stopping = false;
|
|
455
683
|
status = `Claude Code exited (${exitCode}). Start to open a new session.`;
|
|
456
684
|
emit("state", snapshot());
|
|
457
685
|
done();
|
|
458
686
|
}));
|
|
459
687
|
}
|
|
688
|
+
} else if (request.url === "/terminal/prompt") {
|
|
689
|
+
if (!process) throw new Error("Start Claude Code in the selected agent pane first.");
|
|
690
|
+
if (working) throw new Error("Claude Code is working. Wait for the current turn to finish.");
|
|
691
|
+
if (typeof value.message !== "string" || !value.message.trim() || value.message.length > 16e3 || [...value.message].some((character) => {
|
|
692
|
+
const code = character.charCodeAt(0);
|
|
693
|
+
return code < 32 && code !== 9 && code !== 10 || code === 127;
|
|
694
|
+
})) throw new Error("Invalid inline prompt");
|
|
695
|
+
updateFocus();
|
|
696
|
+
working = true;
|
|
697
|
+
process.write("\x1B[200~" + value.message + "\x1B[201~");
|
|
698
|
+
await new Promise((done) => setTimeout(done, 100));
|
|
699
|
+
if (!process || stopping) throw new Error("Claude Code stopped before submitting.");
|
|
700
|
+
process.write("\r");
|
|
460
701
|
} else if (request.url === "/terminal/input") {
|
|
461
702
|
if (!process) throw new Error("Start Claude Code first");
|
|
462
703
|
if (typeof value.data !== "string" || value.data.length > 32e3) throw new Error("Invalid terminal input");
|
|
463
704
|
updateFocus();
|
|
705
|
+
if (value.data.includes("")) working = false;
|
|
464
706
|
process.write(value.data);
|
|
465
707
|
} else if (request.url === "/terminal/resize") {
|
|
466
708
|
size();
|
|
@@ -495,7 +737,7 @@ const markdown = new MarkdownIt({
|
|
|
495
737
|
});
|
|
496
738
|
markdown.renderer.rules.image = (tokens, index) => markdown.utils.escapeHtml(tokens[index]?.content ?? "");
|
|
497
739
|
/** One shared conversation and one editing process per dev server. */
|
|
498
|
-
function createChat(entry, notify, busy = () => false) {
|
|
740
|
+
function createChat(entry, notify, busy = () => false, verify = async () => null, review, reviewImages = () => []) {
|
|
499
741
|
let messages = [];
|
|
500
742
|
let provider = "codex";
|
|
501
743
|
let child;
|
|
@@ -517,6 +759,10 @@ function createChat(entry, notify, busy = () => false) {
|
|
|
517
759
|
stopping = true;
|
|
518
760
|
status = "Stopping…";
|
|
519
761
|
const processToStop = child;
|
|
762
|
+
if (processToStop.exitCode !== null || processToStop.signalCode !== null) {
|
|
763
|
+
notify();
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
520
766
|
const kill = (signal) => {
|
|
521
767
|
try {
|
|
522
768
|
if (process.platform !== "win32" && processToStop.pid) process.kill(-processToStop.pid, signal);
|
|
@@ -527,7 +773,8 @@ function createChat(entry, notify, busy = () => false) {
|
|
|
527
773
|
killTimer = setTimeout(() => kill("SIGKILL"), 2e3);
|
|
528
774
|
notify();
|
|
529
775
|
}
|
|
530
|
-
function start(text, selected, context) {
|
|
776
|
+
function start(text, selected, context, repairs = 0, images = [], reviewing = false) {
|
|
777
|
+
if (!repairs && !reviewing) review?.begin();
|
|
531
778
|
provider = selected;
|
|
532
779
|
const user = {
|
|
533
780
|
role: "user",
|
|
@@ -541,6 +788,7 @@ function createChat(entry, notify, busy = () => false) {
|
|
|
541
788
|
const prompt = `You are editing a local PowerPoint TSX project in ${dirname(resolve(entry))}.
|
|
542
789
|
Read the project's authoring instructions. Make focused patches to the TSX source, preserving unrelated work.
|
|
543
790
|
The preview rebuilds automatically on save; do not start another dev server.
|
|
791
|
+
${authoringGuidance}
|
|
544
792
|
The attached focus is context, NOT a restriction: use it for references such as "this slide". For other slides or deck-wide requests, find the relevant source or shared theme instead. Do not rewrite the whole deck for a local edit.
|
|
545
793
|
Slide numbers are 1-based and refer to the last successful preview at send time. Source may have changed since then; verify it before editing. Files are dependency candidates, not an exact slide-to-source mapping.
|
|
546
794
|
Respond in the user's language, briefly describing edits or answering their question. If permissions prevent an action, explain that instead of claiming success.
|
|
@@ -555,14 +803,16 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
|
|
|
555
803
|
if (messages.length > 100) messages = messages.slice(-100);
|
|
556
804
|
status = "Working…";
|
|
557
805
|
stopping = false;
|
|
558
|
-
|
|
806
|
+
const args = [
|
|
559
807
|
"exec",
|
|
560
808
|
"--json",
|
|
561
809
|
"--sandbox",
|
|
562
810
|
"workspace-write",
|
|
563
811
|
"--skip-git-repo-check",
|
|
564
812
|
"-"
|
|
565
|
-
]
|
|
813
|
+
];
|
|
814
|
+
for (const image of images) args.splice(args.length - 1, 0, "--image", image);
|
|
815
|
+
child = spawn(selected, args, {
|
|
566
816
|
cwd: dirname(resolve(entry)),
|
|
567
817
|
stdio: [
|
|
568
818
|
"pipe",
|
|
@@ -616,10 +866,44 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
|
|
|
616
866
|
running.on("error", (error) => {
|
|
617
867
|
failure = `Could not run ${selected}. Install it and log in from your terminal first. ${error.message}`;
|
|
618
868
|
});
|
|
619
|
-
running.on("close", (code) => {
|
|
869
|
+
running.on("close", async (code) => {
|
|
620
870
|
if (buffer.trim()) consume(buffer);
|
|
621
871
|
clearTimeout(killTimer);
|
|
622
872
|
status = stopping ? "Stopped. Saved edits remain in the preview." : failure || (code !== 0 ? stderr || `${selected} exited with code ${code}` : !sawResult ? "CLI exited without a completed result." : "Done");
|
|
873
|
+
if (status === "Done") {
|
|
874
|
+
status = "Verifying preview…";
|
|
875
|
+
notify();
|
|
876
|
+
let buildError;
|
|
877
|
+
try {
|
|
878
|
+
buildError = await verify();
|
|
879
|
+
} catch (cause) {
|
|
880
|
+
buildError = cause instanceof Error ? cause.message : String(cause);
|
|
881
|
+
}
|
|
882
|
+
if (!stopping && buildError && repairs < 3) {
|
|
883
|
+
child = void 0;
|
|
884
|
+
start(`The actual preview build failed. Repair the source and preserve unrelated work (attempt ${repairs + 1}/3). Do not remove requested content or hide the error.\n${buildError.slice(0, 16e3)}`, selected, {
|
|
885
|
+
...context,
|
|
886
|
+
buildError
|
|
887
|
+
}, repairs + 1, [], reviewing);
|
|
888
|
+
await completion;
|
|
889
|
+
done();
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
let reviewFailure = "";
|
|
893
|
+
if (!stopping && !buildError && review) try {
|
|
894
|
+
const visual = await review.next();
|
|
895
|
+
if (!stopping && visual) {
|
|
896
|
+
child = void 0;
|
|
897
|
+
start(visual.prompt, selected, context, 0, visual.images, true);
|
|
898
|
+
await completion;
|
|
899
|
+
done();
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
} catch (error) {
|
|
903
|
+
reviewFailure = error instanceof Error ? error.message : String(error);
|
|
904
|
+
}
|
|
905
|
+
status = stopping ? "Stopped. Saved edits remain in the preview." : buildError ? "Preview still has errors after 3 repair attempts.\n" + buildError.slice(0, 16e3) : reviewFailure || "Done";
|
|
906
|
+
}
|
|
623
907
|
if (status !== "Done") append(status);
|
|
624
908
|
child = void 0;
|
|
625
909
|
notify();
|
|
@@ -685,7 +969,7 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
|
|
|
685
969
|
return;
|
|
686
970
|
}
|
|
687
971
|
const context = focus(value.slide, value.revision);
|
|
688
|
-
start(value.message.trim(), value.provider, context);
|
|
972
|
+
start(value.message.trim(), value.provider, context, 0, reviewImages(value.message.trim()));
|
|
689
973
|
json(202, snapshot());
|
|
690
974
|
} catch (cause) {
|
|
691
975
|
json(400, { error: cause instanceof Error ? cause.message : "Invalid request" });
|
|
@@ -713,13 +997,15 @@ async function serveDeck(entry, port = 4173) {
|
|
|
713
997
|
let closed = false;
|
|
714
998
|
const clients = /* @__PURE__ */ new Set();
|
|
715
999
|
const sessions = /* @__PURE__ */ new Map();
|
|
1000
|
+
const capturedReviews = /* @__PURE__ */ new Map();
|
|
716
1001
|
function session(id) {
|
|
717
1002
|
let result = sessions.get(id);
|
|
718
1003
|
if (!result) {
|
|
1004
|
+
const review = createVisualReviewer(resolve(entry), () => latest?.slides ?? []);
|
|
719
1005
|
const chat = createChat(entry, () => {
|
|
720
1006
|
for (const client of clients) client.write("data: chat\n\n");
|
|
721
|
-
}, () => terminal.isRunning());
|
|
722
|
-
const terminal = createTerminal(entry, () => chat.isRunning(), id ? "/agents/" + id : "");
|
|
1007
|
+
}, () => terminal.isRunning(), verify, review, (prompt) => capturedReviews.get(prompt) ?? []);
|
|
1008
|
+
const terminal = createTerminal(entry, () => chat.isRunning(), id ? "/agents/" + id : "", verify, review);
|
|
723
1009
|
result = {
|
|
724
1010
|
chat,
|
|
725
1011
|
terminal,
|
|
@@ -729,6 +1015,13 @@ async function serveDeck(entry, port = 4173) {
|
|
|
729
1015
|
}
|
|
730
1016
|
return result;
|
|
731
1017
|
}
|
|
1018
|
+
const textEditor = createTextEditor(resolve(entry), () => {
|
|
1019
|
+
if (!latest || building || pending || timer || error) throw new Error("Wait for a successful preview build.");
|
|
1020
|
+
return {
|
|
1021
|
+
...latest,
|
|
1022
|
+
revision
|
|
1023
|
+
};
|
|
1024
|
+
}, verify);
|
|
732
1025
|
const server = createServer((request, response) => {
|
|
733
1026
|
const host = request.headers.host;
|
|
734
1027
|
if (host !== `127.0.0.1:${actualPort}` && host !== `localhost:${actualPort}`) {
|
|
@@ -736,11 +1029,45 @@ async function serveDeck(entry, port = 4173) {
|
|
|
736
1029
|
return;
|
|
737
1030
|
}
|
|
738
1031
|
response.setHeader("Cache-Control", "no-store");
|
|
739
|
-
const match = request.url?.match(/^\/agents\/([\w-]{1,64})(\/(?:chat(?:\/(?:stop|reset))?|terminal\/(?:start|input|resize|stop|events|context)|close))?$/);
|
|
1032
|
+
const match = request.url?.match(/^\/agents\/([\w-]{1,64})(\/(?:chat(?:\/(?:stop|reset))?|terminal\/(?:start|input|prompt|resize|stop|events|context|verify)|close))?$/);
|
|
740
1033
|
if (request.url?.startsWith("/agents/") && !match) {
|
|
741
1034
|
response.writeHead(404).end();
|
|
742
1035
|
return;
|
|
743
1036
|
}
|
|
1037
|
+
if (request.url === "/text-edit" || request.url === "/text-edit/undo") {
|
|
1038
|
+
if (request.method !== "POST" || request.headers.origin !== `http://${host}` || request.headers["content-type"] !== "application/json") {
|
|
1039
|
+
response.writeHead(403).end();
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
(async () => {
|
|
1043
|
+
let body = "";
|
|
1044
|
+
request.setEncoding("utf8");
|
|
1045
|
+
for await (const chunk of request) {
|
|
1046
|
+
body += chunk;
|
|
1047
|
+
if (Buffer.byteLength(body) > 1e5) throw new Error("Request too large");
|
|
1048
|
+
}
|
|
1049
|
+
const manualReview = createVisualReviewer(resolve(entry), () => latest?.slides ?? []);
|
|
1050
|
+
manualReview.begin();
|
|
1051
|
+
if (request.url === "/text-edit/undo") await textEditor.undo();
|
|
1052
|
+
else await textEditor.edit(JSON.parse(body));
|
|
1053
|
+
let review;
|
|
1054
|
+
let reviewError;
|
|
1055
|
+
try {
|
|
1056
|
+
review = await manualReview.next();
|
|
1057
|
+
if (review) {
|
|
1058
|
+
capturedReviews.set(review.prompt, review.images);
|
|
1059
|
+
if (capturedReviews.size > 64) capturedReviews.delete(capturedReviews.keys().next().value);
|
|
1060
|
+
}
|
|
1061
|
+
} catch (cause) {
|
|
1062
|
+
reviewError = cause instanceof Error ? cause.message : String(cause);
|
|
1063
|
+
}
|
|
1064
|
+
response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({
|
|
1065
|
+
review,
|
|
1066
|
+
reviewError
|
|
1067
|
+
}));
|
|
1068
|
+
})().catch((cause) => response.writeHead(400, { "Content-Type": "application/json" }).end(JSON.stringify({ error: cause instanceof Error ? cause.message : String(cause) })));
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
744
1071
|
const id = match?.[1] ?? "";
|
|
745
1072
|
if (match) {
|
|
746
1073
|
if (request.headers["sec-fetch-site"] === "cross-site" || request.headers.origin && request.headers.origin !== `http://${host}`) {
|
|
@@ -840,6 +1167,15 @@ async function serveDeck(entry, port = 4173) {
|
|
|
840
1167
|
response.end(page);
|
|
841
1168
|
} else response.writeHead(404).end();
|
|
842
1169
|
});
|
|
1170
|
+
async function verify() {
|
|
1171
|
+
rebuild();
|
|
1172
|
+
const deadline = Date.now() + 3e4;
|
|
1173
|
+
while (!closed && (building || timer || pending)) {
|
|
1174
|
+
if (Date.now() >= deadline) return "Preview verification timed out. Inspect the build before claiming success.";
|
|
1175
|
+
await new Promise((done) => setTimeout(done, 50));
|
|
1176
|
+
}
|
|
1177
|
+
return closed ? "Preview server closed." : error;
|
|
1178
|
+
}
|
|
843
1179
|
async function rebuild() {
|
|
844
1180
|
if (closed) return;
|
|
845
1181
|
if (building) {
|
|
@@ -935,7 +1271,7 @@ try {
|
|
|
935
1271
|
if (args.length && (args.length !== 2 || !option || args[0] !== option || !args[1])) throw new Error(`Unexpected arguments: ${args.join(" ")}\n${usage}`);
|
|
936
1272
|
if (command === "init") {
|
|
937
1273
|
const directory = await initProject(entry);
|
|
938
|
-
console.log(`Created ${directory}\
|
|
1274
|
+
console.log(`Created ${directory}\nInside that directory, run npm install once.\nStart development: npm run dev (or npx office-pptx dev deck.tsx).`);
|
|
939
1275
|
} else if (command === "inspect") console.log(JSON.stringify(await inspectTemplate(entry), null, 2));
|
|
940
1276
|
else if (command === "build") {
|
|
941
1277
|
const output = resolve(args[1] ?? "deck.pptx");
|