@office-kit/pptx-dev 0.6.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,13 +1,328 @@
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, lstat, mkdir, readFile, readdir, realpath, stat, unlink, 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/history.ts
14
+ const SNAPSHOT_LIMIT = 64 * 1024 * 1024;
15
+ const HISTORY_LIMIT = 128 * 1024 * 1024;
16
+ const ENTRY_LIMIT = 50;
17
+ const ignored = new Set([
18
+ "node_modules",
19
+ ".git",
20
+ "dist",
21
+ ".office-kit"
22
+ ]);
23
+ const historyFile = (name) => !name.split(sep).some((part) => ignored.has(part)) && /\.([cm]?[jt]sx?|json|css|md|ya?ml|pptx|png|jpe?g|gif|bmp|tiff?|emf|wmf|svg)$/i.test(name);
24
+ const equal = (a, b) => a === b || !!a && !!b && a.equals(b);
25
+ /** Session-local project history. Overlapping agent turns form one transaction. */
26
+ async function createHistory(directory, notify = () => {}) {
27
+ const root = await realpath(directory);
28
+ let baseline = /* @__PURE__ */ new Map();
29
+ let initialized = false;
30
+ const past = [], future = [];
31
+ const active = /* @__PURE__ */ new Set();
32
+ let label = "";
33
+ let error = null;
34
+ let queue = Promise.resolve();
35
+ let restoring = false;
36
+ function serial(action) {
37
+ const result = queue.then(action);
38
+ queue = result.then(() => {}, () => {});
39
+ return result.catch((cause) => {
40
+ error = cause instanceof Error ? cause.message : String(cause);
41
+ throw cause;
42
+ }).finally(notify);
43
+ }
44
+ async function snapshot() {
45
+ const files = /* @__PURE__ */ new Map();
46
+ let bytes = 0;
47
+ async function visit(dir) {
48
+ const entries = await readdir(dir, { withFileTypes: true });
49
+ await Promise.all(entries.map(async (item) => {
50
+ if (ignored.has(item.name) || item.isSymbolicLink()) return;
51
+ const file = join(dir, item.name);
52
+ if (item.isDirectory()) return visit(file);
53
+ if (!item.isFile() || !historyFile(file)) return;
54
+ const info = await lstat(file);
55
+ bytes += info.size;
56
+ if (bytes > SNAPSHOT_LIMIT) throw new Error("Undo history exceeds 64 MiB of project source/assets. Move unused assets outside the deck folder.");
57
+ if (info.isSymbolicLink() || await realpath(file) !== file) throw new Error("Project paths changed while recording history. Try again.");
58
+ const content = await readFile(file);
59
+ files.set(relative(root, file), content);
60
+ }));
61
+ }
62
+ await visit(root);
63
+ return files;
64
+ }
65
+ function record(next, description) {
66
+ if (!initialized) {
67
+ baseline = next;
68
+ initialized = true;
69
+ error = null;
70
+ return;
71
+ }
72
+ const changes = [];
73
+ for (const file of new Set([...baseline.keys(), ...next.keys()])) {
74
+ const before = baseline.get(file), after = next.get(file);
75
+ if (!equal(before, after)) changes.push({
76
+ file,
77
+ before,
78
+ after
79
+ });
80
+ }
81
+ if (changes.length) {
82
+ past.push({
83
+ label: description,
84
+ changes
85
+ });
86
+ future.length = 0;
87
+ const size = () => past.reduce((sum, item) => sum + item.changes.reduce((n, change) => n + (change.before?.length ?? 0) + (change.after?.length ?? 0), 0), 0);
88
+ while (past.length > ENTRY_LIMIT || past.length > 1 && size() > HISTORY_LIMIT) past.shift();
89
+ }
90
+ baseline = next;
91
+ error = null;
92
+ }
93
+ async function contents(file) {
94
+ const path = join(root, file);
95
+ let parent = dirname(path);
96
+ while (true) try {
97
+ if (await realpath(parent) !== parent) throw new Error("Undo refused: a project directory is now a symbolic link.");
98
+ break;
99
+ } catch (cause) {
100
+ if (cause.code !== "ENOENT") throw cause;
101
+ parent = dirname(parent);
102
+ }
103
+ try {
104
+ const info = await lstat(path);
105
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error("Undo refused: a project file changed type.");
106
+ return await readFile(path);
107
+ } catch (cause) {
108
+ if (cause.code === "ENOENT") return void 0;
109
+ throw cause;
110
+ }
111
+ }
112
+ async function replace(change, expected, next) {
113
+ if (!equal(await contents(change.file), expected)) throw new Error(`History conflict in ${change.file}. Newer changes were preserved.`);
114
+ const file = join(root, change.file);
115
+ if (next) {
116
+ await mkdir(dirname(file), { recursive: true });
117
+ await writeFile(file, next);
118
+ } else await unlink(file);
119
+ }
120
+ try {
121
+ record(await snapshot(), "Initial state");
122
+ } catch (cause) {
123
+ error = cause instanceof Error ? cause.message : String(cause);
124
+ }
125
+ return {
126
+ state: () => ({
127
+ undo: past.at(-1)?.label ?? null,
128
+ redo: future.at(-1)?.label ?? null,
129
+ busy: active.size > 0 || restoring,
130
+ error
131
+ }),
132
+ capture: () => serial(async () => {
133
+ if (!active.size && !restoring) record(await snapshot(), "Source edit");
134
+ }),
135
+ begin: (id, description) => serial(async () => {
136
+ if (active.has(id)) return;
137
+ if (!active.size) {
138
+ record(await snapshot(), "Source edit");
139
+ label = description;
140
+ } else label = "Concurrent edits";
141
+ active.add(id);
142
+ }),
143
+ end: (id) => serial(async () => {
144
+ if (!active.delete(id) || active.size) return;
145
+ record(await snapshot(), label);
146
+ }),
147
+ move: (direction) => serial(async () => {
148
+ if (active.size) throw new Error("Wait for all agents and text edits to finish before using Undo or Redo.");
149
+ record(await snapshot(), "Source edit");
150
+ const from = direction === "undo" ? past : future;
151
+ const to = direction === "undo" ? future : past;
152
+ const item = from.at(-1);
153
+ if (!item) throw new Error(`Nothing to ${direction}.`);
154
+ const before = (c) => direction === "undo" ? c.after : c.before;
155
+ const after = (c) => direction === "undo" ? c.before : c.after;
156
+ const applied = [];
157
+ restoring = true;
158
+ try {
159
+ await Promise.all(item.changes.map(async (change) => {
160
+ if (!equal(await contents(change.file), before(change))) throw new Error(`History conflict in ${change.file}. Newer changes were preserved.`);
161
+ }));
162
+ for (const change of item.changes) {
163
+ await replace(change, before(change), after(change));
164
+ applied.push(change);
165
+ }
166
+ for (const change of item.changes) {
167
+ const content = after(change);
168
+ if (content) baseline.set(change.file, content);
169
+ else baseline.delete(change.file);
170
+ }
171
+ from.pop();
172
+ to.push(item);
173
+ error = null;
174
+ } catch (cause) {
175
+ const failures = [];
176
+ for (const change of applied.reverse()) try {
177
+ await replace(change, after(change), before(change));
178
+ } catch (rollback) {
179
+ failures.push(String(rollback));
180
+ }
181
+ if (failures.length) throw new Error(`${String(cause)}; rollback incomplete: ${failures.join("; ")}`);
182
+ throw cause;
183
+ } finally {
184
+ restoring = false;
185
+ }
186
+ })
187
+ };
188
+ }
189
+ //#endregion
190
+ //#region src/text-edit.ts
191
+ /** Literal edits are deliberately conservative: computed and ambiguous text goes to the agent. */
192
+ function createTextEditor(entry, preview, verify) {
193
+ let busy = false;
194
+ async function restore(change) {
195
+ 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.");
196
+ await writeFile(change.file, change.before);
197
+ }
198
+ return { async edit(value) {
199
+ if (busy) throw new Error("A text edit is already running.");
200
+ busy = true;
201
+ try {
202
+ const current = preview();
203
+ if (value.revision !== current.revision) throw new Error("Preview changed. Select the text again.");
204
+ 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.");
205
+ 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.");
206
+ const root = await realpath(dirname(entry));
207
+ const files = await Promise.all([...new Set(current.dependencies)].filter((file) => /\.[cm]?[jt]sx?$/.test(file)).map(async (file) => {
208
+ const path = await realpath(file), local = relative(root, path);
209
+ if (local.startsWith("..") || isAbsolute(local) || local.split(/[\\/]/).includes("node_modules")) return null;
210
+ return {
211
+ file: path,
212
+ before: await readFile(path, "utf8")
213
+ };
214
+ }));
215
+ const candidates = [];
216
+ for (const item of files) {
217
+ if (!item) continue;
218
+ const source = ts.createSourceFile(item.file, item.before, ts.ScriptTarget.Latest, true, /[jt]sx$/.test(extname(item.file)) ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
219
+ const visit = (node) => {
220
+ if ((ts.isJsxText(node) ? node.text.split(/\r\n|\n|\r/).map((line, index, lines) => {
221
+ let value = line.replaceAll(" ", " ");
222
+ if (index) value = value.trimStart();
223
+ if (index < lines.length - 1) value = value.trimEnd();
224
+ return value;
225
+ }).filter(Boolean).join(" ") : ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) ? node.text : void 0) === value.before) {
226
+ const replacement = ts.isJsxText(node) || ts.isJsxAttribute(node.parent) ? "{" + JSON.stringify(value.after) + "}" : JSON.stringify(value.after);
227
+ const start = ts.isJsxText(node) ? node.pos : node.getStart(source);
228
+ candidates.push({
229
+ ...item,
230
+ after: item.before.slice(0, start) + replacement + item.before.slice(node.end)
231
+ });
232
+ }
233
+ ts.forEachChild(node, visit);
234
+ };
235
+ visit(source);
236
+ }
237
+ if (candidates.length !== 1) throw new Error("Text is computed or has multiple source matches. Use Apply with AI.");
238
+ const change = candidates[0];
239
+ 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.");
240
+ await writeFile(change.file, change.after);
241
+ try {
242
+ if (await verify()) throw new Error("The text change did not build.");
243
+ const next = preview();
244
+ 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.");
245
+ if (await realpath(change.file) !== change.file || await readFile(change.file, "utf8") !== change.after) throw new Error("Source changed during editing.");
246
+ } catch (error) {
247
+ await restore(change);
248
+ await verify();
249
+ throw error;
250
+ }
251
+ } finally {
252
+ busy = false;
253
+ }
254
+ } };
255
+ }
256
+ //#endregion
257
+ //#region src/visual-review.ts
258
+ /** Each session compares against its own turn's preview, including offscreen slides. */
259
+ function createVisualReviewer(entry, slides) {
260
+ let before = [];
261
+ let rounds = 0;
262
+ return {
263
+ begin() {
264
+ before = slides().slice();
265
+ rounds = 0;
266
+ },
267
+ async next() {
268
+ const current = slides().slice();
269
+ const changed = current.flatMap((svg, index) => svg !== before[index] ? [{
270
+ svg,
271
+ index
272
+ }] : []);
273
+ if (!changed.length) return;
274
+ if (rounds >= 3) throw new Error("Visual review reached its correction limit. Review the latest slide manually before continuing.");
275
+ const round = rounds + 1;
276
+ let browser;
277
+ try {
278
+ browser = await chromium.launch({
279
+ channel: process.env.PLAYWRIGHT_CHANNEL || "chrome",
280
+ headless: true
281
+ });
282
+ } catch {
283
+ try {
284
+ browser = await chromium.launch({ headless: true });
285
+ } catch {
286
+ 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.");
287
+ }
288
+ }
289
+ try {
290
+ const directory = join(dirname(entry), ".office-kit", "reviews", randomUUID());
291
+ await mkdir(directory, { recursive: true });
292
+ const page = await browser.newPage({
293
+ viewport: {
294
+ width: 1600,
295
+ height: 1e3
296
+ },
297
+ deviceScaleFactor: 1
298
+ });
299
+ await page.route("**/*", (route) => route.abort());
300
+ const images = [];
301
+ for (const { svg, index } of changed) {
302
+ 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);
303
+ await page.evaluate(() => document.fonts.ready.then(() => void 0));
304
+ const file = join(directory, `slide-${index + 1}.png`);
305
+ await page.locator("svg").first().screenshot({
306
+ path: file,
307
+ timeout: 15e3
308
+ });
309
+ images.push(file);
310
+ }
311
+ 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)}`;
312
+ await writeFile(join(directory, "review.txt"), prompt);
313
+ before = current;
314
+ rounds = round;
315
+ return {
316
+ prompt,
317
+ images
318
+ };
319
+ } finally {
320
+ await browser.close();
321
+ }
322
+ }
323
+ };
324
+ }
325
+ //#endregion
11
326
  //#region src/styles.ts
12
327
  const previewStyles = `*{box-sizing:border-box}
13
328
  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 +392,8 @@ const page = `<!doctype html>
77
392
  <link rel="stylesheet" href="/terminal.css">
78
393
  <style>
79
394
  ${previewStyles}
395
+ .slide-edit-tools{display:flex;gap:6px;padding:8px 14px;flex-shrink:0}.slide-edit-tools{align-items:center;flex-wrap:wrap}.slide-edit-tools span{font-size:11px;color:#625873;flex:1}.slide-edit-tools small{font-size:11px;color:#634396}.slide-hover{position:fixed;border:1px solid #b094ff;pointer-events:none;z-index:19;border-radius:2px}.slide-text-input{position:fixed;z-index:22;resize:none;background:#202438;color:#fff;border:2px solid #b094ff;border-radius:3px;padding:0;box-shadow:0 0 0 3px #b094ff30;line-height:1.15}.slide-text-input[hidden],.slide-hover[hidden]{display:none}.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}
396
+
80
397
  #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
398
  </style>
82
399
  <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 +404,7 @@ ${previewStyles}
87
404
  <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
405
  <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
406
  </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>
407
+ <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
408
  <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
409
  <script>
93
410
  let state={slides:[],error:null,aspectRatio:16/9},index=0,urls=[],presenting=false;
@@ -236,7 +553,7 @@ async function refresh(){
236
553
  catch{if(id===refreshId)byId('status').textContent='Reconnecting…';}
237
554
  }
238
555
  byId('toggle-chat').onclick=()=>{const hidden=document.body.classList.toggle('chat-hidden');byId('toggle-chat').setAttribute('aria-expanded',String(!hidden));resize();};
239
- const events=new EventSource('/events');events.onmessage=event=>{if(event.data==='chat'){window.dispatchEvent(new Event('agent-chat'));return;}if(event.data==='ready'){delete state.revision;}void refresh();};events.onerror=()=>{byId('status').textContent='Reconnecting…'};refresh();
556
+ const events=new EventSource('/events');events.onmessage=event=>{if(event.data==='history'){window.dispatchEvent(new Event('agent-history'));return;}if(event.data==='chat'){window.dispatchEvent(new Event('agent-chat'));return;}if(event.data==='ready'){delete state.revision;}void refresh();};events.onerror=()=>{byId('status').textContent='Reconnecting…'};refresh();
240
557
  <\/script><script type="module" src="/terminal.js"><\/script></html>`;
241
558
  //#endregion
242
559
  //#region src/agent-page.ts
@@ -257,6 +574,10 @@ window.addEventListener('message',event=>{
257
574
  if(event.origin!==location.origin||event.source!==parent)return;
258
575
  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
576
  if(event.data?.type==='chat')void refreshChat();
577
+ if(event.data?.type==='inline-edit'&&byId('chat-provider').value==='codex'){
578
+ 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);}})();
579
+ }
580
+
260
581
  });
