@office-kit/pptx-dev 0.4.2 → 0.5.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 CHANGED
@@ -50,7 +50,7 @@ latency. The deck is still evaluated in a fresh worker on every build. Changes t
50
50
  shared resources, including themes, relationships, charts and images, redraw all
51
51
  slides. Update time depends on the deck size and any code it runs.
52
52
 
53
- ## Chat in the preview
53
+ ## Agents in the preview
54
54
 
55
55
  The right panel defaults to **Claude Code**. Click **Start** to open the locally
56
56
  installed `claude` CLI in an interactive terminal. Install and sign in to Claude
@@ -80,14 +80,13 @@ Dependency paths are source candidates, not an exact slide-to-file mapping.
80
80
  whole deck can edit other files or shared styling.
81
81
 
82
82
  Reloading the browser reconnects to the same process and replays its recent
83
- terminal output (up to 2 MB). One browser tab controls the session; other tabs
84
- can view it. Keep the owning tab open, or end the session there before moving to
85
- another tab. **End session** stops the process; saved edits remain. Start opens
83
+ terminal output (up to 2 MB). Each pane has its own session and terminal controls. Keep the owning tab open
84
+ while agents are working. **End session** stops the process; saved edits remain. Start opens
86
85
  a new session, and Claude's `/resume` command can restore an earlier conversation.
87
- Stopping the dev server also stops the embedded session. This is independent of
86
+ Stopping the dev server also stops all embedded sessions. This is independent of
88
87
  the Claude Code conversation that may have launched your dev server.
89
88
 
90
- Select **Codex** while Claude Code is stopped to use the existing message-based
89
+ Select **Codex** while Claude Code in that pane is stopped to use the existing message-based
91
90
  chat. Codex replies render Markdown (including lists, code blocks and tables);
92
91
  raw HTML, unsafe links and remote images are not rendered. Codex still uses
93
92
  `exec --json` with the `workspace-write` sandbox and its installed CLI login.
@@ -98,16 +97,25 @@ each request. The dev server retains up to 100 messages until it exits.
98
97
  sends a message. Model/settings menus in the embedded terminal are currently
99
98
  specific to Claude Code.
100
99
 
100
+ Use the **Split right** or **Split down** icons in a pane's title bar to run up to
101
+ four agents side by side or stacked. Each pane independently selects Claude Code
102
+ or Codex, retains its own conversation and receives the currently selected slide.
103
+ Drag the divider to adjust the split, or focus it and use the arrow keys. Layout
104
+ and Claude terminal sessions survive a browser reload. Splitting preserves running
105
+ sessions and unsent drafts. **Close** ends only that pane's agent; closing the last
106
+ pane opens a fresh one. All agents edit the same project directory, so assign
107
+ separate slides or files when working simultaneously.
108
+
101
109
  Drag the left edge of the chat panel to adjust its width. The preview remembers the
102
110
  width in this browser; double-click the edge to reset it. You can also focus the
103
111
  edge and use the left/right arrow keys (Shift for larger steps), or Home/End for
104
112
  the minimum/maximum width. Narrow screens keep the stacked layout.
105
113
 
106
- Use **Chat** in the header to hide/show the panel. On narrow screens the panel
114
+ Use **Agents** in the header to hide/show the panel. On narrow screens the panel
107
115
  moves below the preview. Presentation mode hides it without ending the session.
108
116
  Both providers save TSX directly, which triggers the usual automatic rebuild.
109
117
  The server binds to loopback and requires same-origin JSON for terminal control;
110
- the local context hook uses a separate per-server token.
118
+ the local context hook uses a separate per-session token.
111
119
 
112
120
  The terminal uses `node-pty` and xterm.js. Linux systems without a matching PTY
113
121
  prebuild need the native build prerequisites documented by
package/dist/cli.mjs CHANGED
@@ -8,38 +8,32 @@ import { createServer } from "node:http";
8
8
  import { randomBytes } from "node:crypto";
9
9
  import MarkdownIt from "markdown-it";
10
10
  import { spawn } from "node:child_process";
