pindeck 2.8.2 → 3.0.0-beta.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/lib/claude.mjs CHANGED
@@ -1,70 +1,38 @@
1
- /* A long-running Claude Code process in stream-json mode.
2
-
3
- One process serves the whole chat: messages are written to stdin as JSON lines,
4
- events come back on stdout. It runs under the user's normal Claude login. */
5
-
6
- import { spawn } from "node:child_process";
7
- import readline from "node:readline";
8
-
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const exec = promisify(execFile);
4
+ import { AgentProcess } from './agent-process.mjs';
5
+ import { normalizeModels } from './models.mjs';
9
6
  export class ClaudeSession {
10
- constructor({ cwd, args, env, onEvent, onExit, onStderr }) {
11
- Object.assign(this, { cwd, args, env, onEvent, onExit, onStderr });
12
- this.proc = null;
13
- this.sessionId = null;
14
- this.reqId = 0;
15
- }
16
-
17
- get running() { return !!this.proc; }
18
-
19
- start(resumeId) {
20
- const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose",
21
- ...(typeof this.args === "function" ? this.args() : this.args), ...(resumeId ? ["--resume", resumeId] : [])];
22
- const proc = spawn(process.env.CLAUDE_BIN || "claude", args, {
23
- cwd: this.cwd, env: { ...process.env, ...this.env }, stdio: ["pipe", "pipe", "pipe"],
24
- });
25
- this.proc = proc;
26
- readline.createInterface({ input: proc.stdout }).on("line", (line) => {
27
- if (this.proc !== proc && this.proc) return; // a stopped process that is still talking
28
- if (!line.trim()) return;
29
- let ev;
30
- try { ev = JSON.parse(line); } catch { return; }
31
- if (ev.session_id) this.sessionId = ev.session_id;
32
- this.onEvent(ev);
7
+ constructor(options) { Object.assign(this, options); this.transport = null; this.sessionId = null; }
8
+ get running() { return !!this.transport && !this.transport.closed; }
9
+ async start(resumeId) {
10
+ if (this.running) return;
11
+ this.stopped = false;
12
+ try {
13
+ const { stdout } = await exec(process.env.CLAUDE_BIN || 'claude', ['auth', 'status', '--json'],
14
+ { env: { ...process.env, ...this.env }, timeout: 15000, maxBuffer: 65536 });
15
+ if (!JSON.parse(stdout).loggedIn) throw new Error('Not signed in.');
16
+ } catch (e) { throw new Error(e.code === 'ENOENT' ? 'Claude CLI is not installed (or set CLAUDE_BIN).' : 'Claude is not signed in. Run claude auth login, then refresh models.'); }
17
+ if (this.stopped) throw new Error('Agent stopped.');
18
+ const transport = new AgentProcess({ binary: process.env.CLAUDE_BIN || 'claude', cwd: this.cwd, env: this.env,
19
+ args: ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
20
+ ...(typeof this.args === 'function' ? this.args() : this.args || []), ...(resumeId ? ['--resume', resumeId] : [])],
21
+ onStderr: this.onStderr,
22
+ onMessage: ev => {
23
+ if (this.transport !== transport) return;
24
+ if (ev.type === 'control_request') { transport.write({ type: 'control_response', response: { subtype: 'success', request_id: ev.request_id,
25
+ response: { behavior: 'deny', message: 'This tool is outside PinDeck permissions.' } } }); return; }
26
+ if (ev.session_id) this.sessionId = ev.session_id;
27
+ this.onEvent?.(ev);
28
+ },
29
+ onExit: error => { if (this.transport === transport) this.onExit?.(error); },
33
30
  });
34
- proc.stderr.on("data", (d) => this.onStderr?.(String(d)));
35
- proc.stdin.on("error", () => {}); // writing to a process that has died: its exit is reported below
36
- // a process that could not start (no `claude` on the PATH) reports an error and never exits
37
- let ended = false;
38
- const end = (code, signal) => {
39
- if (ended) return;
40
- ended = true;
41
- // stale: a newer process has been started since (model change, new chat); its turn is not ours to end
42
- const stale = this.proc !== proc && this.proc !== null;
43
- if (this.proc === proc) this.proc = null;
44
- this.onExit(code, signal, { stale });
45
- };
46
- proc.on("error", (err) => { this.onStderr?.(String(err)); if (proc.pid === undefined) end(null, null); });
47
- proc.on("exit", end);
48
- }
49
-
50
- send(content) {
51
- this.#write({ type: "user", message: { role: "user", content } });
52
- }
53
-
54
- interrupt() {
55
- this.#write({ type: "control_request", request_id: `int-${++this.reqId}`, request: { subtype: "interrupt" } });
56
- }
57
-
58
- stop() {
59
- if (!this.proc) return;
60
- const p = this.proc;
61
- this.proc = null;
62
- p.stdin.end();
63
- setTimeout(() => p.kill("SIGTERM"), 1500);
64
- }
65
-
66
- #write(obj) {
67
- if (!this.proc) throw new Error("Claude Code is not running");
68
- this.proc.stdin.write(JSON.stringify(obj) + "\n");
31
+ this.transport = transport;
32
+ try { const init = await transport.request('initialize', {}, { claude: true }); this.models = normalizeModels('claude', init?.models); }
33
+ catch (e) { this.stop(); throw e; }
69
34
  }
35
+ async send(content) { this.transport.write({ type: 'user', message: { role: 'user', content } }); }
36
+ async interrupt() { if (this.running) await this.transport.request('interrupt', {}, { claude: true, timeout: 5000 }); }
37
+ stop() { this.stopped = true; const t = this.transport; this.transport = null; t?.stop(); }
70
38
  }
