@office-kit/pptx-dev 0.6.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
@@ -140,6 +153,40 @@ References: [Claude Code commands](https://code.claude.com/docs/en/commands),
140
153
  [Claude Code hooks](https://code.claude.com/docs/en/hooks), and
141
154
  [Codex non-interactive mode](https://developers.openai.com/codex/noninteractive).
142
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
+
143
190
  ## Edit only what changed
144
191
 
145
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='';
@@ -316,8 +476,9 @@ Choose foreground and actual background colors together. Explicitly set readable
316
476
  //#region src/terminal.ts
317
477
  const REQUEST_LIMIT = 128e3;
318
478
  /** A real interactive Claude session, shared across reloads of its owning tab. */
319
- function createTerminal(entry, busy = () => false, base = "", verify = async () => null) {
479
+ function createTerminal(entry, busy = () => false, base = "", verify = async () => null, review) {
320
480
  let repairs = 0;
481
+ let working = false;
321
482
  let process;
322
483
  let owner = "";
323
484
  let output = "";
@@ -387,13 +548,34 @@ function createTerminal(entry, busy = () => false, base = "", verify = async ()
387
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)}`
388
549
  });
389
550
  } else {
390
- status = error ? "Preview still has errors after 3 repair attempts." : "Preview build verified";
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";
391
571
  json(200, {});
392
572
  }
393
573
  emit("state", snapshot());
394
574
  return;
395
575
  }
396
576
  repairs = 0;
577
+ working = true;
578
+ review?.begin();
397
579
  json(200, { hookSpecificOutput: {
398
580
  hookEventName: "UserPromptSubmit",
399
581
  additionalContext: `You are editing the local PowerPoint TSX project at ${resolve(entry)}.
@@ -465,7 +647,7 @@ Preview context captured with the latest terminal input: ${JSON.stringify(contex
465
647
  type: "http",
466
648
  url: `${origin}${base}/terminal/verify`,
467
649
  headers: { Authorization: `Bearer ${token}` },
468
- timeout: 40
650
+ timeout: 120
469
651
  }] }],
470
652
  UserPromptSubmit: [{ hooks: [{
471
653
  type: "http",
@@ -496,16 +678,31 @@ Preview context captured with the latest terminal input: ${JSON.stringify(contex
496
678
  });
497
679
  completion = new Promise((done) => process.onExit(({ exitCode }) => {
498
680
  process = void 0;
681
+ working = false;
499
682
  stopping = false;
500
683
  status = `Claude Code exited (${exitCode}). Start to open a new session.`;
501
684
  emit("state", snapshot());
502
685
  done();
503
686
  }));
504
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");
505
701
  } else if (request.url === "/terminal/input") {
506
702
  if (!process) throw new Error("Start Claude Code first");
507
703
  if (typeof value.data !== "string" || value.data.length > 32e3) throw new Error("Invalid terminal input");
508
704
  updateFocus();
705
+ if (value.data.includes("")) working = false;
509
706
  process.write(value.data);
510
707
  } else if (request.url === "/terminal/resize") {
511
708
  size();
@@ -540,7 +737,7 @@ const markdown = new MarkdownIt({
540
737
  });
541
738
  markdown.renderer.rules.image = (tokens, index) => markdown.utils.escapeHtml(tokens[index]?.content ?? "");
542
739
  /** One shared conversation and one editing process per dev server. */
543
- function createChat(entry, notify, busy = () => false, verify = async () => null) {
740
+ function createChat(entry, notify, busy = () => false, verify = async () => null, review, reviewImages = () => []) {
544
741
  let messages = [];
545
742
  let provider = "codex";
546
743
  let child;
@@ -576,7 +773,8 @@ function createChat(entry, notify, busy = () => false, verify = async () => null
576
773
  killTimer = setTimeout(() => kill("SIGKILL"), 2e3);
577
774
  notify();
578
775
  }
579
- function start(text, selected, context, repairs = 0) {
776
+ function start(text, selected, context, repairs = 0, images = [], reviewing = false) {
777
+ if (!repairs && !reviewing) review?.begin();
580
778
  provider = selected;
581
779
  const user = {
582
780
  role: "user",
@@ -605,14 +803,16 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
605
803
  if (messages.length > 100) messages = messages.slice(-100);
606
804
  status = "Working…";
607
805
  stopping = false;
608
- child = spawn(selected, [
806
+ const args = [
609
807
  "exec",
610
808
  "--json",
611
809
  "--sandbox",
612
810
  "workspace-write",
613
811
  "--skip-git-repo-check",
614
812
  "-"
615
- ], {
813
+ ];
814
+ for (const image of images) args.splice(args.length - 1, 0, "--image", image);
815
+ child = spawn(selected, args, {
616
816
  cwd: dirname(resolve(entry)),
617
817
  stdio: [
618
818
  "pipe",
@@ -684,12 +884,25 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
684
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, {
685
885
  ...context,
686
886
  buildError
687
- }, repairs + 1);
887
+ }, repairs + 1, [], reviewing);
688
888
  await completion;
689
889
  done();
690
890
  return;
691
891
  }
692
- status = stopping ? "Stopped. Saved edits remain in the preview." : buildError ? "Preview still has errors after 3 repair attempts.\n" + buildError.slice(0, 16e3) : "Done";
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";
693
906
  }
694
907
  if (status !== "Done") append(status);
695
908
  child = void 0;
@@ -756,7 +969,7 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
756
969
  return;
757
970
  }
758
971
  const context = focus(value.slide, value.revision);
759
- start(value.message.trim(), value.provider, context);
972
+ start(value.message.trim(), value.provider, context, 0, reviewImages(value.message.trim()));
760
973
  json(202, snapshot());
761
974
  } catch (cause) {
762
975
  json(400, { error: cause instanceof Error ? cause.message : "Invalid request" });
@@ -784,13 +997,15 @@ async function serveDeck(entry, port = 4173) {
784
997
  let closed = false;
785
998
  const clients = /* @__PURE__ */ new Set();
786
999
  const sessions = /* @__PURE__ */ new Map();
1000
+ const capturedReviews = /* @__PURE__ */ new Map();
787
1001
  function session(id) {
788
1002
  let result = sessions.get(id);
789
1003
  if (!result) {
1004
+ const review = createVisualReviewer(resolve(entry), () => latest?.slides ?? []);
790
1005
  const chat = createChat(entry, () => {
791
1006
  for (const client of clients) client.write("data: chat\n\n");
792
- }, () => terminal.isRunning(), verify);
793
- const terminal = createTerminal(entry, () => chat.isRunning(), id ? "/agents/" + id : "", verify);
1007
+ }, () => terminal.isRunning(), verify, review, (prompt) => capturedReviews.get(prompt) ?? []);
1008
+ const terminal = createTerminal(entry, () => chat.isRunning(), id ? "/agents/" + id : "", verify, review);
794
1009
  result = {
795
1010
  chat,
796
1011
  terminal,
@@ -800,6 +1015,13 @@ async function serveDeck(entry, port = 4173) {
800
1015
  }
801
1016
  return result;
802
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);
803
1025
  const server = createServer((request, response) => {
804
1026
  const host = request.headers.host;
805
1027
  if (host !== `127.0.0.1:${actualPort}` && host !== `localhost:${actualPort}`) {
@@ -807,11 +1029,45 @@ async function serveDeck(entry, port = 4173) {
807
1029
  return;
808
1030
  }
809
1031
  response.setHeader("Cache-Control", "no-store");
810
- const match = request.url?.match(/^\/agents\/([\w-]{1,64})(\/(?:chat(?:\/(?:stop|reset))?|terminal\/(?:start|input|resize|stop|events|context|verify)|close))?$/);
1032
+ const match = request.url?.match(/^\/agents\/([\w-]{1,64})(\/(?:chat(?:\/(?:stop|reset))?|terminal\/(?:start|input|prompt|resize|stop|events|context|verify)|close))?$/);
811
1033
  if (request.url?.startsWith("/agents/") && !match) {
812
1034
  response.writeHead(404).end();
813
1035
  return;
814
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
+ }
815
1071
  const id = match?.[1] ?? "";
816
1072
  if (match) {
817
1073
  if (request.headers["sec-fetch-site"] === "cross-site" || request.headers.origin && request.headers.origin !== `http://${host}`) {
@@ -1015,7 +1271,7 @@ try {
1015
1271
  if (args.length && (args.length !== 2 || !option || args[0] !== option || !args[1])) throw new Error(`Unexpected arguments: ${args.join(" ")}\n${usage}`);
1016
1272
  if (command === "init") {
1017
1273
  const directory = await initProject(entry);
1018
- 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).`);
1019
1275
  } else if (command === "inspect") console.log(JSON.stringify(await inspectTemplate(entry), null, 2));
1020
1276
  else if (command === "build") {
1021
1277
  const output = resolve(args[1] ?? "deck.pptx");