pennyrouter 0.3.43 → 0.3.45

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