@office-kit/pptx-dev 0.2.4 → 0.4.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 +66 -3
- package/dist/cli.mjs +510 -7
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +1 -0
- package/dist/terminal-client.css +285 -0
- package/dist/terminal-client.js +53 -0
- package/dist/worker.mjs +2 -1
- package/dist/worker.mjs.map +1 -1
- package/package.json +8 -3
package/dist/cli.mjs
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { a as createDeckBuilder, i as initProject, n as exportDeck, r as inspectTemplate } from "./src-31HzWTvZ.mjs";
|
|
3
|
-
import {
|
|
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
6
|
import { watch } from "node:fs";
|
|
5
7
|
import { createServer } from "node:http";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import MarkdownIt from "markdown-it";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
6
11
|
//#region src/page.ts
|
|
7
12
|
const page = `<!doctype html>
|
|
8
13
|
<html lang="en">
|
|
9
14
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
|
10
15
|
<title>Office Kit — PowerPoint preview</title>
|
|
16
|
+
<link rel="stylesheet" href="/terminal.css">
|
|
11
17
|
<style>
|
|
12
18
|
*{box-sizing:border-box}
|
|
13
19
|
body{margin:0;height:100dvh;overflow:hidden;background:#e9ecf1;color:#202735;font:13px system-ui;display:grid;grid-template-rows:60px minmax(0,1fr) 42px}
|
|
@@ -17,7 +23,7 @@ button:hover:not(:disabled),.download:hover{background:#f0f3f9}button:disabled{o
|
|
|
17
23
|
header{display:flex;align-items:center;gap:16px;padding:0 20px;background:#fff;border-bottom:1px solid #d4d9e2;min-width:0}
|
|
18
24
|
.brand{font-weight:700;font-size:17px;white-space:nowrap}.badge{font-size:11px;color:#616b7c;background:#f1f3f7;border-radius:4px;padding:3px 6px}
|
|
19
25
|
#status{flex:1;color:#667085;min-width:0}#present{background:#293c73;color:#fff;border-color:#293c73}
|
|
20
|
-
.workspace{display:grid;grid-template-columns:
|
|
26
|
+
.workspace{display:grid;grid-template-columns:190px minmax(0,1fr) minmax(380px,38vw);min-height:0}
|
|
21
27
|
.filmstrip{background:#f7f8fa;border-right:1px solid #d4d9e2;overflow:auto;overscroll-behavior:contain;padding:16px 12px}
|
|
22
28
|
.filmstrip h2{margin:0 0 12px 26px;text-transform:uppercase;letter-spacing:.1em;font-size:10px;color:#7c8596;font-weight:600}
|
|
23
29
|
#thumbnails{display:flex;flex-direction:column;gap:12px;margin:0;padding:0;list-style:none}
|
|
@@ -36,18 +42,48 @@ footer{display:flex;align-items:center;gap:14px;padding:0 16px;background:#fff;b
|
|
|
36
42
|
#count{min-width:90px}footer .hint{flex:1;color:#737d8e}footer button{padding:3px 10px}footer select{padding:3px 8px}
|
|
37
43
|
#presentation-controls{display:none}
|
|
38
44
|
body.presenting{grid-template-rows:minmax(0,1fr);background:#111}
|
|
39
|
-
.presenting header,.presenting footer,.presenting .filmstrip{display:none}
|
|
45
|
+
.presenting header,.presenting footer,.presenting .filmstrip,.presenting #chat{display:none}
|
|
40
46
|
.presenting .workspace{grid-template-columns:minmax(0,1fr)}.presenting #stage{padding:0;background:#111}.presenting #slide{box-shadow:none}
|
|
41
47
|
.presenting #presentation-controls{display:flex;position:fixed;bottom:16px;left:50%;transform:translateX(-50%);align-items:center;gap:12px;background:#202735e8;color:white;padding:6px;border-radius:8px;opacity:0;transition:opacity .15s}
|
|
42
48
|
.presenting #presentation-controls:hover,.presenting #presentation-controls:focus-within{opacity:1}
|
|
43
49
|
#presentation-controls button{background:transparent;color:white;border-color:#5d6575}
|
|
44
50
|
@media(max-width:700px){.workspace{grid-template-columns:140px minmax(0,1fr)}.filmstrip{padding:12px 5px}header{padding:0 12px;gap:10px}.badge,footer .hint{display:none}#stage{padding:16px}footer{gap:8px}#status{font-size:11px}.download{padding:7px 8px}}
|
|
51
|
+
#chat{min-height:0;display:flex;flex-direction:column;background:#fff;border-left:1px solid #d4d9e2}
|
|
52
|
+
.chat-heading{display:flex;align-items:center;gap:8px;padding:16px;border-bottom:1px solid #e6e9ef}.chat-heading strong{flex:1}.chat-heading button{padding:5px 8px}
|
|
53
|
+
#messages{flex:1;overflow:auto;padding:16px;display:flex;flex-direction:column;gap:16px;overscroll-behavior:contain}
|
|
54
|
+
.chat-message{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.6}.chat-message.user{background:#eef2ff;border-radius:8px;padding:10px 12px}.chat-message small{display:block;color:#697386;font-size:11px;margin-bottom:4px}.chat-intro{color:#737d8e;line-height:1.7}
|
|
55
|
+
#chat-form{padding:14px;border-top:1px solid #e6e9ef}#chat-context{font-size:11px;color:#4967dd;margin-bottom:8px}#chat-input{font:inherit;resize:vertical;min-height:90px;max-height:200px;width:100%;padding:10px;border:1px solid #d4d9e2;border-radius:8px} .chat-actions{display:flex;gap:8px;margin-top:8px;align-items:center}.chat-actions select{min-width:0;flex:1}#chat-send{background:#293c73;color:white}#chat-status{padding:0 14px 12px;color:#667085;font-size:11px;white-space:pre-wrap;max-height:90px;overflow:auto}
|
|
56
|
+
body.chat-hidden .workspace{grid-template-columns:190px minmax(0,1fr)}body.chat-hidden #chat{display:none}
|
|
57
|
+
body.presenting .workspace{grid-template-columns:minmax(0,1fr)}
|
|
58
|
+
@media(max-width:1000px){.workspace{grid-template-columns:120px minmax(0,1fr) 380px}.filmstrip{padding:12px 5px}#stage{padding:16px}}
|
|
59
|
+
@media(max-width:700px){.workspace{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(160px,1fr) minmax(200px,1fr)}.filmstrip{display:none}#chat{border-top:1px solid #d4d9e2}.chat-heading{padding:8px 12px}#chat-form{padding:8px 12px}#chat-input{min-height:50px}body.chat-hidden .workspace,.presenting .workspace{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr)}.brand{font-size:14px}header{gap:6px}.download{font-size:11px}}
|
|
45
60
|
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}
|
|
61
|
+
|
|
62
|
+
[hidden]{display:none!important}
|
|
63
|
+
.chat-heading{padding:10px 14px}.chat-heading select{flex:1;min-width:0;font-weight:600;border:0;padding-left:0}
|
|
64
|
+
#chat-context{margin:0;padding:8px 14px;border-bottom:1px solid #e6e9ef;color:#667085;font-size:11px}
|
|
65
|
+
#claude-panel,#codex-panel{display:flex;flex-direction:column;flex:1;min-height:0}
|
|
66
|
+
#claude-panel{background:#171b24;color:#e4e8f0}
|
|
67
|
+
.terminal-toolbar{display:flex;align-items:center;gap:8px;padding:8px 12px;font-size:11px;min-height:40px}.terminal-toolbar span{flex:1;overflow-wrap:anywhere}.terminal-toolbar button{font-size:11px;padding:5px 8px;background:#262d3b;border-color:#45516a;color:#e4e8f0}
|
|
68
|
+
.terminal-toolbar button:hover:not(:disabled){background:#35415a}
|
|
69
|
+
#terminal{flex:1;min-height:0;overflow:hidden;padding:4px 8px 8px}.xterm{height:100%}
|
|
70
|
+
#chat-input{min-height:70px;resize:vertical}.chat-shortcut{flex:1;color:#9098a7;font-size:10px}
|
|
71
|
+
.markdown{white-space:normal;line-height:1.65}.markdown>:first-child{margin-top:0}.markdown>:last-child{margin-bottom:0}.markdown p{margin:0 0 .8em}.markdown pre{overflow:auto;padding:10px;background:#f3f5f8;border:1px solid #e6e9ef;border-radius:6px;white-space:pre}.markdown code{font-size:.9em;background:#f3f5f8;border-radius:3px;padding:1px 4px}.markdown pre code{padding:0}.markdown ul,.markdown ol{padding-left:22px}.markdown blockquote{margin-left:0;padding-left:12px;border-left:3px solid #d4d9e2;color:#667085}.markdown table{display:block;overflow:auto;border-collapse:collapse}.markdown th,.markdown td{padding:6px 8px;border:1px solid #d4d9e2}.markdown h1,.markdown h2,.markdown h3{font-size:1.1em}.markdown a{color:#3654c1;text-decoration:underline}
|
|
46
72
|
</style>
|
|
47
|
-
<header><span class="brand">Office Kit</span><span class="badge">Preview</span><span id="status" role="status">Building…</span><button id="present" disabled>Present</button><a class="download" href="/deck.pptx">Download PPTX</a></header>
|
|
73
|
+
<header><span class="brand">Office Kit</span><span class="badge">Preview</span><span id="status" role="status">Building…</span><button id="toggle-chat" aria-expanded="true" aria-controls="chat">Chat</button><button id="present" disabled>Present</button><a class="download" href="/deck.pptx">Download PPTX</a></header>
|
|
48
74
|
<div class="workspace">
|
|
49
75
|
<nav class="filmstrip" aria-label="Slides"><h2>Slides</h2><ol id="thumbnails"></ol></nav>
|
|
50
76
|
<main aria-label="Slide viewer"><pre id="error" role="alert" hidden></pre><div id="stage" tabindex="-1"><div id="empty">Waiting for slides…</div><div id="slide" hidden></div></div></main>
|
|
77
|
+
<aside id="chat" aria-label="Slide chat">
|
|
78
|
+
<div class="chat-heading"><select id="chat-provider" aria-label="AI provider"><option value="claude">Claude Code</option><option value="codex">Codex</option></select><button id="chat-reset" hidden>New chat</button></div>
|
|
79
|
+
<div id="chat-context">No slide selected</div>
|
|
80
|
+
<section id="claude-panel" aria-label="Claude Code terminal"><div class="terminal-toolbar"><span id="terminal-status" role="status">Start Claude Code to edit your slides.</span><button id="terminal-start">Start</button><button id="terminal-stop" hidden>End session</button></div><div id="terminal"></div></section>
|
|
81
|
+
<div id="codex-panel" hidden>
|
|
82
|
+
<div id="messages" role="log" aria-label="Conversation"><p class="chat-intro">Describe what you want to change. The current slide is included automatically. You can also ask about other slides or the whole deck.<br><br>Uses your locally installed Codex. Sign in through its CLI first. Sending a message allows it to edit this project.</p></div>
|
|
83
|
+
<form id="chat-form"><textarea id="chat-input" aria-label="Message" maxlength="16000" placeholder="Ask for a change…" required></textarea><div class="chat-actions"><span class="chat-shortcut">⌘ / Ctrl + Enter</span><button id="chat-stop" type="button" hidden>Stop</button><button id="chat-send" type="submit">Send</button></div></form>
|
|
84
|
+
<div id="chat-status" role="status">Ready</div>
|
|
85
|
+
</div>
|
|
86
|
+
</aside>
|
|
51
87
|
</div>
|
|
52
88
|
<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>
|
|
53
89
|
<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>
|
|
@@ -74,6 +110,8 @@ function resize(){
|
|
|
74
110
|
function selectSlide(next,focusThumbnail=false,reveal=true){
|
|
75
111
|
index=Math.max(0,Math.min(next,state.slides.length-1));
|
|
76
112
|
const count=state.slides.length?'Slide '+(index+1)+' of '+state.slides.length:'No slides';
|
|
113
|
+
byId('chat-context').textContent=state.slides.length?count:'Whole project';
|
|
114
|
+
byId('chat-context').dataset.focus=JSON.stringify({slide:state.slides.length?index:null,revision:state.revision??0});
|
|
77
115
|
byId('count').textContent=count;byId('present-count').textContent=count;
|
|
78
116
|
for(const id of ['prev','present-prev'])byId(id).disabled=index===0;
|
|
79
117
|
for(const id of ['next','present-next'])byId(id).disabled=index>=state.slides.length-1;
|
|
@@ -164,8 +202,447 @@ async function refresh(){
|
|
|
164
202
|
try{const response=await fetch('/state'+(state.revision===undefined?'':'?since='+state.revision));if(!response.ok)throw new Error('Preview unavailable');const updated=await response.json();if(id===refreshId){if(updated.changes){updated.slides=state.slides.slice(0,updated.count);updated.slides.length=updated.count;for(const [position,svg] of Object.entries(updated.changes))updated.slides[Number(position)]=svg;}update(updated);}}
|
|
165
203
|
catch{if(id===refreshId)byId('status').textContent='Reconnecting…';}
|
|
166
204
|
}
|
|
167
|
-
|
|
168
|
-
|
|
205
|
+
let chatRequest=0,chatBusy=false,lastMessages='';
|
|
206
|
+
function renderChat(chat){
|
|
207
|
+
chatBusy=chat.running;
|
|
208
|
+
byId('chat-send').disabled=chat.running;
|
|
209
|
+
byId('chat-reset').disabled=chat.running;
|
|
210
|
+
if(byId('chat-provider').value==='codex')byId('chat-provider').disabled=chat.running;
|
|
211
|
+
byId('chat-stop').hidden=!chat.running;
|
|
212
|
+
byId('chat-status').textContent=chat.status;
|
|
213
|
+
|
|
214
|
+
const serialized=JSON.stringify(chat.messages);
|
|
215
|
+
if(serialized===lastMessages)return;
|
|
216
|
+
lastMessages=serialized;
|
|
217
|
+
const log=byId('messages'),atBottom=log.scrollHeight-log.scrollTop-log.clientHeight<60;
|
|
218
|
+
log.replaceChildren();
|
|
219
|
+
if(!chat.messages.length){const intro=document.createElement('p');intro.className='chat-intro';intro.textContent='Ask for a focused slide edit or a change across the deck. Uses your locally installed CLI and its login. Sending allows edits to this project.';log.append(intro);}
|
|
220
|
+
for(const message of chat.messages){
|
|
221
|
+
const item=document.createElement('div'),label=document.createElement('small'),body=document.createElement('div');
|
|
222
|
+
item.className='chat-message '+message.role;
|
|
223
|
+
label.textContent=message.role==='user'?'You · '+(message.context?.slide?'Slide '+message.context.slide:'Whole project'):'Assistant';
|
|
224
|
+
if(message.role==='assistant'&&message.html){body.className='markdown';body.innerHTML=message.html;}else body.textContent=message.text||(chat.running?'Working…':'No response');item.append(label,body);log.append(item);
|
|
225
|
+
}
|
|
226
|
+
if(atBottom)log.scrollTop=log.scrollHeight;
|
|
227
|
+
}
|
|
228
|
+
async function refreshChat(){
|
|
229
|
+
const id=++chatRequest;
|
|
230
|
+
try{const response=await fetch('/chat');if(!response.ok)throw new Error('Chat unavailable');const chat=await response.json();if(id===chatRequest)renderChat(chat);}
|
|
231
|
+
catch(error){if(id===chatRequest)byId('chat-status').textContent=error.message;}
|
|
232
|
+
}
|
|
233
|
+
async function chatAction(path,payload){
|
|
234
|
+
++chatRequest;
|
|
235
|
+
const response=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
236
|
+
const result=await response.json();if(!response.ok)throw new Error(result.error||'Chat request failed');
|
|
237
|
+
await refreshChat();
|
|
238
|
+
}
|
|
239
|
+
byId('chat-form').onsubmit=async event=>{
|
|
240
|
+
event.preventDefault();if(chatBusy)return;
|
|
241
|
+
const input=byId('chat-input'),message=input.value.trim();if(!message)return;
|
|
242
|
+
chatBusy=true;byId('chat-send').disabled=true;
|
|
243
|
+
try{await chatAction('/chat',{message,provider:byId('chat-provider').value,slide:state.slides.length?index:null,revision:state.revision??0});if(input.value.trim()===message)input.value='';}
|
|
244
|
+
catch(error){byId('chat-status').textContent=error.message;chatBusy=false;byId('chat-send').disabled=false;}
|
|
245
|
+
};
|
|
246
|
+
byId('chat-input').onkeydown=event=>{if(event.key==='Enter'&&(event.metaKey||event.ctrlKey)&&!event.isComposing){event.preventDefault();byId('chat-form').requestSubmit();}};
|
|
247
|
+
for(const action of ['stop','reset'])byId('chat-'+action).onclick=()=>chatAction('/chat/'+action,{}).catch(error=>{byId('chat-status').textContent=error.message;});
|
|
248
|
+
byId('toggle-chat').onclick=()=>{const hidden=document.body.classList.toggle('chat-hidden');byId('toggle-chat').setAttribute('aria-expanded',String(!hidden));resize();};
|
|
249
|
+
byId('chat-provider').onchange=()=>{const claude=byId('chat-provider').value==='claude';byId('claude-panel').hidden=!claude;byId('codex-panel').hidden=claude;byId('chat-reset').hidden=claude;};
|
|
250
|
+
void refreshChat();
|
|
251
|
+
const events=new EventSource('/events');events.onmessage=event=>{if(event.data==='chat'){void refreshChat();return;}if(event.data==='ready'){delete state.revision;void refreshChat();}void refresh();};events.onerror=()=>{byId('status').textContent='Reconnecting…'};refresh();
|
|
252
|
+
<\/script><script type="module" src="/terminal.js"><\/script></html>`;
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region src/terminal.ts
|
|
255
|
+
const REQUEST_LIMIT = 128e3;
|
|
256
|
+
/** A real interactive Claude session, shared across reloads of its owning tab. */
|
|
257
|
+
function createTerminal(entry, busy = () => false) {
|
|
258
|
+
let process;
|
|
259
|
+
let owner = "";
|
|
260
|
+
let output = "";
|
|
261
|
+
let context;
|
|
262
|
+
let status = "Start Claude Code to edit your slides.";
|
|
263
|
+
let completion = Promise.resolve();
|
|
264
|
+
let closing = false;
|
|
265
|
+
let stopping = false;
|
|
266
|
+
let starting = false;
|
|
267
|
+
const token = randomBytes(32).toString("hex");
|
|
268
|
+
const clients = /* @__PURE__ */ new Set();
|
|
269
|
+
const snapshot = () => ({
|
|
270
|
+
running: !!process,
|
|
271
|
+
owner,
|
|
272
|
+
status
|
|
273
|
+
});
|
|
274
|
+
const emit = (event, value) => {
|
|
275
|
+
for (const client of clients) if (!client.write(`event: ${event}\ndata: ${JSON.stringify(value)}\n\n`)) {
|
|
276
|
+
clients.delete(client);
|
|
277
|
+
client.end();
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
async function stop() {
|
|
281
|
+
if (!process || stopping) return completion;
|
|
282
|
+
stopping = true;
|
|
283
|
+
status = "Stopping…";
|
|
284
|
+
emit("state", snapshot());
|
|
285
|
+
const running = process;
|
|
286
|
+
running.kill();
|
|
287
|
+
const timer = setTimeout(() => {
|
|
288
|
+
if (process === running) running.kill("SIGKILL");
|
|
289
|
+
}, 2e3);
|
|
290
|
+
await completion;
|
|
291
|
+
clearTimeout(timer);
|
|
292
|
+
}
|
|
293
|
+
async function handle(request, response, focus) {
|
|
294
|
+
const json = (code, value) => {
|
|
295
|
+
response.writeHead(code, { "Content-Type": "application/json" });
|
|
296
|
+
response.end(JSON.stringify(value));
|
|
297
|
+
};
|
|
298
|
+
if (request.url === "/terminal/context") {
|
|
299
|
+
if (request.method !== "POST" || request.headers.authorization !== `Bearer ${token}`) {
|
|
300
|
+
json(403, { error: "Invalid hook credentials" });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
request.resume();
|
|
304
|
+
json(200, { hookSpecificOutput: {
|
|
305
|
+
hookEventName: "UserPromptSubmit",
|
|
306
|
+
additionalContext: `You are editing the local PowerPoint TSX project at ${resolve(entry)}.
|
|
307
|
+
Read the project's authoring instructions. Make focused source patches and preserve unrelated work.
|
|
308
|
+
The preview rebuilds on save; do not start another dev server.
|
|
309
|
+
The focused slide is context, NOT a restriction. For "this slide", edit only its relevant source. For other slides or deck-wide requests, locate the relevant source or shared theme.
|
|
310
|
+
Slide numbers are 1-based. Verify source before editing; dependency paths are candidates, not an exact slide-to-file mapping.
|
|
311
|
+
Preview context captured with the latest terminal input: ${JSON.stringify(context)}`
|
|
312
|
+
} });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const origin = `http://${request.headers.host}`;
|
|
316
|
+
if (request.headers["sec-fetch-site"] === "cross-site" || request.headers.origin && request.headers.origin !== origin) {
|
|
317
|
+
json(403, { error: "Same-origin requests only" });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (request.method === "GET" && request.url === "/terminal/events") {
|
|
321
|
+
response.writeHead(200, { "Content-Type": "text/event-stream" });
|
|
322
|
+
response.write(`event: replay\ndata: ${JSON.stringify(output)}\n\n`);
|
|
323
|
+
response.write(`event: state\ndata: ${JSON.stringify(snapshot())}\n\n`);
|
|
324
|
+
clients.add(response);
|
|
325
|
+
request.on("close", () => clients.delete(response));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (request.method !== "POST" || request.headers.origin !== origin || request.headers["content-type"] !== "application/json") {
|
|
329
|
+
json(403, { error: "Same-origin JSON requests only" });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
let body = "";
|
|
334
|
+
request.setEncoding("utf8");
|
|
335
|
+
for await (const chunk of request) {
|
|
336
|
+
body += chunk;
|
|
337
|
+
if (Buffer.byteLength(body) > REQUEST_LIMIT) throw new Error("Request too large");
|
|
338
|
+
}
|
|
339
|
+
const value = JSON.parse(body);
|
|
340
|
+
if (!value || typeof value.client !== "string" || !/^[\w-]{1,64}$/.test(value.client)) throw new Error("Invalid terminal client");
|
|
341
|
+
if (closing || starting || stopping || process && owner !== value.client) {
|
|
342
|
+
json(409, { error: stopping ? "Claude Code is stopping" : "Claude Code is open in another tab. Use that tab to control it." });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const size = () => {
|
|
346
|
+
if (!Number.isInteger(value.cols) || value.cols < 20 || value.cols > 500 || !Number.isInteger(value.rows) || value.rows < 5 || value.rows > 300) throw new Error("Invalid terminal dimensions");
|
|
347
|
+
};
|
|
348
|
+
const updateFocus = () => {
|
|
349
|
+
if (!(value.slide === null || Number.isInteger(value.slide) && value.slide >= 0) || !Number.isInteger(value.revision)) throw new Error("Invalid preview context");
|
|
350
|
+
context = focus(value.slide, value.revision);
|
|
351
|
+
};
|
|
352
|
+
if (request.url === "/terminal/start") {
|
|
353
|
+
if (busy()) throw new Error("A Codex edit is already running");
|
|
354
|
+
size();
|
|
355
|
+
updateFocus();
|
|
356
|
+
if (!process) {
|
|
357
|
+
starting = true;
|
|
358
|
+
try {
|
|
359
|
+
if (globalThis.process.platform === "darwin") {
|
|
360
|
+
const helper = join(dirname(createRequire(import.meta.url).resolve("node-pty/package.json")), "prebuilds", `darwin-${globalThis.process.arch}`, "spawn-helper");
|
|
361
|
+
const info = await stat(helper).catch((error) => {
|
|
362
|
+
if (error.code === "ENOENT") return void 0;
|
|
363
|
+
throw error;
|
|
364
|
+
});
|
|
365
|
+
if (info && !(info.mode & 64)) await chmod(helper, info.mode | 64);
|
|
366
|
+
}
|
|
367
|
+
const pty = await import("node-pty");
|
|
368
|
+
if (process || closing) throw new Error("Terminal state changed. Try again.");
|
|
369
|
+
const settings = { hooks: { UserPromptSubmit: [{ hooks: [{
|
|
370
|
+
type: "http",
|
|
371
|
+
url: `${origin}/terminal/context`,
|
|
372
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
373
|
+
timeout: 5
|
|
374
|
+
}] }] } };
|
|
375
|
+
const env = { ...globalThis.process.env };
|
|
376
|
+
delete env.CLAUDECODE;
|
|
377
|
+
process = pty.spawn("claude", ["--settings", JSON.stringify(settings)], {
|
|
378
|
+
cwd: dirname(resolve(entry)),
|
|
379
|
+
name: "xterm-256color",
|
|
380
|
+
env,
|
|
381
|
+
cols: value.cols,
|
|
382
|
+
rows: value.rows
|
|
383
|
+
});
|
|
384
|
+
} finally {
|
|
385
|
+
starting = false;
|
|
386
|
+
}
|
|
387
|
+
owner = value.client;
|
|
388
|
+
output = "";
|
|
389
|
+
status = "Claude Code";
|
|
390
|
+
emit("replay", "");
|
|
391
|
+
process.onData((data) => {
|
|
392
|
+
output = (output + data).slice(-2e6);
|
|
393
|
+
emit("output", data);
|
|
394
|
+
});
|
|
395
|
+
completion = new Promise((done) => process.onExit(({ exitCode }) => {
|
|
396
|
+
process = void 0;
|
|
397
|
+
stopping = false;
|
|
398
|
+
status = `Claude Code exited (${exitCode}). Start to open a new session.`;
|
|
399
|
+
emit("state", snapshot());
|
|
400
|
+
done();
|
|
401
|
+
}));
|
|
402
|
+
}
|
|
403
|
+
} else if (request.url === "/terminal/input") {
|
|
404
|
+
if (!process) throw new Error("Start Claude Code first");
|
|
405
|
+
if (typeof value.data !== "string" || value.data.length > 32e3) throw new Error("Invalid terminal input");
|
|
406
|
+
updateFocus();
|
|
407
|
+
process.write(value.data);
|
|
408
|
+
} else if (request.url === "/terminal/resize") {
|
|
409
|
+
size();
|
|
410
|
+
process?.resize(value.cols, value.rows);
|
|
411
|
+
} else if (request.url === "/terminal/stop") await stop();
|
|
412
|
+
else {
|
|
413
|
+
json(404, { error: "Not found" });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
emit("state", snapshot());
|
|
417
|
+
json(200, snapshot());
|
|
418
|
+
} catch (cause) {
|
|
419
|
+
json(400, { error: cause instanceof Error ? cause.message : "Invalid terminal request" });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
handle,
|
|
424
|
+
isRunning: () => starting || !!process,
|
|
425
|
+
async close() {
|
|
426
|
+
closing = true;
|
|
427
|
+
await stop();
|
|
428
|
+
for (const client of clients) client.end();
|
|
429
|
+
clients.clear();
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
//#endregion
|
|
434
|
+
//#region src/chat.ts
|
|
435
|
+
const markdown = new MarkdownIt({
|
|
436
|
+
html: false,
|
|
437
|
+
linkify: false
|
|
438
|
+
});
|
|
439
|
+
markdown.renderer.rules.image = (tokens, index) => markdown.utils.escapeHtml(tokens[index]?.content ?? "");
|
|
440
|
+
/** One shared conversation and one editing process per dev server. */
|
|
441
|
+
function createChat(entry, notify, busy = () => false) {
|
|
442
|
+
let messages = [];
|
|
443
|
+
let provider = "codex";
|
|
444
|
+
let child;
|
|
445
|
+
let status = "Ready";
|
|
446
|
+
let stopping = false;
|
|
447
|
+
let killTimer;
|
|
448
|
+
let completion = Promise.resolve();
|
|
449
|
+
const snapshot = () => ({
|
|
450
|
+
messages: messages.map((message) => ({
|
|
451
|
+
...message,
|
|
452
|
+
...message.role === "assistant" ? { html: markdown.render(message.text) } : {}
|
|
453
|
+
})),
|
|
454
|
+
provider,
|
|
455
|
+
running: !!child,
|
|
456
|
+
status
|
|
457
|
+
});
|
|
458
|
+
function stop() {
|
|
459
|
+
if (!child || stopping) return;
|
|
460
|
+
stopping = true;
|
|
461
|
+
status = "Stopping…";
|
|
462
|
+
const processToStop = child;
|
|
463
|
+
const kill = (signal) => {
|
|
464
|
+
try {
|
|
465
|
+
if (process.platform !== "win32" && processToStop.pid) process.kill(-processToStop.pid, signal);
|
|
466
|
+
else processToStop.kill(signal);
|
|
467
|
+
} catch {}
|
|
468
|
+
};
|
|
469
|
+
kill("SIGTERM");
|
|
470
|
+
killTimer = setTimeout(() => kill("SIGKILL"), 2e3);
|
|
471
|
+
notify();
|
|
472
|
+
}
|
|
473
|
+
function start(text, selected, context) {
|
|
474
|
+
provider = selected;
|
|
475
|
+
const user = {
|
|
476
|
+
role: "user",
|
|
477
|
+
text,
|
|
478
|
+
context
|
|
479
|
+
};
|
|
480
|
+
const history = messages.slice(-12).map((message) => ({
|
|
481
|
+
...message,
|
|
482
|
+
text: message.text.slice(-12e3)
|
|
483
|
+
}));
|
|
484
|
+
const prompt = `You are editing a local PowerPoint TSX project in ${dirname(resolve(entry))}.
|
|
485
|
+
Read the project's authoring instructions. Make focused patches to the TSX source, preserving unrelated work.
|
|
486
|
+
The preview rebuilds automatically on save; do not start another dev server.
|
|
487
|
+
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.
|
|
488
|
+
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.
|
|
489
|
+
Respond in the user's language, briefly describing edits or answering their question. If permissions prevent an action, explain that instead of claiming success.
|
|
490
|
+
Recent conversation (JSON): ${JSON.stringify(history)}
|
|
491
|
+
Current request and preview context (JSON): ${JSON.stringify(user)}`;
|
|
492
|
+
messages.push(user);
|
|
493
|
+
const answer = {
|
|
494
|
+
role: "assistant",
|
|
495
|
+
text: ""
|
|
496
|
+
};
|
|
497
|
+
messages.push(answer);
|
|
498
|
+
if (messages.length > 100) messages = messages.slice(-100);
|
|
499
|
+
status = "Working…";
|
|
500
|
+
stopping = false;
|
|
501
|
+
child = spawn(selected, [
|
|
502
|
+
"exec",
|
|
503
|
+
"--json",
|
|
504
|
+
"--sandbox",
|
|
505
|
+
"workspace-write",
|
|
506
|
+
"--skip-git-repo-check",
|
|
507
|
+
"-"
|
|
508
|
+
], {
|
|
509
|
+
cwd: dirname(resolve(entry)),
|
|
510
|
+
stdio: [
|
|
511
|
+
"pipe",
|
|
512
|
+
"pipe",
|
|
513
|
+
"pipe"
|
|
514
|
+
],
|
|
515
|
+
detached: process.platform !== "win32",
|
|
516
|
+
shell: false
|
|
517
|
+
});
|
|
518
|
+
const running = child;
|
|
519
|
+
let buffer = "", stderr = "", failure = "", sawResult = false;
|
|
520
|
+
function append(value) {
|
|
521
|
+
answer.text = (answer.text + (answer.text ? "\n\n" : "") + value).slice(-1e5);
|
|
522
|
+
notify();
|
|
523
|
+
}
|
|
524
|
+
function consume(line) {
|
|
525
|
+
if (!line.trim()) return;
|
|
526
|
+
try {
|
|
527
|
+
const event = JSON.parse(line);
|
|
528
|
+
if (event.type === "item.completed" && event.item?.type === "agent_message") append(event.item.text ?? "");
|
|
529
|
+
if (event.type === "item.started") {
|
|
530
|
+
status = "Editing / inspecting…";
|
|
531
|
+
notify();
|
|
532
|
+
}
|
|
533
|
+
if (event.type === "turn.completed") sawResult = true;
|
|
534
|
+
if (event.type === "turn.failed" || event.type === "error") failure = event.error?.message ?? event.message ?? "Agent failed";
|
|
535
|
+
} catch {
|
|
536
|
+
failure = "Unexpected CLI output. Update your CLI and try again.";
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
running.stdout.setEncoding("utf8");
|
|
540
|
+
running.stdout.on("data", (chunk) => {
|
|
541
|
+
buffer += chunk;
|
|
542
|
+
let end;
|
|
543
|
+
while ((end = buffer.indexOf("\n")) !== -1) {
|
|
544
|
+
consume(buffer.slice(0, end));
|
|
545
|
+
buffer = buffer.slice(end + 1);
|
|
546
|
+
}
|
|
547
|
+
if (buffer.length > 2e6) {
|
|
548
|
+
failure = "CLI output exceeded the limit.";
|
|
549
|
+
stop();
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
running.stderr.setEncoding("utf8");
|
|
553
|
+
running.stderr.on("data", (chunk) => {
|
|
554
|
+
stderr = (stderr + chunk).slice(-8e3);
|
|
555
|
+
});
|
|
556
|
+
running.stdin.on("error", () => {});
|
|
557
|
+
running.stdin.end(prompt);
|
|
558
|
+
completion = new Promise((done) => {
|
|
559
|
+
running.on("error", (error) => {
|
|
560
|
+
failure = `Could not run ${selected}. Install it and log in from your terminal first. ${error.message}`;
|
|
561
|
+
});
|
|
562
|
+
running.on("close", (code) => {
|
|
563
|
+
if (buffer.trim()) consume(buffer);
|
|
564
|
+
clearTimeout(killTimer);
|
|
565
|
+
status = stopping ? "Stopped. Saved edits remain in the preview." : failure || (code !== 0 ? stderr || `${selected} exited with code ${code}` : !sawResult ? "CLI exited without a completed result." : "Done");
|
|
566
|
+
if (status !== "Done") append(status);
|
|
567
|
+
child = void 0;
|
|
568
|
+
notify();
|
|
569
|
+
done();
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
notify();
|
|
573
|
+
}
|
|
574
|
+
async function handle(request, response, focus) {
|
|
575
|
+
const json = (code, value) => {
|
|
576
|
+
response.writeHead(code, { "Content-Type": "application/json" });
|
|
577
|
+
response.end(JSON.stringify(value));
|
|
578
|
+
};
|
|
579
|
+
if (request.method === "GET" && request.url === "/chat") {
|
|
580
|
+
json(200, snapshot());
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (request.method !== "POST") {
|
|
584
|
+
json(405, { error: "Method not allowed" });
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (request.headers.origin !== `http://${request.headers.host}` || request.headers["content-type"]?.split(";")[0] !== "application/json") {
|
|
588
|
+
json(403, { error: "Same-origin JSON required" });
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
try {
|
|
592
|
+
let body = "";
|
|
593
|
+
request.setEncoding("utf8");
|
|
594
|
+
for await (const chunk of request) {
|
|
595
|
+
body += chunk;
|
|
596
|
+
if (Buffer.byteLength(body) > 32e3) {
|
|
597
|
+
json(413, { error: "Message too large" });
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
const value = JSON.parse(body);
|
|
602
|
+
if (request.url === "/chat/stop") {
|
|
603
|
+
stop();
|
|
604
|
+
json(200, snapshot());
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
if (child) {
|
|
608
|
+
json(409, { error: "An edit is already running" });
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (request.url === "/chat/reset") {
|
|
612
|
+
messages = [];
|
|
613
|
+
status = "Ready";
|
|
614
|
+
notify();
|
|
615
|
+
json(200, snapshot());
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
if (request.url !== "/chat") {
|
|
619
|
+
json(404, { error: "Not found" });
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (typeof value.message !== "string" || !value.message.trim() || value.message.length > 16e3 || value.provider !== "codex" || !(value.slide === null || Number.isInteger(value.slide) && value.slide >= 0) || !Number.isInteger(value.revision)) {
|
|
623
|
+
json(400, { error: "Invalid chat request" });
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (busy()) {
|
|
627
|
+
json(409, { error: "End the Claude Code session before starting a Codex edit" });
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
const context = focus(value.slide, value.revision);
|
|
631
|
+
start(value.message.trim(), value.provider, context);
|
|
632
|
+
json(202, snapshot());
|
|
633
|
+
} catch (cause) {
|
|
634
|
+
json(400, { error: cause instanceof Error ? cause.message : "Invalid request" });
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return {
|
|
638
|
+
handle,
|
|
639
|
+
isRunning: () => !!child,
|
|
640
|
+
async close() {
|
|
641
|
+
stop();
|
|
642
|
+
await completion;
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
}
|
|
169
646
|
//#endregion
|
|
170
647
|
//#region src/server.ts
|
|
171
648
|
async function serveDeck(entry, port = 4173) {
|
|
@@ -178,6 +655,10 @@ async function serveDeck(entry, port = 4173) {
|
|
|
178
655
|
let pending = false;
|
|
179
656
|
let closed = false;
|
|
180
657
|
const clients = /* @__PURE__ */ new Set();
|
|
658
|
+
const chat = createChat(entry, () => {
|
|
659
|
+
for (const client of clients) client.write("data: chat\n\n");
|
|
660
|
+
}, () => terminal.isRunning());
|
|
661
|
+
const terminal = createTerminal(entry, () => chat.isRunning());
|
|
181
662
|
const server = createServer((request, response) => {
|
|
182
663
|
const host = request.headers.host;
|
|
183
664
|
if (host !== `127.0.0.1:${actualPort}` && host !== `localhost:${actualPort}`) {
|
|
@@ -185,7 +666,27 @@ async function serveDeck(entry, port = 4173) {
|
|
|
185
666
|
return;
|
|
186
667
|
}
|
|
187
668
|
response.setHeader("Cache-Control", "no-store");
|
|
188
|
-
if (request.url === "/
|
|
669
|
+
if (request.url === "/chat" || request.url?.startsWith("/chat/") || request.url?.startsWith("/terminal/")) (request.url.startsWith("/terminal/") ? terminal : chat).handle(request, response, (index, viewedRevision) => {
|
|
670
|
+
if (viewedRevision !== revision) throw new Error("Preview changed. Review the current slide and send again.");
|
|
671
|
+
if (index !== null && (!latest || index >= latest.slides.length)) throw new Error("Slide no longer exists.");
|
|
672
|
+
return {
|
|
673
|
+
slide: index === null ? null : index + 1,
|
|
674
|
+
count: latest?.slides.length ?? 0,
|
|
675
|
+
revision,
|
|
676
|
+
text: index === null ? "" : (latest?.slideTexts[index] ?? "").slice(0, 12e3),
|
|
677
|
+
entry: resolve(entry),
|
|
678
|
+
files: latest?.dependencies ?? [resolve(entry)],
|
|
679
|
+
buildError: error
|
|
680
|
+
};
|
|
681
|
+
});
|
|
682
|
+
else if (request.method === "GET" && ["/terminal.js", "/terminal.css"].includes(request.url ?? "")) {
|
|
683
|
+
const asset = request.url === "/terminal.js" ? "terminal-client.js" : "terminal-client.css";
|
|
684
|
+
readFile(new URL(asset, import.meta.url)).then((bytes) => {
|
|
685
|
+
response.writeHead(200, { "Content-Type": asset.endsWith(".css") ? "text/css" : "text/javascript" });
|
|
686
|
+
response.end(bytes);
|
|
687
|
+
}, () => response.writeHead(500).end("Terminal assets unavailable. Rebuild pptx-dev."));
|
|
688
|
+
} else if (request.method !== "GET") response.writeHead(405).end();
|
|
689
|
+
else if (request.url === "/events") {
|
|
189
690
|
response.writeHead(200, { "Content-Type": "text/event-stream" });
|
|
190
691
|
response.write("data: ready\n\n");
|
|
191
692
|
clients.add(response);
|
|
@@ -289,6 +790,8 @@ async function serveDeck(entry, port = 4173) {
|
|
|
289
790
|
closed = true;
|
|
290
791
|
clearTimeout(timer);
|
|
291
792
|
watcher.close();
|
|
793
|
+
await terminal.close();
|
|
794
|
+
await chat.close();
|
|
292
795
|
await builder.close();
|
|
293
796
|
for (const client of clients) client.end();
|
|
294
797
|
await new Promise((done, reject) => server.close((cause) => cause ? reject(cause) : done()));
|