261
582
  parent.postMessage({type:'agent-ready'},location.origin);
262
583
  let chatRequest=0,chatBusy=false,lastMessages='';
@@ -316,8 +637,9 @@ Choose foreground and actual background colors together. Explicitly set readable
316
637
  //#region src/terminal.ts
317
638
  const REQUEST_LIMIT = 128e3;
318
639
  /** A real interactive Claude session, shared across reloads of its owning tab. */
319
- function createTerminal(entry, busy = () => false, base = "", verify = async () => null) {
640
+ function createTerminal(entry, busy = () => false, base = "", verify = async () => null, review, history) {
320
641
  let repairs = 0;
642
+ let working = false;
321
643
  let process;
322
644
  let owner = "";
323
645
  let output = "";
@@ -387,13 +709,45 @@ function createTerminal(entry, busy = () => false, base = "", verify = async ()
387
709
  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
710
  });
389
711
  } else {
390
- status = error ? "Preview still has errors after 3 repair attempts." : "Preview build verified";
712
+ if (!error && review) try {
713
+ const visual = await review.next();
714
+ if (closing || stopping || !process) {
715
+ json(200, {});
716
+ return;
717
+ }
718
+ if (visual) {
719
+ status = "Reviewing slide screenshots…";
720
+ json(200, {
721
+ decision: "block",
722
+ reason: visual.prompt
723
+ });
724
+ emit("state", snapshot());
725
+ return;
726
+ }
727
+ } catch (cause) {
728
+ error = cause instanceof Error ? cause.message : String(cause);
729
+ }
730
+ try {
731
+ await history?.end();
732
+ } catch (cause) {
733
+ error = "History: " + String(cause);
734
+ }
735
+ working = false;
736
+ status = error ? "Preview verification incomplete: " + error : "Preview build verified";
391
737
  json(200, {});
392
738
  }