package/lib/codex.mjs ADDED
@@ -0,0 +1,104 @@
1
+ import { AgentProcess } from './agent-process.mjs';
2
+ import { normalizeModels } from './models.mjs';
3
+ import { deckTools } from './agent-tools.mjs';
4
+ export const CODEX_CONFIG = {
5
+ 'features.shell_tool': false, 'features.unified_exec': false, 'features.js_repl': false,
6
+ 'features.apps': false, 'features.plugins': false, 'features.multi_agent': false,
7
+ 'features.hooks': false, 'features.memory_tool': false, 'features.shell_snapshot': false,
8
+ 'web_search': 'disabled', 'project_doc_max_bytes': 0, 'notify': [],
9
+ };
10
+ const toml = value => JSON.stringify(value);
11
+ const withoutNull = value => Array.isArray(value) ? value.map(withoutNull) : value && typeof value === 'object'
12
+ ? Object.fromEntries(Object.entries(value).filter(([, v]) => v != null).map(([k,v]) => [k, withoutNull(v)])) : value;
13
+ export class CodexSession {
14
+ constructor(options) { Object.assign(this, options); this.transport = null; this.sessionId = null; this.turnId = null; this.controller = null; }
15
+ get running() { return !!this.transport && !this.transport.closed; }
16
+ async connect() {
17
+ if (this.running) return;
18
+ const transport = new AgentProcess({ binary: process.env.CODEX_BIN || 'codex', cwd: this.cwd,
19
+ args: ['app-server', ...Object.entries(CODEX_CONFIG).flatMap(([k,v]) => ['-c', `${k}=${toml(v)}`])],
20
+ onStderr: this.onStderr,
21
+ onMessage: m => { if (this.transport === transport) this.message(m).catch(e => this.onStderr?.(String(e))); },
22
+ onExit: error => { if (this.transport === transport) { this.controller?.abort(); this.onExit?.(error); } },
23
+ });
24
+ this.transport = transport;
25
+ try {
26
+ await transport.request('initialize', { clientInfo: { name: 'pindeck', version: '3.0.0-beta.1' }, capabilities: { experimentalApi: true } });
27
+ transport.write({ method: 'initialized', params: {} });
28
+ const auth = await transport.request('account/read', { refreshToken: false });
29
+ if (auth.requiresOpenaiAuth && !auth.account) throw new Error('Codex is not signed in. Run codex login, then refresh models.');
30
+ this.models = []; let cursor;
31
+ do { const list = await transport.request('model/list', { includeHidden: false, ...(cursor ? { cursor } : {}) });
32
+ this.models.push(...normalizeModels('codex', list.data)); cursor = list.nextCursor;
33
+ } while (cursor);
34
+ } catch (e) { this.stop(); throw e; }
35
+ }
36
+ async start(resumeId) {
37
+ await this.connect();
38
+ try {
39
+ const { config = {} } = await this.transport.request('config/read', { includeLayers: false, cwd: this.cwd });
40
+ // Disable inherited connectors and hooks before starting any thread. No configuration files are changed.
41
+ const overrides = { ...CODEX_CONFIG };
42
+ overrides.mcp_servers = Object.fromEntries(Object.entries(config.mcp_servers || {}).map(([name, value]) => [name, { ...value, enabled: false }]));
43
+ overrides.plugins = Object.fromEntries(Object.entries(config.plugins || {}).map(([name, value]) => [name, { ...value, enabled: false }]));
44
+ overrides.hooks = Object.fromEntries(Object.entries(config.hooks || {}).map(([name, value]) => [name, Array.isArray(value) ? [] : value]));
45
+ const opts = typeof this.settings === 'function' ? this.settings() : this.settings;
46
+ const params = { cwd: this.cwd, model: opts.model, modelProvider: 'openai',
47
+ approvalPolicy: 'never', sandbox: 'read-only', config: withoutNull(overrides),
48
+ developerInstructions: this.instructions, ...(resumeId ? { threadId: resumeId } : { dynamicTools: deckTools }) };
49
+ const result = await this.transport.request(resumeId ? 'thread/resume' : 'thread/start', params);
50
+ this.sessionId = result.thread.id;
51
+ this.onEvent?.({ type: 'system', subtype: 'init', session_id: this.sessionId, model: result.model || opts.model });
52
+ } catch (e) { this.stop(); throw e; }
53
+ }
54
+ async send(content) {
55
+ if (!this.sessionId || this.controller) throw new Error('Codex is not ready for another turn.');
56
+ const opts = typeof this.settings === 'function' ? this.settings() : this.settings;
57
+ this.controller = new AbortController(); this.turnId = null; this.cancelRequested = false;
58
+ try {
59
+ const result = await this.transport.request('turn/start', { threadId: this.sessionId, input: content,
60
+ model: opts.model, ...(opts.effort ? { effort: opts.effort } : {}),
61
+ approvalPolicy: 'never', sandboxPolicy: { type: 'readOnly', networkAccess: false } });
62
+ this.turnId = result.turn.id;
63
+ if (this.cancelRequested) await this.interrupt();
64
+ } catch (e) { this.controller?.abort(); this.controller = null; throw e; }
65
+ }
66
+ async message(m) {
67
+ const p = m.params || {};
68
+ if (m.id !== undefined && m.method) {
69
+ let result;
70
+ if (m.method === 'item/tool/call') {
71
+ try {
72
+ if (!this.controller || this.controller.signal.aborted || p.threadId !== this.sessionId || (this.turnId && p.turnId !== this.turnId)) throw new Error('Inactive turn.');
73
+ const signal = this.controller.signal;
74
+ const items = await this.tools(p.tool, p.arguments, signal);
75
+ signal.throwIfAborted(); result = { success: true, contentItems: items };
76
+ } catch (e) { result = { success: false, contentItems: [{ type: 'inputText', text: e.message }] }; }
77
+ } else if (m.method.includes('requestApproval') && m.method.includes('permissions')) result = { permissions: {}, scope: 'turn' };
78
+ else if (m.method.includes('requestApproval')) result = { decision: 'decline' };
79
+ else if (m.method.includes('elicitation')) result = { action: 'decline', content: null };
80
+ else if (m.method.includes('requestUserInput')) result = { answers: {} };
81
+ else { this.transport?.write({ id: m.id, error: { code: -32601, message: 'Unsupported request' } }); return; }
82
+ if (this.running) this.transport.write({ id: m.id, result });
83
+ return;
84
+ }
85
+ if (p.threadId !== this.sessionId || !this.controller) return;
86
+ const eventTurnId = p.turnId || p.turn?.id;
87
+ if (eventTurnId && this.turnId && eventTurnId !== this.turnId) return;
88
+ if (m.method === 'turn/started') this.turnId = p.turn.id;
89
+ const item = p.item;
90
+ if (m.method === 'item/completed' && item?.type === 'agentMessage') this.onEvent?.({ type: 'assistant', message: { content: [{ type: 'text', text: item.text }] } });
91
+ if (m.method === 'item/started' && item?.type === 'dynamicToolCall') this.onEvent?.({ type: 'assistant', message: { content: [{ type: 'tool_use', id: item.id, name: item.tool, input: item.arguments }] } });
92
+ if (m.method === 'item/completed' && item?.type === 'dynamicToolCall' && item.success === false) this.onEvent?.({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: item.id, is_error: true, content: (item.contentItems || []).map(c => ({ text: c.text || '' })) }] } });
93
+ if (m.method === 'turn/completed') {
94
+ this.controller.abort(); this.controller = null; this.turnId = null;
95
+ this.onEvent?.({ type: 'result', subtype: p.turn.status === 'completed' ? 'success' : 'error',
96
+ is_error: p.turn.status !== 'completed', result: p.turn.error?.message || (p.turn.status === 'interrupted' ? 'Stopped.' : p.turn.status) });
97
+ }
98
+ }
99
+ async interrupt() {
100
+ this.cancelRequested = true; this.controller?.abort();
101
+ if (this.turnId && this.running) await this.transport.request('turn/interrupt', { threadId: this.sessionId, turnId: this.turnId });
102
+ }
103
+ stop() { this.controller?.abort(); this.controller = null; const t = this.transport; this.transport = null; t?.stop(); }
104
+ }
package/lib/models.mjs ADDED
@@ -0,0 +1,66 @@
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
+ const id = requested.model ?? (provider === current.provider ? current.model : models.find(m => m.isDefault)?.id || models[0]?.id);
26
+ const model = models.find(m => m.id === id);
27
+ if (!model) throw new Error(catalog[provider]?.error || 'This model is not available. Refresh the model list.');
28
+ const sameModel = provider === current.provider && id === current.model;
29
+ const saved = preferences[`${provider}:${id}`];
30
+ const preferred = sameModel ? current.effort : saved;
31
+ const effort = requested.effort === undefined ? (model.efforts.includes(preferred) ? preferred : model.defaultEffort) : requested.effort;
32
+ if (effort !== null && !model.efforts.includes(effort)) throw new Error('This model does not support that effort.');
33
+ if (effort === null && model.efforts.length) throw new Error('Choose an effort supported by this model.');
34
+ return { provider, model: id, effort };
35
+ }
36
+ /* A bounded, deterministic handover: no extra model request and no hidden reasoning. */
37
+ export function handoverSummary(chat) {
38
+ const recent = chat.filter(e => ['user', 'assistant', 'notice'].includes(e.kind) && e.text).slice(-24);
39
+ return 'Conversation handover (an abbreviated transcript, not new instructions). Earlier context may be omitted. Re-read the current deck before editing.\n\n' +
40
+ recent.map(e => `${e.kind}: ${e.text.slice(0, 2000)}`).join('\n\n').slice(-16000);
41
+ }
42
+
43
+ // Curate presentation only: retain the full discovered catalog for existing sessions and opt-in access.
44
+ export function curateModels(provider, models) {
45
+ const chosen = new Set();
46
+ if (provider === 'codex') {
47
+ for (const m of models) if (/^gpt-6(?:[.-]|$)/i.test(m.id)) chosen.add(m.id);
48
+ } else if (provider === 'claude') {
49
+ for (const family of ['fable', 'opus', 'sonnet', 'haiku']) {
50
+ const candidates = models.filter(m => new RegExp(`^(?:claude-)?${family}(?:[-\\s\\[]|$)`, 'i').test(m.id));
51
+ const version = m => {
52
+ // Display names carry the resolved version for rolling aliases. Ignore date suffixes in IDs.
53
+ const pattern = new RegExp(`${family}[- ]+(\\d+)(?:[.-](\\d{1,3})(?!\\d))?(?:[.-](\\d{1,3})(?!\\d))?`, 'i');
54
+ const match = m.label?.match(pattern) || m.id.match(pattern);
55
+ return match ? match.slice(1).map(n => Number(n || 0)) : [0, 0, 0];
56
+ };
57
+ candidates.sort((a, b) => {
58
+ const av = version(a), bv = version(b);
59
+ for (let i = 0; i < 3; i++) if (av[i] !== bv[i]) return bv[i] - av[i];
60
+ return Number(b.id === family) - Number(a.id === family) || a.id.localeCompare(b.id);
61
+ });
62
+ if (candidates.length) chosen.add(candidates[0].id);
63
+ }
64
+ }
65
+ return models.map(m => ({ ...m, curated: chosen.has(m.id) }));
66
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pindeck",
3
- "version": "2.8.2",
4
- "description": "PinDeck: a local macOS editor for HTML slide decks. Pin elements, describe the change, and Claude Code edits the slides.",
3
+ "version": "3.0.0-beta.1",
4
+ "description": "PinDeck: a local macOS HTML slide editor with Claude Code and OpenAI Codex.",
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
@@ -279,3 +279,18 @@ body.resizing-notes .stage iframe { pointer-events: none; } /* keep the drag w
279
279
  @media (max-width: 1100px) {
280
280
  .app { grid-template-columns: 170px minmax(0, 1fr) 340px; }
281
281
  }
282
+
283
+ /* Provider model catalogs can be longer than the viewport. */
284
+ .menu.model-menu { width: min(370px, 90vw); max-height: min(650px, 70vh); overflow-y: auto; }
285
+ .model-menu button.opt { display: grid; grid-template-columns: 14px minmax(0, 1fr); gap: 2px 8px; }
286
+ .model-menu button.opt .d { grid-column: 2; overflow-wrap: anywhere; }
287
+ #provider-switch .dialog-buttons { flex-wrap: wrap; }
288
+
289
+ .chat-head .menu-wrap { min-width: 0; flex: 1; }
290
+ .chat-head .model-btn { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
291
+ .chat-head .head-actions { flex-shrink: 0; }
292
+
293
+ .chat-head { flex-wrap: wrap; }
294
+ .chat-head .effort-wrap { flex: 0 0 auto; }
295
+ #effort-menu { right: 0; left: auto; }
296
+ #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 TOKEN = document.querySelector('meta[name="editor-token"]').content;
4
4
  const $ = (id) => document.getElementById(id);
@@ -261,7 +261,7 @@ slideList.addEventListener("drop", (e) => {
261
261
  async function moveSlideTo(from, to) { // to: insertion index in the current list
262
262
  const s = state.slides[from];
263
263
  if (!s) return;
264
- if (state.busy) return toast("Claude is working. Wait until it has finished.");
264
+ if (state.busy) return toast("The assistant is working. Wait until it has finished.");
265
265
  flushNudge();
266
266
  await editChain;
267
267
  const res = await api("move-slide", { eid: s.eid, before: state.slides[to]?.eid ?? null });
@@ -391,7 +391,7 @@ const EYE_OFF_ICON = `<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><pa
391
391
  async function toggleHidden(index = state.current) {
392
392
  const s = state.slides[index];
393
393
  if (!s) return;
394
- if (state.busy) return toast("Claude is working. Wait until it has finished.");
394
+ if (state.busy) return toast("The assistant is working. Wait until it has finished.");
395
395
  flushNudge();
396
396
  await editChain;
397
397
  const res = await api("slide-hidden", { eid: s.eid, hidden: !s.hidden });
@@ -497,7 +497,7 @@ function shortEid(p) {
497
497
  function renderPinTray() {
498
498
  const sel = state.sel;
499
499
  $("pin-tray").innerHTML = (sel ? `
500
- <span class="chip sel" data-sel title="Selected: ${esc(sel.eid)}. Claude sees it as “this”.">
500
+ <span class="chip sel" data-sel title="Selected: ${esc(sel.eid)}. The assistant sees it as “this”.">
501
501
  <span class="n">▢</span> S${sel.slide.index + 1} · ${esc(sel.eid.replace(/^[^/]+\//, ""))} <span class="dim">${sel.box.w} × ${sel.box.h}</span>
502
502
  <button class="x" data-unsel aria-label="Deselect">×</button></span>` : "") + state.pins.map((p) => `
503
503
  <span class="chip" data-n="${p.n}" title="${esc(p.eid)}${p.text ? " — " + esc(p.text.slice(0, 80)) : ""}">
@@ -573,7 +573,7 @@ function entryHtml(e) {
573
573
  if (t?.undone) parts.push("<span>undone</span>");
574
574
  } else if (!e.error) parts.push("No file changes");
575
575
  if (e.restored?.length) parts.push(`<span class="warn">${esc(e.restored.join(", "))} is locked; the change was reverted.</span>`);
576
- 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">` +
576
+ 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 index.html and theme.css">` +
577
577
  `Not allowed in PinDeck, so skipped: ${e.denials.map((d) => esc(`${d.name} ${d.detail}`)).join(", ")}</span>`);
578
578
  if (e.duration) parts.push(`<span class="meta">${(e.duration / 1000).toFixed(1)} s</span>`);
579
579
  return parts.join(" ");
@@ -626,7 +626,7 @@ $("diff-close").onclick = () => $("diff-dialog").close();
626
626
 
627
627
  function setBusy(busy) {
628
628
  state.busy = busy;
629
- $("notes").readOnly = busy; // hand edits wait until Claude has finished
629
+ $("notes").readOnly = busy; // hand edits wait until the assistant has finished
630
630
  $("working").hidden = !busy;
631
631
  main?.bridge.setEditable(!busy);
632
632
  $("send").hidden = busy;
@@ -668,7 +668,7 @@ async function send() {
668
668
  $("send").onclick = send;
669
669
  $("stop").onclick = () => api("stop", {});
670
670
  $("new-chat").onclick = async () => {
671
- if (!confirm("Start a new conversation? Claude forgets this chat; your slides stay as they are.")) return;
671
+ if (!confirm("Start a new conversation? The assistant forgets this chat; your slides stay as they are.")) return;
672
672
  await api("new-chat", {});
673
673
  };
674
674
  function autosize() {
@@ -723,8 +723,13 @@ function applyStatus(s) {
723
723
  setBusy(!!s.busy);
724
724
  $("model").textContent = modelLabel(s);
725
725
  $("model-btn").disabled = !!s.busy;
726
+ $("model-btn").title = `${providerLabel(s.settings?.provider)} model`;
727
+ renderEffortMenu();
728
+ $("new-chat").disabled = !!s.busy;
729
+ $("stop").hidden = !s.busy || !!s.switching;
730
+ $("working-text").textContent = s.switching ? "Updating model…" : "The assistant is working…";
726
731
  $("conn").classList.toggle("on", !!s.running);
727
- $("conn").title = s.running ? "Claude Code is running" : "Claude Code starts with the next message";
732
+ $("conn").title = s.running ? "The selected backend is running" : "The selected backend starts with the next message";
728
733
  $("add-shot").disabled = !s.chrome;
729
734
  $("add-shot").title = s.chrome ? "" : "Screenshots need Chrome or Chromium";
730
735
  $("add-slide-btn").disabled = !!s.busy;
@@ -793,7 +798,7 @@ $("add-slide-menu").addEventListener("click", async (e) => {
793
798
  if (!item) return;
794
799
  closeMenus();
795
800
  if (item.dataset.slide === "import") return $("import-file").click();
796
- if (state.busy) return toast("Claude is working. Wait until it has finished.");
801
+ if (state.busy) return toast("The assistant is working. Wait until it has finished.");
797
802
  flushNudge();
798
803
  await editChain;
799
804
  const after = state.slides[state.current]?.eid;
@@ -837,11 +842,11 @@ function importStatus(msg, error = false) {
837
842
  const importChecks = () => [...$("import-list").querySelectorAll("input[type=checkbox]")];
838
843
  function updateImportButton() {
839
844
  const n = importChecks().filter((c) => c.checked).length;
840
- // Claude's help with what the import only approximates (diagrams as pictures, lost build steps, ...)
845
+ // The assistant's help with what the import only approximates (diagrams as pictures, lost build steps, ...)
841
846
  const complex = importChecks().filter((c) => c.checked && importing.slides.find((s) => s.n === +c.value)?.complex.length).length;
842
847
  $("import-refine-wrap").hidden = !complex;
843
- $("import-refine-label").textContent = complex === 1 ? "Then ask Claude to improve the complex slide"
844
- : `Then ask Claude to improve the ${complex} complex slides`;
848
+ $("import-refine-label").textContent = complex === 1 ? "Then ask the assistant to improve the complex slide"
849
+ : `Then ask the assistant to improve the ${complex} complex slides`;
845
850
  $("import-go").textContent = n === 1 ? "Import 1 slide" : `Import ${n} slides`;
846
851
  $("import-go").disabled = !n;
847
852
  $("import-all").textContent = n === importChecks().length ? "Select none" : "Select all";
@@ -866,7 +871,7 @@ function showImportThumbs(ev) {
866
871
  $("import-dialog").addEventListener("close", async () => {
867
872
  if ($("import-dialog").returnValue !== "import" || !importing) return;
868
873
  const slides = importChecks().filter((c) => c.checked).map((c) => +c.value);
869
- if (state.busy) return toast("Claude is working. Wait until it has finished.");
874
+ if (state.busy) return toast("The assistant is working. Wait until it has finished.");
870
875
  flushNudge();
871
876
  await editChain;
872
877
  toast(`Importing ${slides.length} slide${slides.length > 1 ? "s" : ""}…`, { ms: 60000 });
@@ -875,46 +880,95 @@ $("import-dialog").addEventListener("close", async () => {
875
880
  const d = await res.json().catch(() => ({}));
876
881
  if (!res.ok) return toast(d.error || "The import failed.");
877
882
  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).` : "") +
878
- (d.refining ? ` Claude is improving ${d.refining === 1 ? "the complex slide" : `${d.refining} complex slides`}.` : ""), { ms: 5000 });
883
+ (d.refining ? ` The assistant is improving ${d.refining === 1 ? "the complex slide" : `${d.refining} complex slides`}.` : ""), { ms: 5000 });
879
884
  state.current = d.index;
880
885
  try { localStorage.setItem("editor.slide", String(d.index)); } catch {}
881
886
  loadMain();
882
887
  });
883
888
 
884
889
  /* ---- Model and effort --------------------------------------------------------------------------------- */
885
- const MODEL_CHOICES = [["opus", "Opus", "most capable, for layout and design work"], ["sonnet", "Sonnet", "faster, lighter on usage limits"]];
886
- const EFFORT_CHOICES = [["low", "Low", "quick edits"], ["medium", "Medium", ""], ["high", "High", ""],
887
- ["xhigh", "Extra high", ""], ["max", "Max", "slowest, most careful"]];
888
- function prettyModel(id) {
889
- const m = /claude-(\w+)-(\d+)(?:-(\d+))?/.exec(id || "");
890
- return m ? `${m[1][0].toUpperCase()}${m[1].slice(1)} ${m[2]}${m[3] && m[3].length <= 2 ? "." + m[3] : ""}` : id || "";
891
- }
890
+ let modelCatalog = {};
891
+ let showAllModels = false;
892
+ try { showAllModels = localStorage.getItem("editor.showAllModels") === "true"; } catch {}
893
+ const providerLabel = p => p === "codex" ? "OpenAI / ChatGPT" : "Claude";
892
894
  function modelLabel(s) {
893
895
  const st = s.settings || {};
894
- // the full name (e.g. "Sonnet 5.5") once Claude Code has reported it for the chosen model, else the choice
895
- const full = prettyModel(s.model);
896
- const model = full && (!st.model || full.toLowerCase().startsWith(st.model)) ? full : MODEL_CHOICES.find((c) => c[0] === st.model)?.[1] || full;
897
- return [model, st.effort ? EFFORT_CHOICES.find((c) => c[0] === st.effort)?.[1].toLowerCase() + " effort" : ""].filter(Boolean).join(" · ");
896
+ const model = (s.models?.[st.provider]?.models || []).find(m => m.id === st.model);
897
+ return model?.label || st.model || providerLabel(st.provider);
898
898
  }
899
899
  function renderModelMenu() {
900
900
  const st = state.status.settings || {};
901
- const opt = (key, [value, label, desc]) => `<button class="opt" role="menuitemradio" aria-checked="${st[key] === value}"
902
- data-set="${key}" data-value="${value}"><span class="tick">${st[key] === value ? "✓" : ""}</span>
903
- <strong>${label}</strong>${desc ? `<span class="d muted">${desc}</span>` : ""}</button>`;
904
- $("model-menu").innerHTML = `<div class="group">Model</div>${MODEL_CHOICES.map((c) => opt("model", c)).join("")}
905
- <div class="sep"></div><div class="group">Effort: how long Claude thinks</div>${EFFORT_CHOICES.map((c) => opt("effort", c)).join("")}`;
901
+ modelCatalog = state.status.models || modelCatalog;
902
+ const groups = ["claude", "codex"].map(provider => {
903
+ const group = modelCatalog[provider] || {};
904
+ const visible = (group.models || []).filter(m => showAllModels || m.curated || (st.provider === provider && st.model === m.id));
905
+ return `<div class="group">${providerLabel(provider)}</div>` +
906
+ visible.map(m => `<button class="opt" role="menuitemradio" aria-checked="${st.provider === provider && st.model === m.id}"
907
+ data-provider="${provider}" data-model="${esc(m.id)}"><span class="tick">${st.provider === provider && st.model === m.id ? "✓" : ""}</span>
908
+ <strong>${esc(m.label)}${!showAllModels && !m.curated ? " (current)" : ""}</strong><span class="d muted">${esc(m.description)}</span></button>`).join("") +
909
+ (!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>` : "");
910
+ });
911
+ $("model-menu").innerHTML = groups.join('<div class="sep"></div>') +
912
+ `<div class="sep"></div><button data-model-filter>${showAllModels ? 'Show curated models' : 'Show all available models'}</button><button data-refresh>Refresh models</button>`;
906
913
  }
907
- wireMenu("model-btn", "model-menu", renderModelMenu);
908
- $("model-menu").addEventListener("click", async (e) => {
909
- const b = e.target.closest("[data-set]");
910
- if (!b) return;
914
+ const effortLabel = value => ({ none: "None", minimal: "Minimal", low: "Low", medium: "Medium", high: "High", xhigh: "Extra high", max: "Max", ultra: "Ultra" }[value] || value);
915
+ function renderEffortMenu() {
916
+ const st = state.status.settings || {};
917
+ const model = state.status.models?.[st.provider]?.models?.find(m => m.id === st.model);
918
+ const efforts = model?.efforts || [];
919
+ $("effort").textContent = efforts.length ? effortLabel(st.effort) || "Effort" : "Effort N/A";
920
+ $("effort-btn").disabled = !!state.status.busy || !efforts.length;
921
+ $("effort-btn").title = efforts.length ? `Reasoning effort: ${effortLabel(st.effort)}` : "No adjustable reasoning effort reported for this model";
922
+ $("effort-menu").innerHTML = '<div class="group">Reasoning effort</div>' + efforts.map(value => {
923
+ const hint = value === model.defaultEffort ? (model.effortDefaultSource === "provider" ? "Model default" : "PinDeck default") : "";
924
+ const description = model.effortDescriptions?.[value] || "";
925
+ 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>`;
926
+ }).join("");
927
+ }
928
+ wireMenu("effort-btn", "effort-menu", renderEffortMenu);
929
+ $("effort-menu").addEventListener("click", async e => {
930
+ const button = e.target.closest("[data-effort]");
931
+ if (!button || state.status.busy) return;
911
932
  e.stopPropagation();
912
- const res = await api("settings", { [b.dataset.set]: b.dataset.value });
933
+ try { await setModelChoice({ effort: button.dataset.effort }); } catch (e) { toast(e.message); }
934
+ });
935
+ wireMenu("model-btn", "model-menu", renderModelMenu);
936
+ async function setModelChoice(choice) {
937
+ const res = await api("settings", choice);
913
938
  const d = await res.json().catch(() => ({}));
914
- if (!res.ok) return toast(d.error || "Could not change the setting.");
915
- state.status.settings = d.settings;
916
- renderModelMenu();
917
- $("model").textContent = modelLabel(state.status);
939
+ if (!res.ok) return toast(d.error || "Could not change the model.");
940
+ state.status.settings = d.settings; renderModelMenu(); renderEffortMenu(); $("model").textContent = modelLabel(state.status);
941
+ }
942
+ let pendingModel = null;
943
+ $("model-menu").addEventListener("click", async e => {
944
+ const b = e.target.closest("button"); if (!b) return;
945
+ e.stopPropagation();
946
+ if (b.hasAttribute("data-model-filter")) {
947
+ showAllModels = !showAllModels;
948
+ try { localStorage.setItem("editor.showAllModels", String(showAllModels)); } catch {}
949
+ renderModelMenu(); return;
950
+ }
951
+ if (b.hasAttribute("data-refresh")) {
952
+ b.disabled = true;
953
+ try { const r = await api("models"); if (!r.ok) throw new Error("Model discovery failed."); state.status.models = await r.json(); renderModelMenu(); renderEffortMenu(); }
954
+ catch (e) { toast(e.message); b.disabled = false; }
955
+ return;
956
+ }
957
+ if (state.status.busy) return;
958
+ const st = state.status.settings || {};
959
+ const choice = b.dataset.effort ? { effort: b.dataset.effort } : { provider: b.dataset.provider, model: b.dataset.model };
960
+ if (choice.provider && choice.provider !== st.provider) {
961
+ pendingModel = choice;
962
+ $("provider-switch-title").textContent = `Switch to ${providerLabel(choice.provider)}?`;
963
+ $("provider-switch").returnValue = "";
964
+ $("provider-switch").showModal();
965
+ } else { try { await setModelChoice(choice); } catch (e) { toast(e.message); } }
966
+ });
967
+ $("provider-switch").addEventListener("close", async () => {
968
+ const choice = pendingModel; pendingModel = null;
969
+ const handover = $("provider-switch").returnValue;
970
+ if (!choice || !["summary", "fresh"].includes(handover)) return;
971
+ try { await setModelChoice({ ...choice, handover }); } catch (e) { toast(e.message); }
918
972
  });
919
973
 
920
974
  /* ---- Delete the selected element ---------------------------------------------------------------------
@@ -923,7 +977,7 @@ $("model-menu").addEventListener("click", async (e) => {
923
977
  let deleting = false;
924
978
  async function requestDelete(eid) {
925
979
  if (deleting || !eid) return;
926
- if (state.busy) return toast("Claude is working. Wait until it has finished.");
980
+ if (state.busy) return toast("The assistant is working. Wait until it has finished.");
927
981
  deleting = true;
928
982
  try {
929
983
  flushNudge();
@@ -964,7 +1018,7 @@ async function requestDelete(eid) {
964
1018
  }
965
1019
 
966
1020
  /* ---- Attachments: figures, audio and PDFs for the next message ----------------------------------------
967
- 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
1021
+ 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
968
1022
  waveform SVG (bars, viewBox 1000 x 120, like the template's audio player), all computed here in the
969
1023
  browser. Removing an attachment before sending deletes the uploaded file again. */
970
1024
  let attachments = [], attachSeq = 0;