pennyrouter 0.3.37 → 0.3.39

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.
@@ -21,26 +21,92 @@
21
21
  .pane { position: relative; }
22
22
  .meter { display: none; position: absolute; top: 4px; right: 13px; z-index: 2;
23
23
  width: 190px; white-space: pre; color: #9ded83; line-height: 1.15; font-size: 13px;
24
- pointer-events: none; }
24
+ pointer-events: none; padding: 5px 8px; border-radius: 3px;
25
+ background: rgba(12, 12, 16, .82); }
25
26
  .pane.metered .meter { display: block; }
26
27
  .delta { display: none; position: absolute; top: 4px; right: 220px; z-index: 2;
27
28
  color: #9ded83; white-space: nowrap; pointer-events: none; }
28
29
  .pane.metered .delta { display: block; }
29
30
  .xterm { height: 100%; }
30
31
  .xterm-viewport { scrollbar-width: thin; }
32
+ .finish { position: fixed; inset: 25px 0 0; z-index: 5; display: grid; place-items: center;
33
+ padding: 24px; background: rgba(10, 10, 14, .94); }
34
+ .finish[hidden] { display: none; }
35
+ .card { width: min(680px, 100%); border: 1px solid #4d4d52; background: #17171d;
36
+ padding: 28px; box-shadow: 0 16px 60px #0009; }
37
+ .card h1 { margin: 0 0 24px; color: #9ded83; font-size: 18px; letter-spacing: .08em; }
38
+ .metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 24px; }
39
+ .metric { padding: 14px; background: #202027; }
40
+ .metric span { display: block; color: #92929c; margin-bottom: 8px; }
41
+ .metric strong { font-size: 20px; }
42
+ .sides { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; color: #c4c4cc; white-space: pre-line; }
43
+ .report-path { margin-top: 24px; color: #9ded83; overflow-wrap: anywhere; }
44
+ .finish-note { margin-top: 20px; color: #92929c; }
45
+ .setup { position: fixed; inset: 25px 0 0; z-index: 4; display: grid; place-items: center;
46
+ overflow: auto; padding: 24px; background: rgba(10, 10, 14, .97); }
47
+ .setup[hidden] { display: none; }
48
+ .setup-card { width: min(760px, 100%); border: 1px solid #4d4d52; background: #17171d;
49
+ padding: 26px; }
50
+ .setup-card h1 { margin: 0 0 22px; color: #9ded83; font-size: 18px; }
51
+ .setup-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
52
+ .setup-field { display: grid; gap: 7px; color: #a5a5ae; }
53
+ .setup-field input, .setup-field select { min-width: 0; padding: 9px; border: 1px solid #4d4d52;
54
+ background: #101015; color: #e5e5ea; font: inherit; }
55
+ .setup-model { margin-top: 16px; }
56
+ .setup-actions { display: flex; align-items: center; gap: 14px; margin-top: 22px; }
57
+ .setup-actions button { padding: 10px 16px; border: 1px solid #9ded83; background: #26362a;
58
+ color: #c8ffd0; font: inherit; cursor: pointer; }
59
+ .setup-actions button:disabled { opacity: .5; cursor: wait; }
60
+ #setup-error { color: #ff8b98; }
61
+ @media (max-width: 600px) { .setup-grid { grid-template-columns: 1fr; } }
31
62
  @media (max-width: 720px) { .status { justify-content: flex-start; overflow-x: auto; padding: 0 8px; } }
32
63
  </style>
33
64
  </head>
34
65
  <body>
35
66
  <div class="status">
36
67
  <button id="sync">SYNC ON ^⇧S</button>
37
- <button id="exit">EXIT ^⇧X</button>
68
+ <button id="exit" disabled>EXIT ^⇧X</button>
38
69
  <span id="connection" style="color:#77777e">connecting…</span>
39
70
  </div>
40
71
  <main class="panes">
41
72
  <section class="pane focused"><div class="meter" id="left-meter"></div><div class="terminal" id="native"></div></section>
42
73
  <section class="pane"><div class="delta" id="delta"></div><div class="meter" id="right-meter"></div><div class="terminal" id="penny"></div></section>
43
74
  </main>
75
+ <section class="setup" id="setup">
76
+ <form class="setup-card" id="setup-form">
77
+ <h1>PENNY COMPARE</h1>
78
+ <div class="setup-grid">
79
+ <label class="setup-field">Left pane
80
+ <select id="left-variant"></select>
81
+ </label>
82
+ <label class="setup-field">Right pane
83
+ <select id="right-variant"></select>
84
+ </label>
85
+ <label class="setup-field">Left directory
86
+ <input id="left-cwd" autocomplete="off" required>
87
+ </label>
88
+ <label class="setup-field">Right directory
89
+ <input id="right-cwd" autocomplete="off" required>
90
+ </label>
91
+ <label class="setup-field">Left label <input id="left-label" placeholder="NATIVE or PENNY"></label>
92
+ <label class="setup-field">Right label <input id="right-label" placeholder="NATIVE or PENNY"></label>
93
+ </div>
94
+ <label class="setup-field setup-model">Model (both panes)
95
+ <input id="model" list="model-options" placeholder="default">
96
+ <datalist id="model-options"></datalist>
97
+ </label>
98
+ <div class="setup-actions"><button id="launch" type="submit" disabled>Start comparison</button><span id="setup-error"></span></div>
99
+ </form>
100
+ </section>
101
+ <section class="finish" id="finish" hidden aria-live="polite">
102
+ <div class="card">
103
+ <h1 id="finish-title">PENNY COMPARISON</h1>
104
+ <div class="metrics" id="finish-metrics"></div>
105
+ <div class="sides" id="finish-sides"></div>
106
+ <div class="report-path" id="finish-path"></div>
107
+ <div class="finish-note" id="finish-note">Comparison complete. You can close this tab.</div>
108
+ </div>
109
+ </section>
44
110
  <script src="/xterm.js"></script>
45
111
  <script src="/addon-fit.js"></script>
46
112
  <script>
@@ -61,36 +127,59 @@
61
127
  native.open(document.getElementById('native'));
62
128
  penny.open(document.getElementById('penny'));
63
129
  let sync = true;
130
+ let pendingConfig = null;
64
131
  const panes = document.querySelectorAll('.pane');
65
132
  const send = message => { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); };
66
- const socket = new WebSocket(`ws://${location.host}/terminal`);
133
+ let socket;
67
134
  const setFocus = index => { panes.forEach((p, i) => p.classList.toggle('focused', i === index)); (index ? penny : native).focus(); };
68
135
  const setSync = value => {
69
136
  sync = value;
70
137
  document.getElementById('sync').textContent = sync ? 'SYNC ON ^⇧S' : 'sync off ^⇧S';
71
138
  };
72
139
  const exit = () => {
140
+ if (document.getElementById('exit').disabled) return;
73
141
  send({ type: 'exit' });
74
142
  document.getElementById('connection').textContent = 'saving report…';
75
143
  document.getElementById('exit').disabled = true;
76
144
  };
77
145
  function resize() {
146
+ if (!socket || socket.readyState !== WebSocket.OPEN) return;
78
147
  for (const [side, terminal] of [['left', native], ['right', penny]]) {
79
148
  fit[side].fit();
80
149
  send({ type: 'resize', side, cols: terminal.cols, rows: terminal.rows });
81
150
  }
82
151
  }
83
- socket.addEventListener('open', () => {
152
+ function connect() {
153
+ socket = new WebSocket(`ws://${location.host}/terminal`);
154
+ socket.addEventListener('open', () => {
84
155
  document.getElementById('connection').textContent = 'live PTYs';
85
156
  resize();
86
- for (const [side, terminal] of [['left', native], ['right', penny]]) {
87
- send({ type: 'start', side, cols: terminal.cols, rows: terminal.rows });
88
- }
89
157
  setFocus(0);
90
- });
158
+ document.getElementById('launch').disabled = false;
159
+ if (!isCompare) {
160
+ document.getElementById('setup').hidden = true;
161
+ document.getElementById('exit').disabled = false;
162
+ for (const [side, terminal] of [['left', native], ['right', penny]])
163
+ send({ type: 'start', side, cols: terminal.cols, rows: terminal.rows });
164
+ }
165
+ });
91
166
  socket.addEventListener('message', event => {
92
167
  const message = JSON.parse(event.data);
93
168
  const terminal = message.side === 'left' ? native : penny;
169
+ if (message.type === 'configured' && pendingConfig) {
170
+ for (const [side, terminal] of [['left', native], ['right', penny]])
171
+ send({ type: 'start', side, cols: terminal.cols, rows: terminal.rows });
172
+ pendingConfig = null;
173
+ document.getElementById('launch').disabled = false;
174
+ document.getElementById('exit').disabled = false;
175
+ document.getElementById('setup').hidden = true;
176
+ resize();
177
+ }
178
+ if (message.type === 'configuration-error') {
179
+ pendingConfig = null;
180
+ document.getElementById('launch').disabled = false;
181
+ document.getElementById('setup-error').textContent = message.error;
182
+ }
94
183
  if (message.type === 'output') terminal.write(message.data);
95
184
  if (message.type === 'exit') terminal.write(`\r\n[process exited: ${message.code}]\r\n`);
96
185
  if (message.type === 'error') terminal.write(`\r\n[launch failed: ${message.error}]\r\n`);
@@ -102,7 +191,7 @@
102
191
  const data = message.data;
103
192
  const total = data.totals;
104
193
  document.getElementById(`${data.side}-meter`).textContent =
105
- `${data.role} ${(data.model || 'waiting').split('/').pop()}\n` +
194
+ `${data.label || data.role} ${(data.model || 'waiting').split('/').pop()}\n` +
106
195
  `read ${total.cache_read.toLocaleString().padStart(11)}\n` +
107
196
  `write ${total.cache_write.toLocaleString().padStart(11)}\n` +
108
197
  `in ${total.fresh_input.toLocaleString().padStart(11)}\n` +
@@ -119,26 +208,88 @@
119
208
  const metric = (amount, percent, unit) =>
120
209
  `${unit}${unit ? Math.abs(amount).toFixed(2) : Math.abs(amount).toLocaleString()}` +
121
210
  ` ${amount >= 0 ? 'saved' : 'extra'} (${Math.abs(percent).toFixed(1)}%)`;
122
- native.write(`\r\n\x1b[1;32mPENNY COMPARISON\x1b[0m\r\n` +
123
- `${metric(savings.tokens, savings.token_percent, '')} tokens\r\n` +
124
- `${metric(savings.cost, savings.cost_percent, '$')}\r\n` +
125
- `Report: ${message.path}\r\n`);
211
+ const addMetric = (label, value) => {
212
+ const item = document.createElement('div'); item.className = 'metric';
213
+ const caption = document.createElement('span'); caption.textContent = label;
214
+ const result = document.createElement('strong'); result.textContent = value;
215
+ item.append(caption, result); document.getElementById('finish-metrics').append(item);
216
+ };
217
+ addMetric('COST', metric(savings.cost, savings.cost_percent, '$'));
218
+ addMetric('TOKENS', `${metric(savings.tokens, savings.token_percent, '')} tokens`);
219
+ const sideSummary = side => `${side.calls} calls · ${side.totals.total.toLocaleString()} tokens · $${side.totals.cost.toFixed(2)}`;
220
+ document.getElementById('finish-sides').textContent =
221
+ `NATIVE ${sideSummary(message.native)}\nPENNY ${sideSummary(message.penny)}`;
222
+ document.getElementById('finish-path').textContent = `Report saved: ${message.path}`;
223
+ document.getElementById('finish').hidden = false;
126
224
  document.getElementById('connection').textContent = 'report saved';
127
225
  }
128
226
  if (message.type === 'report-error') {
129
- native.write(`\r\n[report failed: ${message.error}]\r\n`);
227
+ document.getElementById('finish-title').textContent = 'REPORT COULD NOT BE SAVED';
228
+ document.getElementById('finish-metrics').textContent = message.error;
229
+ document.getElementById('finish-note').textContent = 'Comparison sessions have ended. Reload the page to start again.';
230
+ document.getElementById('finish').hidden = false;
130
231
  document.getElementById('connection').textContent = 'report failed';
131
232
  }
132
- });
233
+ });
133
234
  socket.addEventListener('close', () => {
134
235
  if (!document.getElementById('exit').disabled) {
135
236
  document.getElementById('connection').textContent = 'disconnected — reload to reconnect';
136
237
  }
137
238
  });
239
+ }
138
240
  const resizeObserver = new ResizeObserver(resize);
139
241
  panes.forEach(pane => resizeObserver.observe(pane));
140
242
  document.getElementById('sync').onclick = () => setSync(!sync);
141
243
  document.getElementById('exit').onclick = exit;
244
+ const cwd = __PENNY_CWD__;
245
+ const isCompare = __PENNY_COMPARE__;
246
+ const defaultHarness = __PENNY_DEFAULT_HARNESS__;
247
+ const variants = ['claude', 'penny-claude', 'codex', 'penny-codex'];
248
+ for (const side of ['left', 'right']) {
249
+ const select = document.getElementById(`${side}-variant`);
250
+ for (const variant of variants) {
251
+ const option = document.createElement('option'); option.value = variant; option.textContent = variant;
252
+ select.append(option);
253
+ }
254
+ }
255
+ document.getElementById('left-variant').value = defaultHarness;
256
+ document.getElementById('right-variant').value = `penny-${defaultHarness}`;
257
+ document.getElementById('left-cwd').value = `${cwd}/native`;
258
+ document.getElementById('right-cwd').value = `${cwd}/penny`;
259
+ const refreshModels = () => {
260
+ const left = document.getElementById('left-variant').value;
261
+ const right = document.getElementById('right-variant').value;
262
+ const leftCodex = left.includes('codex');
263
+ const rightCodex = right.includes('codex');
264
+ const model = document.getElementById('model');
265
+ model.disabled = leftCodex !== rightCodex;
266
+ if (model.disabled) model.value = '';
267
+ const values = leftCodex && rightCodex
268
+ ? ['default', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini']
269
+ : leftCodex !== rightCodex ? []
270
+ : ['default', 'opus', 'sonnet', 'haiku', 'fable'];
271
+ const list = document.getElementById('model-options'); list.replaceChildren();
272
+ for (const value of values) { const option = document.createElement('option'); option.value = value; list.append(option); }
273
+ };
274
+ document.getElementById('left-variant').onchange = refreshModels;
275
+ document.getElementById('right-variant').onchange = refreshModels;
276
+ refreshModels();
277
+ connect();
278
+ document.getElementById('setup-form').addEventListener('submit', event => {
279
+ event.preventDefault();
280
+ const config = {};
281
+ for (const side of ['left', 'right']) config[side] = {
282
+ variant: document.getElementById(`${side}-variant`).value,
283
+ cwd: document.getElementById(`${side}-cwd`).value.trim(),
284
+ label: document.getElementById(`${side}-label`).value.trim(),
285
+ model: document.getElementById('model').value.trim(),
286
+ };
287
+ if (socket.readyState !== WebSocket.OPEN) return;
288
+ pendingConfig = config;
289
+ document.getElementById('launch').disabled = true;
290
+ document.getElementById('setup-error').textContent = '';
291
+ send({ type: 'configure', config });
292
+ });
142
293
  document.addEventListener('keydown', event => {
143
294
  if (!event.ctrlKey || !event.shiftKey || event.altKey || event.metaKey) return;
144
295
  if (event.code === 'KeyS') { event.preventDefault(); event.stopPropagation(); setSync(!sync); }