@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 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; the canvas has no editing, dragging or resize handles.
23
- Changes are made only in TSX, including when an AI agent edits the presentation.
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-CShdm4G1.mjs";
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">Changes appear automatically · Text can be selected and copied</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>
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: { UserPromptSubmit: [{ hooks: [{
427
- type: "http",
428
- url: `${origin}${base}/terminal/context`,
429
- headers: { Authorization: `Bearer ${token}` },
430
- timeout: 5
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
- child = spawn(selected, [
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}\nRun npm install, then npm run dev inside that 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");