pennyrouter 0.3.43 → 0.3.44

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.
@@ -1,384 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>Penny Compare — terminal preview</title>
7
- <link rel="stylesheet" href="/xterm.css">
8
- <style>
9
- :root { color-scheme: dark; font: 14px/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
10
- * { box-sizing: border-box; }
11
- html, body { height: 100%; margin: 0; background: #111116; color: #d7d7dd; }
12
- body { display: flex; flex-direction: column; overflow: hidden; }
13
- .status { height: 25px; flex: none; display: flex; align-items: center; justify-content: center; gap: 1.2rem; background: #262629; color: #9ded83; font-size: 12px; white-space: nowrap; }
14
- .status button { appearance: none; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; padding: 0; }
15
- .status button:hover { color: #fff; }
16
- .panes { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); flex: 1; min-height: 0; }
17
- .pane { min-width: 0; min-height: 0; overflow: hidden; background: #16161b; }
18
- .pane + .pane { border-left: 1px solid #4d4d52; }
19
- .pane.focused { box-shadow: inset 0 0 0 1px #6a6a72; }
20
- .pane { display: flex; flex-direction: column; }
21
- .pane header { flex: none; padding: 6px 10px; background: #202027; border-bottom: 1px solid #34343c; }
22
- .files { position: relative; width: fit-content; }
23
- .files summary { cursor: pointer; color: #9ded83; }
24
- .file-list { position: absolute; top: 100%; left: 0; z-index: 3; width: min(420px, 45vw);
25
- max-height: 300px; overflow: auto; background: #202027; border: 1px solid #4d4d52; padding: 8px; }
26
- .file-list p { color: #92929c; margin: 6px; }
27
- .file-list button { display: block; width: 100%; text-align: left; overflow-wrap: anywhere;
28
- padding: 8px; border: 0; background: none; color: #d7d7dd; font: inherit; cursor: pointer; }
29
- .file-list button:hover { background: #34343c; }
30
- .terminal { width: 100%; flex: 1; min-height: 0; padding: 4px 7px; }
31
- .pane { position: relative; }
32
- .meter { display: none; position: absolute; top: 35px; right: 13px; z-index: 2;
33
- width: 190px; white-space: pre; color: #9ded83; line-height: 1.15; font-size: 13px;
34
- pointer-events: none; padding: 5px 8px; border-radius: 3px;
35
- background: rgba(12, 12, 16, .82); }
36
- .pane.metered .meter { display: block; }
37
- .delta { display: none; position: absolute; top: 35px; right: 220px; z-index: 2;
38
- color: #9ded83; white-space: nowrap; pointer-events: none; }
39
- .pane.metered .delta { display: block; }
40
- .xterm { height: 100%; }
41
- .xterm-viewport { scrollbar-width: thin; }
42
- .finish { position: fixed; inset: 25px 0 0; z-index: 5; display: grid; place-items: center;
43
- padding: 24px; background: rgba(10, 10, 14, .94); }
44
- .finish[hidden] { display: none; }
45
- .card { width: min(680px, 100%); border: 1px solid #4d4d52; background: #17171d;
46
- padding: 28px; box-shadow: 0 16px 60px #0009; }
47
- .card h1 { margin: 0 0 24px; color: #9ded83; font-size: 18px; letter-spacing: .08em; }
48
- .metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 24px; }
49
- .metric { padding: 14px; background: #202027; }
50
- .metric span { display: block; color: #92929c; margin-bottom: 8px; }
51
- .metric strong { font-size: 20px; }
52
- .sides { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; color: #c4c4cc; white-space: pre-line; }
53
- .report-path { margin-top: 24px; color: #9ded83; overflow-wrap: anywhere; }
54
- .finish-note { margin-top: 20px; color: #92929c; }
55
- .setup { position: fixed; inset: 25px 0 0; z-index: 4; display: grid; place-items: center;
56
- overflow: auto; padding: 24px; background: rgba(10, 10, 14, .97); }
57
- .setup[hidden] { display: none; }
58
- .setup-card { width: min(760px, 100%); border: 1px solid #4d4d52; background: #17171d;
59
- padding: 26px; }
60
- .setup-card h1 { margin: 0 0 22px; color: #9ded83; font-size: 18px; }
61
- .setup-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
62
- .setup-field { display: grid; gap: 7px; color: #a5a5ae; }
63
- .setup-field input, .setup-field select { min-width: 0; padding: 9px; border: 1px solid #4d4d52;
64
- background: #101015; color: #e5e5ea; font: inherit; }
65
- .setup-model { margin-top: 16px; }
66
- .setup-actions { display: flex; align-items: center; gap: 14px; margin-top: 22px; }
67
- .setup-actions button { padding: 10px 16px; border: 1px solid #9ded83; background: #26362a;
68
- color: #c8ffd0; font: inherit; cursor: pointer; }
69
- .setup-actions button:disabled { opacity: .5; cursor: wait; }
70
- #setup-error { color: #ff8b98; }
71
- @media (max-width: 600px) { .setup-grid { grid-template-columns: 1fr; } }
72
- @media (max-width: 720px) { .status { justify-content: flex-start; overflow-x: auto; padding: 0 8px; } }
73
- </style>
74
- </head>
75
- <body>
76
- <div class="status">
77
- <button id="sync">SYNC ON ^⇧S</button>
78
- <button id="exit" disabled>EXIT ^⇧X</button>
79
- <span id="connection" style="color:#77777e">connecting…</span>
80
- </div>
81
- <main class="panes">
82
- <section class="pane focused"><header><details class="files"><summary id="left-files-count">Files · 0</summary><div class="file-list" id="left-files"><p>HTML files will appear here.</p></div></details></header><div class="meter" id="left-meter"></div><div class="terminal" id="native"></div></section>
83
- <section class="pane"><header><details class="files"><summary id="right-files-count">Files · 0</summary><div class="file-list" id="right-files"><p>HTML files will appear here.</p></div></details></header><div class="delta" id="delta"></div><div class="meter" id="right-meter"></div><div class="terminal" id="penny"></div></section>
84
- </main>
85
- <section class="setup" id="setup">
86
- <form class="setup-card" id="setup-form">
87
- <h1>PENNY COMPARE</h1>
88
- <div class="setup-grid">
89
- <label class="setup-field">Left pane
90
- <select id="left-variant"></select>
91
- </label>
92
- <label class="setup-field">Right pane
93
- <select id="right-variant"></select>
94
- </label>
95
- <label class="setup-field">Left directory
96
- <input id="left-cwd" autocomplete="off" required>
97
- </label>
98
- <label class="setup-field">Right directory
99
- <input id="right-cwd" autocomplete="off" required>
100
- </label>
101
- <label class="setup-field">Left label <input id="left-label" placeholder="NATIVE or PENNY"></label>
102
- <label class="setup-field">Right label <input id="right-label" placeholder="NATIVE or PENNY"></label>
103
- </div>
104
- <label class="setup-field setup-model">Model (both panes)
105
- <input id="model" list="model-options" placeholder="default">
106
- <datalist id="model-options"></datalist>
107
- </label>
108
- <div class="setup-actions"><button id="launch" type="submit" disabled>Start comparison</button><span id="setup-error"></span></div>
109
- </form>
110
- </section>
111
- <section class="finish" id="finish" hidden aria-live="polite">
112
- <div class="card">
113
- <h1 id="finish-title">PENNY COMPARISON</h1>
114
- <div class="metrics" id="finish-metrics"></div>
115
- <div class="sides" id="finish-sides"></div>
116
- <div class="report-path" id="finish-path"></div>
117
- <div class="finish-note" id="finish-note">Comparison complete. You can close this tab.</div>
118
- </div>
119
- </section>
120
- <script src="/xterm.js"></script>
121
- <script src="/addon-fit.js"></script>
122
- <script src="/addon-unicode11.js"></script>
123
- <script src="/terminal-unicode.js"></script>
124
- <script>
125
- const options = {
126
- fontFamily: 'SFMono-Regular, Menlo, Consolas, monospace', fontSize: 13,
127
- lineHeight: 1.15, cursorBlink: true, cursorStyle: 'block',
128
- allowTransparency: false, scrollback: 1000, allowProposedApi: true,
129
- theme: { background: '#16161b', foreground: '#d7d7dd', cursor: '#d7d7dd',
130
- selectionBackground: '#4b5369', black: '#24242a', red: '#e88b8b',
131
- green: '#9ded83', yellow: '#e6c583', blue: '#8eaeeb', magenta: '#c8a7e8',
132
- cyan: '#8ed1cf', white: '#d7d7dd' }
133
- };
134
- const native = new Terminal(options);
135
- const penny = new Terminal(options);
136
- configureCompareUnicode(native, Unicode11Addon.Unicode11Addon);
137
- configureCompareUnicode(penny, Unicode11Addon.Unicode11Addon);
138
- const fit = { left: new FitAddon.FitAddon(), right: new FitAddon.FitAddon() };
139
- native.loadAddon(fit.left);
140
- penny.loadAddon(fit.right);
141
- native.open(document.getElementById('native'));
142
- penny.open(document.getElementById('penny'));
143
- let sync = true;
144
- let pendingConfig = null;
145
- const panes = document.querySelectorAll('.pane');
146
- const send = message => { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); };
147
- let socket;
148
- let sessionToken;
149
- let fileTimer;
150
- let pollingFiles = false;
151
- const previewTabs = new Map();
152
- async function pollFiles() {
153
- if (pollingFiles || !sessionToken) return;
154
- pollingFiles = true;
155
- try {
156
- await Promise.all(['left', 'right'].map(async side => {
157
- const list = document.getElementById(`${side}-files`);
158
- try {
159
- const response = await fetch(`/artifacts?side=${side}`, {
160
- headers: { Authorization: `Bearer ${sessionToken}` }, cache: 'no-store',
161
- signal: AbortSignal.timeout(10000),
162
- });
163
- if (!response.ok) throw new Error('Files unavailable');
164
- const { files } = await response.json();
165
- document.getElementById(`${side}-files-count`).textContent = `Files · ${files.length}`;
166
- const paths = JSON.stringify(files.map(file => file.path));
167
- if (list.dataset.paths === paths) return;
168
- list.dataset.paths = paths;
169
- list.replaceChildren();
170
- if (!files.length) {
171
- const hint = document.createElement('p'); hint.textContent = 'HTML files will appear here.';
172
- list.append(hint);
173
- }
174
- for (const file of files) {
175
- const button = document.createElement('button'); button.textContent = file.path;
176
- button.title = 'Open preview in a new tab';
177
- button.onclick = () => {
178
- for (const tab of previewTabs.keys()) if (tab.closed) previewTabs.delete(tab);
179
- const tab = window.open('/demo-artifact-preview', '_blank');
180
- if (tab) previewTabs.set(tab, { side, path: file.path });
181
- else document.getElementById('connection').textContent = 'Allow pop-ups to open file previews.';
182
- };
183
- list.append(button);
184
- }
185
- } catch {
186
- delete list.dataset.paths;
187
- list.replaceChildren();
188
- const hint = document.createElement('p'); hint.textContent = 'Files unavailable. Retrying…';
189
- list.append(hint);
190
- }
191
- }));
192
- } finally { pollingFiles = false; }
193
- }
194
- window.addEventListener('message', event => {
195
- if (event.origin !== location.origin || event.data?.type !== 'compare-artifact-ready') return;
196
- const file = previewTabs.get(event.source);
197
- if (file && sessionToken) event.source.postMessage(
198
- { type: 'compare-artifact-session', ...file, token: sessionToken }, location.origin);
199
- });
200
- const setFocus = index => { panes.forEach((p, i) => p.classList.toggle('focused', i === index)); (index ? penny : native).focus(); };
201
- const setSync = value => {
202
- sync = value;
203
- document.getElementById('sync').textContent = sync ? 'SYNC ON ^⇧S' : 'sync off ^⇧S';
204
- };
205
- const exit = () => {
206
- if (document.getElementById('exit').disabled) return;
207
- send({ type: 'exit' });
208
- document.getElementById('connection').textContent = 'saving report…';
209
- document.getElementById('exit').disabled = true;
210
- };
211
- function resize() {
212
- if (!socket || socket.readyState !== WebSocket.OPEN) return;
213
- for (const [side, terminal] of [['left', native], ['right', penny]]) {
214
- fit[side].fit();
215
- send({ type: 'resize', side, cols: terminal.cols, rows: terminal.rows });
216
- }
217
- }
218
- function connect() {
219
- socket = new WebSocket(`ws://${location.host}/terminal`);
220
- socket.addEventListener('open', () => {
221
- document.getElementById('connection').textContent = 'live PTYs';
222
- resize();
223
- setFocus(0);
224
- document.getElementById('launch').disabled = false;
225
- if (!isCompare) {
226
- document.getElementById('setup').hidden = true;
227
- document.getElementById('exit').disabled = false;
228
- for (const [side, terminal] of [['left', native], ['right', penny]])
229
- send({ type: 'start', side, cols: terminal.cols, rows: terminal.rows });
230
- }
231
- });
232
- socket.addEventListener('message', event => {
233
- const message = JSON.parse(event.data);
234
- const terminal = message.side === 'left' ? native : penny;
235
- if (message.type === 'artifact-session') {
236
- sessionToken = message.token;
237
- clearInterval(fileTimer);
238
- void pollFiles();
239
- fileTimer = setInterval(pollFiles, 3000);
240
- }
241
- if (message.type === 'configured' && pendingConfig) {
242
- for (const [side, terminal] of [['left', native], ['right', penny]])
243
- send({ type: 'start', side, cols: terminal.cols, rows: terminal.rows });
244
- pendingConfig = null;
245
- document.getElementById('launch').disabled = false;
246
- document.getElementById('exit').disabled = false;
247
- document.getElementById('setup').hidden = true;
248
- resize();
249
- }
250
- if (message.type === 'configuration-error') {
251
- pendingConfig = null;
252
- document.getElementById('launch').disabled = false;
253
- document.getElementById('setup-error').textContent = message.error;
254
- }
255
- if (message.type === 'output') terminal.write(message.data);
256
- if (message.type === 'exit') terminal.write(`\r\n[process exited: ${message.code}]\r\n`);
257
- if (message.type === 'error') terminal.write(`\r\n[launch failed: ${message.error}]\r\n`);
258
- if (message.type === 'meter-mode') {
259
- panes.forEach(pane => pane.classList.add('metered'));
260
- resize();
261
- }
262
- if (message.type === 'meter') {
263
- const data = message.data;
264
- const total = data.totals;
265
- document.getElementById(`${data.side}-meter`).textContent =
266
- `${data.label || data.role} ${(data.model || 'waiting').split('/').pop()}\n` +
267
- `read ${total.cache_read.toLocaleString().padStart(11)}\n` +
268
- `write ${total.cache_write.toLocaleString().padStart(11)}\n` +
269
- `in ${total.fresh_input.toLocaleString().padStart(11)}\n` +
270
- `out ${total.output.toLocaleString().padStart(11)}\n` +
271
- `total ${total.total.toLocaleString().padStart(11)}\n` +
272
- `cost $${total.cost.toFixed(2).padStart(10)}`;
273
- }
274
- if (message.type === 'delta') {
275
- document.getElementById('delta').textContent =
276
- `${message.cost.toFixed(0)}% cost ${message.tokens.toFixed(0)}% tok`;
277
- }
278
- if (message.type === 'report') {
279
- const savings = message.savings;
280
- const metric = (amount, percent, unit) =>
281
- `${unit}${unit ? Math.abs(amount).toFixed(2) : Math.abs(amount).toLocaleString()}` +
282
- ` ${amount >= 0 ? 'saved' : 'extra'} (${Math.abs(percent).toFixed(1)}%)`;
283
- const addMetric = (label, value) => {
284
- const item = document.createElement('div'); item.className = 'metric';
285
- const caption = document.createElement('span'); caption.textContent = label;
286
- const result = document.createElement('strong'); result.textContent = value;
287
- item.append(caption, result); document.getElementById('finish-metrics').append(item);
288
- };
289
- addMetric('COST', metric(savings.cost, savings.cost_percent, '$'));
290
- addMetric('TOKENS', `${metric(savings.tokens, savings.token_percent, '')} tokens`);
291
- const sideSummary = side => `${side.calls} calls · ${side.totals.total.toLocaleString()} tokens · $${side.totals.cost.toFixed(2)}`;
292
- document.getElementById('finish-sides').textContent =
293
- `NATIVE ${sideSummary(message.native)}\nPENNY ${sideSummary(message.penny)}`;
294
- document.getElementById('finish-path').textContent = `Report saved: ${message.path}`;
295
- document.getElementById('finish').hidden = false;
296
- document.getElementById('connection').textContent = 'report saved';
297
- }
298
- if (message.type === 'report-error') {
299
- document.getElementById('finish-title').textContent = 'REPORT COULD NOT BE SAVED';
300
- document.getElementById('finish-metrics').textContent = message.error;
301
- document.getElementById('finish-note').textContent = 'Comparison sessions have ended. Reload the page to start again.';
302
- document.getElementById('finish').hidden = false;
303
- document.getElementById('connection').textContent = 'report failed';
304
- }
305
- });
306
- socket.addEventListener('close', () => {
307
- clearInterval(fileTimer);
308
- if (!document.getElementById('exit').disabled) {
309
- document.getElementById('connection').textContent = 'disconnected — reload to reconnect';
310
- }
311
- });
312
- }
313
- const resizeObserver = new ResizeObserver(resize);
314
- panes.forEach(pane => resizeObserver.observe(pane));
315
- document.getElementById('sync').onclick = () => setSync(!sync);
316
- document.getElementById('exit').onclick = exit;
317
- const cwd = __PENNY_CWD__;
318
- const isCompare = __PENNY_COMPARE__;
319
- const defaultHarness = __PENNY_DEFAULT_HARNESS__;
320
- const variants = ['claude', 'penny-claude', 'codex', 'penny-codex'];
321
- for (const side of ['left', 'right']) {
322
- const select = document.getElementById(`${side}-variant`);
323
- for (const variant of variants) {
324
- const option = document.createElement('option'); option.value = variant; option.textContent = variant;
325
- select.append(option);
326
- }
327
- }
328
- document.getElementById('left-variant').value = defaultHarness;
329
- document.getElementById('right-variant').value = `penny-${defaultHarness}`;
330
- document.getElementById('left-cwd').value = `${cwd}/native`;
331
- document.getElementById('right-cwd').value = `${cwd}/penny`;
332
- const refreshModels = () => {
333
- const left = document.getElementById('left-variant').value;
334
- const right = document.getElementById('right-variant').value;
335
- const leftCodex = left.includes('codex');
336
- const rightCodex = right.includes('codex');
337
- const model = document.getElementById('model');
338
- model.disabled = leftCodex !== rightCodex;
339
- if (model.disabled) model.value = '';
340
- const values = leftCodex && rightCodex
341
- ? ['default', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini']
342
- : leftCodex !== rightCodex ? []
343
- : ['default', 'opus', 'sonnet', 'haiku', 'fable'];
344
- const list = document.getElementById('model-options'); list.replaceChildren();
345
- for (const value of values) { const option = document.createElement('option'); option.value = value; list.append(option); }
346
- };
347
- document.getElementById('left-variant').onchange = refreshModels;
348
- document.getElementById('right-variant').onchange = refreshModels;
349
- refreshModels();
350
- connect();
351
- document.getElementById('setup-form').addEventListener('submit', event => {
352
- event.preventDefault();
353
- const config = {};
354
- for (const side of ['left', 'right']) config[side] = {
355
- variant: document.getElementById(`${side}-variant`).value,
356
- cwd: document.getElementById(`${side}-cwd`).value.trim(),
357
- label: document.getElementById(`${side}-label`).value.trim(),
358
- model: document.getElementById('model').value.trim(),
359
- };
360
- if (socket.readyState !== WebSocket.OPEN) return;
361
- pendingConfig = config;
362
- document.getElementById('launch').disabled = true;
363
- document.getElementById('setup-error').textContent = '';
364
- send({ type: 'configure', config });
365
- });
366
- document.addEventListener('keydown', event => {
367
- if (!event.ctrlKey || !event.shiftKey || event.altKey || event.metaKey) return;
368
- if (event.code === 'KeyS') { event.preventDefault(); event.stopPropagation(); setSync(!sync); }
369
- if (event.code === 'KeyX') { event.preventDefault(); event.stopPropagation(); exit(); }
370
- }, true);
371
- document.getElementById('native').addEventListener('pointerdown', () => setFocus(0));
372
- document.getElementById('penny').addEventListener('pointerdown', () => setFocus(1));
373
- native.onData(data => {
374
- send({ type: 'input', side: 'left', data });
375
- if (sync) send({ type: 'input', side: 'right', data });
376
- });
377
- penny.onData(data => {
378
- send({ type: 'input', side: 'right', data });
379
- if (sync) send({ type: 'input', side: 'left', data });
380
- });
381
- setFocus(0);
382
- </script>
383
- </body>
384
- </html>