393
739
  emit("state", snapshot());
394
740
  return;
395
741
  }
742
+ try {
743
+ await history?.begin();
744
+ } catch (cause) {
745
+ json(409, { error: String(cause) });
746
+ return;
747
+ }
396
748
  repairs = 0;
749
+ working = true;
750
+ review?.begin();
397
751
  json(200, { hookSpecificOutput: {
398
752
  hookEventName: "UserPromptSubmit",
399
753
  additionalContext: `You are editing the local PowerPoint TSX project at ${resolve(entry)}.
@@ -465,7 +819,7 @@ Preview context captured with the latest terminal input: ${JSON.stringify(contex
465
819
  type: "http",
466
820
  url: `${origin}${base}/terminal/verify`,
467
821
  headers: { Authorization: `Bearer ${token}` },
468
- timeout: 40
822
+ timeout: 120
469
823
  }] }],
470
824
  UserPromptSubmit: [{ hooks: [{
471
825
  type: "http",
@@ -494,19 +848,53 @@ Preview context captured with the latest terminal input: ${JSON.stringify(contex
494
848
  output = (output + data).slice(-2e6);
495
849
  emit("output", data);
496
850
  });
497
- completion = new Promise((done) => process.onExit(({ exitCode }) => {
851
+ completion = new Promise((done) => process.onExit(async ({ exitCode }) => {
498
852
  process = void 0;
853
+ working = false;
499
854
  stopping = false;
500
855
  status = `Claude Code exited (${exitCode}). Start to open a new session.`;
856
+ try {
857
+ await history?.end();
858
+ } catch (cause) {
859
+ status += " History: " + String(cause);
860
+ }
501
861
  emit("state", snapshot());
502
862
  done();
503
863
  }));
504
864
  }
865
+ } else if (request.url === "/terminal/prompt") {
866
+ if (!process) throw new Error("Start Claude Code in the selected agent pane first.");
867
+ if (working) throw new Error("Claude Code is working. Wait for the current turn to finish.");
868
+ if (typeof value.message !== "string" || !value.message.trim() || value.message.length > 16e3 || [...value.message].some((character) => {
869
+ const code = character.charCodeAt(0);
870
+ return code < 32 && code !== 9 && code !== 10 || code === 127;
871
+ })) throw new Error("Invalid inline prompt");
872
+ updateFocus();
873
+ working = true;
874
+ try {
875
+ await history?.begin();
876
+ } catch (cause) {
877
+ working = false;
878
+ throw cause;
879
+ }
880
+ if (!process || closing || stopping) {
881
+ await history?.end();
882
+ working = false;
883
+ throw new Error("Claude Code stopped before submitting.");
884
+ }
885
+ process.write("\x1B[200~" + value.message + "\x1B[201~");
886
+ await new Promise((done) => setTimeout(done, 100));
887
+ if (!process || stopping) throw new Error("Claude Code stopped before submitting.");
888
+ process.write("\r");
505
889
  } else if (request.url === "/terminal/input") {
506
890
  if (!process) throw new Error("Start Claude Code first");
507
891
  if (typeof value.data !== "string" || value.data.length > 32e3) throw new Error("Invalid terminal input");
508
892
  updateFocus();
509
893
  process.write(value.data);
894
+ if (value.data.includes("")) {
895
+ working = false;
896
+ await history?.end();
897
+ }
510
898
  } else if (request.url === "/terminal/resize") {
511
899
  size();
512
900
  process?.resize(value.cols, value.rows);
@@ -540,7 +928,9 @@ const markdown = new MarkdownIt({
540
928
  });
541
929
  markdown.renderer.rules.image = (tokens, index) => markdown.utils.escapeHtml(tokens[index]?.content ?? "");
542
930
  /** One shared conversation and one editing process per dev server. */
543
- function createChat(entry, notify, busy = () => false, verify = async () => null) {
931
+ function createChat(entry, notify, busy = () => false, verify = async () => null, review, reviewImages = () => [], history) {
932
+ let starting = false;
933
+ let closing = false;
544
934
  let messages = [];
545
935
  let provider = "codex";
546
936
  let child;
@@ -554,7 +944,7 @@ function createChat(entry, notify, busy = () => false, verify = async () => null
554
944
  ...message.role === "assistant" ? { html: markdown.render(message.text) } : {}
555
945
  })),
556
946
  provider,
557
- running: !!child,
947
+ running: starting || !!child,
558
948
  status
559
949
  });
560
950
  function stop() {
@@ -576,14 +966,15 @@ function createChat(entry, notify, busy = () => false, verify = async () => null
576
966
  killTimer = setTimeout(() => kill("SIGKILL"), 2e3);
577
967
  notify();
578
968
  }
579
- function start(text, selected, context, repairs = 0) {
969
+ function start(text, selected, context, repairs = 0, images = [], reviewing = false) {
970
+ if (!repairs && !reviewing) review?.begin();
580
971
  provider = selected;
581
972
  const user = {
582
973
  role: "user",
583
974
  text,
584
975
  context
585
976
  };
586
- const history = messages.slice(-12).map((message) => ({
977
+ const conversation = messages.slice(-12).map((message) => ({
587
978
  ...message,
588
979
  text: message.text.slice(-12e3)
589
980
  }));
@@ -594,7 +985,7 @@ ${authoringGuidance}
594
985
  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.
595
986
  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.
596
987
  Respond in the user's language, briefly describing edits or answering their question. If permissions prevent an action, explain that instead of claiming success.
597
- Recent conversation (JSON): ${JSON.stringify(history)}
988
+ Recent conversation (JSON): ${JSON.stringify(conversation)}
598
989
  Current request and preview context (JSON): ${JSON.stringify(user)}`;
599
990
  messages.push(user);
600
991
  const answer = {
@@ -605,14 +996,16 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
605
996
  if (messages.length > 100) messages = messages.slice(-100);
606
997
  status = "Working…";
607
998
  stopping = false;
608
- child = spawn(selected, [
999
+ const args = [
609
1000
  "exec",
610
1001
  "--json",
611
1002
  "--sandbox",
612
1003
  "workspace-write",
613
1004
  "--skip-git-repo-check",
614
1005
  "-"
615
- ], {
1006
+ ];
1007
+ for (const image of images) args.splice(args.length - 1, 0, "--image", image);
1008
+ child = spawn(selected, args, {
616
1009
  cwd: dirname(resolve(entry)),
617
1010
  stdio: [
618
1011
  "pipe",
@@ -684,12 +1077,30 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
684
1077
  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
1078
  ...context,
686
1079
  buildError
687
- }, repairs + 1);
1080
+ }, repairs + 1, [], reviewing);
688
1081
  await completion;
689
1082
  done();
690
1083
  return;
691
1084
  }
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";
1085
+ let reviewFailure = "";
1086
+ if (!stopping && !buildError && review) try {
1087
+ const visual = await review.next();
1088
+ if (!stopping && visual) {
1089
+ child = void 0;
1090
+ start(visual.prompt, selected, context, 0, visual.images, true);
1091
+ await completion;
1092
+ done();
1093
+ return;
1094
+ }
1095
+ } catch (error) {
1096
+ reviewFailure = error instanceof Error ? error.message : String(error);
1097
+ }
1098
+ 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";
1099
+ }
1100
+ try {
1101
+ await history?.end();
1102
+ } catch (cause) {
1103
+ status = "History: " + String(cause);
693
1104
  }
694
1105
  if (status !== "Done") append(status);
695
1106
  child = void 0;
@@ -732,7 +1143,7 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
732
1143
  json(200, snapshot());
733
1144
  return;
734
1145
  }
735
- if (child) {
1146
+ if (child || starting || closing) {
736
1147
  json(409, { error: "An edit is already running" });
737
1148
  return;
738
1149
  }
@@ -756,7 +1167,17 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
756
1167
  return;
757
1168
  }
758
1169
  const context = focus(value.slide, value.revision);
759
- start(value.message.trim(), value.provider, context);
1170
+ starting = true;
1171
+ try {
1172
+ await history?.begin();
1173
+ if (closing) {
1174
+ await history?.end();
1175
+ throw new Error("Agent is closing");
1176
+ }
1177
+ start(value.message.trim(), value.provider, context, 0, reviewImages(value.message.trim()));
1178
+ } finally {
1179
+ starting = false;
1180
+ }
760
1181
  json(202, snapshot());
761
1182
  } catch (cause) {
762
1183
  json(400, { error: cause instanceof Error ? cause.message : "Invalid request" });
@@ -764,8 +1185,9 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
764
1185
  }
765
1186
  return {
766
1187
  handle,
767
- isRunning: () => !!child,
1188
+ isRunning: () => starting || !!child,
768
1189
  async close() {
1190
+ closing = true;
769
1191
  stop();
770
1192
  await completion;
771
1193
  }
@@ -784,13 +1206,23 @@ async function serveDeck(entry, port = 4173) {
784
1206
  let closed = false;
785
1207
  const clients = /* @__PURE__ */ new Set();
786
1208
  const sessions = /* @__PURE__ */ new Map();
1209
+ const history = await createHistory(dirname(resolve(entry)), () => {
1210
+ for (const client of clients) client.write("data: history\n\n");
1211
+ });
1212
+ let textEdits = 0;
1213
+ const capturedReviews = /* @__PURE__ */ new Map();
787
1214
  function session(id) {
788
1215
  let result = sessions.get(id);
789
1216
  if (!result) {
1217
+ const review = createVisualReviewer(resolve(entry), () => latest?.slides ?? []);
1218
+ const turnHistory = {
1219
+ begin: () => history.begin("agent:" + id, "AI edit"),
1220
+ end: () => history.end("agent:" + id)
1221
+ };
790
1222
  const chat = createChat(entry, () => {
791
1223
  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);
1224
+ }, () => terminal.isRunning(), verify, review, (prompt) => capturedReviews.get(prompt) ?? [], turnHistory);
1225
+ const terminal = createTerminal(entry, () => chat.isRunning(), id ? "/agents/" + id : "", verify, review, turnHistory);
794
1226
  result = {
795
1227
  chat,
796
1228
  terminal,
@@ -800,6 +1232,13 @@ async function serveDeck(entry, port = 4173) {
800
1232
  }
801
1233
  return result;
802
1234
  }
1235
+ const textEditor = createTextEditor(resolve(entry), () => {
1236
+ if (!latest || building || pending || timer || error) throw new Error("Wait for a successful preview build.");
1237
+ return {
1238
+ ...latest,
1239
+ revision
1240
+ };
1241
+ }, verify);
803
1242
  const server = createServer((request, response) => {
804
1243
  const host = request.headers.host;
805
1244
  if (host !== `127.0.0.1:${actualPort}` && host !== `localhost:${actualPort}`) {
@@ -807,11 +1246,63 @@ async function serveDeck(entry, port = 4173) {
807
1246
  return;
808
1247
  }
809
1248
  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))?$/);
1249
+ const match = request.url?.match(/^\/agents\/([\w-]{1,64})(\/(?:chat(?:\/(?:stop|reset))?|terminal\/(?:start|input|prompt|resize|stop|events|context|verify)|close))?$/);
811
1250
  if (request.url?.startsWith("/agents/") && !match) {
812
1251
  response.writeHead(404).end();
813
1252
  return;
814
1253
  }
1254
+ if (request.url === "/history" && request.method === "GET") {
1255
+ response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(history.state()));
1256
+ return;
1257
+ }
1258
+ if (request.url === "/text-edit" || request.url === "/history/undo" || request.url === "/history/redo") {
1259
+ if (request.method !== "POST" || request.headers.origin !== `http://${host}` || request.headers["content-type"] !== "application/json") {
1260
+ response.writeHead(403).end();
1261
+ return;
1262
+ }
1263
+ (async () => {
1264
+ let body = "";
1265
+ request.setEncoding("utf8");
1266
+ for await (const chunk of request) {
1267
+ body += chunk;
1268
+ if (Buffer.byteLength(body) > 1e5) throw new Error("Request too large");
1269
+ }
1270
+ if (request.url !== "/text-edit") {
1271
+ await history.move(request.url === "/history/undo" ? "undo" : "redo");
1272
+ const buildError = await verify();
1273
+ response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({
1274
+ ...history.state(),
1275
+ buildError
1276
+ }));
1277
+ return;
1278
+ }
1279
+ const manualReview = createVisualReviewer(resolve(entry), () => latest?.slides ?? []);
1280
+ manualReview.begin();
1281
+ const editId = "text:" + ++textEdits;
1282
+ await history.begin(editId, "Text edit");
1283
+ try {
1284
+ await textEditor.edit(JSON.parse(body));
1285
+ } finally {
1286
+ await history.end(editId);
1287
+ }
1288
+ let review;
1289
+ let reviewError;
1290
+ try {
1291
+ review = await manualReview.next();
1292
+ if (review) {
1293
+ capturedReviews.set(review.prompt, review.images);
1294
+ if (capturedReviews.size > 64) capturedReviews.delete(capturedReviews.keys().next().value);
1295
+ }
1296
+ } catch (cause) {
1297
+ reviewError = cause instanceof Error ? cause.message : String(cause);
1298
+ }
1299
+ response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({
1300
+ review,
1301
+ reviewError
1302
+ }));
1303
+ })().catch((cause) => response.writeHead(400, { "Content-Type": "application/json" }).end(JSON.stringify({ error: cause instanceof Error ? cause.message : String(cause) })));
1304
+ return;
1305
+ }
815
1306
  const id = match?.[1] ?? "";
816
1307
  if (match) {
817
1308
  if (request.headers["sec-fetch-site"] === "cross-site" || request.headers.origin && request.headers.origin !== `http://${host}`) {
@@ -944,6 +1435,7 @@ async function serveDeck(entry, port = 4173) {
944
1435
  if (started === generation) error = cause instanceof Error ? cause.stack ?? cause.message : String(cause);
945
1436
  } finally {
946
1437
  building = false;
1438
+ history.capture().catch(() => {});
947
1439
  for (const client of clients) client.write("data: updated\n\n");
948
1440
  if (pending && !timer) {
949
1441
  pending = false;
@@ -959,7 +1451,7 @@ async function serveDeck(entry, port = 4173) {
959
1451
  "dist",
960
1452
  ".office-kit"
961
1453
  ].includes(part))) return;
962
- if (!/\.([cm]?[jt]sx?|json|pptx|png|jpe?g|gif|bmp|tiff?|emf|wmf|svg)$/i.test(filename)) return;
1454
+ if (!historyFile(filename)) return;
963
1455
  generation++;
964
1456
  builder.cancel();
965
1457
  clearTimeout(timer);
@@ -1015,7 +1507,7 @@ try {
1015
1507
  if (args.length && (args.length !== 2 || !option || args[0] !== option || !args[1])) throw new Error(`Unexpected arguments: ${args.join(" ")}\n${usage}`);
1016
1508
  if (command === "init") {
1017
1509
  const directory = await initProject(entry);
1018
- console.log(`Created ${directory}\nRun npm install, then npm run dev inside that directory.`);
1510
+ console.log(`Created ${directory}\nInside that directory, run npm install once.\nStart development: npm run dev (or npx office-pptx dev deck.tsx).`);
1019
1511
  } else if (command === "inspect") console.log(JSON.stringify(await inspectTemplate(entry), null, 2));
1020
1512
  else if (command === "build") {
1021
1513
  const output = resolve(args[1] ?? "deck.pptx");