@office-kit/pptx-dev 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -40
- package/dist/cli.mjs +282 -52
- 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,71 @@ 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
|
+
Drag the left edge of the chat panel to adjust its width. The preview remembers the
|
|
97
|
+
width in this browser; double-click the edge to reset it. You can also focus the
|
|
98
|
+
edge and use the left/right arrow keys (Shift for larger steps), or Home/End for
|
|
99
|
+
the minimum/maximum width. Narrow screens keep the stacked layout.
|
|
100
|
+
|
|
101
|
+
Use **Chat** in the header to hide/show the panel. On narrow screens the panel
|
|
102
|
+
moves below the preview. Presentation mode hides it without ending the session.
|
|
103
|
+
Both providers save TSX directly, which triggers the usual automatic rebuild.
|
|
104
|
+
The server binds to loopback and requires same-origin JSON for terminal control;
|
|
105
|
+
the local context hook uses a separate per-server token.
|
|
106
|
+
|
|
107
|
+
The terminal uses `node-pty` and xterm.js. Linux systems without a matching PTY
|
|
108
|
+
prebuild need the native build prerequisites documented by
|
|
109
|
+
[node-pty](https://github.com/microsoft/node-pty). With pnpm, allow `node-pty`'s
|
|
110
|
+
install script (`pnpm approve-builds`) if your project blocks dependency scripts.
|
|
111
|
+
|
|
112
|
+
References: [Claude Code commands](https://code.claude.com/docs/en/commands),
|
|
113
|
+
[Claude Code hooks](https://code.claude.com/docs/en/hooks), and
|
|
114
|
+
[Codex non-interactive mode](https://developers.openai.com/codex/noninteractive).
|
|
90
115
|
|
|
91
116
|
## Edit only what changed
|
|
92
117
|
|
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:
|
|
26
|
+
.workspace{--filmstrip-width:190px;--chat-width:38vw;display:grid;grid-template-columns:var(--filmstrip-width) minmax(0,1fr) clamp(280px,var(--chat-width),calc(100vw - var(--filmstrip-width) - 240px));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}
|
|
@@ -43,26 +48,43 @@ body.presenting{grid-template-rows:minmax(0,1fr);background:#111}
|
|
|
43
48
|
.presenting #presentation-controls:hover,.presenting #presentation-controls:focus-within{opacity:1}
|
|
44
49
|
#presentation-controls button{background:transparent;color:white;border-color:#5d6575}
|
|
45
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}}
|
|
46
|
-
#chat{min-height:0;display:flex;flex-direction:column;background:#fff;border-left:1px solid #d4d9e2}
|
|
51
|
+
#chat{position:relative;min-width:0;min-height:0;display:flex;flex-direction:column;background:#fff;border-left:1px solid #d4d9e2}
|
|
52
|
+
#chat-resizer{position:absolute;left:-5px;top:0;bottom:0;width:10px;z-index:3;cursor:col-resize;touch-action:none}#chat-resizer::after{content:"";position:absolute;left:4px;top:0;bottom:0;width:2px}#chat-resizer:hover::after,#chat-resizer:focus-visible::after,.resizing-chat #chat-resizer::after{background:#4967dd}body.resizing-chat,body.resizing-chat *{cursor:col-resize!important;user-select:none!important}
|
|
47
53
|
.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}
|
|
48
54
|
#messages{flex:1;overflow:auto;padding:16px;display:flex;flex-direction:column;gap:16px;overscroll-behavior:contain}
|
|
49
55
|
.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}
|
|
50
56
|
#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
57
|
body.chat-hidden .workspace{grid-template-columns:190px minmax(0,1fr)}body.chat-hidden #chat{display:none}
|
|
52
58
|
body.presenting .workspace{grid-template-columns:minmax(0,1fr)}
|
|
53
|
-
@media(max-width:1000px){.workspace{
|
|
54
|
-
@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}}
|
|
59
|
+
@media(max-width:1000px){.workspace{--filmstrip-width:120px;--chat-width:380px}.filmstrip{padding:12px 5px}#stage{padding:16px}}
|
|
60
|
+
@media(max-width:700px){#chat-resizer{display:none}.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
61
|
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}
|
|
62
|
+
|
|
63
|
+
[hidden]{display:none!important}
|
|
64
|
+
.chat-heading{padding:10px 14px}.chat-heading select{flex:1;min-width:0;font-weight:600;border:0;padding-left:0}
|
|
65
|
+
#chat-context{margin:0;padding:8px 14px;border-bottom:1px solid #e6e9ef;color:#667085;font-size:11px}
|
|
66
|
+
#claude-panel,#codex-panel{display:flex;flex-direction:column;flex:1;min-height:0}
|
|
67
|
+
#claude-panel{background:#171b24;color:#e4e8f0}
|
|
68
|
+
.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}
|
|
69
|
+
.terminal-toolbar button:hover:not(:disabled){background:#35415a}
|
|
70
|
+
#terminal{flex:1;min-height:0;overflow:hidden;padding:4px 8px 8px}.xterm{height:100%}
|
|
71
|
+
#chat-input{min-height:70px;resize:vertical}.chat-shortcut{flex:1;color:#9098a7;font-size:10px}
|
|
72
|
+
.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
73
|
</style>
|
|
57
74
|
<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
75
|
<div class="workspace">
|
|
59
76
|
<nav class="filmstrip" aria-label="Slides"><h2>Slides</h2><ol id="thumbnails"></ol></nav>
|
|
60
77
|
<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
78
|
<aside id="chat" aria-label="Slide chat">
|
|
62
|
-
<div
|
|
63
|
-
<div
|
|
64
|
-
<
|
|
79
|
+
<div id="chat-resizer" role="separator" tabindex="0" aria-label="Chat width" aria-orientation="vertical" aria-controls="chat" title="Drag to resize · Double-click to reset"></div>
|
|
80
|
+
<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>
|
|
81
|
+
<div id="chat-context">No slide selected</div>
|
|
82
|
+
<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>
|
|
83
|
+
<div id="codex-panel" hidden>
|
|
84
|
+
<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>
|
|
85
|
+
<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
86
|
<div id="chat-status" role="status">Ready</div>
|
|
87
|
+
</div>
|
|
66
88
|
</aside>
|
|
67
89
|
</div>
|
|
68
90
|
<footer><span id="count" aria-live="polite">No slides</span><span class="hint">Changes appear automatically · Text can be selected and copied</span><button id="prev" aria-label="Previous slide" disabled>‹</button><button id="next" aria-label="Next slide" disabled>›</button><label for="zoom">Zoom</label><select id="zoom"><option value="fit">Fit</option><option value="0.5">50%</option><option value="0.75">75%</option><option value="1">100%</option><option value="1.25">125%</option><option value="1.5">150%</option><option value="2">200%</option></select></footer>
|
|
@@ -72,6 +94,36 @@ let state={slides:[],error:null,aspectRatio:16/9},index=0,urls=[],presenting=fal
|
|
|
72
94
|
let displayedSvg;
|
|
73
95
|
const byId=id=>document.getElementById(id);
|
|
74
96
|
const stage=byId('stage'),slide=byId('slide'),thumbnails=byId('thumbnails');
|
|
97
|
+
const workspace=document.querySelector('.workspace'),chatResizer=byId('chat-resizer');
|
|
98
|
+
const chatWidthKey='office-kit-chat-width',minChatWidth=280,minStageWidth=240;
|
|
99
|
+
function chatWidthLimits(){return {min:minChatWidth,max:Math.max(minChatWidth,workspace.clientWidth-document.querySelector('.filmstrip').getBoundingClientRect().width-minStageWidth)};}
|
|
100
|
+
function updateChatWidthAria(){
|
|
101
|
+
const {min,max}=chatWidthLimits();
|
|
102
|
+
chatResizer.setAttribute('aria-valuemin',String(min));chatResizer.setAttribute('aria-valuemax',String(Math.round(max)));
|
|
103
|
+
const width=Math.round(byId('chat').getBoundingClientRect().width);
|
|
104
|
+
chatResizer.setAttribute('aria-valuenow',String(width));chatResizer.setAttribute('aria-valuetext',width+' pixels');
|
|
105
|
+
}
|
|
106
|
+
function setChatWidth(width){const {min,max}=chatWidthLimits();workspace.style.setProperty('--chat-width',Math.min(max,Math.max(min,width))+'px');}
|
|
107
|
+
function saveChatWidth(){try{const width=workspace.style.getPropertyValue('--chat-width');if(width)localStorage.setItem(chatWidthKey,String(parseFloat(width)));else localStorage.removeItem(chatWidthKey);}catch(error){console.warn('Could not save chat width',error);}}
|
|
108
|
+
try{const saved=Number(localStorage.getItem(chatWidthKey));if(Number.isFinite(saved)&&saved>=minChatWidth)workspace.style.setProperty('--chat-width',saved+'px');}catch(error){console.warn('Could not restore chat width',error);}
|
|
109
|
+
let chatDrag=null;
|
|
110
|
+
chatResizer.onpointerdown=event=>{
|
|
111
|
+
if(event.button!==0||!event.isPrimary)return;
|
|
112
|
+
event.preventDefault();chatResizer.focus();chatDrag={id:event.pointerId,x:event.clientX,width:byId('chat').getBoundingClientRect().width};
|
|
113
|
+
chatResizer.setPointerCapture(event.pointerId);document.body.classList.add('resizing-chat');
|
|
114
|
+
};
|
|
115
|
+
chatResizer.onpointermove=event=>{if(chatDrag?.id===event.pointerId)setChatWidth(chatDrag.width+chatDrag.x-event.clientX);};
|
|
116
|
+
function finishChatResize(){if(!chatDrag)return;chatDrag=null;document.body.classList.remove('resizing-chat');saveChatWidth();}
|
|
117
|
+
chatResizer.onpointerup=finishChatResize;chatResizer.onpointercancel=finishChatResize;chatResizer.onlostpointercapture=finishChatResize;
|
|
118
|
+
chatResizer.ondblclick=()=>{workspace.style.removeProperty('--chat-width');saveChatWidth();};
|
|
119
|
+
chatResizer.onkeydown=event=>{
|
|
120
|
+
const {min,max}=chatWidthLimits(),width=byId('chat').getBoundingClientRect().width,step=event.shiftKey?50:10;
|
|
121
|
+
let next;
|
|
122
|
+
if(event.key==='ArrowLeft')next=width+step;else if(event.key==='ArrowRight')next=width-step;else if(event.key==='Home')next=min;else if(event.key==='End')next=max;else return;
|
|
123
|
+
event.preventDefault();event.stopPropagation();setChatWidth(next);saveChatWidth();
|
|
124
|
+
};
|
|
125
|
+
new ResizeObserver(updateChatWidthAria).observe(byId('chat'));
|
|
126
|
+
|
|
75
127
|
// The slide lives in a shadow root rather than a sandboxed iframe so its text
|
|
76
128
|
// (XHTML inside <foreignObject>) can be selected and copied while the deck's
|
|
77
129
|
// SVG stays out of the viewer's own DOM and CSS. Keyboard events still reach the
|
|
@@ -90,7 +142,8 @@ function resize(){
|
|
|
90
142
|
function selectSlide(next,focusThumbnail=false,reveal=true){
|
|
91
143
|
index=Math.max(0,Math.min(next,state.slides.length-1));
|
|
92
144
|
const count=state.slides.length?'Slide '+(index+1)+' of '+state.slides.length:'No slides';
|
|
93
|
-
byId('chat-context').textContent=state.slides.length?
|
|
145
|
+
byId('chat-context').textContent=state.slides.length?count:'Whole project';
|
|
146
|
+
byId('chat-context').dataset.focus=JSON.stringify({slide:state.slides.length?index:null,revision:state.revision??0});
|
|
94
147
|
byId('count').textContent=count;byId('present-count').textContent=count;
|
|
95
148
|
for(const id of ['prev','present-prev'])byId(id).disabled=index===0;
|
|
96
149
|
for(const id of ['next','present-next'])byId(id).disabled=index>=state.slides.length-1;
|
|
@@ -186,10 +239,10 @@ function renderChat(chat){
|
|
|
186
239
|
chatBusy=chat.running;
|
|
187
240
|
byId('chat-send').disabled=chat.running;
|
|
188
241
|
byId('chat-reset').disabled=chat.running;
|
|
189
|
-
byId('chat-provider').disabled=chat.running;
|
|
242
|
+
if(byId('chat-provider').value==='codex')byId('chat-provider').disabled=chat.running;
|
|
190
243
|
byId('chat-stop').hidden=!chat.running;
|
|
191
244
|
byId('chat-status').textContent=chat.status;
|
|
192
|
-
|
|
245
|
+
|
|
193
246
|
const serialized=JSON.stringify(chat.messages);
|
|
194
247
|
if(serialized===lastMessages)return;
|
|
195
248
|
lastMessages=serialized;
|
|
@@ -200,7 +253,7 @@ function renderChat(chat){
|
|
|
200
253
|
const item=document.createElement('div'),label=document.createElement('small'),body=document.createElement('div');
|
|
201
254
|
item.className='chat-message '+message.role;
|
|
202
255
|
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);
|
|
256
|
+
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
257
|
}
|
|
205
258
|
if(atBottom)log.scrollTop=log.scrollHeight;
|
|
206
259
|
}
|
|
@@ -225,22 +278,211 @@ byId('chat-form').onsubmit=async event=>{
|
|
|
225
278
|
byId('chat-input').onkeydown=event=>{if(event.key==='Enter'&&(event.metaKey||event.ctrlKey)&&!event.isComposing){event.preventDefault();byId('chat-form').requestSubmit();}};
|
|
226
279
|
for(const action of ['stop','reset'])byId('chat-'+action).onclick=()=>chatAction('/chat/'+action,{}).catch(error=>{byId('chat-status').textContent=error.message;});
|
|
227
280
|
byId('toggle-chat').onclick=()=>{const hidden=document.body.classList.toggle('chat-hidden');byId('toggle-chat').setAttribute('aria-expanded',String(!hidden));resize();};
|
|
281
|
+
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
282
|
void refreshChat();
|
|
229
283
|
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>`;
|
|
284
|
+
<\/script><script type="module" src="/terminal.js"><\/script></html>`;
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region src/terminal.ts
|
|
287
|
+
const REQUEST_LIMIT = 128e3;
|
|
288
|
+
/** A real interactive Claude session, shared across reloads of its owning tab. */
|
|
289
|
+
function createTerminal(entry, busy = () => false) {
|
|
290
|
+
let process;
|
|
291
|
+
let owner = "";
|
|
292
|
+
let output = "";
|
|
293
|
+
let context;
|
|
294
|
+
let status = "Start Claude Code to edit your slides.";
|
|
295
|
+
let completion = Promise.resolve();
|
|
296
|
+
let closing = false;
|
|
297
|
+
let stopping = false;
|
|
298
|
+
let starting = false;
|
|
299
|
+
const token = randomBytes(32).toString("hex");
|
|
300
|
+
const clients = /* @__PURE__ */ new Set();
|
|
301
|
+
const snapshot = () => ({
|
|
302
|
+
running: !!process,
|
|
303
|
+
owner,
|
|
304
|
+
status
|
|
305
|
+
});
|
|
306
|
+
const emit = (event, value) => {
|
|
307
|
+
for (const client of clients) if (!client.write(`event: ${event}\ndata: ${JSON.stringify(value)}\n\n`)) {
|
|
308
|
+
clients.delete(client);
|
|
309
|
+
client.end();
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
async function stop() {
|
|
313
|
+
if (!process || stopping) return completion;
|
|
314
|
+
stopping = true;
|
|
315
|
+
status = "Stopping…";
|
|
316
|
+
emit("state", snapshot());
|
|
317
|
+
const running = process;
|
|
318
|
+
running.kill();
|
|
319
|
+
const timer = setTimeout(() => {
|
|
320
|
+
if (process === running) running.kill("SIGKILL");
|
|
321
|
+
}, 2e3);
|
|
322
|
+
await completion;
|
|
323
|
+
clearTimeout(timer);
|
|
324
|
+
}
|
|
325
|
+
async function handle(request, response, focus) {
|
|
326
|
+
const json = (code, value) => {
|
|
327
|
+
response.writeHead(code, { "Content-Type": "application/json" });
|
|
328
|
+
response.end(JSON.stringify(value));
|
|
329
|
+
};
|
|
330
|
+
if (request.url === "/terminal/context") {
|
|
331
|
+
if (request.method !== "POST" || request.headers.authorization !== `Bearer ${token}`) {
|
|
332
|
+
json(403, { error: "Invalid hook credentials" });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
request.resume();
|
|
336
|
+
json(200, { hookSpecificOutput: {
|
|
337
|
+
hookEventName: "UserPromptSubmit",
|
|
338
|
+
additionalContext: `You are editing the local PowerPoint TSX project at ${resolve(entry)}.
|
|
339
|
+
Read the project's authoring instructions. Make focused source patches and preserve unrelated work.
|
|
340
|
+
The preview rebuilds on save; do not start another dev server.
|
|
341
|
+
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.
|
|
342
|
+
Slide numbers are 1-based. Verify source before editing; dependency paths are candidates, not an exact slide-to-file mapping.
|
|
343
|
+
Preview context captured with the latest terminal input: ${JSON.stringify(context)}`
|
|
344
|
+
} });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const origin = `http://${request.headers.host}`;
|
|
348
|
+
if (request.headers["sec-fetch-site"] === "cross-site" || request.headers.origin && request.headers.origin !== origin) {
|
|
349
|
+
json(403, { error: "Same-origin requests only" });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (request.method === "GET" && request.url === "/terminal/events") {
|
|
353
|
+
response.writeHead(200, { "Content-Type": "text/event-stream" });
|
|
354
|
+
response.write(`event: replay\ndata: ${JSON.stringify(output)}\n\n`);
|
|
355
|
+
response.write(`event: state\ndata: ${JSON.stringify(snapshot())}\n\n`);
|
|
356
|
+
clients.add(response);
|
|
357
|
+
request.on("close", () => clients.delete(response));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (request.method !== "POST" || request.headers.origin !== origin || request.headers["content-type"] !== "application/json") {
|
|
361
|
+
json(403, { error: "Same-origin JSON requests only" });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
let body = "";
|
|
366
|
+
request.setEncoding("utf8");
|
|
367
|
+
for await (const chunk of request) {
|
|
368
|
+
body += chunk;
|
|
369
|
+
if (Buffer.byteLength(body) > REQUEST_LIMIT) throw new Error("Request too large");
|
|
370
|
+
}
|
|
371
|
+
const value = JSON.parse(body);
|
|
372
|
+
if (!value || typeof value.client !== "string" || !/^[\w-]{1,64}$/.test(value.client)) throw new Error("Invalid terminal client");
|
|
373
|
+
if (closing || starting || stopping || process && owner !== value.client) {
|
|
374
|
+
json(409, { error: stopping ? "Claude Code is stopping" : "Claude Code is open in another tab. Use that tab to control it." });
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const size = () => {
|
|
378
|
+
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");
|
|
379
|
+
};
|
|
380
|
+
const updateFocus = () => {
|
|
381
|
+
if (!(value.slide === null || Number.isInteger(value.slide) && value.slide >= 0) || !Number.isInteger(value.revision)) throw new Error("Invalid preview context");
|
|
382
|
+
context = focus(value.slide, value.revision);
|
|
383
|
+
};
|
|
384
|
+
if (request.url === "/terminal/start") {
|
|
385
|
+
if (busy()) throw new Error("A Codex edit is already running");
|
|
386
|
+
size();
|
|
387
|
+
updateFocus();
|
|
388
|
+
if (!process) {
|
|
389
|
+
starting = true;
|
|
390
|
+
try {
|
|
391
|
+
if (globalThis.process.platform === "darwin") {
|
|
392
|
+
const helper = join(dirname(createRequire(import.meta.url).resolve("node-pty/package.json")), "prebuilds", `darwin-${globalThis.process.arch}`, "spawn-helper");
|
|
393
|
+
const info = await stat(helper).catch((error) => {
|
|
394
|
+
if (error.code === "ENOENT") return void 0;
|
|
395
|
+
throw error;
|
|
396
|
+
});
|
|
397
|
+
if (info && !(info.mode & 64)) await chmod(helper, info.mode | 64);
|
|
398
|
+
}
|
|
399
|
+
const pty = await import("node-pty");
|
|
400
|
+
if (process || closing) throw new Error("Terminal state changed. Try again.");
|
|
401
|
+
const settings = { hooks: { UserPromptSubmit: [{ hooks: [{
|
|
402
|
+
type: "http",
|
|
403
|
+
url: `${origin}/terminal/context`,
|
|
404
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
405
|
+
timeout: 5
|
|
406
|
+
}] }] } };
|
|
407
|
+
const env = { ...globalThis.process.env };
|
|
408
|
+
delete env.CLAUDECODE;
|
|
409
|
+
process = pty.spawn("claude", ["--settings", JSON.stringify(settings)], {
|
|
410
|
+
cwd: dirname(resolve(entry)),
|
|
411
|
+
name: "xterm-256color",
|
|
412
|
+
env,
|
|
413
|
+
cols: value.cols,
|
|
414
|
+
rows: value.rows
|
|
415
|
+
});
|
|
416
|
+
} finally {
|
|
417
|
+
starting = false;
|
|
418
|
+
}
|
|
419
|
+
owner = value.client;
|
|
420
|
+
output = "";
|
|
421
|
+
status = "Claude Code";
|
|
422
|
+
emit("replay", "");
|
|
423
|
+
process.onData((data) => {
|
|
424
|
+
output = (output + data).slice(-2e6);
|
|
425
|
+
emit("output", data);
|
|
426
|
+
});
|
|
427
|
+
completion = new Promise((done) => process.onExit(({ exitCode }) => {
|
|
428
|
+
process = void 0;
|
|
429
|
+
stopping = false;
|
|
430
|
+
status = `Claude Code exited (${exitCode}). Start to open a new session.`;
|
|
431
|
+
emit("state", snapshot());
|
|
432
|
+
done();
|
|
433
|
+
}));
|
|
434
|
+
}
|
|
435
|
+
} else if (request.url === "/terminal/input") {
|
|
436
|
+
if (!process) throw new Error("Start Claude Code first");
|
|
437
|
+
if (typeof value.data !== "string" || value.data.length > 32e3) throw new Error("Invalid terminal input");
|
|
438
|
+
updateFocus();
|
|
439
|
+
process.write(value.data);
|
|
440
|
+
} else if (request.url === "/terminal/resize") {
|
|
441
|
+
size();
|
|
442
|
+
process?.resize(value.cols, value.rows);
|
|
443
|
+
} else if (request.url === "/terminal/stop") await stop();
|
|
444
|
+
else {
|
|
445
|
+
json(404, { error: "Not found" });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
emit("state", snapshot());
|
|
449
|
+
json(200, snapshot());
|
|
450
|
+
} catch (cause) {
|
|
451
|
+
json(400, { error: cause instanceof Error ? cause.message : "Invalid terminal request" });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return {
|
|
455
|
+
handle,
|
|
456
|
+
isRunning: () => starting || !!process,
|
|
457
|
+
async close() {
|
|
458
|
+
closing = true;
|
|
459
|
+
await stop();
|
|
460
|
+
for (const client of clients) client.end();
|
|
461
|
+
clients.clear();
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
}
|
|
231
465
|
//#endregion
|
|
232
466
|
//#region src/chat.ts
|
|
467
|
+
const markdown = new MarkdownIt({
|
|
468
|
+
html: false,
|
|
469
|
+
linkify: false
|
|
470
|
+
});
|
|
471
|
+
markdown.renderer.rules.image = (tokens, index) => markdown.utils.escapeHtml(tokens[index]?.content ?? "");
|
|
233
472
|
/** One shared conversation and one editing process per dev server. */
|
|
234
|
-
function createChat(entry, notify) {
|
|
473
|
+
function createChat(entry, notify, busy = () => false) {
|
|
235
474
|
let messages = [];
|
|
236
|
-
let provider = "
|
|
475
|
+
let provider = "codex";
|
|
237
476
|
let child;
|
|
238
477
|
let status = "Ready";
|
|
239
478
|
let stopping = false;
|
|
240
479
|
let killTimer;
|
|
241
480
|
let completion = Promise.resolve();
|
|
242
481
|
const snapshot = () => ({
|
|
243
|
-
messages
|
|
482
|
+
messages: messages.map((message) => ({
|
|
483
|
+
...message,
|
|
484
|
+
...message.role === "assistant" ? { html: markdown.render(message.text) } : {}
|
|
485
|
+
})),
|
|
244
486
|
provider,
|
|
245
487
|
running: !!child,
|
|
246
488
|
status
|
|
@@ -288,22 +530,13 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
|
|
|
288
530
|
if (messages.length > 100) messages = messages.slice(-100);
|
|
289
531
|
status = "Working…";
|
|
290
532
|
stopping = false;
|
|
291
|
-
child = spawn(selected,
|
|
533
|
+
child = spawn(selected, [
|
|
292
534
|
"exec",
|
|
293
535
|
"--json",
|
|
294
536
|
"--sandbox",
|
|
295
537
|
"workspace-write",
|
|
296
538
|
"--skip-git-repo-check",
|
|
297
539
|
"-"
|
|
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
540
|
], {
|
|
308
541
|
cwd: dirname(resolve(entry)),
|
|
309
542
|
stdio: [
|
|
@@ -324,29 +557,13 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
|
|
|
324
557
|
if (!line.trim()) return;
|
|
325
558
|
try {
|
|
326
559
|
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
|
-
}
|
|
560
|
+
if (event.type === "item.completed" && event.item?.type === "agent_message") append(event.item.text ?? "");
|
|
561
|
+
if (event.type === "item.started") {
|
|
562
|
+
status = "Editing / inspecting…";
|
|
563
|
+
notify();
|
|
349
564
|
}
|
|
565
|
+
if (event.type === "turn.completed") sawResult = true;
|
|
566
|
+
if (event.type === "turn.failed" || event.type === "error") failure = event.error?.message ?? event.message ?? "Agent failed";
|
|
350
567
|
} catch {
|
|
351
568
|
failure = "Unexpected CLI output. Update your CLI and try again.";
|
|
352
569
|
}
|
|
@@ -434,10 +651,14 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
|
|
|
434
651
|
json(404, { error: "Not found" });
|
|
435
652
|
return;
|
|
436
653
|
}
|
|
437
|
-
if (typeof value.message !== "string" || !value.message.trim() || value.message.length > 16e3 ||
|
|
654
|
+
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
655
|
json(400, { error: "Invalid chat request" });
|
|
439
656
|
return;
|
|
440
657
|
}
|
|
658
|
+
if (busy()) {
|
|
659
|
+
json(409, { error: "End the Claude Code session before starting a Codex edit" });
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
441
662
|
const context = focus(value.slide, value.revision);
|
|
442
663
|
start(value.message.trim(), value.provider, context);
|
|
443
664
|
json(202, snapshot());
|
|
@@ -447,6 +668,7 @@ Current request and preview context (JSON): ${JSON.stringify(user)}`;
|
|
|
447
668
|
}
|
|
448
669
|
return {
|
|
449
670
|
handle,
|
|
671
|
+
isRunning: () => !!child,
|
|
450
672
|
async close() {
|
|
451
673
|
stop();
|
|
452
674
|
await completion;
|
|
@@ -467,7 +689,8 @@ async function serveDeck(entry, port = 4173) {
|
|
|
467
689
|
const clients = /* @__PURE__ */ new Set();
|
|
468
690
|
const chat = createChat(entry, () => {
|
|
469
691
|
for (const client of clients) client.write("data: chat\n\n");
|
|
470
|
-
});
|
|
692
|
+
}, () => terminal.isRunning());
|
|
693
|
+
const terminal = createTerminal(entry, () => chat.isRunning());
|
|
471
694
|
const server = createServer((request, response) => {
|
|
472
695
|
const host = request.headers.host;
|
|
473
696
|
if (host !== `127.0.0.1:${actualPort}` && host !== `localhost:${actualPort}`) {
|
|
@@ -475,7 +698,7 @@ async function serveDeck(entry, port = 4173) {
|
|
|
475
698
|
return;
|
|
476
699
|
}
|
|
477
700
|
response.setHeader("Cache-Control", "no-store");
|
|
478
|
-
if (request.url === "/chat" || request.url?.startsWith("/chat/")) chat.handle(request, response, (index, viewedRevision) => {
|
|
701
|
+
if (request.url === "/chat" || request.url?.startsWith("/chat/") || request.url?.startsWith("/terminal/")) (request.url.startsWith("/terminal/") ? terminal : chat).handle(request, response, (index, viewedRevision) => {
|
|
479
702
|
if (viewedRevision !== revision) throw new Error("Preview changed. Review the current slide and send again.");
|
|
480
703
|
if (index !== null && (!latest || index >= latest.slides.length)) throw new Error("Slide no longer exists.");
|
|
481
704
|
return {
|
|
@@ -488,7 +711,13 @@ async function serveDeck(entry, port = 4173) {
|
|
|
488
711
|
buildError: error
|
|
489
712
|
};
|
|
490
713
|
});
|
|
491
|
-
else if (request.method
|
|
714
|
+
else if (request.method === "GET" && ["/terminal.js", "/terminal.css"].includes(request.url ?? "")) {
|
|
715
|
+
const asset = request.url === "/terminal.js" ? "terminal-client.js" : "terminal-client.css";
|
|
716
|
+
readFile(new URL(asset, import.meta.url)).then((bytes) => {
|
|
717
|
+
response.writeHead(200, { "Content-Type": asset.endsWith(".css") ? "text/css" : "text/javascript" });
|
|
718
|
+
response.end(bytes);
|
|
719
|
+
}, () => response.writeHead(500).end("Terminal assets unavailable. Rebuild pptx-dev."));
|
|
720
|
+
} else if (request.method !== "GET") response.writeHead(405).end();
|
|
492
721
|
else if (request.url === "/events") {
|
|
493
722
|
response.writeHead(200, { "Content-Type": "text/event-stream" });
|
|
494
723
|
response.write("data: ready\n\n");
|
|
@@ -593,6 +822,7 @@ async function serveDeck(entry, port = 4173) {
|
|
|
593
822
|
closed = true;
|
|
594
823
|
clearTimeout(timer);
|
|
595
824
|
watcher.close();
|
|
825
|
+
await terminal.close();
|
|
596
826
|
await chat.close();
|
|
597
827
|
await builder.close();
|
|
598
828
|
for (const client of clients) client.end();
|