@office-kit/pptx-dev 0.3.0 → 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 +60 -40
- package/dist/cli.mjs +248 -50
- package/dist/cli.mjs.map +1 -1
- package/dist/terminal-client.css +285 -0
- package/dist/terminal-client.js +53 -0
- package/package.json +9 -4
package/README.md
CHANGED
|
@@ -47,46 +47,66 @@ workflow. Clicking a slide supplies chat context; opening its exact TSX location
|
|
|
47
47
|
|
|
48
48
|
## Chat in the preview
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
50
|
+
The right panel defaults to **Claude Code**. Click **Start** to open the locally
|
|
51
|
+
installed `claude` CLI in an interactive terminal. Install and sign in to Claude
|
|
52
|
+
Code first. Use a current version with HTTP `UserPromptSubmit` hook support.
|
|
53
|
+
The session runs in the deck entry's directory and uses your normal account,
|
|
54
|
+
user/project settings, CLAUDE.md, plugins, skills, MCP servers and hooks.
|
|
55
|
+
|
|
56
|
+
Type `/model` to choose a model, `/config` to change settings, or `/` to browse
|
|
57
|
+
available commands and skills. Bash and other tools are available according to
|
|
58
|
+
Claude Code's own configuration, and permission prompts are answered directly in
|
|
59
|
+
the panel. The preview does not force `acceptEdits`, restrict the tool list or
|
|
60
|
+
bypass permissions. Installation and account setup use the CLI's usual flow.
|
|
61
|
+
Markdown responses are displayed by Claude Code's terminal renderer; its native
|
|
62
|
+
formatting may differ from a web Markdown page. OS-specific actions such as
|
|
63
|
+
opening an external editor still use your local environment.
|
|
64
|
+
|
|
65
|
+
Each submitted Claude prompt receives the focused slide's **1-based number,
|
|
66
|
+
text, slide count, preview revision, deck entry and source dependency paths**,
|
|
67
|
+
plus any build error, through a session-scoped `UserPromptSubmit` HTTP hook.
|
|
68
|
+
The preview captures context with terminal input before forwarding it to the
|
|
69
|
+
CLI. Navigation while Claude is replying does not change that turn's context.
|
|
70
|
+
Stale input is rejected with a retry message. If your Claude settings or managed
|
|
71
|
+
policy disable hooks, automatic context attachment is unavailable; enable the
|
|
72
|
+
preview hook in Claude Code to use it. No project settings file is rewritten.
|
|
73
|
+
Dependency paths are source candidates, not an exact slide-to-file mapping.
|
|
74
|
+
“This slide” requests a focused patch; requests about another slide or the
|
|
75
|
+
whole deck can edit other files or shared styling.
|
|
76
|
+
|
|
77
|
+
Reloading the browser reconnects to the same process and replays its recent
|
|
78
|
+
terminal output (up to 2 MB). One browser tab controls the session; other tabs
|
|
79
|
+
can view it. Keep the owning tab open, or end the session there before moving to
|
|
80
|
+
another tab. **End session** stops the process; saved edits remain. Start opens
|
|
81
|
+
a new session, and Claude's `/resume` command can restore an earlier conversation.
|
|
82
|
+
Stopping the dev server also stops the embedded session. This is independent of
|
|
83
|
+
the Claude Code conversation that may have launched your dev server.
|
|
84
|
+
|
|
85
|
+
Select **Codex** while Claude Code is stopped to use the existing message-based
|
|
86
|
+
chat. Codex replies render Markdown (including lists, code blocks and tables);
|
|
87
|
+
raw HTML, unsafe links and remote images are not rendered. Codex still uses
|
|
88
|
+
`exec --json` with the `workspace-write` sandbox and its installed CLI login.
|
|
89
|
+
Operations requiring interactive approval are not supported in this adapter.
|
|
90
|
+
The most recent 12 messages (bounded text) and the focused slide are sent with
|
|
91
|
+
each request. The dev server retains up to 100 messages until it exits.
|
|
92
|
+
**New chat** clears this history; **Stop** cancels the running edit. Ctrl/Cmd+Enter
|
|
93
|
+
sends a message. Model/settings menus in the embedded terminal are currently
|
|
94
|
+
specific to Claude Code.
|
|
95
|
+
|
|
96
|
+
Use **Chat** in the header to hide/show the panel. On narrow screens the panel
|
|
97
|
+
moves below the preview. Presentation mode hides it without ending the session.
|
|
98
|
+
Both providers save TSX directly, which triggers the usual automatic rebuild.
|
|
99
|
+
The server binds to loopback and requires same-origin JSON for terminal control;
|
|
100
|
+
the local context hook uses a separate per-server token.
|
|
101
|
+
|
|
102
|
+
The terminal uses `node-pty` and xterm.js. Linux systems without a matching PTY
|
|
103
|
+
prebuild need the native build prerequisites documented by
|
|
104
|
+
[node-pty](https://github.com/microsoft/node-pty). With pnpm, allow `node-pty`'s
|
|
105
|
+
install script (`pnpm approve-builds`) if your project blocks dependency scripts.
|
|
106
|
+
|
|
107
|
+
References: [Claude Code commands](https://code.claude.com/docs/en/commands),
|
|
108
|
+
[Claude Code hooks](https://code.claude.com/docs/en/hooks), and
|
|
109
|
+
[Codex non-interactive mode](https://developers.openai.com/codex/noninteractive).
|
|
90
110
|
|
|
91
111
|
## Edit only what changed
|
|
92
112
|
|
package/dist/cli.mjs
CHANGED
|
@@ -1,14 +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";
|
|
6
10
|
import { spawn } from "node:child_process";
|
|
7
11
|
//#region src/page.ts
|
|
8
12
|
const page = `<!doctype html>
|
|
9
13
|
<html lang="en">
|
|
10
14
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
|
11
15
|
<title>Office Kit — PowerPoint preview</title>
|
|
16
|
+
<link rel="stylesheet" href="/terminal.css">
|
|
12
17
|
<style>
|
|
13
18
|
*{box-sizing:border-box}
|
|
14
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}
|
|
@@ -18,7 +23,7 @@ button:hover:not(:disabled),.download:hover{background:#f0f3f9}button:disabled{o
|
|
|
18
23
|
header{display:flex;align-items:center;gap:16px;padding:0 20px;background:#fff;border-bottom:1px solid #d4d9e2;min-width:0}
|
|
19
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}
|
|
20
25
|
#status{flex:1;color:#667085;min-width:0}#present{background:#293c73;color:#fff;border-color:#293c73}
|
|
21
|
-
.workspace{display:grid;grid-template-columns:190px minmax(0,1fr)
|
|
26
|
+
.workspace{display:grid;grid-template-columns:190px minmax(0,1fr) minmax(380px,38vw);min-height:0}
|
|
22
27
|
.filmstrip{background:#f7f8fa;border-right:1px solid #d4d9e2;overflow:auto;overscroll-behavior:contain;padding:16px 12px}
|
|
23
28
|
.filmstrip h2{margin:0 0 12px 26px;text-transform:uppercase;letter-spacing:.1em;font-size:10px;color:#7c8596;font-weight:600}
|
|
24
29
|
#thumbnails{display:flex;flex-direction:column;gap:12px;margin:0;padding:0;list-style:none}
|
|
@@ -50,19 +55,34 @@ body.presenting{grid-template-rows:minmax(0,1fr);background:#111}
|
|
|
50
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}
|
|
51
56
|
body.chat-hidden .workspace{grid-template-columns:190px minmax(0,1fr)}body.chat-hidden #chat{display:none}
|
|
52
57
|
body.presenting .workspace{grid-template-columns:minmax(0,1fr)}
|
|
53
|
-
@media(max-width:1000px){.workspace{grid-template-columns:120px minmax(0,1fr)
|
|
58
|
+
@media(max-width:1000px){.workspace{grid-template-columns:120px minmax(0,1fr) 380px}.filmstrip{padding:12px 5px}#stage{padding:16px}}
|
|
54
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}}
|
|
55
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}
|
|
56
72
|
</style>
|
|
57
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>
|
|
58
74
|
<div class="workspace">
|
|
59
75
|
<nav class="filmstrip" aria-label="Slides"><h2>Slides</h2><ol id="thumbnails"></ol></nav>
|
|
60
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>
|
|
61
77
|
<aside id="chat" aria-label="Slide chat">
|
|
62
|
-
<div class="chat-heading"><
|
|
63
|
-
<div id="
|
|
64
|
-
<
|
|
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>
|
|
65
84
|
<div id="chat-status" role="status">Ready</div>
|
|
85
|
+
</div>
|
|
66
86
|
</aside>
|
|
67
87
|
</div>
|
|
68
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>
|
|
@@ -90,7 +110,8 @@ function resize(){
|
|
|
90
110
|
function selectSlide(next,focusThumbnail=false,reveal=true){
|
|
91
111
|
index=Math.max(0,Math.min(next,state.slides.length-1));
|
|
92
112
|
const count=state.slides.length?'Slide '+(index+1)+' of '+state.slides.length:'No slides';
|
|
93
|
-
byId('chat-context').textContent=state.slides.length?
|
|
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});
|
|
94
115
|
byId('count').textContent=count;byId('present-count').textContent=count;
|
|
95
116
|
for(const id of ['prev','present-prev'])byId(id).disabled=index===0;
|
|
96
117
|
for(const id of ['next','present-next'])byId(id).disabled=index>=state.slides.length-1;
|
|
@@ -186,10 +207,10 @@ function renderChat(chat){
|
|
|
186
207
|
chatBusy=chat.running;
|
|
187
208
|
byId('chat-send').disabled=chat.running;
|
|
188
209
|
byId('chat-reset').disabled=chat.running;
|
|
189
|
-
byId('chat-provider').disabled=chat.running;
|
|
210
|
+
if(byId('chat-provider').value==='codex')byId('chat-provider').disabled=chat.running;
|
|
190
211
|
byId('chat-stop').hidden=!chat.running;
|
|
191
212
|
byId('chat-status').textContent=chat.status;
|
|
192
|
-
|
|
213
|
+
|
|
193
214
|
const serialized=JSON.stringify(chat.messages);
|
|
194
215
|
if(serialized===lastMessages)return;
|
|
195
216
|
lastMessages=serialized;
|
|
@@ -200,7 +221,7 @@ function renderChat(chat){
|
|
|
200
221
|
const item=document.createElement('div'),label=document.createElement('small'),body=document.createElement('div');
|
|
201
222
|
item.className='chat-message '+message.role;
|
|
202
223
|
label.textContent=message.role==='user'?'You · '+(message.context?.slide?'Slide '+message.context.slide:'Whole project'):'Assistant';
|
|
203
|
-
body.textContent=message.text||(chat.running?'Working…':'No response');item.append(label,body);log.append(item);
|
|
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);
|
|
204
225
|
}
|
|
205
226
|
if(atBottom)log.scrollTop=log.scrollHeight;
|
|
206
227
|
}
|
|
@@ -225,22 +246,211 @@ byId('chat-form').onsubmit=async event=>{
|
|
|
225
246
|
byId('chat-input').onkeydown=event=>{if(event.key==='Enter'&&(event.metaKey||event.ctrlKey)&&!event.isComposing){event.preventDefault();byId('chat-form').requestSubmit();}};
|
|
226
247
|
for(const action of ['stop','reset'])byId('chat-'+action).onclick=()=>chatAction('/chat/'+action,{}).catch(error=>{byId('chat-status').textContent=error.message;});
|
|
227
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;};
|
|
228
250
|
void refreshChat();
|
|
229
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();
|
|
230
|
-
<\/script></html>`;
|
|
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
|
+
}
|
|
231
433
|
//#endregion
|
|
232
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 ?? "");
|
|
233
440
|
/** One shared conversation and one editing process per dev server. */
|
|
234
|
-
function createChat(entry, notify) {
|
|
441
|
+
function createChat(entry, notify, busy = () => false) {
|
|
235
442
|
let messages = [];
|
|
236
|
-
let provider = "
|
|
443
|
+
let provider = "codex";
|
|
237
444
|
let child;
|
|
238
445
|
let status = "Ready";
|
|
239
446
|
let stopping = false;
|
|
240
447
|
let killTimer;
|
|
241
448
|
let completion = Promise.resolve();
|
|
242
449
|
const snapshot = () => ({
|
|
243
|
-
messages
|
|
450
|
+
messages: messages.map((message) => ({
|
|
451
|
+
...message,
|
|
452
|
+
...message.role === "assistant" ? { html: markdown.render(message.text) } : {}
|
|
453
|
+
})),
|
|
244
454
|
provider,
|
|
245
455
|
running: !!child,
|
|
246
456
|
status
|
|
@@ -288,22 +498,13 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
|
|
|
288
498
|
if (messages.length > 100) messages = messages.slice(-100);
|
|
289
499
|
status = "Working…";
|
|
290
500
|
stopping = false;
|
|
291
|
-
child = spawn(selected,
|
|
501
|
+
child = spawn(selected, [
|
|
292
502
|
"exec",
|
|
293
503
|
"--json",
|
|
294
504
|
"--sandbox",
|
|
295
505
|
"workspace-write",
|
|
296
506
|
"--skip-git-repo-check",
|
|
297
507
|
"-"
|
|
298
|
-
] : [
|
|
299
|
-
"--print",
|
|
300
|
-
"--verbose",
|
|
301
|
-
"--output-format",
|
|
302
|
-
"stream-json",
|
|
303
|
-
"--permission-mode",
|
|
304
|
-
"acceptEdits",
|
|
305
|
-
"--tools",
|
|
306
|
-
"Read,Edit,Write,Glob,Grep"
|
|
307
508
|
], {
|
|
308
509
|
cwd: dirname(resolve(entry)),
|
|
309
510
|
stdio: [
|
|
@@ -324,29 +525,13 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
|
|
|
324
525
|
if (!line.trim()) return;
|
|
325
526
|
try {
|
|
326
527
|
const event = JSON.parse(line);
|
|
327
|
-
if (
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
notify();
|
|
332
|
-
}
|
|
333
|
-
if (event.type === "turn.completed") sawResult = true;
|
|
334
|
-
if (event.type === "turn.failed" || event.type === "error") failure = event.error?.message ?? event.message ?? "Agent failed";
|
|
335
|
-
} else {
|
|
336
|
-
if (event.type === "assistant") for (const block of event.message?.content ?? []) {
|
|
337
|
-
if (block.type === "text") append(block.text);
|
|
338
|
-
if (block.type === "tool_use") {
|
|
339
|
-
status = "Using " + block.name + "…";
|
|
340
|
-
notify();
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
if (event.type === "result") {
|
|
344
|
-
sawResult = true;
|
|
345
|
-
if (event.is_error) failure = event.result || event.errors?.join("\n") || "Agent failed";
|
|
346
|
-
else if (!answer.text && event.result) append(event.result);
|
|
347
|
-
if (event.permission_denials?.length) failure = "Some actions were denied by CLI permissions. " + (event.result ?? "");
|
|
348
|
-
}
|
|
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();
|
|
349
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";
|
|
350
535
|
} catch {
|
|
351
536
|
failure = "Unexpected CLI output. Update your CLI and try again.";
|
|
352
537
|
}
|
|
@@ -434,10 +619,14 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
|
|
|
434
619
|
json(404, { error: "Not found" });
|
|
435
620
|
return;
|
|
436
621
|
}
|
|
437
|
-
if (typeof value.message !== "string" || !value.message.trim() || value.message.length > 16e3 ||
|
|
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)) {
|
|
438
623
|
json(400, { error: "Invalid chat request" });
|
|
439
624
|
return;
|
|
440
625
|
}
|
|
626
|
+
if (busy()) {
|
|
627
|
+
json(409, { error: "End the Claude Code session before starting a Codex edit" });
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
441
630
|
const context = focus(value.slide, value.revision);
|
|
442
631
|
start(value.message.trim(), value.provider, context);
|
|
443
632
|
json(202, snapshot());
|
|
@@ -447,6 +636,7 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
|
|
|
447
636
|
}
|
|
448
637
|
return {
|
|
449
638
|
handle,
|
|
639
|
+
isRunning: () => !!child,
|
|
450
640
|
async close() {
|
|
451
641
|
stop();
|
|
452
642
|
await completion;
|
|
@@ -467,7 +657,8 @@ async function serveDeck(entry, port = 4173) {
|
|
|
467
657
|
const clients = /* @__PURE__ */ new Set();
|
|
468
658
|
const chat = createChat(entry, () => {
|
|
469
659
|
for (const client of clients) client.write("data: chat\n\n");
|
|
470
|
-
});
|
|
660
|
+
}, () => terminal.isRunning());
|
|
661
|
+
const terminal = createTerminal(entry, () => chat.isRunning());
|
|
471
662
|
const server = createServer((request, response) => {
|
|
472
663
|
const host = request.headers.host;
|
|
473
664
|
if (host !== `127.0.0.1:${actualPort}` && host !== `localhost:${actualPort}`) {
|
|
@@ -475,7 +666,7 @@ async function serveDeck(entry, port = 4173) {
|
|
|
475
666
|
return;
|
|
476
667
|
}
|
|
477
668
|
response.setHeader("Cache-Control", "no-store");
|
|
478
|
-
if (request.url === "/chat" || request.url?.startsWith("/chat/")) chat.handle(request, response, (index, viewedRevision) => {
|
|
669
|
+
if (request.url === "/chat" || request.url?.startsWith("/chat/") || request.url?.startsWith("/terminal/")) (request.url.startsWith("/terminal/") ? terminal : chat).handle(request, response, (index, viewedRevision) => {
|
|
479
670
|
if (viewedRevision !== revision) throw new Error("Preview changed. Review the current slide and send again.");
|
|
480
671
|
if (index !== null && (!latest || index >= latest.slides.length)) throw new Error("Slide no longer exists.");
|
|
481
672
|
return {
|
|
@@ -488,7 +679,13 @@ async function serveDeck(entry, port = 4173) {
|
|
|
488
679
|
buildError: error
|
|
489
680
|
};
|
|
490
681
|
});
|
|
491
|
-
else if (request.method
|
|
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();
|
|
492
689
|
else if (request.url === "/events") {
|
|
493
690
|
response.writeHead(200, { "Content-Type": "text/event-stream" });
|
|
494
691
|
response.write("data: ready\n\n");
|
|
@@ -593,6 +790,7 @@ async function serveDeck(entry, port = 4173) {
|
|
|
593
790
|
closed = true;
|
|
594
791
|
clearTimeout(timer);
|
|
595
792
|
watcher.close();
|
|
793
|
+
await terminal.close();
|
|
596
794
|
await chat.close();
|
|
597
795
|
await builder.close();
|
|
598
796
|
for (const client of clients) client.end();
|