@office-kit/pptx-dev 0.7.0 → 0.8.2

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,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
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, mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
4
+ import { chmod, lstat, mkdir, readFile, readdir, realpath, stat, unlink, writeFile } from "node:fs/promises";
5
5
  import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import { watch } from "node:fs";
7
7
  import ts from "typescript";
@@ -10,87 +10,248 @@ import { chromium } from "playwright";
10
10
  import { createServer } from "node:http";
11
11
  import MarkdownIt from "markdown-it";
12
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
13
190
  //#region src/text-edit.ts
14
191
  /** Literal edits are deliberately conservative: computed and ambiguous text goes to the agent. */
15
192
  function createTextEditor(entry, preview, verify) {
16
193
  let busy = false;
17
- let undo;
18
194
  async function restore(change) {
19
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.");
20
196
  await writeFile(change.file, change.before);
21
197
  }
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;
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);
79
236
  }
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;
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);
85
241
  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;
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;
91
250
  }
251
+ } finally {
252
+ busy = false;
92
253
  }
93
- };
254
+ } };
94
255
  }
95
256
  //#endregion
96
257
  //#region src/visual-review.ts
@@ -231,7 +392,7 @@ const page = `<!doctype html>
231
392
  <link rel="stylesheet" href="/terminal.css">
232
393
  <style>
233
394
  ${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}
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}
235
396
 
236
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}
237
398
  </style>
@@ -392,7 +553,7 @@ async function refresh(){
392
553
  catch{if(id===refreshId)byId('status').textContent='Reconnecting…';}
393
554
  }
394
555
  byId('toggle-chat').onclick=()=>{const hidden=document.body.classList.toggle('chat-hidden');byId('toggle-chat').setAttribute('aria-expanded',String(!hidden));resize();};
395
- 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();
396
557
  <\/script><script type="module" src="/terminal.js"><\/script></html>`;
397
558
  //#endregion
398
559
  //#region src/agent-page.ts
@@ -476,7 +637,7 @@ Choose foreground and actual background colors together. Explicitly set readable
476
637
  //#region src/terminal.ts
477
638
  const REQUEST_LIMIT = 128e3;
478
639
  /** A real interactive Claude session, shared across reloads of its owning tab. */
