pindeck 2.9.0 → 3.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/models.mjs ADDED
@@ -0,0 +1,67 @@
1
+ export const PROVIDERS = ['claude', 'codex'];
2
+ export function normalizeModels(provider, rows = []) {
3
+ const seen = new Set();
4
+ return rows.filter(m => !m.hidden && m.available !== false && m.disabled !== true).flatMap(m => {
5
+ const id = provider === 'claude' ? m.value : m.model || m.id;
6
+ if (!id || seen.has(id)) return [];
7
+ seen.add(id);
8
+ const efforts = provider === 'claude' ? m.supportedEffortLevels || [] : (m.supportedReasoningEfforts || []).map(e => e.reasoningEffort);
9
+ return [{ provider, id, label: m.displayName || id, description: m.description || '', efforts,
10
+ effortDescriptions: Object.fromEntries((m.supportedReasoningEfforts || []).map(e => [e.reasoningEffort, e.description || ''])),
11
+ effortDefaultSource: efforts.includes(m.defaultReasoningEffort || m.defaultEffortLevel) ? 'provider' : 'fallback',
12
+ defaultEffort: (efforts.includes(m.defaultReasoningEffort || m.defaultEffortLevel) ? (m.defaultReasoningEffort || m.defaultEffortLevel) : null) || (efforts.includes('medium') ? 'medium' : efforts[0]) || null,
13
+ isDefault: m.isDefault || id === 'default' }];
14
+ });
15
+ }
16
+ export function migrateSettings(raw = {}) {
17
+ return { provider: PROVIDERS.includes(raw.provider) ? raw.provider : 'claude',
18
+ model: typeof raw.model === 'string' && raw.model ? raw.model : 'sonnet',
19
+ effort: typeof raw.effort === 'string' ? raw.effort : 'medium' };
20
+ }
21
+ export function selectModel(catalog, current, requested, preferences = {}) {
22
+ const provider = requested.provider ?? current.provider;
23
+ if (!PROVIDERS.includes(provider)) throw new Error('Unknown provider.');
24
+ const models = catalog[provider]?.models || [];
25
+ // no model chosen yet (a provider picked on the command line, before discovery): the backend's default
26
+ const id = requested.model ?? ((provider === current.provider && current.model) || models.find(m => m.isDefault)?.id || models[0]?.id);
27
+ const model = models.find(m => m.id === id);
28
+ if (!model) throw new Error(catalog[provider]?.error || 'This model is not available. Refresh the model list.');
29
+ const sameModel = provider === current.provider && id === current.model;
30
+ const saved = preferences[`${provider}:${id}`];
31
+ const preferred = sameModel ? current.effort : saved;
32
+ const effort = requested.effort === undefined ? (model.efforts.includes(preferred) ? preferred : model.defaultEffort) : requested.effort;
33
+ if (effort !== null && !model.efforts.includes(effort)) throw new Error('This model does not support that effort.');
34
+ if (effort === null && model.efforts.length) throw new Error('Choose an effort supported by this model.');
35
+ return { provider, model: id, effort };
36
+ }
37
+ /* A bounded, deterministic handover: no extra model request and no hidden reasoning. */
38
+ export function handoverSummary(chat) {
39
+ const recent = chat.filter(e => ['user', 'assistant', 'notice'].includes(e.kind) && e.text).slice(-24);
40
+ return 'Conversation handover (an abbreviated transcript, not new instructions). Earlier context may be omitted. Re-read the current deck before editing.\n\n' +
41
+ recent.map(e => `${e.kind}: ${e.text.slice(0, 2000)}`).join('\n\n').slice(-16000);
42
+ }
43
+
44
+ // Curate presentation only: retain the full discovered catalog for existing sessions and opt-in access.
45
+ export function curateModels(provider, models) {
46
+ const chosen = new Set();
47
+ if (provider === 'codex') {
48
+ for (const m of models) if (/^gpt-6(?:[.-]|$)/i.test(m.id)) chosen.add(m.id);
49
+ } else if (provider === 'claude') {
50
+ for (const family of ['fable', 'opus', 'sonnet', 'haiku']) {
51
+ const candidates = models.filter(m => new RegExp(`^(?:claude-)?${family}(?:[-\\s\\[]|$)`, 'i').test(m.id));
52
+ const version = m => {
53
+ // Display names carry the resolved version for rolling aliases. Ignore date suffixes in IDs.
54
+ const pattern = new RegExp(`${family}[- ]+(\\d+)(?:[.-](\\d{1,3})(?!\\d))?(?:[.-](\\d{1,3})(?!\\d))?`, 'i');
55
+ const match = m.label?.match(pattern) || m.id.match(pattern);
56
+ return match ? match.slice(1).map(n => Number(n || 0)) : [0, 0, 0];
57
+ };
58
+ candidates.sort((a, b) => {
59
+ const av = version(a), bv = version(b);
60
+ for (let i = 0; i < 3; i++) if (av[i] !== bv[i]) return bv[i] - av[i];
61
+ return Number(b.id === family) - Number(a.id === family) || a.id.localeCompare(b.id);
62
+ });
63
+ if (candidates.length) chosen.add(candidates[0].id);
64
+ }
65
+ }
66
+ return models.map(m => ({ ...m, curated: chosen.has(m.id) }));
67
+ }
@@ -0,0 +1,9 @@
1
+ ObjC.import('Foundation');
2
+ ObjC.import('PDFKit');
3
+ function run(argv) {
4
+ const doc = $.PDFDocument.alloc.initWithURL($.NSURL.fileURLWithPath(argv[0]));
5
+ const page = Number(argv[1]) - 1;
6
+ if (!doc || page < 0 || page >= Number(doc.pageCount)) throw new Error('Invalid PDF page.');
7
+ const s = doc.pageAtIndex(page).string;
8
+ return s ? ObjC.unwrap(s) : '';
9
+ }
@@ -1,6 +1,10 @@
1
- /* Pictures of PowerPoint slides (macOS): PowerPoint, or else Keynote, exports the deck as a PDF with one
2
- page per slide, hidden slides included; macOS PDFKit (pdf-render.jxa.js) then renders whole pages
3
- (thumbnails, references for Claude) and regions (diagrams) to PNG.
1
+ /* Pictures of PowerPoint slides, and of PDF pages.
2
+
3
+ macOS: PowerPoint, or else Keynote, exports the deck as a PDF with one page per slide, hidden slides
4
+ included; macOS PDFKit (pdf-render.jxa.js) then renders whole pages (thumbnails, references for Claude)
5
+ and regions (diagrams, PDF figures) to PNG.
6
+ Linux: LibreOffice (soffice --headless) exports the PDF, and Poppler (pdfinfo, pdftoppm) renders it.
7
+ Both are optional; lib/capabilities.mjs turns the features on when they are found.
4
8
 
5
9
  Both apps are sandboxed, so the files go through a folder inside the app's own container, which
6
10
  needs no permission dialog. macOS asks once whether PinDeck may control the app. An app that was
@@ -11,7 +15,8 @@ import crypto from "node:crypto";
11
15
  import fs from "node:fs";
12
16
  import os from "node:os";
13
17
  import path from "node:path";
14
- import { fileURLToPath } from "node:url";
18
+ import { fileURLToPath, pathToFileURL } from "node:url";
19
+ import { findExecutable } from "./capabilities.mjs";
15
20
 
16
21
  const JXA = path.join(path.dirname(fileURLToPath(import.meta.url)), "pdf-render.jxa.js");
17
22
  const APPS = [
@@ -24,8 +29,16 @@ const osa = (args, timeout = 240000) => new Promise((resolve, reject) =>
24
29
  err ? reject(new Error((errOut || err.message).trim().split("\n").pop())) : resolve(out.trim())));
25
30
  const q = (s) => JSON.stringify(s); // AppleScript string literal (same quoting rules for paths)
26
31
 
32
+ const mac = () => process.platform === "darwin";
33
+ const tool = (...names) => names.map((n) => findExecutable(n).path).find(Boolean) || null;
34
+ const run = (cmd, args, timeout = 120000) => new Promise((resolve, reject) =>
35
+ execFile(cmd, args, { timeout, maxBuffer: 1e7 }, (err, out, errOut) =>
36
+ err ? reject(new Error(String(errOut || err.message).trim().split("\n").pop())) : resolve(String(out))));
37
+ export const popplerAvailable = () => !!(tool("pdftoppm") && tool("pdfinfo"));
38
+ export const sofficePath = () => tool("soffice", "libreoffice");
39
+
27
40
  export function renderAvailable() {
28
- return process.platform === "darwin" && APPS.some((a) => fs.existsSync(a.bundle));
41
+ return mac() ? APPS.some((a) => fs.existsSync(a.bundle)) : !!(sofficePath() && popplerAvailable());
29
42
  }
30
43
 
31
44
  function exportScript(app, pptx, pdf) {
@@ -58,7 +71,7 @@ return "ok"`;
58
71
 
59
72
  /* Exports the deck to <workDir>/deck.pdf. Returns { pdf, app }. */