11
- //#region src/page.ts
12
- const page = `<!doctype html>
13
- <html lang="en">
14
- <meta charset="utf-8"><meta name="viewport" content="width=device-width">
15
- <title>Office Kit — PowerPoint preview</title>
16
- <link rel="stylesheet" href="/terminal.css">
17
- <style>
18
- *{box-sizing:border-box}
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}
20
- button,a,select{font:inherit;color:inherit}button,select,.download{border:1px solid #d4d9e2;border-radius:6px;background:#fff;padding:7px 12px;text-decoration:none;cursor:pointer}
21
- button:hover:not(:disabled),.download:hover{background:#f0f3f9}button:disabled{opacity:.4;cursor:default}
22
- :focus-visible{outline:2px solid #4967dd;outline-offset:3px}
23
- header{display:flex;align-items:center;gap:16px;padding:0 20px;background:#fff;border-bottom:1px solid #d4d9e2;min-width:0}
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}
25
- #status{flex:1;color:#667085;min-width:0}#present{background:#293c73;color:#fff;border-color:#293c73}
11
+ //#region src/styles.ts
12
+ const previewStyles = `*{box-sizing:border-box}
13
+ body{margin:0;height:100dvh;overflow:hidden;background:#e9eaf4;color:#dce1f1;font:13px system-ui;display:grid;grid-template-rows:60px minmax(0,1fr) 42px}
14
+ button,a,select{font:inherit;color:inherit}button,select,.download{border:1px solid #30364d;border-radius:9px;background:#191e30;padding:7px 12px;text-decoration:none;cursor:pointer}
15
+ button:hover:not(:disabled),.download:hover{background:#292f48}button:disabled{opacity:.4;cursor:default}
16
+ :focus-visible{outline:2px solid #9b87ff;outline-offset:3px}
17
+ header{display:flex;align-items:center;gap:16px;padding:0 20px;background:linear-gradient(110deg,#171b2f,#211c38 60%,#171b2f);border-bottom:1px solid #38304d;min-width:0}
18
+ .brand{font-weight:700;font-size:17px;white-space:nowrap}.badge{font-size:11px;color:#b2a4fb;background:#342c54;border-radius:4px;padding:3px 6px}
19
+ #status{flex:1;color:#9ba5be;min-width:0}#present{background:#7154e8;color:#191e30;border-color:#7154e8}
26
20
  .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}
27
- .filmstrip{background:#f7f8fa;border-right:1px solid #d4d9e2;overflow:auto;overscroll-behavior:contain;padding:16px 12px}
28
- .filmstrip h2{margin:0 0 12px 26px;text-transform:uppercase;letter-spacing:.1em;font-size:10px;color:#7c8596;font-weight:600}
21
+ .filmstrip{background:#111625;border-right:1px solid #30364d;overflow:auto;overscroll-behavior:contain;padding:16px 12px}
22
+ .filmstrip h2{margin:0 0 12px 26px;text-transform:uppercase;letter-spacing:.1em;font-size:10px;color:#8490ac;font-weight:600}
29
23
  #thumbnails{display:flex;flex-direction:column;gap:12px;margin:0;padding:0;list-style:none}
30
24
  .thumbnail{display:flex;align-items:flex-start;gap:8px;width:100%;padding:4px 3px;border:0;background:transparent;border-radius:5px;text-align:left}
31
- .slide-number{width:18px;flex:none;text-align:right;font-size:11px;color:#737d8e;padding-top:5px}
32
- .thumbnail img{display:block;min-width:0;width:calc(100% - 26px);background:white;box-shadow:0 1px 4px #19212d18;border:2px solid transparent;border-radius:3px;aspect-ratio:var(--slide-ratio,16/9);object-fit:contain}
33
- .thumbnail[aria-current="true"]{background:#e7edff}.thumbnail[aria-current="true"] img{border-color:#4967dd}.thumbnail[aria-current="true"] .slide-number{color:#3654c1;font-weight:700}
25
+ .slide-number{width:18px;flex:none;text-align:right;font-size:11px;color:#8995b0;padding-top:5px}
26
+ .thumbnail img{display:block;min-width:0;width:calc(100% - 26px);background:white;box-shadow:0 1px 4px #080b1933;border:2px solid transparent;border-radius:3px;aspect-ratio:var(--slide-ratio,16/9);object-fit:contain}
27
+ .thumbnail[aria-current="true"]{background:#31274e}.thumbnail[aria-current="true"] img{border-color:#9b87ff}.thumbnail[aria-current="true"] .slide-number{color:#b6a5ff;font-weight:700}
34
28
  main{min-width:0;min-height:0;display:flex;flex-direction:column}
35
29
  #error{flex:none;max-height:30%;overflow:auto;background:#fff0ef;color:#922e25;margin:0;padding:16px 20px;white-space:pre-wrap;border-bottom:1px solid #e8bbb7}
36
30
  #error[hidden]{display:none}
37
- #stage{flex:1;min-height:0;overflow:auto;display:flex;padding:32px;overscroll-behavior:contain}
38
- #slide{position:relative;flex:none;margin:auto;background:white;box-shadow:0 3px 24px #19212d20;overflow:hidden}
31
+ #stage{background:radial-gradient(ellipse at 20% 15%,#ddd8f6aa,transparent 65%),radial-gradient(#a0a4bf33 1px,transparent 1px),#edeef6;background-size:auto,18px 18px,auto;flex:1;min-height:0;overflow:auto;display:flex;padding:32px;overscroll-behavior:contain}
32
+ #slide{position:relative;flex:none;margin:auto;background:white;box-shadow:0 20px 70px #28234825,0 2px 8px #28234815;overflow:hidden}
39
33
  #slide{user-select:text;-webkit-user-select:text}
40
- #empty{margin:auto;color:#737d8e}
41
- footer{display:flex;align-items:center;gap:14px;padding:0 16px;background:#fff;border-top:1px solid #d4d9e2;font-size:12px}
42
- #count{min-width:90px}footer .hint{flex:1;color:#737d8e}footer button{padding:3px 10px}footer select{padding:3px 8px}
34
+ #empty{margin:auto;color:#8995b0}
35
+ footer{display:flex;align-items:center;gap:14px;padding:0 16px;background:#191e30;border-top:1px solid #30364d;font-size:12px}
36
+ #count{min-width:90px}footer .hint{flex:1;color:#8995b0}footer button{padding:3px 10px}footer select{padding:3px 8px}
43
37
  #presentation-controls{display:none}
44
38
  body.presenting{grid-template-rows:minmax(0,1fr);background:#111}
45
39
  .presenting header,.presenting footer,.presenting .filmstrip,.presenting #chat{display:none}
@@ -48,44 +42,50 @@ body.presenting{grid-template-rows:minmax(0,1fr);background:#111}
48
42
  .presenting #presentation-controls:hover,.presenting #presentation-controls:focus-within{opacity:1}
49
43
  #presentation-controls button{background:transparent;color:white;border-color:#5d6575}
50
44
  @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{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}
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}
45
+ #chat{position:relative;min-width:0;min-height:0;display:flex;flex-direction:column;background:#191e30;border-left:1px solid #30364d}
46
+ #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:#9b87ff}body.resizing-chat,body.resizing-chat *{cursor:col-resize!important;user-select:none!important}
47
+ .chat-heading{display:flex;align-items:center;gap:8px;padding:16px;border-bottom:1px solid #2a3046}.chat-heading strong{flex:1}.chat-heading button{padding:5px 8px}
54
48
  #messages{flex:1;overflow:auto;padding:16px;display:flex;flex-direction:column;gap:16px;overscroll-behavior:contain}
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}
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}
49
+ .chat-message{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.6}.chat-message.user{background:#302752;border-radius:8px;padding:10px 12px}.chat-message small{display:block;color:#a4afc6;font-size:11px;margin-bottom:4px}.chat-intro{color:#8995b0;line-height:1.7}
50
+ #chat-form{padding:14px;border-top:1px solid #2a3046}#chat-context{font-size:11px;color:#9b87ff;margin-bottom:8px}#chat-input{font:inherit;resize:vertical;min-height:90px;max-height:200px;width:100%;padding:10px;border:1px solid #30364d;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:#7154e8;color:white}#chat-status{padding:0 14px 12px;color:#9ba5be;font-size:11px;white-space:pre-wrap;max-height:90px;overflow:auto}
57
51
  body.chat-hidden .workspace{grid-template-columns:190px minmax(0,1fr)}body.chat-hidden #chat{display:none}
58
52
  body.presenting .workspace{grid-template-columns:minmax(0,1fr)}
59
53
  @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}}
54
+ @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 #30364d}.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}}
61
55
  @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}
62
56
 
63
57
  [hidden]{display:none!important}
64
58
  .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}
59
+ #chat-context{margin:0;padding:8px 14px;border-bottom:1px solid #2a3046;color:#9ba5be;font-size:11px}
66
60
  #claude-panel,#codex-panel{display:flex;flex-direction:column;flex:1;min-height:0}
67
61
  #claude-panel{background:#171b24;color:#e4e8f0}
68
62
  .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
63
  .terminal-toolbar button:hover:not(:disabled){background:#35415a}
70
- #terminal{flex:1;min-height:0;overflow:hidden;padding:4px 8px 8px}.xterm{height:100%}
64
+ #terminal{flex:1;min-height:0;overflow:hidden;padding:0}.xterm{height:100%}
71
65
  #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}
66
+ .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:#22283b;border:1px solid #2a3046;border-radius:6px;white-space:pre}.markdown code{font-size:.9em;background:#22283b;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 #30364d;color:#9ba5be}.markdown table{display:block;overflow:auto;border-collapse:collapse}.markdown th,.markdown td{padding:6px 8px;border:1px solid #30364d}.markdown h1,.markdown h2,.markdown h3{font-size:1.1em}.markdown a{color:#b6a5ff;text-decoration:underline}
67
+
68
+ .brand{display:flex;align-items:center;gap:10px;letter-spacing:-.5px}.brand svg{width:32px;height:32px;flex:none}.brand em{font-style:normal;font-weight:400;color:#aca4ce}.badge{text-transform:uppercase;letter-spacing:.14em;font-size:9px;border:1px solid #61508866;border-radius:5px}#status{font-size:11px}#present,#chat-send{color:#fff;background:linear-gradient(120deg,#8061ed,#634de0);border-color:#a38aef55;box-shadow:0 3px 15px #7455e82a}#toggle-chat[aria-expanded="true"]{background:#35294f;border-color:#6f528b;color:#d6c5ff}.download{background:transparent;color:#bcc5dc}header button,header .download{font-size:12px}.filmstrip{background:linear-gradient(180deg,#161b2c,#111625)}.thumbnail{transition:background .15s}.thumbnail[aria-current="true"]{box-shadow:inset 0 0 0 1px #9980ef33}.thumbnail[aria-current="true"] img{box-shadow:0 0 16px #9567ed22}footer{background:#141929;color:#b9c2d8}footer .hint{font-size:10px}#count{font-size:11px;font-weight:600}#chat{background:#101422}.workspace-heading{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;color:#c7bee5;font-size:10px;font-weight:600;letter-spacing:.12em}.workspace-heading b{font-size:9px;color:#89cdbc;font-weight:500;letter-spacing:.08em}.workspace-heading b::before{content:"";display:inline-block;width:5px;height:5px;background:#86d5bb;border-radius:50%;margin-right:6px;box-shadow:0 0 8px #86d5bb66}.chat-heading{background:#191e30}.chat-heading select{background:transparent;color:#e4e5f3;font-size:12px}.chat-heading option{background:#191e30}#chat-context{background:#1a1e30;color:#a59abb;font-size:10px}.terminal-toolbar{gap:6px;padding:8px;font-size:10px}.terminal-toolbar button{white-space:nowrap}#terminal-start{background:#6c52c9;border-color:#9678e9}.terminal-frame{display:flex;flex:1;min-height:0;min-width:0;padding:4px 8px 8px;overflow:hidden}#terminal{width:100%;min-width:0}#chat-input{color:#e2e5f3;background:#131827;border-color:#3b3f58}#chat-input:focus{outline:none;border-color:#9b87ff;box-shadow:0 0 0 3px #9b87ff18}.chat-message.user{border:1px solid #67509655}.chat-intro{font-size:12px}.markdown pre{border-color:#3a4059}.markdown code{color:#d2c6fa}#error{background:#38242b;color:#ffb8b0}
69
+ @media(max-width:700px){.brand svg{width:26px;height:26px}.brand{gap:6px}.workspace-heading{padding:7px 12px}header .download{font-size:10px;padding:7px}.badge{display:none}}
70
+ `;
71
+ //#endregion
72
+ //#region src/page.ts
73
+ const page = `<!doctype html>
74
+ <html lang="en">
75
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width">
76
+ <title>Office Kit — PowerPoint preview</title>
77
+ <link rel="stylesheet" href="/terminal.css">
78
+ <style>
79
+ ${previewStyles}
80
+ #agent-workspace{position:relative;flex:1;min-height:0;min-width:0;display:flex;overflow:auto}.agent-pane{position:absolute;padding:3px 5px;display:flex;flex-direction:column;flex:1;min-width:0;min-height:0;overflow:hidden}.agent-pane iframe{border:0;width:100%;flex:1;min-height:0;border-radius:0 0 9px 9px}.agent-tools{display:flex;gap:4px;align-items:center;flex-wrap:wrap;padding:6px;background:#20253a;border:1px solid #34334e;border-radius:9px 9px 0 0}.agent-tools span{flex:1;font-size:10px;letter-spacing:.04em;color:#b3abc9}.agent-tools svg{width:14px;height:14px}.agent-pane:focus-within .agent-tools{border-color:#8b6ed577;background:#29243e}.agent-tools button{font-size:11px;padding:5px;background:transparent;border:0;display:grid;place-items:center}.agent-divider{position:absolute;z-index:2;background:#101422;cursor:col-resize;touch-action:none}.agent-divider.vertical{cursor:row-resize}.agent-divider:hover,.agent-divider:focus-visible{background:#9b87ff}.splitting-agents iframe,.resizing-chat iframe{pointer-events:none}
73
81
  </style>
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>
82
+ <header><span class="brand"><svg viewBox="0 0 36 36" aria-hidden="true"><defs><linearGradient id="brand-gradient" x2="1" y2="1"><stop stop-color="#c39aff"/><stop offset="1" stop-color="#7165f4"/></linearGradient></defs><rect x="2" y="2" width="32" height="32" rx="10" fill="url(#brand-gradient)"/><path d="M11 10h9l6 6v10H11z" fill="none" stroke="white" stroke-width="1.8" stroke-linejoin="round"/><path d="M20 10v7h6M15 21h7M15 25h4" fill="none" stroke="white" stroke-width="1.8" stroke-linecap="round"/><path d="m28 3 1.2 3.8L33 8l-3.8 1.2L28 13l-1.2-3.8L23 8l3.8-1.2z" fill="#e7dbff"/></svg>Office <em>Kit</em></span><span class="badge">Studio</span><span id="status" role="status">Building…</span><button id="toggle-chat" aria-expanded="true" aria-controls="chat">✦ Agents</button><button id="present" disabled>Present</button><a class="download" href="/deck.pptx">Download PPTX</a></header>
75
83
  <div class="workspace">
76
84
  <nav class="filmstrip" aria-label="Slides"><h2>Slides</h2><ol id="thumbnails"></ol></nav>
77
85
  <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>
78
86
  <aside id="chat" aria-label="Slide chat">
79
87
  <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>
86
- <div id="chat-status" role="status">Ready</div>
87
- </div>
88
- </aside>
88
+ <div class="workspace-heading"><span>✦ AI WORKSPACE</span><b>LOCAL</b></div><div id="agent-workspace"></div><div id="chat-context" hidden></div></aside>
89
89
  </div>
90
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>
91
91
  <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>
@@ -144,6 +144,7 @@ function selectSlide(next,focusThumbnail=false,reveal=true){
144
144
  const count=state.slides.length?'Slide '+(index+1)+' of '+state.slides.length:'No slides';
145
145
  byId('chat-context').textContent=state.slides.length?count:'Whole project';
146
146
  byId('chat-context').dataset.focus=JSON.stringify({slide:state.slides.length?index:null,revision:state.revision??0});
147
+ window.dispatchEvent(new Event('agent-focus'));
147
148
  byId('count').textContent=count;byId('present-count').textContent=count;
148
149
  for(const id of ['prev','present-prev'])byId(id).disabled=index===0;
149
150
  for(const id of ['next','present-next'])byId(id).disabled=index>=state.slides.length-1;
@@ -234,6 +235,30 @@ async function refresh(){
234
235
  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);}}
235
236
  catch{if(id===refreshId)byId('status').textContent='Reconnecting…';}
236
237
  }
238
+ byId('toggle-chat').onclick=()=>{const hidden=document.body.classList.toggle('chat-hidden');byId('toggle-chat').setAttribute('aria-expanded',String(!hidden));resize();};
239
+ const events=new EventSource('/events');events.onmessage=event=>{if(event.data==='chat'){window.dispatchEvent(new Event('agent-chat'));return;}if(event.data==='ready'){delete state.revision;}void refresh();};events.onerror=()=>{byId('status').textContent='Reconnecting…'};refresh();
240
+ <\/script><script type="module" src="/terminal.js"><\/script></html>`;
241
+ //#endregion
242
+ //#region src/agent-page.ts
243
+ const agentPage = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><link rel="stylesheet" href="/terminal.css"><style>${previewStyles}
244
+ body{display:flex;flex-direction:column;background:#191e30;height:100dvh;min-width:0;overflow:auto;overflow-x:hidden} .chat-heading{flex-wrap:wrap} .chat-heading select{min-width:100px}.chat-heading,#chat-context{flex-shrink:0}#claude-panel{min-height:100px}#codex-panel{min-height:240px}
245
+ </style><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>
246
+ <div id="chat-context">No slide selected</div>
247
+ <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 class="terminal-frame"><div id="terminal"></div></div></section>
248
+ <div id="codex-panel" hidden>
249
+ <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>
250
+ <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>
251
+ <div id="chat-status" role="status">Ready</div>
252
+ </div>
253
+ <script>
254
+ const byId=id=>document.getElementById(id),base=location.pathname;
255
+ let focus={slide:null,revision:0};
256
+ window.addEventListener('message',event=>{
257
+ if(event.origin!==location.origin||event.source!==parent)return;
258
+ if(event.data?.type==='focus'){focus=event.data.focus;byId('chat-context').dataset.focus=JSON.stringify(focus);byId('chat-context').textContent=event.data.label;}
259
+ if(event.data?.type==='chat')void refreshChat();
260
+ });
261
+ parent.postMessage({type:'agent-ready'},location.origin);
237
262
  let chatRequest=0,chatBusy=false,lastMessages='';
238
263
  function renderChat(chat){
239
264
  chatBusy=chat.running;
@@ -259,12 +284,12 @@ function renderChat(chat){
259
284
  }
260
285
  async function refreshChat(){
261
286
  const id=++chatRequest;
262
- try{const response=await fetch('/chat');if(!response.ok)throw new Error('Chat unavailable');const chat=await response.json();if(id===chatRequest)renderChat(chat);}
287
+ try{const response=await fetch(base+'/chat');if(!response.ok)throw new Error('Chat unavailable');const chat=await response.json();if(id===chatRequest)renderChat(chat);}
263
288
  catch(error){if(id===chatRequest)byId('chat-status').textContent=error.message;}
264
289
  }
265
290
  async function chatAction(path,payload){
266
291
  ++chatRequest;
267
- const response=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
292
+ const response=await fetch(base+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
268
293
  const result=await response.json();if(!response.ok)throw new Error(result.error||'Chat request failed');
269
294
  await refreshChat();
270
295
  }
@@ -272,21 +297,21 @@ byId('chat-form').onsubmit=async event=>{
272
297
  event.preventDefault();if(chatBusy)return;
273
298
  const input=byId('chat-input'),message=input.value.trim();if(!message)return;
274
299
  chatBusy=true;byId('chat-send').disabled=true;
275
- 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='';}
300
+ try{await chatAction('/chat',{message,provider:byId('chat-provider').value,...focus});if(input.value.trim()===message)input.value='';}
276
301
  catch(error){byId('chat-status').textContent=error.message;chatBusy=false;byId('chat-send').disabled=false;}
277
302
  };
278
303
  byId('chat-input').onkeydown=event=>{if(event.key==='Enter'&&(event.metaKey||event.ctrlKey)&&!event.isComposing){event.preventDefault();byId('chat-form').requestSubmit();}};
279
304
  for(const action of ['stop','reset'])byId('chat-'+action).onclick=()=>chatAction('/chat/'+action,{}).catch(error=>{byId('chat-status').textContent=error.message;});
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;};
305
+ byId('chat-provider').onchange=()=>{try{sessionStorage.setItem('office-kit-provider'+base,byId('chat-provider').value);}catch{}const claude=byId('chat-provider').value==='claude';byId('claude-panel').hidden=!claude;byId('codex-panel').hidden=claude;byId('chat-reset').hidden=claude;};
306
+ try{byId('chat-provider').value=sessionStorage.getItem('office-kit-provider'+base)||'claude';}catch{}
307
+ byId('chat-provider').onchange();
282
308
  void refreshChat();
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();
284
309
  <\/script><script type="module" src="/terminal.js"><\/script></html>`;
285
310
  //#endregion
286
311
  //#region src/terminal.ts
287
312
  const REQUEST_LIMIT = 128e3;
288
313
  /** A real interactive Claude session, shared across reloads of its owning tab. */
289
- function createTerminal(entry, busy = () => false) {
314
+ function createTerminal(entry, busy = () => false, base = "") {
290
315
  let process;
291
316
  let owner = "";
292
317
  let output = "";
@@ -375,7 +400,7 @@ Preview context captured with the latest terminal input: ${JSON.stringify(contex
375
400
  return;
376
401
  }
377
402
  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");
403
+ if (!Number.isInteger(value.cols) || value.cols < 2 || value.cols > 500 || !Number.isInteger(value.rows) || value.rows < 1 || value.rows > 300) throw new Error("Invalid terminal dimensions");
379
404
  };
380
405
  const updateFocus = () => {
381
406
  if (!(value.slide === null || Number.isInteger(value.slide) && value.slide >= 0) || !Number.isInteger(value.revision)) throw new Error("Invalid preview context");
@@ -400,7 +425,7 @@ Preview context captured with the latest terminal input: ${JSON.stringify(contex
400
425
  if (process || closing) throw new Error("Terminal state changed. Try again.");
401
426
  const settings = { hooks: { UserPromptSubmit: [{ hooks: [{
402
427
  type: "http",
403
- url: `${origin}/terminal/context`,
428
+ url: `${origin}${base}/terminal/context`,
404
429
  headers: { Authorization: `Bearer ${token}` },
405
430
  timeout: 5
406
431
  }] }] } };
@@ -687,10 +712,23 @@ async function serveDeck(entry, port = 4173) {
687
712
  let pending = false;
688
713
  let closed = false;
689
714
  const clients = /* @__PURE__ */ new Set();
690
- const chat = createChat(entry, () => {
691
- for (const client of clients) client.write("data: chat\n\n");
692
- }, () => terminal.isRunning());
693
- const terminal = createTerminal(entry, () => chat.isRunning());
715
+ const sessions = /* @__PURE__ */ new Map();
716
+ function session(id) {
717
+ let result = sessions.get(id);
718
+ if (!result) {
719
+ const chat = createChat(entry, () => {
720
+ for (const client of clients) client.write("data: chat\n\n");
721
+ }, () => terminal.isRunning());
722
+ const terminal = createTerminal(entry, () => chat.isRunning(), id ? "/agents/" + id : "");
723
+ result = {
724
+ chat,
725
+ terminal,
726
+ closing: false
727
+ };
728
+ sessions.set(id, result);
729
+ }
730
+ return result;
731
+ }
694
732
  const server = createServer((request, response) => {
695
733
  const host = request.headers.host;
696
734
  if (host !== `127.0.0.1:${actualPort}` && host !== `localhost:${actualPort}`) {
@@ -698,20 +736,73 @@ async function serveDeck(entry, port = 4173) {
698
736
  return;
699
737
  }
700
738
  response.setHeader("Cache-Control", "no-store");
701
- if (request.url === "/chat" || request.url?.startsWith("/chat/") || request.url?.startsWith("/terminal/")) (request.url.startsWith("/terminal/") ? terminal : chat).handle(request, response, (index, viewedRevision) => {
702
- if (viewedRevision !== revision) throw new Error("Preview changed. Review the current slide and send again.");
703
- if (index !== null && (!latest || index >= latest.slides.length)) throw new Error("Slide no longer exists.");
704
- return {
705
- slide: index === null ? null : index + 1,
706
- count: latest?.slides.length ?? 0,
707
- revision,
708
- text: index === null ? "" : (latest?.slideTexts[index] ?? "").slice(0, 12e3),
709
- entry: resolve(entry),
710
- files: latest?.dependencies ?? [resolve(entry)],
711
- buildError: error
712
- };
713
- });
714
- else if (request.method === "GET" && ["/terminal.js", "/terminal.css"].includes(request.url ?? "")) {
739
+ const match = request.url?.match(/^\/agents\/([\w-]{1,64})(\/(?:chat(?:\/(?:stop|reset))?|terminal\/(?:start|input|resize|stop|events|context)|close))?$/);
740
+ if (request.url?.startsWith("/agents/") && !match) {
741
+ response.writeHead(404).end();
742
+ return;
743
+ }
744
+ const id = match?.[1] ?? "";
745
+ if (match) {
746
+ if (request.headers["sec-fetch-site"] === "cross-site" || request.headers.origin && request.headers.origin !== `http://${host}`) {
747
+ response.writeHead(403).end();
748
+ return;
749
+ }
750
+ if (sessions.get(id)?.closing) {
751
+ response.writeHead(409).end("{\"error\":\"Agent is closing\"}");
752
+ return;
753
+ }
754
+ if (match[2] === "/close") {
755
+ if (request.method !== "POST" || request.headers.origin !== `http://${host}` || request.headers["content-type"] !== "application/json") {
756
+ response.writeHead(403).end();
757
+ return;
758
+ }
759
+ request.resume();
760
+ const current = sessions.get(id);
761
+ (async () => {
762
+ if (current) {
763
+ current.closing = true;
764
+ await Promise.all([current.terminal.close(), current.chat.close()]);
765
+ sessions.delete(id);
766
+ }
767
+ response.writeHead(200).end("{}");
768
+ })().catch(() => response.writeHead(500).end());
769
+ return;
770
+ }
771
+ if (!sessions.has(id) && sessions.size >= 8) {
772
+ response.writeHead(429).end("Too many agent sessions. Close an existing pane.");
773
+ return;
774
+ }
775
+ if (!match[2]) {
776
+ if (request.method !== "GET") {
777
+ response.writeHead(405).end();
778
+ return;
779
+ }
780
+ session(id);
781
+ response.writeHead(200, { "Content-Type": "text/html" }).end(agentPage);
782
+ return;
783
+ }
784
+ if (!sessions.has(id)) {
785
+ response.writeHead(404).end();
786
+ return;
787
+ }
788
+ request.url = match[2];
789
+ }
790
+ if (request.url === "/chat" || request.url?.startsWith("/chat/") || request.url?.startsWith("/terminal/")) {
791
+ const current = session(id);
792
+ (request.url.startsWith("/terminal/") ? current.terminal : current.chat).handle(request, response, (index, viewedRevision) => {
793
+ if (viewedRevision !== revision) throw new Error("Preview changed. Review the current slide and send again.");
794
+ if (index !== null && (!latest || index >= latest.slides.length)) throw new Error("Slide no longer exists.");
795
+ return {
796
+ slide: index === null ? null : index + 1,
797
+ count: latest?.slides.length ?? 0,
798
+ revision,
799
+ text: index === null ? "" : (latest?.slideTexts[index] ?? "").slice(0, 12e3),
800
+ entry: resolve(entry),
801
+ files: latest?.dependencies ?? [resolve(entry)],
802
+ buildError: error
803
+ };
804
+ });
805
+ } else if (request.method === "GET" && ["/terminal.js", "/terminal.css"].includes(request.url ?? "")) {
715
806
  const asset = request.url === "/terminal.js" ? "terminal-client.js" : "terminal-client.css";
716
807
  readFile(new URL(asset, import.meta.url)).then((bytes) => {
717
808
  response.writeHead(200, { "Content-Type": asset.endsWith(".css") ? "text/css" : "text/javascript" });
@@ -822,8 +913,7 @@ async function serveDeck(entry, port = 4173) {
822
913
  closed = true;
823
914
  clearTimeout(timer);
824
915
  watcher.close();
825
- await terminal.close();
826
- await chat.close();
916
+ await Promise.all([...sessions.values()].flatMap(({ terminal, chat }) => [terminal.close(), chat.close()]));
827
917
  await builder.close();
828
918
  for (const client of clients) client.end();
829
919
  await new Promise((done, reject) => server.close((cause) => cause ? reject(cause) : done()));