479
- function createTerminal(entry, busy = () => false, base = "", verify = async () => null, review) {
640
+ function createTerminal(entry, busy = () => false, base = "", verify = async () => null, review, history) {
480
641
  let repairs = 0;
481
642
  let working = false;
482
643
  let process;
@@ -566,6 +727,11 @@ function createTerminal(entry, busy = () => false, base = "", verify = async ()
566
727
  } catch (cause) {
567
728
  error = cause instanceof Error ? cause.message : String(cause);
568
729
  }
730
+ try {
731
+ await history?.end();
732
+ } catch (cause) {
733
+ error = "History: " + String(cause);
734
+ }
569
735
  working = false;
570
736
  status = error ? "Preview verification incomplete: " + error : "Preview build verified";
571
737
  json(200, {});
@@ -573,6 +739,12 @@ function createTerminal(entry, busy = () => false, base = "", verify = async ()
573
739
  emit("state", snapshot());
574
740
  return;
575
741
  }
742
+ try {
743
+ await history?.begin();
744
+ } catch (cause) {
745
+ json(409, { error: String(cause) });
746
+ return;
747
+ }
576
748
  repairs = 0;
577
749
  working = true;
578
750
  review?.begin();
@@ -676,11 +848,16 @@ Preview context captured with the latest terminal input: ${JSON.stringify(contex
676
848
  output = (output + data).slice(-2e6);
677
849
  emit("output", data);
678
850
  });
679
- completion = new Promise((done) => process.onExit(({ exitCode }) => {
851
+ completion = new Promise((done) => process.onExit(async ({ exitCode }) => {
680
852
  process = void 0;
681
853
  working = false;
682
854
  stopping = false;
683
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
+ }
684
861
  emit("state", snapshot());
685
862
  done();
686
863
  }));
@@ -694,6 +871,17 @@ Preview context captured with the latest terminal input: ${JSON.stringify(contex
694
871
  })) throw new Error("Invalid inline prompt");
695
872
  updateFocus();
696
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
+ }
697
885
  process.write("\x1B[200~" + value.message + "\x1B[201~");
698
886
  await new Promise((done) => setTimeout(done, 100));
699
887
  if (!process || stopping) throw new Error("Claude Code stopped before submitting.");
@@ -702,8 +890,11 @@ Preview context captured with the latest terminal input: ${JSON.stringify(contex
702
890
  if (!process) throw new Error("Start Claude Code first");
703
891
  if (typeof value.data !== "string" || value.data.length > 32e3) throw new Error("Invalid terminal input");
704
892
  updateFocus();
705
- if (value.data.includes("")) working = false;
706
893
  process.write(value.data);
894
+ if (value.data.includes("")) {
895
+ working = false;
896
+ await history?.end();
897
+ }
707
898
  } else if (request.url === "/terminal/resize") {
708
899
  size();
709
900
  process?.resize(value.cols, value.rows);
@@ -737,7 +928,9 @@ const markdown = new MarkdownIt({
737
928
  });
738
929
  markdown.renderer.rules.image = (tokens, index) => markdown.utils.escapeHtml(tokens[index]?.content ?? "");
739
930
  /** One shared conversation and one editing process per dev server. */
740
- function createChat(entry, notify, busy = () => false, verify = async () => null, review, reviewImages = () => []) {
931
+ function createChat(entry, notify, busy = () => false, verify = async () => null, review, reviewImages = () => [], history) {
932
+ let starting = false;
933
+ let closing = false;
741
934
  let messages = [];
742
935
  let provider = "codex";
743
936
  let child;
@@ -751,7 +944,7 @@ function createChat(entry, notify, busy = () => false, verify = async () => null
751
944
  ...message.role === "assistant" ? { html: markdown.render(message.text) } : {}
752
945
  })),
753
946
  provider,
754
- running: !!child,
947
+ running: starting || !!child,
755
948
  status
756
949
  });
757
950
  function stop() {
@@ -781,7 +974,7 @@ function createChat(entry, notify, busy = () => false, verify = async () => null
781
974
  text,
782
975
  context
783
976
  };
784
- const history = messages.slice(-12).map((message) => ({
977
+ const conversation = messages.slice(-12).map((message) => ({
785
978
  ...message,
786
979
  text: message.text.slice(-12e3)
787
980
  }));
@@ -792,7 +985,7 @@ ${authoringGuidance}
792
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.
793
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.
794
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.
795
- Recent conversation (JSON): ${JSON.stringify(history)}
988
+ Recent conversation (JSON): ${JSON.stringify(conversation)}
796
989
  Current request and preview context (JSON): ${JSON.stringify(user)}`;
797
990
  messages.push(user);
798
991
  const answer = {
@@ -904,6 +1097,11 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
904
1097
  }
905
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";
906
1099
  }
1100
+ try {
1101
+ await history?.end();
1102
+ } catch (cause) {
1103
+ status = "History: " + String(cause);
1104
+ }
907
1105
  if (status !== "Done") append(status);
908
1106
  child = void 0;
909
1107
  notify();
@@ -945,7 +1143,7 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
945
1143
  json(200, snapshot());
946
1144
  return;
947
1145
  }
948
- if (child) {
1146
+ if (child || starting || closing) {
949
1147
  json(409, { error: "An edit is already running" });
950
1148
  return;
951
1149
  }
@@ -969,7 +1167,17 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
969
1167
  return;
970
1168
  }
971
1169
  const context = focus(value.slide, value.revision);
972
- start(value.message.trim(), value.provider, context, 0, reviewImages(value.message.trim()));
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
+ }
973
1181
  json(202, snapshot());
974
1182
  } catch (cause) {
975
1183
  json(400, { error: cause instanceof Error ? cause.message : "Invalid request" });
@@ -977,8 +1185,9 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
977
1185
  }
978
1186
  return {
979
1187
  handle,
980
- isRunning: () => !!child,
1188
+ isRunning: () => starting || !!child,
981
1189
  async close() {
1190
+ closing = true;
982
1191
  stop();
983
1192
  await completion;
984
1193
  }
@@ -997,15 +1206,23 @@ async function serveDeck(entry, port = 4173) {
997
1206
  let closed = false;
998
1207
  const clients = /* @__PURE__ */ new Set();
999
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;
1000
1213
  const capturedReviews = /* @__PURE__ */ new Map();
1001
1214
  function session(id) {
1002
1215
  let result = sessions.get(id);
1003
1216
  if (!result) {
1004
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
+ };
1005
1222
  const chat = createChat(entry, () => {
1006
1223
  for (const client of clients) client.write("data: chat\n\n");
1007
- }, () => terminal.isRunning(), verify, review, (prompt) => capturedReviews.get(prompt) ?? []);
1008
- const terminal = createTerminal(entry, () => chat.isRunning(), id ? "/agents/" + id : "", verify, review);
1224
+ }, () => terminal.isRunning(), verify, review, (prompt) => capturedReviews.get(prompt) ?? [], turnHistory);
1225
+ const terminal = createTerminal(entry, () => chat.isRunning(), id ? "/agents/" + id : "", verify, review, turnHistory);
1009
1226
  result = {
1010
1227
  chat,
1011
1228
  terminal,
@@ -1034,7 +1251,11 @@ async function serveDeck(entry, port = 4173) {
1034
1251
  response.writeHead(404).end();
1035
1252
  return;
1036
1253
  }
1037
- if (request.url === "/text-edit" || request.url === "/text-edit/undo") {
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") {
1038
1259
  if (request.method !== "POST" || request.headers.origin !== `http://${host}` || request.headers["content-type"] !== "application/json") {
1039
1260
  response.writeHead(403).end();
1040
1261
  return;
@@ -1046,10 +1267,24 @@ async function serveDeck(entry, port = 4173) {
1046
1267
  body += chunk;
1047
1268
  if (Buffer.byteLength(body) > 1e5) throw new Error("Request too large");
1048
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
+ }
1049
1279
  const manualReview = createVisualReviewer(resolve(entry), () => latest?.slides ?? []);
1050
1280
  manualReview.begin();
1051
- if (request.url === "/text-edit/undo") await textEditor.undo();
1052
- else await textEditor.edit(JSON.parse(body));
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
+ }
1053
1288
  let review;
1054
1289
  let reviewError;
1055
1290
  try {
@@ -1200,6 +1435,7 @@ async function serveDeck(entry, port = 4173) {
1200
1435
  if (started === generation) error = cause instanceof Error ? cause.stack ?? cause.message : String(cause);
1201
1436
  } finally {
1202
1437
  building = false;
1438
+ history.capture().catch(() => {});
1203
1439
  for (const client of clients) client.write("data: updated\n\n");
1204
1440
  if (pending && !timer) {
1205
1441
  pending = false;
@@ -1215,7 +1451,7 @@ async function serveDeck(entry, port = 4173) {
1215
1451
  "dist",
1216
1452
  ".office-kit"
1217
1453
  ].includes(part))) return;
1218
- if (!/\.([cm]?[jt]sx?|json|pptx|png|jpe?g|gif|bmp|tiff?|emf|wmf|svg)$/i.test(filename)) return;
1454
+ if (!historyFile(filename)) return;
1219
1455
  generation++;
1220
1456
  builder.cancel();
1221
1457
  clearTimeout(timer);