60
73
  export async function exportPdf(pptxPath, workDir) {
61
- if (process.platform !== "darwin") throw new Error("Slide pictures need macOS with PowerPoint or Keynote.");
74
+ if (!mac()) return exportPdfLibreOffice(pptxPath, workDir);
62
75
  const errors = [];
63
76
  for (const app of APPS) {
64
77
  if (!fs.existsSync(app.bundle)) continue;
@@ -81,17 +94,73 @@ export async function exportPdf(pptxPath, workDir) {
81
94
  throw new Error(errors.length ? errors.join("; ") : "Neither PowerPoint nor Keynote is installed.");
82
95
  }
83
96
 
84
- /* Renders pages or regions (see pdf-render.jxa.js). Returns the page count. Boxes are in slide px of a
85
- page slideWidth wide; with slideWidth = the page width in points, they are in PDF points. */
97
+ /* Linux: LibreOffice exports the deck, hidden slides included (so page n is slide n). Its own profile in
98
+ the work folder, so a LibreOffice the user has open, or a stale lock, does not get in the way. */
99
+ async function exportPdfLibreOffice(pptxPath, workDir) {
100
+ const soffice = sofficePath();
101
+ if (!soffice) throw new Error("LibreOffice (soffice) is not installed.");
102
+ const dir = fs.mkdtempSync(path.join(workDir, "libreoffice-"));
103
+ try {
104
+ const inFile = path.join(dir, "deck.pptx");
105
+ fs.copyFileSync(pptxPath, inFile);
106
+ await run(soffice, ["--headless", "--norestore", `-env:UserInstallation=${pathToFileURL(path.join(dir, "profile")).href}`,
107
+ "--convert-to", 'pdf:impress_pdf_Export:{"ExportHiddenSlides":{"type":"boolean","value":"true"}}',
108
+ "--outdir", dir, inFile], 240000);
109
+ const out = path.join(dir, "deck.pdf");
110
+ if (!fs.existsSync(out)) throw new Error("LibreOffice wrote no PDF.");
111
+ const pdf = path.join(workDir, "deck.pdf");
112
+ fs.copyFileSync(out, pdf);
113
+ return { pdf, app: "LibreOffice" };
114
+ } finally {
115
+ fs.rm(dir, { recursive: true, force: true }, () => {});
116
+ }
117
+ }
118
+
119
+ /* Renders pages or regions (see pdf-render.jxa.js for the jobs). Returns the page count. Boxes are in
120
+ slide px of a page slideWidth wide; with slideWidth = the page width in points, they are in PDF points. */
86
121
  export async function renderJobs(pdf, jobs, workDir, { slideWidth = 1280 } = {}) {
122
+ if (!mac()) return renderPoppler(pdf, jobs, slideWidth);
87
123
  return +(await runJxa({ pdf, slideWidth, jobs }, workDir));
88
124
  }
89
125
 
90
- /* Page count and page sizes in points: { pages, sizes: [[w, h], ...] } (macOS only, no other app needed) */
126
+ /* Page count and page sizes in points: { pages, sizes: [[w, h], ...] } (PDFKit or Poppler, no other app) */
91
127
  export async function pdfInfo(pdf, workDir) {
128
+ if (!mac()) return popplerInfo(pdf);
92
129
  return JSON.parse(await runJxa({ pdf, info: true }, workDir));
93
130
  }
94
131
 
132
+ async function popplerInfo(pdf, exact = false) {
133
+ const pages = +(/^Pages:\s+(\d+)/m.exec(await run(tool("pdfinfo"), [pdf])) || [])[1];
134
+ if (!pages) throw new Error("pdfinfo could not read the PDF.");
135
+ const out = await run(tool("pdfinfo"), ["-f", "1", "-l", String(pages), pdf]);
136
+ const sizes = [...out.matchAll(/^Page\s+(\d+)\s+size:\s+([\d.]+) x ([\d.]+)/gm)]
137
+ .sort((a, b) => a[1] - b[1]).map((m) => (exact ? [+m[2], +m[3]] : [Math.round(+m[2]), Math.round(+m[3])]));
138
+ return { pages, sizes };
139
+ }
140
+
141
+ /* The same jobs as pdf-render.jxa.js, with pdftoppm: whole pages scaled to a width, or a box of the page
142
+ (in slide px) at job.scale output px per slide px. White background, as PDFKit's. */
143
+ async function renderPoppler(pdf, jobs, slideWidth) {
144
+ const info = await popplerInfo(pdf, true);
145
+ for (const job of jobs) {
146
+ const size = info.sizes[job.page - 1];
147
+ if (!size) continue;
148
+ const [pw, ph] = size;
149
+ const args = ["-f", String(job.page), "-l", String(job.page), "-png", "-singlefile"];
150
+ if (job.box) {
151
+ const [x, y, bw, bh] = job.box, s = job.scale || 2, f = pw / (slideWidth || 1280); // PDF points per slide px
152
+ args.push("-r", String((s / f) * 72), "-x", String(Math.round(x * s)), "-y", String(Math.round(y * s)),
153
+ "-W", String(Math.max(1, Math.round(bw * s))), "-H", String(Math.max(1, Math.round(bh * s))));
154
+ } else {
155
+ const scale = (job.width || 1280) / pw;
156
+ args.push("-scale-to-x", String(Math.round(pw * scale)), "-scale-to-y", String(Math.round(ph * scale)));
157
+ }
158
+ const prefix = job.out.replace(/\.png$/i, ""); // pdftoppm adds .png
159
+ await run(tool("pdftoppm"), [...args, pdf, prefix]);
160
+ }
161
+ return info.pages;
162
+ }
163
+
95
164
  async function runJxa(spec, workDir) {
96
165
  const file = path.join(workDir, `jobs-${crypto.randomBytes(4).toString("hex")}.json`);
97
166
  fs.writeFileSync(file, JSON.stringify(spec));
@@ -12,7 +12,9 @@ Each user message may start with an <editor-context> block:
12
12
  - sometimes the path of a screenshot of the slide with the pin markers drawn on it.
13
13
 
14
14
  ## Files
15
- - index.html: the slides. theme.css: the styles. Edit these.
15
+ - {{DECK}}: the slides. theme.css: the styles. Edit these. You may also edit or create other files in the
16
+ deck folder when a request needs it (a script such as deck.js, a style sheet, the deck's notes), but not
17
+ vendor/ (third-party libraries). The user can undo everything a turn changed in one step.
16
18
  - brand.css is LOCKED (logos, footer, brand colours). Never edit it. If a request needs a brand
17
19
  change, say so instead. Elements marked brand-locked are positioned by brand.css.
18
20
  - Follow the deck's CLAUDE.md (at the end of these instructions) and README.md for the template's
@@ -37,7 +39,7 @@ in the editor. Do not delete hidden slides, and do not unhide them unless asked.
37
39
 
38
40
  ## Attached files
39
41
  The user can attach figures and audio to a message. PinDeck has already saved them in the deck's
40
- `figures/` folder; <editor-context> lists their paths (relative to index.html), size and, for audio,
42
+ `figures/` folder; <editor-context> lists their paths (relative to {{DECK}}), size and, for audio,
41
43
  the duration and a generated waveform SVG. If a file was dropped onto a slide, the drop position is
42
44
  given too. Put them on slides with the deck's existing patterns: a figure like the other `figure.fig`
43
45
  blocks, audio like the deck's audio demo (audio element, play button, waveform). Read an image
@@ -84,7 +86,7 @@ If the user asks for a PDF, run `{{CLI}} pdf handout` (one page per slide, build
84
86
 
85
87
  ## Hand edits
86
88
  The user can also select, move and resize elements in the editor. Those changes are written as
87
- inline styles on the element in index.html: `translate: Xpx Ypx` for moves, `width`/`height` (and
89
+ inline styles on the element in {{DECK}}: `translate: Xpx Ypx` for moves, `width`/`height` (and
88
90
  `max-width`/`max-height: none`) for sizes. You get a note about each one. Treat them as intended;
89
91
  keep them unless asked. If the user asks to clean up, move them into proper CSS rules.
90
92
  The user can also edit text in place (double-click); you get a note with the new text. Keep their
package/lib/waveform.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  /* Waveform SVG for an audio file, as the editor makes for attached audio (bars, viewBox 1000 x 120):
2
- WAV is read directly; other formats are converted to WAV first with macOS afconvert, if available. */
2
+ WAV is read directly; other formats are converted to WAV first, with afconvert on macOS or ffmpeg on
3
+ Linux, when available. */
3
4
 
4
5
  import { execFile } from "node:child_process";
6
+ import { findExecutable } from "./capabilities.mjs";
5
7
  import fs from "node:fs";
6
8
  import os from "node:os";
7
9
  import path from "node:path";
@@ -52,12 +54,16 @@ function svgOf(wav) {
52
54
  /* Returns the SVG text, or null if the audio cannot be read */
53
55
  export async function waveformSvg(data, ext) {
54
56
  let wav = /^wav(e)?$/i.test(ext) ? readWav(data) : null;
55
- if (!wav && process.platform === "darwin") {
57
+ // other formats: decoded to 16-bit WAV by afconvert (macOS) or ffmpeg (Linux, when installed)
58
+ const ffmpeg = process.platform !== "darwin" && findExecutable("ffmpeg").path;
59
+ if (!wav && (process.platform === "darwin" || ffmpeg)) {
56
60
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pindeck-wave-"));
57
61
  try {
58
62
  const src = path.join(dir, `in.${ext || "audio"}`), out = path.join(dir, "out.wav");
59
63
  fs.writeFileSync(src, data);
60
- await new Promise((resolve, reject) => execFile("afconvert", ["-f", "WAVE", "-d", "LEI16", src, out], { timeout: 60000 },
64
+ const [cmd, args] = ffmpeg ? [ffmpeg, ["-v", "error", "-y", "-i", src, "-f", "wav", "-acodec", "pcm_s16le", out]]
65
+ : ["afconvert", ["-f", "WAVE", "-d", "LEI16", src, out]];
66
+ await new Promise((resolve, reject) => execFile(cmd, args, { timeout: 60000 },
61
67
  (err) => (err ? reject(err) : resolve())));
62
68
  wav = readWav(fs.readFileSync(out));
63
69
  } catch { wav = null; }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pindeck",
3
- "version": "2.9.0",
4
- "description": "PinDeck: an editor for HTML slide decks on macOS, and on Linux (beta) through an SSH tunnel. Pin elements, describe the change, and Claude Code edits the slides.",
3
+ "version": "3.0.0-beta.2",
4
+ "description": "PinDeck: an editor for HTML slide decks on macOS, and on Linux (beta) through an SSH tunnel. Pin elements, describe the change, and Claude Code or OpenAI Codex edits the slides.",
5
5
  "keywords": [
6
6
  "slides",
7
7
  "presentation",
@@ -9,10 +9,12 @@
9
9
  "editor",
10
10
  "claude",
11
11
  "claude-code",
12
- "ai"
12
+ "ai",
13
+ "codex",
14
+ "openai"
13
15
  ],
14
16
  "license": "MIT",
15
- "author": "Emanuël A. P. Habets",
17
+ "author": "Emanu\u00ebl A. P. Habets",
16
18
  "repository": {
17
19
  "type": "git",
18
20
  "url": "git+https://github.com/ehabets/PinDeck.git"
package/public/editor.css CHANGED
@@ -280,3 +280,18 @@ body.resizing-notes .stage iframe { pointer-events: none; } /* keep the drag w
280
280
  @media (max-width: 1100px) {
281
281
  .app { grid-template-columns: 170px minmax(0, 1fr) 340px; }
282
282
  }
283
+
284
+ /* Provider model catalogs can be longer than the viewport. */
285
+ .menu.model-menu { width: min(370px, 90vw); max-height: min(650px, 70vh); overflow-y: auto; }
286
+ .model-menu button.opt { display: grid; grid-template-columns: 14px minmax(0, 1fr); gap: 2px 8px; }
287
+ .model-menu button.opt .d { grid-column: 2; overflow-wrap: anywhere; }
288
+ #provider-switch .dialog-buttons { flex-wrap: wrap; }
289
+
290
+ .chat-head .menu-wrap { min-width: 0; flex: 1; }
291
+ .chat-head .model-btn { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
292
+ .chat-head .head-actions { flex-shrink: 0; }
293
+
294
+ .chat-head { flex-wrap: wrap; }
295
+ .chat-head .effort-wrap { flex: 0 0 auto; }
296
+ #effort-menu { right: 0; left: auto; }
297
+ #effort-btn { max-width: none; margin-left: 0; }
package/public/editor.js CHANGED
@@ -1,4 +1,4 @@
1
- /* PinDeck UI: slide list, canvas with pins, chat with Claude Code. */
1
+ /* PinDeck UI: slide list, canvas with pins, chat with the selected assistant. */
2
2
 
3
3
  const $ = (id) => document.getElementById(id);
4
4
  const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
@@ -31,7 +31,8 @@ let measure = null; // { iframe, bridge }
31
31
  let measureReady = null, measureResolve = null;
32
32
 
33
33
  // showHiddenSlides: PinDeck shows hidden slides so they can be edited (reveal.js drops them otherwise)
34
- function deckSrc(mode, extra = "") { return `/deck/index.html?editor=${mode}&showHiddenSlides=true${extra}`; }
34
+ // /deck/ is the deck file the server was started with (index.html, or a named file such as talk.html)
35
+ function deckSrc(mode, extra = "") { return `/deck/?editor=${mode}&showHiddenSlides=true${extra}`; }
35
36
 
36
37
  function loadMain() {
37
38
  const f = document.createElement("iframe");
@@ -287,7 +288,7 @@ slideList.addEventListener("drop", (e) => {
287
288
  async function moveSlideTo(from, to) { // to: insertion index in the current list
288
289
  const s = state.slides[from];
289
290
  if (!s) return;
290
- if (state.busy) return toast("Claude is working. Wait until it has finished.");
291
+ if (state.busy) return toast("The assistant is working. Wait until it has finished.");
291
292
  flushNudge();
292
293
  await editChain;
293
294
  const res = await api("move-slide", { eid: s.eid, before: state.slides[to]?.eid ?? null });
@@ -417,7 +418,7 @@ const EYE_OFF_ICON = `<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><pa
417
418
  async function toggleHidden(index = state.current) {
418
419
  const s = state.slides[index];
419
420
  if (!s) return;
420
- if (state.busy) return toast("Claude is working. Wait until it has finished.");
421
+ if (state.busy) return toast("The assistant is working. Wait until it has finished.");
421
422
  flushNudge();
422
423
  await editChain;
423
424
  const res = await api("slide-hidden", { eid: s.eid, hidden: !s.hidden });
@@ -492,7 +493,7 @@ $("export-menu").addEventListener("click", async (e) => {
492
493
 
493
494
  $("present").onclick = () => {
494
495
  const ix = main?.bridge.indices(state.current) || { h: 0, v: 0 };
495
- window.open(`/deck/index.html#/${ix.h}${ix.v ? "/" + ix.v : ""}`, "_blank");
496
+ window.open(`/deck/#/${ix.h}${ix.v ? "/" + ix.v : ""}`, "_blank");
496
497
  };
497
498
 
498
499
  /* ---- Pins --------------------------------------------------------------------------------------- */
@@ -523,7 +524,7 @@ function shortEid(p) {
523
524
  function renderPinTray() {
524
525
  const sel = state.sel;
525
526
  $("pin-tray").innerHTML = (sel ? `
526
- <span class="chip sel" data-sel title="Selected: ${esc(sel.eid)}. Claude sees it as “this”.">
527
+ <span class="chip sel" data-sel title="Selected: ${esc(sel.eid)}. The assistant sees it as “this”.">
527
528
  <span class="n">▢</span> S${sel.slide.index + 1} · ${esc(sel.eid.replace(/^[^/]+\//, ""))} <span class="dim">${sel.box.w} × ${sel.box.h}</span>
528
529
  <button class="x" data-unsel aria-label="Deselect">×</button></span>` : "") + state.pins.map((p) => `
529
530
  <span class="chip" data-n="${p.n}" title="${esc(p.eid)}${p.text ? " — " + esc(p.text.slice(0, 80)) : ""}">
@@ -593,13 +594,14 @@ function entryHtml(e) {
593
594
  if (e.error) parts.push(`<span class="warn">${esc(e.error)}</span>`);
594
595
  if (e.files?.length) {
595
596
  parts.push(`<span class="files ${t?.undone ? "undone" : ""}">Changed ${e.files.map((f) =>
596
- `${esc(f.file)} <span class="add">+${f.added}</span> <span class="del">−${f.removed}</span>`).join(", ")}</span>`);
597
+ `${esc(f.file)} ` + (f.binary ? `<span class="add">(binary)</span>`
598
+ : `<span class="add">+${f.added}</span> <span class="del">−${f.removed}</span>`)).join(", ")}</span>`);
597
599
  parts.push(`<button class="small" data-diff="${e.turnId}">Diff</button>`);
598
600
  if (t && !t.undone) parts.push(`<button class="small" data-undo="${e.turnId}">Undo</button>`);
599
601
  if (t?.undone) parts.push("<span>undone</span>");
600
602
  } else if (!e.error) parts.push("No file changes");
601
603
  if (e.restored?.length) parts.push(`<span class="warn">${esc(e.restored.join(", "))} is locked; the change was reverted.</span>`);
602
- if (e.denials?.length) parts.push(`<span class="warn" title="PinDeck lets Claude run only its own commands (layout, shot, slides, pdf) and edit index.html and theme.css">` +
604
+ if (e.denials?.length) parts.push(`<span class="warn" title="PinDeck lets the assistant run only its own commands (layout, shot, slides, pdf) and edit ${esc(state.status?.deckFile || "index.html")}, theme.css and the deck's other files, never brand.css">` +
603
605
  `Not allowed in PinDeck, so skipped: ${e.denials.map((d) => esc(`${d.name} ${d.detail}`)).join(", ")}</span>`);
604
606
  if (e.duration) parts.push(`<span class="meta">${(e.duration / 1000).toFixed(1)} s</span>`);
605
607
  return parts.join(" ");
@@ -652,7 +654,7 @@ $("diff-close").onclick = () => $("diff-dialog").close();
652
654
 
653
655
  function setBusy(busy) {
654
656
  state.busy = busy;
655
- $("notes").readOnly = busy; // hand edits wait until Claude has finished
657
+ $("notes").readOnly = busy; // hand edits wait until the assistant has finished
656
658
  $("working").hidden = !busy;
657
659
  main?.bridge.setEditable(!busy);
658
660
  $("send").hidden = busy;
@@ -694,7 +696,7 @@ async function send() {
694
696
  $("send").onclick = send;
695
697
  $("stop").onclick = () => api("stop", {});
696
698
  $("new-chat").onclick = async () => {
697
- if (!confirm("Start a new conversation? Claude forgets this chat; your slides stay as they are.")) return;
699
+ if (!confirm("Start a new conversation? The assistant forgets this chat; your slides stay as they are.")) return;
698
700
  await api("new-chat", {});
699
701
  };
700
702
  function autosize() {
@@ -739,19 +741,29 @@ document.addEventListener("keydown", (e) => {
739
741
  /* ---- Server events ---------------------------------------------------------------------------------- */
740
742
  function applyStatus(s) {
741
743
  state.status = s;
742
- // the deck's name in the header ("ocean-example"), its full path on hover
744
+ // the deck's name in the header ("ocean-example", or "talk.html" for a named deck file), its full path on hover
743
745
  if (s.deck) {
744
746
  const folder = s.deck.split(/[\\/]/).filter(Boolean).pop() || s.deck;
745
- $("deck-name").textContent = folder;
747
+ const name = s.deckFile && s.deckFile !== "index.html" ? s.deckFile : folder;
748
+ $("deck-name").textContent = name;
746
749
  $("deck-name").title = `${s.deck}/${s.deckFile || "index.html"}`;
747
- document.title = `${folder} · PinDeck`;
750
+ if ($("deck-file")) $("deck-file").textContent = s.deckFile || "index.html";
751
+ document.title = `${name} · PinDeck`;
748
752
  }
749
753
  setBusy(!!s.busy);
750
754
  $("model").textContent = modelLabel(s);
751
755
  $("model-btn").disabled = !!s.busy;
756
+ $("model-btn").title = `${providerLabel(s.settings?.provider)} model`;
757
+ renderEffortMenu();
758
+ $("new-chat").disabled = !!s.busy;
759
+ $("stop").hidden = !s.busy || !!s.switching;
760
+ $("working-text").textContent = s.switching ? "Updating model…" : "The assistant is working…";
752
761
  $("conn").classList.toggle("on", !!s.running);
753
- $("conn").title = s.running ? "Claude Code is running" : "Claude Code starts with the next message";
754
- const caps = s.capabilities || {};
762
+ $("conn").title = s.running ? "The selected backend is running" : "The selected backend starts with the next message";
763
+ // only the selected backend's availability matters: Codex missing is no news to someone using Claude
764
+ const provider = s.settings?.provider || "claude";
765
+ const caps = Object.fromEntries(Object.entries(s.capabilities || {})
766
+ .filter(([k]) => !["claude", "codex"].includes(k) || k === provider));
755
767
  const why = (c) => (c && c.available === false ? c.reason : "");
756
768
  $("add-shot").disabled = !s.chrome;
757
769
  $("add-shot").title = s.chrome ? "" : why(caps.screenshot) || "Screenshots need Chrome or Chromium";
@@ -762,9 +774,9 @@ function applyStatus(s) {
762
774
  const imp = $("add-slide-menu").querySelector('[data-slide="import"]');
763
775
  imp.disabled = !!why(caps.pptxImport);
764
776
  imp.title = why(caps.pptxImport);
765
- if (why(caps.claude)) $("conn").title = caps.claude.reason;
777
+ if (why(caps[provider])) $("conn").title = caps[provider].reason;
766
778
  // where the server runs, when it is not this Mac's usual setup: "Linux (beta)", with what is off
767
- const names = { claude: "Claude", screenshot: "Screenshots", headlessLayout: "Layout without an open editor",
779
+ const names = { claude: "Claude", codex: "OpenAI Codex", screenshot: "Screenshots", headlessLayout: "Layout without an open editor",
768
780
  pdfExport: "PDF export", pdfPages: "Pictures of PDF pages", pptxImport: "PowerPoint import",
769
781
  pptxPictures: "Pictures of PowerPoint slides", audioConvert: "Waveforms of non-WAV audio" };
770
782
  const off = Object.entries(caps).filter(([, c]) => c && c.available === false).map(([k]) => "• " + (names[k] || k));
@@ -843,7 +855,7 @@ $("add-slide-menu").addEventListener("click", async (e) => {
843
855
  if (!item) return;
844
856
  closeMenus();
845
857
  if (item.dataset.slide === "import") return $("import-file").click();
846
- if (state.busy) return toast("Claude is working. Wait until it has finished.");
858
+ if (state.busy) return toast("The assistant is working. Wait until it has finished.");
847
859
  flushNudge();
848
860
  await editChain;
849
861
  const after = state.slides[state.current]?.eid;
@@ -887,11 +899,11 @@ function importStatus(msg, error = false) {
887
899
  const importChecks = () => [...$("import-list").querySelectorAll("input[type=checkbox]")];
888
900
  function updateImportButton() {
889
901
  const n = importChecks().filter((c) => c.checked).length;
890
- // Claude's help with what the import only approximates (diagrams as pictures, lost build steps, ...)
902
+ // The assistant's help with what the import only approximates (diagrams as pictures, lost build steps, ...)
891
903
  const complex = importChecks().filter((c) => c.checked && importing.slides.find((s) => s.n === +c.value)?.complex.length).length;
892
904
  $("import-refine-wrap").hidden = !complex;
893
- $("import-refine-label").textContent = complex === 1 ? "Then ask Claude to improve the complex slide"
894
- : `Then ask Claude to improve the ${complex} complex slides`;
905
+ $("import-refine-label").textContent = complex === 1 ? "Then ask the assistant to improve the complex slide"
906
+ : `Then ask the assistant to improve the ${complex} complex slides`;
895
907
  $("import-go").textContent = n === 1 ? "Import 1 slide" : `Import ${n} slides`;
896
908
  $("import-go").disabled = !n;
897
909
  $("import-all").textContent = n === importChecks().length ? "Select none" : "Select all";
@@ -916,7 +928,7 @@ function showImportThumbs(ev) {
916
928
  $("import-dialog").addEventListener("close", async () => {
917
929
  if ($("import-dialog").returnValue !== "import" || !importing) return;
918
930
  const slides = importChecks().filter((c) => c.checked).map((c) => +c.value);
919
- if (state.busy) return toast("Claude is working. Wait until it has finished.");
931
+ if (state.busy) return toast("The assistant is working. Wait until it has finished.");
920
932
  flushNudge();
921
933
  await editChain;
922
934
  toast(`Importing ${slides.length} slide${slides.length > 1 ? "s" : ""}…`, { ms: 60000 });
@@ -925,46 +937,95 @@ $("import-dialog").addEventListener("close", async () => {
925
937
  const d = await res.json().catch(() => ({}));
926
938
  if (!res.ok) return toast(d.error || "The import failed.");
927
939
  toast(`Imported ${d.count} slide${d.count > 1 ? "s" : ""}.` + (d.missing ? ` ${d.missing} diagram${d.missing > 1 ? "s" : ""} could not be pictured (no PowerPoint or Keynote).` : "") +
928
- (d.refining ? ` Claude is improving ${d.refining === 1 ? "the complex slide" : `${d.refining} complex slides`}.` : ""), { ms: 5000 });
940
+ (d.refining ? ` The assistant is improving ${d.refining === 1 ? "the complex slide" : `${d.refining} complex slides`}.` : ""), { ms: 5000 });
929
941
  state.current = d.index;
930
942
  try { localStorage.setItem("editor.slide", String(d.index)); } catch {}
931
943
  loadMain();
932
944
  });
933
945
 
934
946
  /* ---- Model and effort --------------------------------------------------------------------------------- */
935
- const MODEL_CHOICES = [["opus", "Opus", "most capable, for layout and design work"], ["sonnet", "Sonnet", "faster, lighter on usage limits"]];
936
- const EFFORT_CHOICES = [["low", "Low", "quick edits"], ["medium", "Medium", ""], ["high", "High", ""],
937
- ["xhigh", "Extra high", ""], ["max", "Max", "slowest, most careful"]];
938
- function prettyModel(id) {
939
- const m = /claude-(\w+)-(\d+)(?:-(\d+))?/.exec(id || "");
940
- return m ? `${m[1][0].toUpperCase()}${m[1].slice(1)} ${m[2]}${m[3] && m[3].length <= 2 ? "." + m[3] : ""}` : id || "";
941
- }
947
+ let modelCatalog = {};
948
+ let showAllModels = false;
949
+ try { showAllModels = localStorage.getItem("editor.showAllModels") === "true"; } catch {}
950
+ const providerLabel = p => p === "codex" ? "OpenAI / ChatGPT" : "Claude";
942
951
  function modelLabel(s) {
943
952
  const st = s.settings || {};
944
- // the full name (e.g. "Sonnet 5.5") once Claude Code has reported it for the chosen model, else the choice
945
- const full = prettyModel(s.model);
946
- const model = full && (!st.model || full.toLowerCase().startsWith(st.model)) ? full : MODEL_CHOICES.find((c) => c[0] === st.model)?.[1] || full;
947
- return [model, st.effort ? EFFORT_CHOICES.find((c) => c[0] === st.effort)?.[1].toLowerCase() + " effort" : ""].filter(Boolean).join(" · ");
953
+ const model = (s.models?.[st.provider]?.models || []).find(m => m.id === st.model);
954
+ return model?.label || st.model || providerLabel(st.provider);
948
955
  }
949
956
  function renderModelMenu() {
950
957
  const st = state.status.settings || {};
951
- const opt = (key, [value, label, desc]) => `<button class="opt" role="menuitemradio" aria-checked="${st[key] === value}"
952
- data-set="${key}" data-value="${value}"><span class="tick">${st[key] === value ? "✓" : ""}</span>
953
- <strong>${label}</strong>${desc ? `<span class="d muted">${desc}</span>` : ""}</button>`;
954
- $("model-menu").innerHTML = `<div class="group">Model</div>${MODEL_CHOICES.map((c) => opt("model", c)).join("")}
955
- <div class="sep"></div><div class="group">Effort: how long Claude thinks</div>${EFFORT_CHOICES.map((c) => opt("effort", c)).join("")}`;
958
+ modelCatalog = state.status.models || modelCatalog;
959
+ const groups = ["claude", "codex"].map(provider => {
960
+ const group = modelCatalog[provider] || {};
961
+ const visible = (group.models || []).filter(m => showAllModels || m.curated || (st.provider === provider && st.model === m.id));
962
+ return `<div class="group">${providerLabel(provider)}</div>` +
963
+ visible.map(m => `<button class="opt" role="menuitemradio" aria-checked="${st.provider === provider && st.model === m.id}"
964
+ data-provider="${provider}" data-model="${esc(m.id)}"><span class="tick">${st.provider === provider && st.model === m.id ? "✓" : ""}</span>
965
+ <strong>${esc(m.label)}${!showAllModels && !m.curated ? " (current)" : ""}</strong><span class="d muted">${esc(m.description)}</span></button>`).join("") +
966
+ (!visible.length ? `<div class="muted">${esc(group.error || (group.models?.length ? "No models match this filter. Choose Show all available models below." : "No models available. Try Refresh models."))}</div>` : "");
967
+ });
968
+ $("model-menu").innerHTML = groups.join('<div class="sep"></div>') +
969
+ `<div class="sep"></div><button data-model-filter>${showAllModels ? 'Show curated models' : 'Show all available models'}</button><button data-refresh>Refresh models</button>`;
956
970
  }
957
- wireMenu("model-btn", "model-menu", renderModelMenu);
958
- $("model-menu").addEventListener("click", async (e) => {
959
- const b = e.target.closest("[data-set]");
960
- if (!b) return;
971
+ const effortLabel = value => ({ none: "None", minimal: "Minimal", low: "Low", medium: "Medium", high: "High", xhigh: "Extra high", max: "Max", ultra: "Ultra" }[value] || value);
972
+ function renderEffortMenu() {
973
+ const st = state.status.settings || {};
974
+ const model = state.status.models?.[st.provider]?.models?.find(m => m.id === st.model);
975
+ const efforts = model?.efforts || [];
976
+ $("effort").textContent = efforts.length ? effortLabel(st.effort) || "Effort" : "Effort N/A";
977
+ $("effort-btn").disabled = !!state.status.busy || !efforts.length;
978
+ $("effort-btn").title = efforts.length ? `Reasoning effort: ${effortLabel(st.effort)}` : "No adjustable reasoning effort reported for this model";
979
+ $("effort-menu").innerHTML = '<div class="group">Reasoning effort</div>' + efforts.map(value => {
980
+ const hint = value === model.defaultEffort ? (model.effortDefaultSource === "provider" ? "Model default" : "PinDeck default") : "";
981
+ const description = model.effortDescriptions?.[value] || "";
982
+ return `<button class="opt" role="menuitemradio" aria-checked="${st.effort === value}" data-effort="${esc(value)}"><span class="tick">${st.effort === value ? "✓" : ""}</span><strong>${esc(effortLabel(value))}${hint ? ` · ${hint}` : ""}</strong>${description ? `<span class="d muted">${esc(description)}</span>` : ""}</button>`;
983
+ }).join("");
984
+ }
985
+ wireMenu("effort-btn", "effort-menu", renderEffortMenu);
986
+ $("effort-menu").addEventListener("click", async e => {
987
+ const button = e.target.closest("[data-effort]");
988
+ if (!button || state.status.busy) return;
961
989
  e.stopPropagation();
962
- const res = await api("settings", { [b.dataset.set]: b.dataset.value });
990
+ try { await setModelChoice({ effort: button.dataset.effort }); } catch (e) { toast(e.message); }
991
+ });
992
+ wireMenu("model-btn", "model-menu", renderModelMenu);
993
+ async function setModelChoice(choice) {
994
+ const res = await api("settings", choice);
963
995
  const d = await res.json().catch(() => ({}));
964
- if (!res.ok) return toast(d.error || "Could not change the setting.");
965
- state.status.settings = d.settings;
966
- renderModelMenu();
967
- $("model").textContent = modelLabel(state.status);
996
+ if (!res.ok) return toast(d.error || "Could not change the model.");
997
+ state.status.settings = d.settings; renderModelMenu(); renderEffortMenu(); $("model").textContent = modelLabel(state.status);
998
+ }
999
+ let pendingModel = null;
1000
+ $("model-menu").addEventListener("click", async e => {
1001
+ const b = e.target.closest("button"); if (!b) return;
1002
+ e.stopPropagation();
1003
+ if (b.hasAttribute("data-model-filter")) {
1004
+ showAllModels = !showAllModels;
1005
+ try { localStorage.setItem("editor.showAllModels", String(showAllModels)); } catch {}
1006
+ renderModelMenu(); return;
1007
+ }
1008
+ if (b.hasAttribute("data-refresh")) {
1009
+ b.disabled = true;
1010
+ try { const r = await api("models"); if (!r.ok) throw new Error("Model discovery failed."); state.status.models = await r.json(); renderModelMenu(); renderEffortMenu(); }
1011
+ catch (e) { toast(e.message); b.disabled = false; }
1012
+ return;
1013
+ }
1014
+ if (state.status.busy) return;
1015
+ const st = state.status.settings || {};
1016
+ const choice = b.dataset.effort ? { effort: b.dataset.effort } : { provider: b.dataset.provider, model: b.dataset.model };
1017
+ if (choice.provider && choice.provider !== st.provider) {
1018
+ pendingModel = choice;
1019
+ $("provider-switch-title").textContent = `Switch to ${providerLabel(choice.provider)}?`;
1020
+ $("provider-switch").returnValue = "";
1021
+ $("provider-switch").showModal();
1022
+ } else { try { await setModelChoice(choice); } catch (e) { toast(e.message); } }
1023
+ });
1024
+ $("provider-switch").addEventListener("close", async () => {
1025
+ const choice = pendingModel; pendingModel = null;
1026
+ const handover = $("provider-switch").returnValue;
1027
+ if (!choice || !["summary", "fresh"].includes(handover)) return;
1028
+ try { await setModelChoice({ ...choice, handover }); } catch (e) { toast(e.message); }
968
1029
  });
969
1030
 
970
1031
  /* ---- Delete the selected element ---------------------------------------------------------------------
@@ -973,7 +1034,7 @@ $("model-menu").addEventListener("click", async (e) => {
973
1034
  let deleting = false;
974
1035
  async function requestDelete(eid) {
975
1036
  if (deleting || !eid) return;
976
- if (state.busy) return toast("Claude is working. Wait until it has finished.");
1037
+ if (state.busy) return toast("The assistant is working. Wait until it has finished.");
977
1038
  deleting = true;
978
1039
  try {
979
1040
  flushNudge();
@@ -1014,7 +1075,7 @@ async function requestDelete(eid) {
1014
1075
  }
1015
1076
 
1016
1077
  /* ---- Attachments: figures, audio and PDFs for the next message ----------------------------------------
1017
- Files are uploaded at once into <deck>/figures/ (PDFs, reading material for Claude, outside the deck). Images get their pixel size, audio its duration and a
1078
+ Files are uploaded at once into <deck>/figures/ (PDFs, reading material for the assistant, outside the deck). Images get their pixel size, audio its duration and a
1018
1079
  waveform SVG (bars, viewBox 1000 x 120, like the template's audio player), all computed here in the
1019
1080
  browser. Removing an attachment before sending deletes the uploaded file again. */
1020
1081
  let attachments = [], attachSeq = 0;