pindeck 2.8.1 → 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
+ }
@@ -0,0 +1,34 @@
1
+ import http from 'node:http';
2
+
3
+ export function createPlatformNoticeServer(platform) {
4
+ const windows = platform === 'win32';
5
+ const heading = windows ? 'Windows support is on the roadmap' : 'PinDeck currently requires macOS';
6
+ const explanation = windows
7
+ ? 'PinDeck is currently available for macOS only. Windows support is planned for a future version; there is no release date yet.'
8
+ : 'This version of PinDeck supports macOS only. Linux and other operating systems are not supported yet.';
9
+ const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PinDeck · macOS required</title><style>
10
+ :root{font-family:system-ui,sans-serif;color:#243348;background:#f4f6f8;color-scheme:light}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px}main{width:min(100%,620px);padding:40px;background:white;border:1px solid #dde3ea;border-radius:18px;box-shadow:0 12px 40px #20334d0a}.brand{font-weight:700;color:#c36516;margin:0 0 32px}h1{font-size:clamp(26px,5vw,36px);line-height:1.2;margin:0 0 20px}p{line-height:1.65}a{color:#945012}footer{margin-top:28px;font-size:14px;color:#596577}
11
+ </style></head><body><main><p class="brand">PinDeck</p><h1>${heading}</h1><p>${explanation}</p><p>Your deck has not been opened or changed, and no AI backend has been started.</p><p>You can use PinDeck on a Mac or follow updates on <a href="https://github.com/ehabets/PinDeck">GitHub</a>.</p><footer>Close this tab and press Ctrl+C in the terminal to stop this notice.</footer></main></body></html>`;
12
+ return http.createServer((req, res) => {
13
+ if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405, { Allow: 'GET, HEAD' }); return res.end(); }
14
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store',
15
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'" });
16
+ res.end(req.method === 'HEAD' ? undefined : html);
17
+ });
18
+ }
19
+
20
+ // Keep startup here until the notice is closed: no deck/state initialization runs on unsupported OSes.
21
+ export async function showPlatformNotice(port, platform = process.platform) {
22
+ const server = createPlatformNoticeServer(platform);
23
+ await new Promise((resolve, reject) => {
24
+ const stop = () => server.close();
25
+ server.once('error', reject);
26
+ server.once('close', () => {
27
+ process.off('SIGINT', stop); process.off('SIGTERM', stop); resolve();
28
+ });
29
+ server.listen(port, '127.0.0.1', () => {
30
+ process.once('SIGINT', stop); process.once('SIGTERM', stop);
31
+ console.log(`PinDeck requires macOS. Open http://127.0.0.1:${server.address().port}/ for details. Press Ctrl+C to stop.`);
32
+ });
33
+ });
34
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pindeck",
3
- "version": "2.8.1",
4
- "description": "PinDeck: a local 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"
@@ -35,6 +37,7 @@
35
37
  "start": "node server.mjs",
36
38
  "test": "node --test"
37
39
  },
40
+ "os": ["darwin"],
38
41
  "engines": {
39
42
  "node": ">=20"
40
43
  }
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; }