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.
@@ -0,0 +1,1428 @@
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</title>
7
+ <link rel="stylesheet" href="/xterm.css">
8
+ <style>
9
+ :root {
10
+ color-scheme: dark;
11
+ --bg: #111116; --bar: #24242b; --pane-head: #1c1c22; --card: #19191f; --field: #23232b;
12
+ --line: #35353f; --line-strong: #4b4b55; --text: #e3e3e8; --muted: #96969f; --dim: #6c6c76;
13
+ --accent: #9ded83; --accent-ink: #14200f; --accent-wash: #203025; --warn: #ffb84d; --error: #ff9da6;
14
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
15
+ --sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
16
+ font: 13px/1.4 var(--mono);
17
+ }
18
+ * { box-sizing: border-box; }
19
+ html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); }
20
+ body { display: flex; flex-direction: column; overflow: hidden; }
21
+ button, input, select { font: inherit; }
22
+ button { cursor: pointer; color: inherit; }
23
+ [hidden] { display: none !important; }
24
+
25
+ .bar { min-height: 42px; flex: none; display: flex; align-items: center; gap: 14px; padding: 8px 14px;
26
+ background: var(--bar); border-bottom: 1px solid #44444d; white-space: nowrap; }
27
+ .bar strong { color: var(--accent); letter-spacing: .08em; }
28
+ .bar button { border: 1px solid #74747c; background: transparent; padding: 4px 9px; }
29
+ .bar button:hover:not(:disabled) { border-color: #a0a0aa; }
30
+ .bar button:disabled { opacity: .45; cursor: default; }
31
+ .bar .sync-on { color: var(--accent); border-color: #547a50; background: var(--accent-wash); }
32
+ .bar .sync-off { color: #a0a0aa; border-color: #62626d; background: #292930; }
33
+ #delta { color: var(--accent); }
34
+ #versions { margin-left: auto; color: #92929c; font-size: 12px; }
35
+ #status { color: #b8b8c3; }
36
+ @media (max-width: 760px) { .bar { overflow-x: auto; } #versions { display: none; } }
37
+
38
+ .panes { flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
39
+ .pane { position: relative; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
40
+ .pane + .pane { border-left: 1px solid var(--line-strong); }
41
+ .pane header { display: flex; align-items: center; gap: 12px; min-height: 36px; padding: 6px 12px;
42
+ background: var(--pane-head); border-bottom: 1px solid var(--line); border-top: 2px solid transparent; }
43
+ .pane.focused header { border-top-color: #5d7d55; }
44
+ .pane header .label { color: #b8b8c3; letter-spacing: .06em; }
45
+ .pane.focused header .label { color: var(--text); }
46
+ .terminal { flex: 1; min-height: 0; padding: 5px 7px; }
47
+ .xterm { height: 100%; }
48
+ .xterm-viewport { scrollbar-width: thin; }
49
+
50
+ .files { position: relative; margin-left: auto; display: flex; align-items: center; gap: 8px; min-width: 0; }
51
+ .files .newest { min-width: 0; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
52
+ padding: 2px 4px; border: 0; background: none; color: #c8c8d0; text-underline-offset: 3px; }
53
+ .files .newest::before { content: '▸ '; color: var(--accent); }
54
+ .files .newest:hover { color: var(--text); text-decoration: underline; }
55
+ .files .count { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border: 1px solid #3c3c46;
56
+ background: transparent; color: var(--dim); transition: background .4s, color .4s, border-color .4s; }
57
+ .files .count::after { content: '▾'; font-size: 10px; }
58
+ .files.has-files .count { color: #d0d0d6; background: #2a2a32; }
59
+ .files.fresh .count { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); font-weight: 600;
60
+ animation: arrive 1.1s ease-out 3; }
61
+ @keyframes arrive { 0% { box-shadow: 0 0 0 0 #9ded83aa; } 100% { box-shadow: 0 0 0 9px #9ded8300; } }
62
+ .file-list { position: absolute; right: 0; top: calc(100% + 6px); z-index: 4; width: min(380px, 80vw);
63
+ max-height: 300px; overflow: auto; padding: 6px; background: var(--bar); border: 1px solid #555560;
64
+ box-shadow: 0 12px 40px #0008; }
65
+ .file-list button { display: flex; justify-content: space-between; gap: 12px; width: 100%; text-align: left;
66
+ padding: 8px; border: 0; background: transparent; }
67
+ .file-list button span:first-child { overflow-wrap: anywhere; }
68
+ .file-list button span:last-child { flex: none; color: var(--muted); }
69
+ .file-list button:hover, .file-list button:focus-visible { background: #354331; }
70
+ .file-list p { margin: 8px; color: #b8b8c3; }
71
+
72
+ .meter { display: none; position: absolute; top: 46px; right: 12px; z-index: 2; width: 190px; overflow: hidden;
73
+ padding: 6px 9px; background: rgba(0, 0, 0, .72); color: #92929c; line-height: 1.35; pointer-events: none; }
74
+ .meter.has-data { display: grid; gap: 1px; }
75
+ .meter .field, .meter .model { display: flex; justify-content: space-between; gap: 10px; }
76
+ .meter .model { min-width: 0; margin-bottom: 2px; color: var(--accent); white-space: nowrap; }
77
+ .meter .model span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
78
+ .meter .field strong { color: var(--text); font-weight: 600; }
79
+ .meter .field.read { color: #77777e; }
80
+ .meter .field.read strong { color: var(--accent); }
81
+ .meter .field.write strong { color: var(--warn); }
82
+ .meter .field.in strong, .meter .field.out strong { color: #75ec45; }
83
+ .meter .field.side { color: #77777e; }
84
+ .meter .wait { display: none; color: #77777e; }
85
+ .meter.waiting .wait { display: block; }
86
+ .meter.waiting .field { display: none; }
87
+ .meter .field.side[hidden] { display: none; }
88
+
89
+ @media (max-width: 760px) { .panes { grid-template-columns: 1fr; grid-template-rows: repeat(2, minmax(0, 1fr)); }
90
+ .pane + .pane { border-left: 0; border-top: 1px solid var(--line-strong); } .files .newest { display: none; } }
91
+
92
+ .overlay { position: fixed; inset: 42px 0 0; z-index: 5; display: grid; place-items: center; padding: 24px;
93
+ background: #111116f2; overflow-y: auto; }
94
+ .card { width: min(440px, 100%); padding: 24px; font: 14px/1.5 var(--sans); color: var(--text);
95
+ background: var(--card); border: 1px solid #303038; border-radius: 16px; box-shadow: 0 24px 80px #0006; }
96
+ .card.wide { width: min(640px, 100%); }
97
+ .eyebrow { display: block; margin-bottom: 10px; color: var(--accent); font-size: 10px; font-weight: 600;
98
+ letter-spacing: 1.8px; text-transform: uppercase; }
99
+ .card h2 { margin: 0 0 8px; font-size: 20px; font-weight: 650; letter-spacing: -.4px; }
100
+ .card .lede { margin: 0 0 20px; color: var(--muted); }
101
+ .card .notice { margin: 0 0 20px; padding: 12px 14px; border-radius: 8px; background: #1d2219;
102
+ color: #b4b4be; font-size: 12px; }
103
+ .card .notice strong { color: var(--text); }
104
+ .card label { display: block; margin: 14px 0 0; color: #b8b8c1; font-size: 12px; }
105
+ .card input:not([type=checkbox]), .card select { display: block; width: 100%; min-width: 0; margin-top: 8px;
106
+ padding: 10px 12px; border: 1px solid #3a3a44; border-radius: 8px; background: var(--field); color: #eee;
107
+ outline-offset: 3px; }
108
+ .card input:disabled, .card select:disabled { opacity: .55; }
109
+ .card .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
110
+ .card .grid label:nth-child(-n+2) { margin-top: 0; }
111
+ @media (max-width: 560px) { .card .grid { grid-template-columns: 1fr; } .card .grid label:nth-child(2) { margin-top: 14px; } }
112
+ .card .picker { position: relative; margin-top: 8px; }
113
+ .card .picker select { position: absolute; inset: 0; opacity: 0; pointer-events: none; }
114
+ .picker-button { display: flex; align-items: center; gap: 10px; width: 100%; min-width: 0; padding: 10px 12px;
115
+ border: 1px solid #3a3a44; border-radius: 8px; background: var(--field); color: #eee; text-align: left;
116
+ outline-offset: 3px; }
117
+ .picker-button:hover:not(:disabled) { border-color: #50505c; }
118
+ .picker-button:disabled { opacity: .55; cursor: default; }
119
+ .picker-button[aria-expanded=true] { border-color: #5d7d55; }
120
+ .picker-button .text, .picker-option .text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
121
+ .picker-button::after { content: ''; flex: none; margin-left: auto; width: 7px; height: 7px; margin-top: -4px;
122
+ border: solid var(--muted); border-width: 0 1.5px 1.5px 0; transform: rotate(45deg); }
123
+ .picker-combo .picker-button { padding: 0; }
124
+ .card .picker-combo input.picker-input { flex: 1; margin: 0; border: 0; background: transparent; padding: 10px 0 10px 12px;
125
+ outline: none; }
126
+ .picker-combo .picker-button:focus-within { border-color: #5d7d55; }
127
+ .picker-combo .picker-button { cursor: text; }
128
+ .picker-combo .picker-button.disabled { opacity: .55; cursor: default; }
129
+ .picker-combo .picker-button::after { margin-right: 14px; }
130
+ .picker-list { position: absolute; left: 0; right: 0; top: calc(100% + 6px); z-index: 10; margin: 0; padding: 5px;
131
+ list-style: none; max-height: 260px; overflow-y: auto; background: #1f1f26; border: 1px solid #3a3a44;
132
+ border-radius: 10px; box-shadow: 0 18px 50px #000a; }
133
+ .picker-option { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 6px;
134
+ color: #d8d8de; cursor: pointer; }
135
+ .picker-option.active { background: #2c3a2a; color: var(--text); }
136
+ .picker-option[aria-selected=true]::after { content: '✓'; margin-left: auto; color: var(--accent); }
137
+ .picker-option .detail { color: var(--muted); font-size: 12px; }
138
+ .tag { flex: none; padding: 1px 6px; border-radius: 4px; font: 600 10px/1.5 var(--mono); letter-spacing: .06em;
139
+ text-transform: uppercase; }
140
+ .tag.native { color: #b8b8c1; background: #33333d; }
141
+ .tag.penny { color: var(--accent-ink); background: var(--accent); }
142
+ .card textarea { display: block; width: 100%; margin-top: 8px; padding: 10px 12px; resize: vertical;
143
+ border: 1px solid #3a3a44; border-radius: 8px; background: var(--field); color: #eee; font: inherit; }
144
+ .card input[type=file] { display: block; margin-top: 8px; color: var(--muted); font-size: 12px; }
145
+ .field-label { display: block; margin: 14px 0 8px; color: #b8b8c1; font-size: 12px; }
146
+ .segmented { display: flex; gap: 6px; flex-wrap: wrap; }
147
+ .segmented label { margin: 0; }
148
+ .segmented input { position: absolute; opacity: 0; pointer-events: none; }
149
+ .segmented span { display: inline-block; padding: 7px 12px; border: 1px solid #3a3a44; border-radius: 999px;
150
+ color: #c8c8d0; cursor: pointer; }
151
+ .segmented input:checked + span { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); font-weight: 600; }
152
+ .segmented input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
153
+ .card.wide:has(.publish-layout) { width: min(920px, 100%); }
154
+ .publish-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 24px; }
155
+ @media (max-width: 760px) { .publish-layout { grid-template-columns: 1fr; } }
156
+ .actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; }
157
+ .actions .primary { width: auto; margin: 0; }
158
+ .card .secondary { padding: 11px 16px; border: 1px solid #3a3a44; border-radius: 8px; background: transparent;
159
+ color: var(--text); font-size: 13px; }
160
+ .publish-done { margin: 14px 0 0; color: var(--accent); }
161
+ .publish-done:empty { display: none; }
162
+ .gallery-card { overflow: hidden; border: 1px solid #303038; border-radius: 12px; background: #15151a; }
163
+ .gallery-cover { position: relative; aspect-ratio: 16 / 10; overflow: hidden; background: #fff; }
164
+ .gallery-cover iframe { position: absolute; top: 0; left: 0; width: 400%; height: 400%; border: 0;
165
+ transform: scale(.25); transform-origin: 0 0; pointer-events: none; background: #fff; }
166
+ .gallery-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: top center; }
167
+ .gallery-body { display: grid; gap: 6px; padding: 12px 14px 14px; }
168
+ .gallery-body strong { font-size: 15px; line-height: 1.3; }
169
+ .gallery-tag { justify-self: start; padding: 2px 8px; border-radius: 999px; font: 600 10px/1.6 var(--mono);
170
+ letter-spacing: .06em; text-transform: uppercase; color: var(--accent-ink); background: var(--accent); }
171
+ .gallery-tag.loss { color: #2a0d10; background: #ff9da6; }
172
+ .gallery-tag.interesting { color: #1d1a0c; background: #e6c583; }
173
+ .gallery-savings { font: 600 18px/1.2 var(--mono); color: var(--accent); }
174
+ .gallery-savings.loss { color: #ff9da6; }
175
+ .gallery-meta { color: var(--muted); font-size: 12px; }
176
+ .card details { margin-top: 16px; }
177
+ .card details + details { margin-top: 10px; }
178
+ .card summary { cursor: pointer; color: #b8b8c1; font-size: 13px; }
179
+ .card summary:hover { color: var(--text); }
180
+ .card summary .choice { color: var(--muted); }
181
+ .card .hint { margin: 8px 0 0; color: var(--muted); font-size: 12px; }
182
+ .card .primary { display: flex; align-items: center; justify-content: center; gap: 10px; width: 100%;
183
+ margin-top: 22px; padding: 11px 16px; border: 0; border-radius: 8px; background: var(--accent);
184
+ color: var(--accent-ink); font-weight: 600; font-size: 13px; transition: filter .12s, transform .06s; }
185
+ .card .primary:hover:not(:disabled) { filter: brightness(1.08); }
186
+ .card .primary:active:not(:disabled) { transform: translateY(1px); }
187
+ .card .primary:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
188
+ .card .primary:disabled { opacity: .4; cursor: default; }
189
+ .card .primary.busy { opacity: .8; }
190
+ .card .primary.busy::before { content: ''; width: 13px; height: 13px; border: 2px solid #14200f55;
191
+ border-top-color: var(--accent-ink); border-radius: 50%; animation: spin .8s linear infinite; }
192
+ @keyframes spin { to { transform: rotate(360deg); } }
193
+ .card .error { margin: 14px 0 0; color: var(--error); font-size: 13px; }
194
+ .card .error:empty { display: none; }
195
+ .card .footnote { margin: 16px 0 0; color: var(--muted); font-size: 12px; }
196
+ .progress { margin: 16px 0 0; font-size: 12px; }
197
+ .progress:empty { display: none; }
198
+ .progress p { margin: 0 0 8px; color: var(--accent); }
199
+ .progress ol { margin: 0; padding: 0; list-style: none; display: grid; gap: 4px; font-family: var(--mono); }
200
+ .progress li { display: flex; gap: 8px; color: var(--dim); }
201
+ .progress li::before { content: '○'; width: 1em; text-align: center; }
202
+ .progress li.done { color: #92929c; }
203
+ .progress li.done::before { content: '✓'; color: var(--accent); }
204
+ .progress li.current { color: var(--text); }
205
+ .progress li.current::before { content: '›'; color: var(--accent); }
206
+ .progress .elapsed { margin-left: auto; color: #92929c; font-variant-numeric: tabular-nums; }
207
+ .metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 18px 0; }
208
+ .metric { padding: 14px; border-radius: 10px; background: var(--field); }
209
+ .metric span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 12px; }
210
+ .metric strong { font-size: 18px; }
211
+ .sides { font: 12px/1.7 var(--mono); color: #c4c4cc; white-space: pre; overflow-x: auto; }
212
+ .report-path { margin-top: 14px; color: var(--accent); font: 12px var(--mono); overflow-wrap: anywhere; }
213
+ @media (prefers-reduced-motion: reduce) {
214
+ .files.fresh .count { animation: none; }
215
+ .card .primary.busy::before { animation-duration: 2s; }
216
+ }
217
+ </style>
218
+ </head>
219
+ <body>
220
+ <div class="bar"><strong>PENNY COMPARE</strong>
221
+ <button id="sync" class="sync-off" disabled aria-pressed="false"
222
+ title="Type into both panes at once (Ctrl/⌘+Shift+S)">Sync off</button>
223
+ <button id="finish" disabled>Finish comparison</button>
224
+ <button id="download-logs" hidden>Download logs</button>
225
+ <button id="add-to-gallery" disabled title="Available after you finish the comparison">Add to gallery</button>
226
+ <span id="delta" aria-live="polite"></span>
227
+ <span id="versions"></span>
228
+ <span id="status">Connecting…</span>
229
+ </div>
230
+ <main class="panes">
231
+ <section class="pane focused" id="left-pane"><header><strong class="label" id="left-label">NATIVE</strong>
232
+ <div class="files" id="left-files"><button class="newest" id="left-newest" hidden></button>
233
+ <button class="count" id="left-files-count" aria-expanded="false" aria-controls="left-file-list">No files</button>
234
+ <div class="file-list" id="left-file-list" hidden><p>HTML files the agent writes will appear here.</p></div></div>
235
+ </header><div class="meter" id="left-meter"></div><div class="terminal" id="left"></div></section>
236
+ <section class="pane" id="right-pane"><header><strong class="label" id="right-label">PENNY</strong>
237
+ <div class="files" id="right-files"><button class="newest" id="right-newest" hidden></button>
238
+ <button class="count" id="right-files-count" aria-expanded="false" aria-controls="right-file-list">No files</button>
239
+ <div class="file-list" id="right-file-list" hidden><p>HTML files the agent writes will appear here.</p></div></div>
240
+ </header><div class="meter" id="right-meter"></div><div class="terminal" id="right"></div></section>
241
+ </main>
242
+
243
+ <section class="overlay" id="setup" hidden>
244
+ <form class="card" id="hosted-setup" hidden>
245
+ <span class="eyebrow">Live demo</span>
246
+ <h2>How well does PennyRouter work?</h2>
247
+ <p class="lede">Two real terminals, one native and one through PennyRouter, with the actual spend side by side.</p>
248
+ <p class="notice"><strong>Use a temporary, dedicated API key with a small spend limit.</strong>
249
+ Your key is used only to run this demo, is not saved to our database, and is discarded
250
+ when this session ends.</p>
251
+ <label>Compare
252
+ <select id="harness"><option value="claude">Claude Code</option><option value="codex">Codex</option></select></label>
253
+ <label><span id="key-label">Anthropic API key</span>
254
+ <input type="password" id="key" autocomplete="off" spellcheck="false" required></label>
255
+ <details id="environment">
256
+ <summary>Environment <span class="choice" id="environment-choice"></span></summary>
257
+ <label>Provider
258
+ <select id="provider">
259
+ <option value="native" selected>Native (default)</option>
260
+ <option value="bedrock">Amazon Bedrock</option>
261
+ <option value="foundry">Azure Foundry</option>
262
+ <option value="vertex">Google Vertex</option>
263
+ </select></label>
264
+ <label>Local Proxy
265
+ <select id="proxy">
266
+ <option value="none" selected>None</option>
267
+ <option value="litellm">LiteLLM</option>
268
+ <option value="bifrost">Bifrost</option>
269
+ <option value="kong">Kong</option>
270
+ <option value="portkey">Portkey</option>
271
+ <option value="other">Other</option>
272
+ </select></label>
273
+ <label>Pricing
274
+ <select id="pricing">
275
+ <option value="standard" data-short="standard pricing" selected>Standard (default)</option>
276
+ <option value="govcloud" data-short="GovCloud pricing">GovCloud</option>
277
+ </select></label>
278
+ </details>
279
+ <details>
280
+ <summary>Optional: second account for cleaner cache metrics</summary>
281
+ <label><span id="penny-key-label">Anthropic API key (second account)</span>
282
+ <input type="password" id="penny-key" autocomplete="off" spellcheck="false"></label>
283
+ <p class="hint">Both terminals otherwise share one key, so they can share prompt-cache entries and blur the cache numbers. A key from a separate account keeps the Penny side's cache isolated. Leave blank to use the key above for both.</p>
284
+ </details>
285
+ <p class="error" id="hosted-error" role="alert"></p>
286
+ <button class="primary" id="hosted-start" disabled>Start terminals</button>
287
+ <div class="progress" id="progress" role="status" aria-live="polite"></div>
288
+ </form>
289
+
290
+ <form class="card wide" id="local-setup" hidden>
291
+ <span class="eyebrow">On this machine</span>
292
+ <h2>Penny Compare</h2>
293
+ <p class="lede">Run a native harness and a PennyRouter harness side by side and measure both.</p>
294
+ <div class="grid">
295
+ <label>Left pane <select id="left-variant"></select></label>
296
+ <label>Right pane <select id="right-variant"></select></label>
297
+ <label>Left directory <input id="left-cwd" autocomplete="off" spellcheck="false" required></label>
298
+ <label>Right directory <input id="right-cwd" autocomplete="off" spellcheck="false" required></label>
299
+ <label>Left label <input id="left-custom-label" placeholder="NATIVE"></label>
300
+ <label>Right label <input id="right-custom-label" placeholder="PENNY"></label>
301
+ </div>
302
+ <label>Model (both panes)
303
+ <input id="model" placeholder="Harness default" autocomplete="off" spellcheck="false"></label>
304
+ <div id="local-environment"></div>
305
+ <p class="error" id="local-error" role="alert"></p>
306
+ <button class="primary" id="local-start" type="submit" disabled>Start comparison</button>
307
+ <p class="footnote">Exit saves a JSON report under <code>apps/compare/reports</code>.</p>
308
+ </form>
309
+ </section>
310
+
311
+ <section class="overlay" id="results" hidden aria-live="polite">
312
+ <div class="card wide">
313
+ <span class="eyebrow">Finished</span>
314
+ <h2 id="results-title">Comparison complete</h2>
315
+ <div class="metrics" id="results-metrics"></div>
316
+ <div class="sides" id="results-sides"></div>
317
+ <div class="report-path" id="results-path"></div>
318
+ <p class="footnote" id="results-note"></p>
319
+ <button class="primary" id="results-publish" type="button" hidden>Add to gallery</button>
320
+ </div>
321
+ </section>
322
+
323
+ <section class="overlay" id="publish" hidden>
324
+ <form class="card wide" id="publish-form">
325
+ <span class="eyebrow">Penny Compare gallery</span>
326
+ <h2>Add to the gallery</h2>
327
+ <p class="lede">Share a comparison that shows something interesting, good or bad. It goes public after review.</p>
328
+ <div class="publish-layout">
329
+ <div>
330
+ <label>Title
331
+ <input id="publish-title" maxlength="120" required placeholder="What was the session about?"></label>
332
+ <label>Comment
333
+ <textarea id="publish-comment" maxlength="2000" rows="3" placeholder="What stood out?"></textarea></label>
334
+ <span class="field-label">Result</span>
335
+ <div class="segmented" role="radiogroup" aria-label="Result">
336
+ <label><input type="radio" name="publish-tag" value="win" checked><span>Penny win</span></label>
337
+ <label><input type="radio" name="publish-tag" value="loss"><span>Penny loss</span></label>
338
+ <label><input type="radio" name="publish-tag" value="interesting"><span>Interesting</span></label>
339
+ </div>
340
+ <div class="grid">
341
+ <label>Native page <select id="publish-native"></select></label>
342
+ <label>Penny page <select id="publish-penny"></select></label>
343
+ </div>
344
+ <span class="field-label">Featured page</span>
345
+ <div class="segmented" role="radiogroup" aria-label="Featured page">
346
+ <label><input type="radio" name="publish-cover" value="penny" checked><span>Penny page</span></label>
347
+ <label><input type="radio" name="publish-cover" value="native"><span>Native page</span></label>
348
+ </div>
349
+ <label>Your name, as shown publicly
350
+ <input id="publish-name" maxlength="40" required autocomplete="nickname"></label>
351
+ <label>Screenshot for the card (optional; choose a file or paste one here)
352
+ <input id="publish-cover-image" type="file" accept="image/png,image/jpeg,image/webp"></label>
353
+ <p class="hint" id="publish-cover-note">Without one, a screenshot is added during review.</p>
354
+ <label>Video (optional, MP4 or WebM, up to 100 MB)
355
+ <input id="publish-video" type="file" accept="video/mp4,video/webm"></label>
356
+ </div>
357
+ <div class="preview-column">
358
+ <span class="field-label">Preview</span>
359
+ <article class="gallery-card" aria-label="Card preview">
360
+ <div class="gallery-cover"><iframe id="publish-preview" title="Featured page preview" sandbox="allow-scripts" tabindex="-1"></iframe>
361
+ <img id="publish-preview-image" alt="Screenshot for the card" hidden></div>
362
+ <div class="gallery-body">
363
+ <span class="gallery-tag" id="preview-tag"></span>
364
+ <strong id="preview-title">Untitled comparison</strong>
365
+ <span class="gallery-savings" id="preview-savings"></span>
366
+ <span class="gallery-meta" id="preview-meta"></span>
367
+ </div>
368
+ </article>
369
+ <p class="footnote">Prompts and transcripts are never published.</p>
370
+ </div>
371
+ </div>
372
+ <p class="error" id="publish-error" role="alert"></p>
373
+ <p class="publish-done" id="publish-done" role="status"></p>
374
+ <div class="actions">
375
+ <button class="secondary" id="publish-cancel" type="button">Cancel</button>
376
+ <button class="primary" id="publish-submit">Submit for review</button>
377
+ </div>
378
+ </form>
379
+ </section>
380
+
381
+ <script src="/xterm.js"></script><script src="/addon-fit.js"></script>
382
+ <script src="/addon-unicode11.js"></script>
383
+ <script src="/terminal-unicode.js"></script>
384
+ <script>
385
+ const config = __COMPARE_CONFIG__;
386
+ const hosted = config.mode === 'hosted';
387
+ const $ = (id) => document.getElementById(id);
388
+ const sides = ['left', 'right'];
389
+ const other = (side) => side === 'left' ? 'right' : 'left';
390
+ const setStatus = (text) => { $('status').textContent = text; };
391
+
392
+ // Pickers: a styled list for each setup <select> (which stays the source of truth for
393
+ // values and change events) and for the free-text model field.
394
+ const pickers = [];
395
+ let modelSuggestions = [];
396
+ const refreshPickers = () => { for (const render of pickers) render(); };
397
+ let openPopup = null;
398
+ document.addEventListener('pointerdown', (event) => {
399
+ if (openPopup && !openPopup.root.contains(event.target)) openPopup.close();
400
+ });
401
+ const pickerContent = ({ label, detail, tag }) => {
402
+ const fragment = document.createDocumentFragment();
403
+ const text = document.createElement('span'); text.className = 'text'; text.textContent = label;
404
+ fragment.append(text);
405
+ if (tag) { const badge = document.createElement('span'); badge.className = `tag ${tag}`; badge.textContent = tag; fragment.append(badge); }
406
+ if (detail) { const extra = document.createElement('span'); extra.className = 'detail'; extra.textContent = detail; fragment.append(extra); }
407
+ return fragment;
408
+ };
409
+ const createPopup = (root, owner) => {
410
+ const list = document.createElement('ul');
411
+ list.className = 'picker-list';
412
+ list.id = `picker-${pickers.length}`;
413
+ list.setAttribute('role', 'listbox');
414
+ list.hidden = true;
415
+ // The picker sits inside its <label>, which would otherwise forward clicks in the list
416
+ // back to the owner and reopen it.
417
+ list.addEventListener('click', (event) => event.preventDefault());
418
+ root.append(list);
419
+ owner.setAttribute('aria-controls', list.id);
420
+ owner.setAttribute('aria-expanded', 'false');
421
+ let entries = [];
422
+ let active = -1;
423
+ let onChoose;
424
+ const popup = {
425
+ root,
426
+ get isOpen() { return !list.hidden; },
427
+ open(items, selected, choose) {
428
+ if (openPopup && openPopup !== popup) openPopup.close();
429
+ entries = items;
430
+ onChoose = choose;
431
+ list.replaceChildren();
432
+ items.forEach((entry, index) => {
433
+ const option = document.createElement('li');
434
+ option.className = 'picker-option';
435
+ option.id = `${list.id}-${index}`;
436
+ option.setAttribute('role', 'option');
437
+ option.setAttribute('aria-selected', String(entry.value === selected));
438
+ option.append(pickerContent(entry));
439
+ // Keep focus on the owner so the field does not blur before the click lands.
440
+ option.addEventListener('pointerdown', (event) => event.preventDefault());
441
+ option.addEventListener('pointermove', () => popup.setActive(index));
442
+ option.addEventListener('click', () => popup.choose(index));
443
+ list.append(option);
444
+ });
445
+ list.hidden = !items.length;
446
+ owner.setAttribute('aria-expanded', String(!list.hidden));
447
+ openPopup = list.hidden ? null : popup;
448
+ popup.setActive(Math.max(0, items.findIndex((entry) => entry.value === selected)));
449
+ },
450
+ close() {
451
+ list.hidden = true;
452
+ owner.setAttribute('aria-expanded', 'false');
453
+ owner.removeAttribute('aria-activedescendant');
454
+ if (openPopup === popup) openPopup = null;
455
+ },
456
+ setActive(index) {
457
+ active = index;
458
+ [...list.children].forEach((option, position) => option.classList.toggle('active', position === index));
459
+ const option = list.children[index];
460
+ if (!option) return;
461
+ owner.setAttribute('aria-activedescendant', option.id);
462
+ option.scrollIntoView({ block: 'nearest' });
463
+ },
464
+ move(step) { if (entries.length) popup.setActive((active + step + entries.length) % entries.length); },
465
+ choose(index = active) {
466
+ const entry = entries[index];
467
+ popup.close();
468
+ if (entry) onChoose(entry);
469
+ },
470
+ };
471
+ return popup;
472
+ };
473
+ const enhanceSelect = (select) => {
474
+ const root = document.createElement('div');
475
+ root.className = 'picker';
476
+ select.before(root);
477
+ const button = document.createElement('button');
478
+ button.type = 'button';
479
+ button.className = 'picker-button';
480
+ button.setAttribute('aria-haspopup', 'listbox');
481
+ root.append(button, select);
482
+ select.tabIndex = -1;
483
+ select.setAttribute('aria-hidden', 'true');
484
+ const popup = createPopup(root, button);
485
+ const render = () => {
486
+ const option = select.options[select.selectedIndex];
487
+ button.replaceChildren(pickerContent({ label: option?.textContent || '', tag: option?.dataset.tag }));
488
+ button.disabled = select.disabled;
489
+ };
490
+ const choose = (entry) => {
491
+ if (select.value !== entry.value) {
492
+ select.value = entry.value;
493
+ select.dispatchEvent(new Event('change', { bubbles: true }));
494
+ }
495
+ render();
496
+ button.focus();
497
+ };
498
+ const open = () => popup.open([...select.options].map((option) => ({ value: option.value,
499
+ label: option.textContent, detail: option.dataset.detail, tag: option.dataset.tag })), select.value, choose);
500
+ button.addEventListener('click', () => popup.isOpen ? popup.close() : open());
501
+ button.addEventListener('keydown', (event) => {
502
+ if (!popup.isOpen) {
503
+ if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key)) { event.preventDefault(); open(); }
504
+ return;
505
+ }
506
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); popup.move(event.key === 'ArrowDown' ? 1 : -1); }
507
+ else if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); popup.choose(); }
508
+ else if (event.key === 'Escape') { event.preventDefault(); popup.close(); }
509
+ else if (event.key === 'Tab') popup.close();
510
+ });
511
+ select.addEventListener('change', render);
512
+ pickers.push(render);
513
+ render();
514
+ };
515
+ const comboValue = (input, suggestions) => {
516
+ const text = input.value.trim();
517
+ return suggestions.find((entry) => entry.value && entry.label.toLowerCase() === text.toLowerCase())?.value ?? text;
518
+ };
519
+ // A text field that also offers suggestions; typing filters them and any value can be entered.
520
+ const enhanceCombo = (input, suggestions) => {
521
+ const root = document.createElement('div');
522
+ root.className = 'picker picker-combo';
523
+ input.before(root);
524
+ const frame = document.createElement('div');
525
+ frame.className = 'picker-button';
526
+ frame.addEventListener('pointerdown', (event) => {
527
+ if (event.target !== input && !input.disabled) { event.preventDefault(); input.focus(); }
528
+ });
529
+ frame.append(input);
530
+ root.append(frame);
531
+ input.classList.add('picker-input');
532
+ input.setAttribute('role', 'combobox');
533
+ input.setAttribute('aria-autocomplete', 'list');
534
+ const popup = createPopup(root, input);
535
+ // The field shows a suggestion's label; comboValue maps a shown label back to its value.
536
+ const choose = (entry) => { input.value = entry.value ? entry.label : ''; input.dispatchEvent(new Event('change', { bubbles: true })); };
537
+ const open = (filtered) => {
538
+ const query = filtered ? input.value.trim().toLowerCase() : '';
539
+ const items = suggestions().filter((entry) => !query ||
540
+ entry.value.toLowerCase().includes(query) || entry.label.toLowerCase().includes(query));
541
+ popup.open(items, comboValue(input, suggestions()), choose);
542
+ };
543
+ input.addEventListener('focus', () => open(false));
544
+ input.addEventListener('click', () => { if (!popup.isOpen) open(false); });
545
+ input.addEventListener('input', () => open(true));
546
+ input.addEventListener('blur', () => popup.close());
547
+ input.addEventListener('keydown', (event) => {
548
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
549
+ event.preventDefault();
550
+ if (popup.isOpen) popup.move(event.key === 'ArrowDown' ? 1 : -1); else open(false);
551
+ } else if (event.key === 'Enter' && popup.isOpen) { event.preventDefault(); popup.choose(); }
552
+ else if (event.key === 'Escape' && popup.isOpen) { event.preventDefault(); popup.close(); }
553
+ });
554
+ const render = () => { frame.classList.toggle('disabled', input.disabled); };
555
+ pickers.push(render);
556
+ render();
557
+ };
558
+
559
+ if (config.versions) {
560
+ $('versions').textContent = [config.versions.claude && `Claude Code ${config.versions.claude}`,
561
+ config.versions.codex && `Codex ${config.versions.codex}`].filter(Boolean).join(' · ');
562
+ }
563
+
564
+ // Terminals
565
+ const terminals = {};
566
+ const fits = {};
567
+ for (const side of sides) {
568
+ const terminal = new Terminal({ fontSize: 13, lineHeight: 1.15, scrollback: 1000, allowProposedApi: true,
569
+ cursorBlink: true, fontFamily: 'SFMono-Regular, Menlo, Consolas, monospace',
570
+ theme: { background: '#111116', foreground: '#e3e3e8', cursor: '#9ded83', selectionBackground: '#4b5369',
571
+ black: '#24242a', red: '#e88b8b', green: '#9ded83', yellow: '#e6c583', blue: '#8eaeeb',
572
+ magenta: '#c8a7e8', cyan: '#8ed1cf', white: '#d7d7dd' } });
573
+ const fit = new FitAddon.FitAddon();
574
+ // Match Claude's two-column emoji widths so cursor-based status redraws do not
575
+ // overwrite the wrong cells and leave stale countdown digits behind.
576
+ configureCompareUnicode(terminal, Unicode11Addon.Unicode11Addon);
577
+ terminal.loadAddon(fit);
578
+ terminal.open($(side));
579
+ terminals[side] = terminal; fits[side] = fit;
580
+ }
581
+
582
+ let socket;
583
+ let sessionToken;
584
+ let started = false;
585
+ const send = (message) => {
586
+ if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
587
+ };
588
+ const resize = () => {
589
+ for (const side of sides) {
590
+ fits[side].fit();
591
+ send({ type: 'resize', side, cols: terminals[side].cols, rows: terminals[side].rows });
592
+ }
593
+ };
594
+ new ResizeObserver(resize).observe(document.querySelector('.panes'));
595
+
596
+ const setFocus = (side) => {
597
+ for (const each of sides) $(`${each}-pane`).classList.toggle('focused', each === side);
598
+ };
599
+ let sync = !hosted;
600
+ const setSync = (value) => {
601
+ sync = value;
602
+ const button = $('sync');
603
+ button.textContent = sync ? 'Sync on' : 'Sync off';
604
+ button.classList.toggle('sync-on', sync);
605
+ button.classList.toggle('sync-off', !sync);
606
+ button.setAttribute('aria-pressed', String(sync));
607
+ };
608
+ setSync(sync);
609
+ $('sync').onclick = () => setSync(!sync);
610
+ document.addEventListener('keydown', (event) => {
611
+ if (!(event.ctrlKey || event.metaKey) || !event.shiftKey || event.altKey ||
612
+ event.code !== 'KeyS' || $('sync').disabled) return;
613
+ event.preventDefault();
614
+ event.stopPropagation();
615
+ setSync(!sync);
616
+ }, true);
617
+
618
+ for (const side of sides) {
619
+ const takeControl = (event) => {
620
+ if (event.type === 'keydown' && ['Shift', 'Control', 'Alt', 'Meta'].includes(event.key)) return;
621
+ send({ type: 'take-control', side });
622
+ if (sync) send({ type: 'take-control', side: other(side) });
623
+ };
624
+ // Capture actual typing/pasting before xterm sends input. Terminal protocol replies
625
+ // also use onData and must not disable startup automation on their own.
626
+ $(side).addEventListener('keydown', takeControl, true);
627
+ $(side).addEventListener('paste', takeControl, true);
628
+ $(side).addEventListener('pointerdown', () => setFocus(side));
629
+ terminals[side].textarea?.addEventListener('focus', () => setFocus(side));
630
+ terminals[side].onData((data) => {
631
+ send({ type: 'input', side, data });
632
+ if (sync) send({ type: 'input', side: other(side), data });
633
+ });
634
+ }
635
+
636
+ const markStarted = () => {
637
+ started = true;
638
+ $('setup').hidden = true;
639
+ $('sync').disabled = false;
640
+ $('finish').disabled = false;
641
+ setStatus('Live');
642
+ resize();
643
+ terminals.left.focus();
644
+ startFilePolling();
645
+ };
646
+ $('finish').onclick = async () => {
647
+ $('finish').disabled = true;
648
+ setStatus('Saving files…');
649
+ clearInterval(fileTimer);
650
+ await pollFiles().catch(() => {});
651
+ await snapshotFiles();
652
+ send({ type: 'exit' });
653
+ setStatus(hosted ? 'Finishing…' : 'Saving report…');
654
+ };
655
+
656
+ // Files the agents write, previewed in a separate tab
657
+ const previewTabs = new Map();
658
+ const fileState = { left: { paths: null, known: null }, right: { paths: null, known: null } };
659
+ let fileTimer;
660
+ let pollingFiles = false;
661
+ const openPreview = (side, path) => {
662
+ for (const tab of previewTabs.keys()) if (tab.closed) previewTabs.delete(tab);
663
+ const tab = window.open('/demo-artifact-preview', '_blank');
664
+ if (tab) previewTabs.set(tab, { side, path });
665
+ else setStatus('Allow pop-ups to open file previews.');
666
+ };
667
+ const toggleList = (side, open) => {
668
+ const list = $(`${side}-file-list`);
669
+ list.hidden = !open;
670
+ $(`${side}-files-count`).setAttribute('aria-expanded', String(open));
671
+ if (open) settleFiles(side);
672
+ };
673
+ const settleFiles = (side) => {
674
+ clearTimeout(fileState[side].freshTimer);
675
+ $(`${side}-files`).classList.remove('fresh');
676
+ };
677
+ for (const side of sides) {
678
+ $(`${side}-files-count`).onclick = () => toggleList(side, $(`${side}-file-list`).hidden);
679
+ }
680
+ document.addEventListener('pointerdown', (event) => {
681
+ for (const side of sides) if (!$(`${side}-files`).contains(event.target)) toggleList(side, false);
682
+ });
683
+ document.addEventListener('keydown', (event) => {
684
+ if (event.key === 'Escape') for (const side of sides) toggleList(side, false);
685
+ });
686
+ const fileSize = (bytes) => bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${bytes} B`;
687
+ const renderFiles = (side, files) => {
688
+ const state = fileState[side];
689
+ state.files = files;
690
+ const paths = JSON.stringify(files.map((file) => file.path));
691
+ const arrived = state.known && files.some((file) => !state.known.has(file.path));
692
+ state.known = new Set(files.map((file) => file.path));
693
+ const wrapper = $(`${side}-files`);
694
+ $(`${side}-files-count`).textContent = files.length
695
+ ? `${files.length} file${files.length === 1 ? '' : 's'}` : 'No files';
696
+ wrapper.classList.toggle('has-files', files.length > 0);
697
+ const newest = files.reduce((best, file) => !best || file.modifiedAt > best.modifiedAt ? file : best, null);
698
+ const newestButton = $(`${side}-newest`);
699
+ newestButton.hidden = !newest;
700
+ if (newest) {
701
+ newestButton.textContent = newest.path.split('/').pop();
702
+ newestButton.title = `Preview ${newest.path}`;
703
+ newestButton.onclick = () => { settleFiles(side); openPreview(side, newest.path); };
704
+ }
705
+ // A new file lights the count up green until it is looked at, or for ten seconds.
706
+ if (arrived) {
707
+ wrapper.classList.remove('fresh');
708
+ void wrapper.offsetWidth;
709
+ wrapper.classList.add('fresh');
710
+ clearTimeout(state.freshTimer);
711
+ state.freshTimer = setTimeout(() => wrapper.classList.remove('fresh'), 10_000);
712
+ }
713
+ // Avoid replacing focused buttons when the file paths have not changed.
714
+ if (state.paths === paths) return;
715
+ state.paths = paths;
716
+ const list = $(`${side}-file-list`);
717
+ list.replaceChildren();
718
+ if (!files.length) {
719
+ const hint = document.createElement('p');
720
+ hint.textContent = 'HTML files the agent writes will appear here.';
721
+ list.append(hint);
722
+ }
723
+ for (const file of files) {
724
+ const button = document.createElement('button');
725
+ const name = document.createElement('span'); name.textContent = file.path;
726
+ const size = document.createElement('span'); size.textContent = fileSize(file.size);
727
+ button.append(name, size);
728
+ button.title = 'Open preview in a new tab';
729
+ button.onclick = () => { toggleList(side, false); openPreview(side, file.path); };
730
+ list.append(button);
731
+ }
732
+ };
733
+ async function pollFiles() {
734
+ if (pollingFiles || !sessionToken) return;
735
+ pollingFiles = true;
736
+ try {
737
+ await Promise.all(sides.map(async (side) => {
738
+ try {
739
+ const response = await fetch(`/artifacts?side=${side}`, {
740
+ headers: { Authorization: `Bearer ${sessionToken}` }, cache: 'no-store',
741
+ signal: AbortSignal.timeout(10000),
742
+ });
743
+ if (!response.ok) throw new Error('Could not refresh files.');
744
+ renderFiles(side, (await response.json()).files);
745
+ } catch {
746
+ const list = $(`${side}-file-list`);
747
+ fileState[side].paths = null;
748
+ list.replaceChildren();
749
+ const hint = document.createElement('p'); hint.textContent = 'Files unavailable. Retrying…';
750
+ list.append(hint);
751
+ }
752
+ }));
753
+ } finally { pollingFiles = false; }
754
+ }
755
+ function startFilePolling() {
756
+ clearInterval(fileTimer);
757
+ void pollFiles();
758
+ fileTimer = setInterval(pollFiles, 3000);
759
+ }
760
+ window.addEventListener('message', (event) => {
761
+ if (event.origin !== location.origin || event.data?.type !== 'compare-artifact-ready') return;
762
+ const file = previewTabs.get(event.source);
763
+ if (file && sessionToken) event.source.postMessage(
764
+ { type: 'compare-artifact-session', ...file, token: sessionToken }, location.origin);
765
+ });
766
+
767
+ $('download-logs').addEventListener('click', async () => {
768
+ const button = $('download-logs');
769
+ button.disabled = true;
770
+ try {
771
+ const response = await fetch('/capture-logs', {
772
+ headers: { Authorization: `Bearer ${sessionToken}` }, cache: 'no-store',
773
+ });
774
+ if (!response.ok) {
775
+ const detail = await response.json().catch(() => ({}));
776
+ throw new Error(detail.error || 'Could not download comparison logs');
777
+ }
778
+ const url = URL.createObjectURL(await response.blob());
779
+ const link = document.createElement('a');
780
+ link.href = url; link.download = 'penny-compare-logs.tar.gz';
781
+ link.click();
782
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
783
+ } catch (error) {
784
+ setStatus(error.message);
785
+ } finally { button.disabled = false; }
786
+ });
787
+
788
+ // Meters
789
+ const meterSnapshots = {};
790
+ // Port of CountUp in ../hud.py: counts arrive in jumps at any magnitude, so each value
791
+ // chases its target continuously and a target that moves mid-roll retargets.
792
+ class CountUp {
793
+ static RATE = 0.88;
794
+ static MIN_STEP_SHARE = 0.55;
795
+ shown = {};
796
+ origin = {};
797
+ advance(targets, elapsed) {
798
+ const remaining = (1 - CountUp.RATE) ** Math.max(0, elapsed);
799
+ const out = {};
800
+ for (const [key, goal] of Object.entries(targets)) {
801
+ if (!(key in this.shown)) {
802
+ // A session's existing total is not motion worth watching.
803
+ this.shown[key] = this.origin[key] = goal;
804
+ out[key] = goal;
805
+ continue;
806
+ }
807
+ let current = this.shown[key];
808
+ if (goal < current) {
809
+ // A drop is a new session; land rather than counting backwards.
810
+ current = this.origin[key] = goal;
811
+ } else {
812
+ if (goal > this.origin[key] && current >= goal) this.origin[key] = current;
813
+ const span = Math.max(1, goal - Math.min(this.origin[key], current));
814
+ const floorStep = span * CountUp.MIN_STEP_SHARE * Math.max(0, elapsed);
815
+ current += Math.max((goal - current) * (1 - remaining), Math.min(goal - current, floorStep));
816
+ }
817
+ this.shown[key] = current;
818
+ out[key] = Math.min(goal, Math.floor(current));
819
+ }
820
+ return out;
821
+ }
822
+ }
823
+ // Same abbreviation as hud.py: 1.2k, 3.45M.
824
+ const abbreviate = (value) => value >= 1_000_000 ? `${(value / 1_000_000).toFixed(2)}M`
825
+ : value >= 1_000 ? `${(value / 1_000).toFixed(1)}k` : String(value);
826
+ const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
827
+ const meters = {};
828
+ const buildMeter = (side) => {
829
+ const root = $(`${side}-meter`);
830
+ const model = document.createElement('span');
831
+ model.className = 'model';
832
+ const role = document.createElement('span');
833
+ const name = document.createElement('span');
834
+ model.append(role, name);
835
+ root.append(model);
836
+ const amounts = {};
837
+ for (const label of ['read', 'write', 'in', 'out', 'total', 'side', 'cost']) {
838
+ const field = document.createElement('span'); field.className = `field ${label}`;
839
+ field.append(document.createTextNode(`${label} `));
840
+ amounts[label] = document.createElement('strong');
841
+ field.append(amounts[label]);
842
+ root.append(field);
843
+ }
844
+ // Background calls (auto-mode reviewer, titles) that Claude Code makes outside the transcript.
845
+ amounts.side.parentElement.hidden = true;
846
+ // Zeros would read as a measurement; the pane has simply not seen a turn yet.
847
+ const wait = document.createElement('span'); wait.className = 'wait'; wait.textContent = 'waiting for first turn';
848
+ root.append(wait);
849
+ return { root, role, name, amounts, counter: new CountUp(), targets: null, cost: 0 };
850
+ };
851
+ let frameRequest = 0;
852
+ let lastFrame = 0;
853
+ const paintMeters = (now) => {
854
+ frameRequest = 0;
855
+ const elapsed = lastFrame ? Math.min(0.25, (now - lastFrame) / 1000) : 0;
856
+ lastFrame = now;
857
+ let settled = true;
858
+ for (const meter of Object.values(meters)) {
859
+ if (!meter.targets) continue;
860
+ const rolled = reducedMotion.matches
861
+ ? meter.targets : meter.counter.advance(meter.targets, elapsed);
862
+ const { amounts } = meter;
863
+ amounts.read.textContent = abbreviate(rolled.cache_read);
864
+ amounts.write.textContent = abbreviate(rolled.cache_write);
865
+ amounts.in.textContent = abbreviate(rolled.fresh_input);
866
+ amounts.out.textContent = abbreviate(rolled.output);
867
+ amounts.total.textContent = abbreviate(rolled.cache_read + rolled.cache_write +
868
+ rolled.fresh_input + rolled.output);
869
+ amounts.cost.textContent = `$${meter.cost.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
870
+ if (Object.keys(rolled).some((key) => rolled[key] !== meter.targets[key])) settled = false;
871
+ }
872
+ if (settled) lastFrame = 0;
873
+ else frameRequest = requestAnimationFrame(paintMeters);
874
+ };
875
+ const showMeter = (snapshot) => {
876
+ const meter = meters[snapshot.side] ||= buildMeter(snapshot.side);
877
+ const { totals } = snapshot;
878
+ meter.role.textContent = snapshot.label || snapshot.role;
879
+ meter.name.textContent = (snapshot.model || 'waiting').split('/').pop();
880
+ meter.targets = { cache_read: totals.cache_read, cache_write: totals.cache_write,
881
+ fresh_input: totals.fresh_input, output: totals.output };
882
+ meter.cost = totals.cost;
883
+ const side = snapshot.side_calls;
884
+ const sideField = meter.amounts.side.parentElement;
885
+ sideField.hidden = !side?.count;
886
+ if (side?.count) meter.amounts.side.textContent = `${side.count} · $${side.cost.toFixed(2)}`;
887
+ meter.root.classList.add('has-data');
888
+ meter.root.classList.toggle('waiting', !totals.total);
889
+ if (!frameRequest) frameRequest = requestAnimationFrame(paintMeters);
890
+ };
891
+ const updateDelta = () => {
892
+ const native = meterSnapshots.left?.totals;
893
+ const penny = meterSnapshots.right?.totals;
894
+ if (!native || !penny) return;
895
+ const cost = native.cost ? (native.cost - penny.cost) / native.cost * 100 : 0;
896
+ const tokens = native.total ? (native.total - penny.total) / native.total * 100 : 0;
897
+ $('delta').textContent =
898
+ `${cost > 0 ? '-' : '+'}${Math.abs(cost).toFixed(0)}% cost ${tokens > 0 ? '-' : '+'}${Math.abs(tokens).toFixed(0)}% tok`;
899
+ };
900
+
901
+ // Results
902
+ const showReport = ({ native, penny, savings, path }) => {
903
+ const metric = (amount, percent, unit) =>
904
+ `${unit}${unit ? Math.abs(amount).toFixed(2) : Math.abs(amount).toLocaleString()}` +
905
+ ` ${amount >= 0 ? 'saved' : 'extra'} (${Math.abs(percent).toFixed(1)}%)`;
906
+ const metrics = $('results-metrics');
907
+ metrics.replaceChildren();
908
+ for (const [label, value] of [['Cost', metric(savings.cost, savings.cost_percent, '$')],
909
+ ['Tokens', `${metric(savings.tokens, savings.token_percent, '')}`]]) {
910
+ const item = document.createElement('div'); item.className = 'metric';
911
+ const caption = document.createElement('span'); caption.textContent = label;
912
+ const result = document.createElement('strong'); result.textContent = value;
913
+ item.append(caption, result); metrics.append(item);
914
+ }
915
+ const line = (name, side) => `${name.padEnd(7)}${side.calls ?? side.call_count} calls · ` +
916
+ `${side.totals.total.toLocaleString()} tokens · $${side.totals.cost.toFixed(3)}`;
917
+ $('results-sides').textContent = `${line('NATIVE', native)}\n${line('PENNY', penny)}`;
918
+ $('results-path').textContent = path ? `Report saved: ${path}` : '';
919
+ $('results-note').textContent = hosted
920
+ ? 'Your session has ended. Close this page to destroy its sandbox.'
921
+ : 'Comparison complete. You can close this tab.';
922
+ $('results').hidden = false;
923
+ setStatus(path ? 'Report saved' : 'Finished');
924
+ };
925
+ const showReportError = (error) => {
926
+ $('results-title').textContent = 'Report could not be saved';
927
+ $('results-metrics').textContent = error;
928
+ $('results-note').textContent = 'Comparison sessions have ended. Reload the page to start again.';
929
+ $('results').hidden = false;
930
+ setStatus('Report failed');
931
+ };
932
+
933
+ // Environment: the pricing schedule both meters use, plus the provider and proxy the
934
+ // comparison represents. The section lives in the hosted form and moves into the local one.
935
+ if (!hosted) $('local-environment').replaceWith($('environment'));
936
+ const environment = () => ({ pricing: $('pricing').value, provider: $('provider').value,
937
+ proxy: $('proxy').value });
938
+ const describeEnvironment = () => {
939
+ const label = (id) => {
940
+ const select = $(id);
941
+ return select.options[select.selectedIndex].text.replace(' (default)', '');
942
+ };
943
+ const pricing = $('pricing');
944
+ $('environment-choice').textContent = `· ${label('provider')} · ` +
945
+ `${$('proxy').value === 'none' ? 'no proxy' : label('proxy')} · ` +
946
+ pricing.options[pricing.selectedIndex].dataset.short;
947
+ };
948
+ for (const id of ['pricing', 'provider', 'proxy']) $(id).addEventListener('change', describeEnvironment);
949
+ // Codex reaches Bedrock and Azure natively but has no Vertex provider.
950
+ const vertexOption = document.querySelector('#provider option[value="vertex"]');
951
+ const limitProviders = (usesCodex) => {
952
+ const provider = $('provider');
953
+ if (usesCodex) {
954
+ if (provider.value === 'vertex') provider.value = 'native';
955
+ vertexOption.remove();
956
+ } else if (!vertexOption.isConnected) provider.append(vertexOption);
957
+ describeEnvironment();
958
+ refreshPickers();
959
+ };
960
+ describeEnvironment();
961
+
962
+ // Gallery publishing. The sandbox shuts down and local file access ends at Finish, so the
963
+ // pages a publisher can choose from are copied first.
964
+ const snapshot = { left: [], right: [] };
965
+ const paneRoles = { left: 'NATIVE', right: 'PENNY' };
966
+ let gallery = null;
967
+ let lastReport = null;
968
+ let published = false;
969
+ async function snapshotFiles() {
970
+ if (!sessionToken) return;
971
+ let bytes = 0;
972
+ for (const side of sides) {
973
+ const files = [...(fileState[side].files || [])].sort((a, b) => b.modifiedAt - a.modifiedAt).slice(0, 30);
974
+ for (const file of files) {
975
+ if (bytes + file.size > 40 * 1024 * 1024) break;
976
+ try {
977
+ const response = await fetch(`/artifact?side=${side}&path=${encodeURIComponent(file.path)}`, {
978
+ headers: { Authorization: `Bearer ${sessionToken}` }, cache: 'no-store', signal: AbortSignal.timeout(10000),
979
+ });
980
+ const { content } = response.ok ? await response.json() : {};
981
+ if (typeof content !== 'string') continue;
982
+ snapshot[side].push({ path: file.path, content });
983
+ bytes += file.size;
984
+ } catch { /* That page is simply not offered. */ }
985
+ }
986
+ }
987
+ }
988
+ const sideOf = (role) => (paneRoles.left === role ? 'left' : 'right');
989
+ const publishBlocker = () => {
990
+ if (!gallery) return 'Publishing needs a native pane and a Penny pane on the same harness.';
991
+ if (!snapshot[sideOf('NATIVE')].length || !snapshot[sideOf('PENNY')].length) {
992
+ return 'Publishing needs an HTML page in each pane.';
993
+ }
994
+ return '';
995
+ };
996
+ const prepareGallery = (message) => {
997
+ lastReport = message;
998
+ gallery = message.gallery || null;
999
+ const blocker = publishBlocker();
1000
+ for (const button of [$('add-to-gallery'), $('results-publish')]) {
1001
+ button.disabled = Boolean(blocker);
1002
+ button.title = blocker || 'Share this comparison in the PennyRouter gallery';
1003
+ }
1004
+ $('results-publish').hidden = false;
1005
+ if (blocker) $('results-publish').textContent = blocker;
1006
+ if (hosted) window.parent.postMessage({ type: 'compare-demo-finished' }, config.parentOrigin);
1007
+ };
1008
+ const remembered = (key, value) => {
1009
+ try {
1010
+ if (value === undefined) return localStorage.getItem(key) || '';
1011
+ localStorage.setItem(key, value);
1012
+ } catch { /* Storage can be unavailable; the field is simply not prefilled. */ }
1013
+ return '';
1014
+ };
1015
+ const pagePolicy = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; " +
1016
+ "font-src data:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
1017
+ const sandboxed = (html) => `<meta http-equiv="Content-Security-Policy" content="${pagePolicy}"><meta name="referrer" content="no-referrer">${html}`;
1018
+ const chosen = (role) => snapshot[sideOf(role)][Number($(role === 'NATIVE' ? 'publish-native' : 'publish-penny').value) || 0];
1019
+ const checkedValue = (name) => document.querySelector(`input[name="${name}"]:checked`).value;
1020
+ const savingsText = (savings) => {
1021
+ if (!savings) return '';
1022
+ const percent = Math.abs(savings.cost_percent).toFixed(0);
1023
+ const dollars = `$${Math.abs(savings.cost).toFixed(savings.cost && Math.abs(savings.cost) < 1 ? 3 : 2)}`;
1024
+ return savings.cost >= 0 ? `${percent}% cheaper · ${dollars} saved` : `${percent}% more · ${dollars} extra`;
1025
+ };
1026
+ let previewedCover = '';
1027
+ // An optional screenshot for the card, chosen as a file or pasted into the dialog.
1028
+ let coverImage = null;
1029
+ let coverImageUrl = '';
1030
+ const setCoverImage = (file) => {
1031
+ if (file && (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size > 5 * 1024 * 1024)) {
1032
+ $('publish-error').textContent = 'Screenshots must be PNG, JPEG or WebP images of at most 5 MB.';
1033
+ return;
1034
+ }
1035
+ $('publish-error').textContent = '';
1036
+ coverImage = file || null;
1037
+ if (coverImageUrl) URL.revokeObjectURL(coverImageUrl);
1038
+ coverImageUrl = coverImage ? URL.createObjectURL(coverImage) : '';
1039
+ $('publish-preview-image').src = coverImageUrl;
1040
+ $('publish-preview-image').hidden = !coverImage;
1041
+ $('publish-cover-note').textContent = coverImage
1042
+ ? `Using ${file.name && file.name !== 'image.png' ? file.name : 'the pasted screenshot'}.`
1043
+ : 'Without one, a screenshot is added during review.';
1044
+ };
1045
+ $('publish-cover-image').addEventListener('change', (event) => setCoverImage(event.target.files[0]));
1046
+ $('publish-form').addEventListener('paste', (event) => {
1047
+ const item = [...event.clipboardData.items].find((entry) => entry.type.startsWith('image/'));
1048
+ if (!item) return;
1049
+ event.preventDefault();
1050
+ setCoverImage(item.getAsFile());
1051
+ });
1052
+ const updatePreview = () => {
1053
+ const tag = checkedValue('publish-tag');
1054
+ $('preview-tag').textContent = { win: 'Penny win', loss: 'Penny loss', interesting: 'Interesting' }[tag];
1055
+ $('preview-tag').className = `gallery-tag ${tag}`;
1056
+ $('preview-title').textContent = $('publish-title').value.trim() || 'Untitled comparison';
1057
+ const savings = lastReport?.savings;
1058
+ $('preview-savings').textContent = savingsText(savings);
1059
+ $('preview-savings').classList.toggle('loss', Boolean(savings && savings.cost < 0));
1060
+ const model = (meterSnapshots[sideOf('PENNY')]?.model || '').split('/').pop();
1061
+ $('preview-meta').textContent = [model, $('publish-name').value.trim()].filter(Boolean).join(' · ');
1062
+ const cover = chosen(checkedValue('publish-cover') === 'native' ? 'NATIVE' : 'PENNY');
1063
+ const key = `${checkedValue('publish-cover')}:${cover?.path}`;
1064
+ if (cover && key !== previewedCover) {
1065
+ previewedCover = key;
1066
+ $('publish-preview').srcdoc = sandboxed(cover.content);
1067
+ }
1068
+ };
1069
+ const openPublish = () => {
1070
+ for (const [role, id] of [['NATIVE', 'publish-native'], ['PENNY', 'publish-penny']]) {
1071
+ const select = $(id);
1072
+ if (!select.options.length) {
1073
+ snapshot[sideOf(role)].forEach((file, index) => select.add(new Option(file.path, String(index))));
1074
+ }
1075
+ }
1076
+ if (!$('publish-name').value) $('publish-name').value = remembered('penny-compare-display-name');
1077
+ refreshPickers();
1078
+ updatePreview();
1079
+ $('publish').hidden = false;
1080
+ $('publish-title').focus();
1081
+ };
1082
+ $('add-to-gallery').onclick = openPublish;
1083
+ $('results-publish').onclick = openPublish;
1084
+ $('publish-cancel').onclick = () => { $('publish').hidden = true; };
1085
+ $('publish-form').addEventListener('input', updatePreview);
1086
+ $('publish-form').addEventListener('change', updatePreview);
1087
+ const relayToParent = (payload) => new Promise((resolve) => {
1088
+ const timer = setTimeout(() => { removeEventListener('message', onResult); resolve({ error: 'The website did not respond. Please try again.' }); }, 10 * 60_000);
1089
+ const onResult = (event) => {
1090
+ if (event.origin !== config.parentOrigin || event.source !== window.parent ||
1091
+ event.data?.type !== 'compare-gallery-result') return;
1092
+ clearTimeout(timer);
1093
+ removeEventListener('message', onResult);
1094
+ resolve(event.data);
1095
+ };
1096
+ addEventListener('message', onResult);
1097
+ window.parent.postMessage({ type: 'compare-gallery-submit', ...payload }, config.parentOrigin);
1098
+ });
1099
+ $('publish-form').onsubmit = async (event) => {
1100
+ event.preventDefault();
1101
+ if (published) return;
1102
+ const native = chosen('NATIVE');
1103
+ const penny = chosen('PENNY');
1104
+ const video = $('publish-video').files[0] || null;
1105
+ const videoType = video && (video.type || (/\.webm$/i.test(video.name) ? 'video/webm' : /\.mp4$/i.test(video.name) ? 'video/mp4' : ''));
1106
+ $('publish-error').textContent = '';
1107
+ if (video && !['video/mp4', 'video/webm'].includes(videoType)) {
1108
+ $('publish-error').textContent = 'Videos must be MP4 or WebM.';
1109
+ return;
1110
+ }
1111
+ if (video && video.size > 100 * 1024 * 1024) {
1112
+ $('publish-error').textContent = 'Videos can be at most 100 MB.';
1113
+ return;
1114
+ }
1115
+ const meta = { title: $('publish-title').value.trim(), comment: $('publish-comment').value.trim(),
1116
+ tag: checkedValue('publish-tag'), display_name: $('publish-name').value.trim(),
1117
+ native_file: native.path, penny_file: penny.path, cover: checkedValue('publish-cover'),
1118
+ video_type: video ? videoType : null };
1119
+ const submit = $('publish-submit');
1120
+ submit.disabled = true;
1121
+ submit.classList.add('busy');
1122
+ submit.textContent = video ? 'Uploading…' : 'Submitting…';
1123
+ let result;
1124
+ try {
1125
+ if (hosted) {
1126
+ result = await relayToParent({ report: gallery.report, signature: gallery.signature,
1127
+ reportNonce: gallery.reportNonce, meta, native: native.content, penny: penny.content, video,
1128
+ cover: coverImage });
1129
+ } else {
1130
+ const form = new FormData();
1131
+ form.set('meta', JSON.stringify(meta));
1132
+ form.set('native', new Blob([native.content], { type: 'text/html' }), 'native.html');
1133
+ form.set('penny', new Blob([penny.content], { type: 'text/html' }), 'penny.html');
1134
+ if (video) form.set('video', video, video.name);
1135
+ if (coverImage) form.set('cover', coverImage, 'cover');
1136
+ const response = await fetch('/gallery/submit', { method: 'POST',
1137
+ headers: { Authorization: `Bearer ${gallery.publishToken}` }, body: form });
1138
+ result = await response.json().catch(() => ({ error: 'Could not publish to the gallery.' }));
1139
+ if (!response.ok && !result.error) result.error = 'Could not publish to the gallery.';
1140
+ }
1141
+ } catch {
1142
+ result = { error: 'Could not publish to the gallery.' };
1143
+ }
1144
+ submit.classList.remove('busy');
1145
+ if (result.error) {
1146
+ $('publish-error').textContent = result.error;
1147
+ submit.disabled = false;
1148
+ submit.textContent = 'Submit for review';
1149
+ return;
1150
+ }
1151
+ published = true;
1152
+ remembered('penny-compare-display-name', meta.display_name);
1153
+ submit.textContent = 'Submitted';
1154
+ $('publish-done').textContent = 'Submitted for review. It appears in the gallery once approved.' +
1155
+ (result.videoError ? ` ${result.videoError}` : '');
1156
+ $('publish-cancel').textContent = 'Close';
1157
+ for (const button of [$('add-to-gallery'), $('results-publish')]) {
1158
+ button.disabled = true;
1159
+ button.textContent = 'Submitted to gallery';
1160
+ }
1161
+ };
1162
+
1163
+ // Messages common to both servers
1164
+ const handleMessage = (message) => {
1165
+ if (message.type === 'output') terminals[message.side]?.write(message.data);
1166
+ else if (message.type === 'meter') {
1167
+ meterSnapshots[message.data.side] = message.data;
1168
+ showMeter(message.data);
1169
+ updateDelta();
1170
+ } else if (message.type === 'exit' || message.type === 'pane-exit') {
1171
+ terminals[message.side]?.write(`\r\n[process exited: ${message.code}]\r\n`);
1172
+ } else if (message.type === 'report') {
1173
+ showReport(message);
1174
+ prepareGallery(message);
1175
+ }
1176
+ else if (message.type === 'report-error') showReportError(message.error);
1177
+ else return false;
1178
+ return true;
1179
+ };
1180
+
1181
+ if (hosted) {
1182
+ // Hosted sandbox: the parent page supplies the session token, then the key form starts both panes.
1183
+ $('setup').hidden = false;
1184
+ $('hosted-setup').hidden = false;
1185
+ let tokenAccepted = false;
1186
+ let launchError = '';
1187
+ let progressTimer;
1188
+ const clearProgress = () => {
1189
+ clearInterval(progressTimer);
1190
+ $('progress').replaceChildren();
1191
+ };
1192
+ const showProgress = (lead, steps = [], current = -1) => {
1193
+ clearInterval(progressTimer);
1194
+ const intro = document.createElement('p');
1195
+ intro.textContent = lead;
1196
+ const list = document.createElement('ol');
1197
+ let elapsed;
1198
+ steps.forEach((label, index) => {
1199
+ const item = document.createElement('li');
1200
+ item.className = index < current ? 'done' : index === current ? 'current' : '';
1201
+ const text = document.createElement('span');
1202
+ text.textContent = label;
1203
+ item.append(text);
1204
+ if (index === current) {
1205
+ elapsed = document.createElement('span');
1206
+ elapsed.className = 'elapsed';
1207
+ elapsed.setAttribute('aria-hidden', 'true');
1208
+ item.append(elapsed);
1209
+ }
1210
+ list.append(item);
1211
+ });
1212
+ $('progress').replaceChildren(intro, list);
1213
+ if (!elapsed) return;
1214
+ const since = Date.now();
1215
+ // Only show a counter once a step is slow enough for the wait to be noticeable.
1216
+ progressTimer = setInterval(() => {
1217
+ const seconds = Math.floor((Date.now() - since) / 1000);
1218
+ elapsed.textContent = seconds >= 2 ? `${seconds}s` : '';
1219
+ }, 500);
1220
+ };
1221
+ // Mirrors the server's key check; the server closes the sandbox on a rejected start.
1222
+ const plausibleKey = (key) => key.length > 3 && key.length <= 1024 && !/\s/.test(key) && key.startsWith('sk-');
1223
+ $('hosted-setup').onsubmit = (event) => {
1224
+ event.preventDefault();
1225
+ const harness = $('harness');
1226
+ const input = $('key');
1227
+ const second = $('penny-key');
1228
+ const start = $('hosted-start');
1229
+ const nativeKey = input.value.trim();
1230
+ const pennyKey = second.value.trim() || nativeKey;
1231
+ const provider = harness.value === 'claude' ? 'Anthropic' : 'OpenAI';
1232
+ if (!plausibleKey(nativeKey) || !plausibleKey(pennyKey)) {
1233
+ $('hosted-error').textContent = `That doesn't look like an ${provider} API key. Keys start with "sk-".`;
1234
+ return;
1235
+ }
1236
+ $('hosted-error').textContent = '';
1237
+ send({ type: 'start', harness: harness.value, nativeKey, pennyKey, environment: environment() });
1238
+ input.value = '';
1239
+ second.value = '';
1240
+ for (const field of [harness, input, second, $('pricing'), $('provider'), $('proxy')]) field.disabled = true;
1241
+ refreshPickers();
1242
+ input.placeholder = 'Key received';
1243
+ start.disabled = true;
1244
+ start.classList.add('busy');
1245
+ start.textContent = 'Starting terminals…';
1246
+ showProgress('Key received. Preparing your terminals…');
1247
+ setStatus('Starting terminals…');
1248
+ };
1249
+ $('harness').onchange = (event) => {
1250
+ limitProviders(event.target.value === 'codex');
1251
+ const name = event.target.value === 'claude' ? 'Anthropic' : 'OpenAI';
1252
+ const suffix = event.target.value === 'claude' ? '' : ' (for Codex)';
1253
+ $('key-label').textContent = `${name} API key${suffix}`;
1254
+ $('penny-key-label').textContent = `${name} API key${suffix} (second account)`;
1255
+ };
1256
+ window.addEventListener('message', (event) => {
1257
+ if (event.origin !== config.parentOrigin || event.source !== window.parent ||
1258
+ event.data?.type !== 'compare-demo-session' || tokenAccepted) return;
1259
+ tokenAccepted = true;
1260
+ sessionToken = event.data.token;
1261
+ socket = new WebSocket(`wss://${location.host}/terminal`);
1262
+ socket.addEventListener('open', () => send({ type: 'auth', token: event.data.token }));
1263
+ socket.addEventListener('message', (item) => {
1264
+ let message;
1265
+ try { message = JSON.parse(item.data); } catch { return; }
1266
+ if (handleMessage(message)) return;
1267
+ if (message.type === 'authenticated') {
1268
+ $('download-logs').hidden = !message.captureLogs;
1269
+ if (message.subscription) {
1270
+ $('setup').hidden = true;
1271
+ setStatus('Starting subscription test · costs are API-equivalent estimates');
1272
+ send({ type: 'start', harness: message.subscriptionHarness || 'claude' });
1273
+ return;
1274
+ }
1275
+ setStatus('Ready for your key');
1276
+ $('hosted-start').disabled = false;
1277
+ } else if (message.type === 'progress') {
1278
+ showProgress('Key received. Preparing your terminals…', message.steps, message.current);
1279
+ } else if (message.type === 'started') {
1280
+ clearProgress();
1281
+ markStarted();
1282
+ } else if (message.type === 'error') {
1283
+ launchError = message.error;
1284
+ $('hosted-start').classList.remove('busy');
1285
+ clearProgress();
1286
+ $('hosted-error').textContent = message.error;
1287
+ setStatus('Could not start');
1288
+ }
1289
+ });
1290
+ socket.addEventListener('close', () => {
1291
+ clearInterval(fileTimer);
1292
+ $('hosted-start').disabled = true;
1293
+ $('finish').disabled = true;
1294
+ if (!$('results').hidden) return;
1295
+ setStatus('Session ended');
1296
+ window.parent.postMessage({ type: 'compare-demo-ended', error: launchError }, config.parentOrigin);
1297
+ });
1298
+ });
1299
+ window.parent.postMessage({ type: 'compare-demo-ready' }, config.parentOrigin);
1300
+ } else {
1301
+ // Local server: the setup form validates both panes, then each pane is started at its fitted size.
1302
+ const startPanes = () => {
1303
+ for (const side of sides) {
1304
+ fits[side].fit();
1305
+ send({ type: 'start', side, cols: terminals[side].cols, rows: terminals[side].rows });
1306
+ }
1307
+ };
1308
+ const roleLabel = (variant) => variant.startsWith('penny-') ? 'PENNY' : 'NATIVE';
1309
+ const variants = ['claude', 'penny-claude', 'codex', 'penny-codex'];
1310
+ for (const side of sides) {
1311
+ const select = $(`${side}-variant`);
1312
+ for (const variant of variants) {
1313
+ const option = document.createElement('option');
1314
+ option.value = variant;
1315
+ option.textContent = variant.includes('codex') ? 'Codex' : 'Claude Code';
1316
+ option.dataset.tag = variant.startsWith('penny-') ? 'penny' : 'native';
1317
+ select.append(option);
1318
+ }
1319
+ }
1320
+ const modelChoices = {
1321
+ // Claude Code resolves these aliases (and the penny wrapper maps them to the gateway's
1322
+ // ids), so the alias is what is sent; the label names the model it currently resolves to.
1323
+ claude: [['opus', 'Claude Opus 5.5'], ['sonnet', 'Claude Sonnet 5.5'], ['haiku', 'Claude Haiku 4.5'],
1324
+ ['fable', 'Claude Fable 5.1']],
1325
+ codex: [['gpt-5.6-sol', 'GPT-5.6 Sol'], ['gpt-5.6-terra', 'GPT-5.6 Terra'], ['gpt-5.6-luna', 'GPT-5.6 Luna'],
1326
+ ['gpt-5.5', 'GPT-5.5'], ['gpt-5.4', 'GPT-5.4'], ['gpt-5.4-mini', 'GPT-5.4 mini']],
1327
+ };
1328
+ $('left-variant').value = config.defaultHarness;
1329
+ $('right-variant').value = `penny-${config.defaultHarness}`;
1330
+ $('left-cwd').value = config.folders.left;
1331
+ $('right-cwd').value = config.folders.right;
1332
+ let modelFamily = 'claude';
1333
+ const refreshVariants = () => {
1334
+ const left = $('left-variant').value;
1335
+ const right = $('right-variant').value;
1336
+ $('left-custom-label').placeholder = roleLabel(left);
1337
+ $('right-custom-label').placeholder = roleLabel(right);
1338
+ const leftCodex = left.includes('codex');
1339
+ const rightCodex = right.includes('codex');
1340
+ limitProviders(leftCodex || rightCodex);
1341
+ const model = $('model');
1342
+ model.disabled = leftCodex !== rightCodex;
1343
+ const family = leftCodex && rightCodex ? 'codex' : !leftCodex && !rightCodex ? 'claude' : null;
1344
+ if (family !== modelFamily) model.value = '';
1345
+ modelFamily = family;
1346
+ modelSuggestions = family ? [{ value: '', label: 'Harness default' },
1347
+ ...modelChoices[family].map(([value, label]) => ({ value, label }))] : [];
1348
+ model.placeholder = family ? 'Harness default' : 'Pick panes from the same harness to choose a model';
1349
+ refreshPickers();
1350
+ };
1351
+ $('left-variant').onchange = refreshVariants;
1352
+ $('right-variant').onchange = refreshVariants;
1353
+ refreshVariants();
1354
+ if (config.compare) {
1355
+ $('setup').hidden = false;
1356
+ $('local-setup').hidden = false;
1357
+ } else {
1358
+ $('left-label').textContent = 'LEFT';
1359
+ $('right-label').textContent = 'RIGHT';
1360
+ $('finish').textContent = 'End session';
1361
+ }
1362
+ let pending = false;
1363
+ $('local-setup').addEventListener('submit', (event) => {
1364
+ event.preventDefault();
1365
+ if (socket?.readyState !== WebSocket.OPEN) return;
1366
+ const settings = {};
1367
+ for (const side of sides) settings[side] = {
1368
+ variant: $(`${side}-variant`).value,
1369
+ cwd: $(`${side}-cwd`).value.trim(),
1370
+ label: $(`${side}-custom-label`).value.trim(),
1371
+ model: comboValue($('model'), modelSuggestions),
1372
+ };
1373
+ for (const side of sides) {
1374
+ $(`${side}-label`).textContent = settings[side].label || roleLabel(settings[side].variant);
1375
+ paneRoles[side] = roleLabel(settings[side].variant);
1376
+ }
1377
+ pending = true;
1378
+ $('local-start').disabled = true;
1379
+ $('local-start').classList.add('busy');
1380
+ $('local-error').textContent = '';
1381
+ send({ type: 'configure', config: settings, environment: environment() });
1382
+ });
1383
+ socket = new WebSocket(`ws://${location.host}/terminal`);
1384
+ socket.addEventListener('open', () => {
1385
+ if (config.compare) {
1386
+ setStatus('Ready');
1387
+ $('local-start').disabled = false;
1388
+ } else {
1389
+ startPanes();
1390
+ markStarted();
1391
+ }
1392
+ });
1393
+ socket.addEventListener('message', (item) => {
1394
+ let message;
1395
+ try { message = JSON.parse(item.data); } catch { return; }
1396
+ if (handleMessage(message)) return;
1397
+ if (message.type === 'artifact-session') {
1398
+ sessionToken = message.token;
1399
+ if (started) startFilePolling();
1400
+ } else if (message.type === 'configured' && pending) {
1401
+ pending = false;
1402
+ $('local-start').classList.remove('busy');
1403
+ startPanes();
1404
+ markStarted();
1405
+ } else if (message.type === 'configuration-error') {
1406
+ pending = false;
1407
+ $('local-start').classList.remove('busy');
1408
+ $('local-start').disabled = false;
1409
+ $('local-error').textContent = message.error;
1410
+ } else if (message.type === 'error') {
1411
+ terminals[message.side]?.write(`\r\n[launch failed: ${message.error}]\r\n`);
1412
+ }
1413
+ });
1414
+ socket.addEventListener('close', () => {
1415
+ clearInterval(fileTimer);
1416
+ $('sync').disabled = true;
1417
+ $('local-start').disabled = true;
1418
+ if (!$('finish').disabled) {
1419
+ $('finish').disabled = true;
1420
+ setStatus('Disconnected · reload to reconnect');
1421
+ }
1422
+ });
1423
+ }
1424
+ for (const select of document.querySelectorAll('.card select')) enhanceSelect(select);
1425
+ if (!hosted) enhanceCombo($('model'), () => modelSuggestions);
1426
+ </script>
1427
+ </body>
1428
+ </html>