@9thprotocol/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/webui.js ADDED
@@ -0,0 +1,1056 @@
1
+ /**
2
+ * HTML for the local session (`9p serve`) and the desktop app window.
3
+ *
4
+ * Deliberately one self-contained file with no build step and no dependencies:
5
+ * it is served by the CLI itself, so a bundler here would mean a second build
6
+ * pipeline for a single page.
7
+ *
8
+ * Layout: a full-height left sidebar, a breadcrumb bar over the main column,
9
+ * and a composer that owns its own controls. The sidebar runs to the top of the
10
+ * window on purpose. On macOS the desktop app hides the title bar, so the
11
+ * traffic lights sit in the sidebar's header strip instead of over the content.
12
+ */
13
+ export function chatHtml() {
14
+ return `<!doctype html>
15
+ <html lang="en">
16
+ <head>
17
+ <meta charset="utf-8" />
18
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
19
+ <title>9th Protocol</title>
20
+ <style>
21
+ :root {
22
+ --bg: #0a0a0e;
23
+ --side: #0d0d12;
24
+ --panel: #131319;
25
+ --panel-2: #191921;
26
+ --border: #22222c;
27
+ --border-soft: #1b1b24;
28
+ --text: #ededf0;
29
+ --dim: #9a9aa6;
30
+ --faint: #61616d;
31
+ --accent: #8b5cf6;
32
+ --accent-dim: #6d43d8;
33
+ --warn: #fbbf24;
34
+ --err: #f87171;
35
+ --radius: 12px;
36
+ --bar-h: 52px;
37
+ --side-w: 244px;
38
+ /* Desktop app raises this on macOS so the sidebar header clears the lights. */
39
+ --titlebar-inset: 0px;
40
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
41
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
42
+ }
43
+ * { box-sizing: border-box; }
44
+ html, body { height: 100%; }
45
+ body {
46
+ margin: 0; background: var(--bg); color: var(--text);
47
+ font: 14px/1.6 var(--sans);
48
+ overflow: hidden; -webkit-user-select: none; user-select: none;
49
+ }
50
+ button, input, textarea, select { font: inherit; color: inherit; }
51
+ .app { display: flex; height: 100%; }
52
+
53
+ /* ── sidebar ─────────────────────────────────────────────── */
54
+ .side {
55
+ width: var(--side-w); flex: none; display: flex; flex-direction: column;
56
+ background: var(--side); border-right: 1px solid var(--border-soft);
57
+ }
58
+ /*
59
+ * The macOS traffic lights are drawn over this strip when the desktop app
60
+ * hides the title bar, so the desktop process raises --lights-inset to push the
61
+ * title clear of them. In a browser the inset stays 0 and nothing shifts.
62
+ */
63
+ .side-head {
64
+ height: var(--bar-h); flex: none;
65
+ display: flex; align-items: center;
66
+ padding: 0 12px 0 calc(12px + var(--lights-inset));
67
+ -webkit-app-region: drag;
68
+ }
69
+ .side-head .title { font-weight: 600; font-size: 14px; letter-spacing: -.01em; }
70
+ .side-head .title b { color: var(--accent); }
71
+ .side-head .spacer { flex: 1; }
72
+
73
+ .icobtn {
74
+ width: 26px; height: 26px; flex: none; display: grid; place-items: center;
75
+ background: none; border: 0; border-radius: 6px; cursor: pointer;
76
+ color: var(--faint); -webkit-app-region: no-drag;
77
+ }
78
+ .icobtn:hover { background: var(--panel); color: var(--text); }
79
+ .icobtn svg { width: 15px; height: 15px; }
80
+
81
+ /* Collapse hides the rail entirely; the toggle reappears in the main bar. */
82
+ .app.collapsed .side { display: none; }
83
+
84
+ .srow .chev { opacity: .5; width: 13px; height: 13px; }
85
+ .srow.nav.on { background: var(--panel); color: var(--text); }
86
+ .srow.nav.on .ico { opacity: 1; color: var(--accent); }
87
+ .sitem.dimlabel {
88
+ font: 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em;
89
+ color: var(--faint); padding: 8px 9px 2px;
90
+ }
91
+ .dd-menu.recent { position: static; margin: 2px 0 6px; box-shadow: none; }
92
+
93
+ .side-body { flex: 1; overflow-y: auto; padding: 4px 8px; }
94
+ .side-foot { padding: 8px; border-top: 1px solid var(--border-soft); }
95
+
96
+ .srow {
97
+ display: flex; align-items: center; gap: 9px; width: 100%;
98
+ padding: 7px 9px; border-radius: 8px; background: none; border: 0;
99
+ color: var(--dim); font-size: 13px; cursor: pointer; text-align: left;
100
+ -webkit-app-region: no-drag;
101
+ }
102
+ .srow:hover { background: var(--panel); color: var(--text); }
103
+ .srow .ico { width: 15px; height: 15px; flex: none; opacity: .75; }
104
+ .srow .grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
105
+ .srow kbd {
106
+ font: 10px/1 var(--mono); border: 1px solid var(--border);
107
+ border-radius: 4px; padding: 3px 5px; color: var(--faint);
108
+ }
109
+ .sgroup { margin-top: 10px; }
110
+ .sgroup > .label {
111
+ font-size: 11px; text-transform: uppercase; letter-spacing: .07em;
112
+ color: var(--faint); font-weight: 600; padding: 8px 9px 4px;
113
+ }
114
+ .sitem {
115
+ font: 11.5px/1.5 var(--mono); color: var(--faint);
116
+ padding: 5px 9px; word-break: break-all;
117
+ }
118
+ .pill {
119
+ display: inline-block; font: 11px/1 var(--mono); color: var(--dim);
120
+ background: var(--panel); border: 1px solid var(--border);
121
+ border-radius: 999px; padding: 5px 9px; margin: 0 4px 4px 0;
122
+ }
123
+ .pill.on { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); }
124
+
125
+ /* ── main column ─────────────────────────────────────────── */
126
+ .main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
127
+ .bar {
128
+ height: var(--bar-h); flex: none;
129
+ display: flex; align-items: center; gap: 10px; padding: 0 16px;
130
+ border-bottom: 1px solid var(--border-soft); -webkit-app-region: drag;
131
+ }
132
+ /* With the rail hidden there is nothing else to clear the lights. */
133
+ .app.collapsed .bar { padding-left: calc(16px + var(--lights-inset)); }
134
+
135
+ /* ── views ───────────────────────────────────────────────── */
136
+ .view { flex: 1; display: flex; flex-direction: column; min-height: 0; }
137
+ .view[hidden] { display: none; }
138
+ .panel {
139
+ flex: 1; overflow-y: auto; padding: 28px;
140
+ max-width: 860px; width: 100%; margin: 0 auto;
141
+ -webkit-user-select: text; user-select: text;
142
+ }
143
+ .panel h2 { font-size: 22px; font-weight: 600; margin: 0 0 4px; letter-spacing: -.01em; }
144
+ .panel h3 { font-size: 14px; font-weight: 600; margin: 28px 0 10px; }
145
+ .panel .sub { color: var(--faint); font-size: 13px; margin: 0 0 22px; }
146
+ .row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
147
+ .row-between h3 { margin: 28px 0 10px; }
148
+
149
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
150
+ .statcard {
151
+ background: var(--panel); border: 1px solid var(--border-soft);
152
+ border-radius: var(--radius); padding: 14px;
153
+ }
154
+ .statcard .k { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--faint); }
155
+ .statcard .v { font: 22px/1.3 var(--mono); margin-top: 6px; }
156
+ .statcard .v small { font-size: 12px; color: var(--faint); }
157
+ .meterbar { height: 5px; background: var(--panel-2); border-radius: 999px; margin-top: 9px; overflow: hidden; }
158
+ .meterbar > div { height: 100%; background: var(--accent); border-radius: 999px; }
159
+ .meterbar.hot > div { background: var(--warn); }
160
+
161
+ .tabs { display: flex; gap: 4px; }
162
+ .tab {
163
+ background: none; border: 1px solid transparent; border-radius: 7px;
164
+ color: var(--faint); font-size: 12.5px; padding: 5px 10px; cursor: pointer;
165
+ }
166
+ .tab:hover { color: var(--text); }
167
+ .tab.on { color: var(--text); background: var(--panel); border-color: var(--border-soft); }
168
+
169
+ .tablewrap {
170
+ background: var(--panel); border: 1px solid var(--border-soft);
171
+ border-radius: var(--radius); overflow-x: auto;
172
+ }
173
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
174
+ th, td { text-align: left; padding: 9px 14px; white-space: nowrap; }
175
+ th { font: 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em; color: var(--faint); }
176
+ tbody tr + tr td { border-top: 1px solid var(--border-soft); }
177
+ td.mono { font: 12.5px/1.5 var(--mono); color: var(--dim); }
178
+ td.grow { white-space: normal; word-break: break-all; }
179
+
180
+ .field { margin-bottom: 24px; }
181
+ .field > label { display: block; font-size: 13px; font-weight: 550; margin-bottom: 8px; }
182
+ .field .help { font-size: 12px; color: var(--faint); margin: 8px 0 0; }
183
+ .seg { display: inline-flex; background: var(--panel); border: 1px solid var(--border-soft); border-radius: 9px; padding: 3px; gap: 3px; }
184
+ .seg button {
185
+ background: none; border: 0; border-radius: 7px; cursor: pointer;
186
+ color: var(--dim); font-size: 12.5px; padding: 6px 13px;
187
+ }
188
+ .seg button:hover { color: var(--text); }
189
+ .seg button.on { background: var(--accent); color: #fff; }
190
+
191
+ .kv { background: var(--panel); border: 1px solid var(--border-soft); border-radius: var(--radius); }
192
+ .kv .r { display: flex; gap: 14px; padding: 10px 14px; font-size: 13px; }
193
+ .kv .r + .r { border-top: 1px solid var(--border-soft); }
194
+ .kv .r .k { width: 150px; flex: none; color: var(--faint); }
195
+ .kv .r .v { flex: 1; min-width: 0; word-break: break-all; color: var(--text); }
196
+ .kv .r .v.mono { font: 12.5px/1.5 var(--mono); color: var(--dim); }
197
+ .empty-note { color: var(--faint); font-size: 13px; padding: 16px; }
198
+ .crumb { display: flex; align-items: center; gap: 8px; font-size: 14px; min-width: 0; }
199
+ .crumb .ico { width: 15px; height: 15px; opacity: .6; flex: none; }
200
+ .crumb .proj { color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 320px; }
201
+ .crumb .sep { color: var(--faint); }
202
+ .crumb .cur { font-weight: 600; }
203
+ .bar .spacer { flex: 1; }
204
+ .bar .meter { font: 11.5px/1 var(--mono); color: var(--faint); white-space: nowrap; -webkit-app-region: no-drag; }
205
+
206
+ /* ── transcript ──────────────────────────────────────────── */
207
+ .log { flex: 1; overflow-y: auto; padding: 28px 0 6px; -webkit-user-select: text; user-select: text; }
208
+ .log::-webkit-scrollbar, .side-body::-webkit-scrollbar { width: 8px; }
209
+ .log::-webkit-scrollbar-thumb, .side-body::-webkit-scrollbar-thumb {
210
+ background: rgb(255 255 255 / 7%); border-radius: 8px;
211
+ }
212
+ .log::-webkit-scrollbar-thumb:hover { background: rgb(255 255 255 / 13%); }
213
+
214
+ .hero { max-width: 720px; margin: 14vh auto 0; padding: 0 28px; text-align: center; }
215
+ .hero h1 { font-size: 34px; font-weight: 600; letter-spacing: -.02em; margin: 0 0 10px; }
216
+ .hero h1 .name {
217
+ text-decoration: underline; text-decoration-style: dotted;
218
+ text-underline-offset: 7px; text-decoration-color: var(--faint);
219
+ }
220
+ .hero p { color: var(--faint); font-size: 13px; margin: 0; }
221
+ .hero kbd {
222
+ font: 11px/1 var(--mono); border: 1px solid var(--border);
223
+ background: var(--panel); border-radius: 5px; padding: 3px 6px; color: var(--dim);
224
+ }
225
+
226
+ .row { max-width: 780px; margin: 0 auto 20px; padding: 0 28px; }
227
+ .who {
228
+ font: 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em;
229
+ color: var(--faint); margin-bottom: 7px;
230
+ }
231
+ .row.user .who { color: var(--accent); }
232
+ .row.user .bubble {
233
+ background: var(--panel); border: 1px solid var(--border-soft);
234
+ border-radius: var(--radius); padding: 11px 14px; white-space: pre-wrap;
235
+ }
236
+ .row.assistant .bubble { white-space: pre-wrap; }
237
+ .bubble code {
238
+ font: 12.5px/1.5 var(--mono); background: var(--panel-2);
239
+ border: 1px solid var(--border-soft); border-radius: 5px; padding: 1px 5px;
240
+ }
241
+ .bubble pre {
242
+ font: 12.5px/1.55 var(--mono); background: var(--panel);
243
+ border: 1px solid var(--border-soft); border-radius: var(--radius);
244
+ padding: 12px 14px; overflow-x: auto; margin: 10px 0;
245
+ }
246
+ .bubble pre code { background: none; border: 0; padding: 0; }
247
+
248
+ .tool {
249
+ max-width: 780px; margin: 0 auto 8px; padding: 0 28px;
250
+ font: 12.5px/1.5 var(--mono); color: var(--dim);
251
+ display: flex; align-items: baseline; gap: 8px;
252
+ }
253
+ .tool .dot { color: var(--faint); }
254
+ .tool.err { color: var(--err); }
255
+ .tool .ms { color: var(--faint); font-size: 11px; }
256
+ .note {
257
+ max-width: 780px; margin: 0 auto 14px; padding: 0 28px;
258
+ font: 12px/1.5 var(--mono); color: var(--faint);
259
+ }
260
+ .note b { color: var(--accent); font-weight: 500; }
261
+ .note.warn { color: var(--warn); }
262
+ .note.err { color: var(--err); }
263
+
264
+ .card {
265
+ max-width: 780px; margin: 0 auto 18px; padding: 14px 16px;
266
+ background: var(--panel); border: 1px solid color-mix(in srgb, var(--accent) 26%, var(--border));
267
+ border-radius: var(--radius);
268
+ }
269
+ .card .q { margin-bottom: 12px; }
270
+ .card .acts { display: flex; gap: 8px; flex-wrap: wrap; }
271
+ button.btn {
272
+ background: var(--accent); border: 0; border-radius: 8px;
273
+ padding: 8px 15px; font-size: 13px; font-weight: 550; cursor: pointer; color: #fff;
274
+ }
275
+ button.btn:hover { background: var(--accent-dim); }
276
+ button.btn.ghost { background: var(--panel-2); border: 1px solid var(--border); color: var(--text); }
277
+ button.btn.ghost:hover { border-color: #34343f; }
278
+
279
+ /* ── composer ────────────────────────────────────────────── */
280
+ .composer { padding: 10px 28px 24px; }
281
+ .box {
282
+ max-width: 780px; margin: 0 auto; background: var(--panel);
283
+ border: 1px solid var(--border); border-radius: 16px; padding: 14px 14px 10px;
284
+ }
285
+ .box:focus-within { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
286
+ textarea#input {
287
+ width: 100%; background: none; border: 0; resize: none; outline: none;
288
+ max-height: 200px; line-height: 1.55; display: block;
289
+ -webkit-user-select: text; user-select: text;
290
+ }
291
+ textarea#input::placeholder { color: var(--faint); }
292
+ .ctl { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
293
+ .ctl .sep { width: 1px; height: 16px; background: var(--border); }
294
+ .ctl .spacer { flex: 1; }
295
+
296
+ /*
297
+ * Custom dropdown. A native <select> renders as an OS widget that ignores the
298
+ * page's styling entirely, which reads as a hole in a dark app, and on macOS it
299
+ * pops a system menu that looks nothing like the rest of the window.
300
+ */
301
+ .dd { position: relative; }
302
+ .dd-btn {
303
+ display: flex; align-items: center; gap: 6px; max-width: 240px;
304
+ background: none; border: 0; border-radius: 6px; cursor: pointer;
305
+ color: var(--dim); font-size: 12.5px; padding: 4px 6px;
306
+ }
307
+ .dd-btn:hover { color: var(--text); background: var(--panel-2); }
308
+ .dd-btn:focus-visible { outline: 2px solid var(--accent-dim); outline-offset: 1px; }
309
+ .dd-btn .lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
310
+ .dd-btn .arw { flex: none; opacity: .6; transition: transform .12s ease; }
311
+ .dd.open .dd-btn { color: var(--text); background: var(--panel-2); }
312
+ .dd.open .dd-btn .arw { transform: rotate(180deg); }
313
+
314
+ .dd-menu {
315
+ position: absolute; bottom: calc(100% + 6px); left: 0; z-index: 60;
316
+ min-width: 230px; max-height: 320px; overflow-y: auto;
317
+ background: var(--panel-2); border: 1px solid var(--border);
318
+ border-radius: 10px; padding: 5px;
319
+ box-shadow: 0 12px 34px rgb(0 0 0 / 55%);
320
+ }
321
+ .dd-menu[hidden] { display: none; }
322
+ .dd-opt {
323
+ display: flex; align-items: center; gap: 8px; width: 100%;
324
+ padding: 7px 9px; border: 0; border-radius: 7px; background: none;
325
+ color: var(--dim); font-size: 13px; cursor: pointer; text-align: left;
326
+ }
327
+ .dd-opt .tick { width: 13px; flex: none; opacity: 0; }
328
+ .dd-opt .txt { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
329
+ .dd-opt[aria-selected="true"] { color: var(--text); }
330
+ .dd-opt[aria-selected="true"] .tick { opacity: 1; color: var(--accent); }
331
+ .dd-opt:hover, .dd-opt.active { background: var(--panel); color: var(--text); }
332
+ #sendBtn {
333
+ width: 34px; height: 34px; border-radius: 50%; border: 0; cursor: pointer;
334
+ background: var(--accent); color: #fff; display: grid; place-items: center;
335
+ }
336
+ #sendBtn:hover { background: var(--accent-dim); }
337
+ #sendBtn:disabled { opacity: .4; cursor: not-allowed; }
338
+ .hint { max-width: 780px; margin: 8px auto 0; font: 11px/1 var(--mono); color: var(--faint); }
339
+ </style>
340
+ </head>
341
+ <body>
342
+ <div class="app" id="app">
343
+ <aside class="side">
344
+ <div class="side-head">
345
+ <div class="title"><b>9th</b> Protocol</div>
346
+ <div class="spacer"></div>
347
+ <button class="icobtn" id="collapseBtn" title="Collapse sidebar" aria-label="Collapse sidebar">
348
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
349
+ <rect x="2" y="3" width="12" height="10" rx="2"/><path d="M6.5 3v10"/></svg>
350
+ </button>
351
+ </div>
352
+
353
+ <div class="side-body">
354
+ <button class="srow" id="projBtn" title="Switch project">
355
+ <svg class="ico" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
356
+ <path d="M2 4.5h4l1.2 1.5H14v6.5H2z" stroke-linejoin="round"/></svg>
357
+ <span class="grow" id="projLabel">…</span>
358
+ <svg class="ico chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
359
+ <path d="M4 6.5L8 10.5l4-4" stroke-linecap="round" stroke-linejoin="round"/></svg>
360
+ </button>
361
+ <div class="dd-menu recent" id="recentMenu" hidden></div>
362
+
363
+ <button class="srow" id="newBtn" title="Clear the transcript and start fresh">
364
+ <svg class="ico" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
365
+ <path d="M8 3.5v9M3.5 8h9" stroke-linecap="round"/></svg>
366
+ <span class="grow">New thread</span>
367
+ </button>
368
+
369
+ <div class="sgroup">
370
+ <div class="label">Workspace</div>
371
+ <button class="srow nav on" data-view="session">
372
+ <svg class="ico" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
373
+ <path d="M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z" stroke-linejoin="round"/></svg>
374
+ <span class="grow">Session</span>
375
+ </button>
376
+ <button class="srow nav" data-view="usage">
377
+ <svg class="ico" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
378
+ <path d="M3 13V7M8 13V3M13 13v-4" stroke-linecap="round"/></svg>
379
+ <span class="grow">Usage</span>
380
+ </button>
381
+ <button class="srow nav" data-view="settings">
382
+ <svg class="ico" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
383
+ <circle cx="8" cy="8" r="2.2"/>
384
+ <path d="M8 1.8v1.6M8 12.6v1.6M14.2 8h-1.6M3.4 8H1.8M12.4 3.6l-1.1 1.1M4.7 11.3l-1.1 1.1M12.4 12.4l-1.1-1.1M4.7 4.7L3.6 3.6" stroke-linecap="round"/></svg>
385
+ <span class="grow">Settings</span>
386
+ </button>
387
+ </div>
388
+
389
+ <div class="sgroup" id="ctxGroup">
390
+ <div class="label">Context</div>
391
+ <div class="sitem" id="cwd">…</div>
392
+ <div id="vaultWrap" hidden><div class="sitem dimlabel">vault</div><div class="sitem" id="vault"></div></div>
393
+ <div id="skillsWrap" hidden><div class="sitem dimlabel">skills</div><div style="padding:2px 9px" id="skills"></div></div>
394
+ <div id="mcpWrap" hidden><div class="sitem dimlabel">mcp</div><div style="padding:2px 9px" id="mcp"></div></div>
395
+ </div>
396
+ </div>
397
+
398
+ <div class="side-foot">
399
+ <div style="padding:0 9px 8px">
400
+ <span class="pill" id="modePill">Ask first</span>
401
+ <span class="pill" id="ctxPill">context 0</span>
402
+ </div>
403
+ <button class="srow" id="docsBtn">
404
+ <svg class="ico" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
405
+ <path d="M3 2.5h7l3 3v8H3z" stroke-linejoin="round"/><path d="M9.5 2.5V6H13"/></svg>
406
+ <span class="grow">Documentation</span>
407
+ </button>
408
+ </div>
409
+ </aside>
410
+
411
+ <main class="main">
412
+ <div class="bar">
413
+ <button class="icobtn" id="expandBtn" title="Show sidebar" aria-label="Show sidebar" hidden>
414
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
415
+ <rect x="2" y="3" width="12" height="10" rx="2"/><path d="M6.5 3v10"/></svg>
416
+ </button>
417
+ <div class="crumb">
418
+ <span class="proj" id="projName">…</span>
419
+ <span class="sep">/</span>
420
+ <span class="cur" id="viewName">Session</span>
421
+ </div>
422
+ <div class="spacer"></div>
423
+ <div class="meter" id="meter"></div>
424
+ </div>
425
+
426
+ <!-- Session -->
427
+ <div class="view" id="view-session">
428
+ <div class="log" id="log">
429
+ <div class="hero" id="hero">
430
+ <h1>What should we build in <span class="name" id="heroName">this project</span>?</h1>
431
+ <p><kbd>Enter</kbd> to send, <kbd>Shift</kbd> + <kbd>Enter</kbd> for a newline</p>
432
+ </div>
433
+ </div>
434
+ <div class="composer">
435
+ <form id="form">
436
+ <div class="box">
437
+ <textarea id="input" rows="1" placeholder="Ask 9p to do something, or /help for commands" autofocus></textarea>
438
+ <div class="ctl">
439
+ <div class="dd" id="modelDd"></div>
440
+ <div class="sep"></div>
441
+ <div class="dd" id="modeDd"></div>
442
+ <div class="spacer"></div>
443
+ <button id="sendBtn" type="submit" title="Send">
444
+ <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor"
445
+ stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
446
+ <path d="M8 13V3M3.5 7.5L8 3l4.5 4.5"/></svg>
447
+ </button>
448
+ </div>
449
+ </div>
450
+ <div class="hint" id="hint"></div>
451
+ </form>
452
+ </div>
453
+ </div>
454
+
455
+ <!-- Usage -->
456
+ <div class="view" id="view-usage" hidden>
457
+ <div class="panel">
458
+ <h2>Usage</h2>
459
+ <p class="sub">Credits meter raw model cost. 1 credit = $0.01 of compute.</p>
460
+ <div class="cards" id="usageCards"></div>
461
+ <div class="row-between">
462
+ <h3>Last 30 days</h3>
463
+ <div class="tabs" id="usageTabs">
464
+ <button class="tab on" data-group="model">by model</button>
465
+ <button class="tab" data-group="day">by day</button>
466
+ <button class="tab" data-group="session">by session</button>
467
+ </div>
468
+ </div>
469
+ <div class="tablewrap"><table id="usageTable"><tbody></tbody></table></div>
470
+ </div>
471
+ </div>
472
+
473
+ <!-- Settings -->
474
+ <div class="view" id="view-settings" hidden>
475
+ <div class="panel">
476
+ <h2>Settings</h2>
477
+ <p class="sub">Applies to this session. Model and permission mode also live in the composer.</p>
478
+
479
+ <div class="field">
480
+ <label>Auto routing bias</label>
481
+ <div class="seg" id="biasSeg">
482
+ <button data-bias="economy">Economy</button>
483
+ <button data-bias="balanced">Balanced</button>
484
+ <button data-bias="quality">Quality</button>
485
+ </div>
486
+ <p class="help">Economy leans on cheaper models. Quality is the only way Auto reaches premium models, and only on complex turns.</p>
487
+ </div>
488
+
489
+ <div class="field">
490
+ <label>Permission mode</label>
491
+ <div class="seg" id="modeSeg">
492
+ <button data-mode="default">Ask first</button>
493
+ <button data-mode="accept-edits">Accept edits</button>
494
+ <button data-mode="plan">Plan</button>
495
+ <button data-mode="bypass">Full access</button>
496
+ </div>
497
+ <p class="help">Plan is read only. Full access stops asking before edits and commands.</p>
498
+ </div>
499
+
500
+ <h3>Account</h3>
501
+ <div class="kv" id="accountKv"></div>
502
+
503
+ <h3>This project</h3>
504
+ <div class="kv" id="projectKv"></div>
505
+ </div>
506
+ </div>
507
+ </main>
508
+ </div>
509
+ <script>
510
+ const log = document.getElementById("log");
511
+ const hero = document.getElementById("hero");
512
+ const meter = document.getElementById("meter");
513
+ const modePill = document.getElementById("modePill");
514
+ const ctxPill = document.getElementById("ctxPill");
515
+ const input = document.getElementById("input");
516
+ const sendBtn = document.getElementById("sendBtn");
517
+ const form = document.getElementById("form");
518
+ const hint = document.getElementById("hint");
519
+
520
+ const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
521
+
522
+ /*
523
+ * In the desktop app on macOS the title bar is hidden and the traffic lights
524
+ * are painted over the top-left of this page, right where the sidebar title
525
+ * sits. Detecting it here (rather than having the main process inject CSS)
526
+ * keeps the browser case untouched and needs no coordination between the two.
527
+ */
528
+ if (/Electron/.test(navigator.userAgent) && /Mac/.test(navigator.userAgent)) {
529
+ document.documentElement.style.setProperty("--lights-inset", "68px");
530
+ }
531
+
532
+ /** Minimal fenced-code and inline-code rendering. Everything else stays literal. */
533
+ function render(text) {
534
+ return String(text).split(/\`\`\`/).map((part, i) => {
535
+ if (i % 2 === 1) {
536
+ const nl = part.indexOf("\\n");
537
+ const body = nl === -1 ? part : part.slice(nl + 1);
538
+ return "<pre><code>" + esc(body.replace(/\\n$/, "")) + "</code></pre>";
539
+ }
540
+ return esc(part).replace(/\`([^\`\\n]+)\`/g, "<code>$1</code>");
541
+ }).join("");
542
+ }
543
+
544
+ /**
545
+ * Custom dropdown.
546
+ *
547
+ * Options are set later (the model list arrives from /state), so the control is
548
+ * built empty and repopulated. Only one may be open at a time, hence the
549
+ * module-level openDd.
550
+ *
551
+ * Note: this whole script lives inside a template literal, so a backtick here
552
+ * would end the string and break the build.
553
+ */
554
+ let openDd = null;
555
+
556
+ function Dropdown(host, opts) {
557
+ let options = [];
558
+ let value = null;
559
+ let active = -1;
560
+
561
+ const btn = document.createElement("button");
562
+ btn.type = "button";
563
+ btn.className = "dd-btn";
564
+ btn.setAttribute("aria-haspopup", "listbox");
565
+ btn.setAttribute("aria-expanded", "false");
566
+ if (opts.title) btn.title = opts.title;
567
+ btn.innerHTML = '<span class="lbl"></span>' +
568
+ '<svg class="arw" width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" ' +
569
+ 'stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6.5L8 10.5l4-4"/></svg>';
570
+
571
+ const menu = document.createElement("div");
572
+ menu.className = "dd-menu";
573
+ menu.setAttribute("role", "listbox");
574
+ menu.hidden = true;
575
+
576
+ host.append(btn, menu);
577
+
578
+ const labelOf = (v) => {
579
+ const found = options.find((o) => o.value === v);
580
+ return found ? found.label : v ?? "";
581
+ };
582
+
583
+ function paint() {
584
+ btn.querySelector(".lbl").textContent = labelOf(value);
585
+ menu.innerHTML = "";
586
+ options.forEach((o, i) => {
587
+ const b = document.createElement("button");
588
+ b.type = "button";
589
+ b.className = "dd-opt" + (i === active ? " active" : "");
590
+ b.setAttribute("role", "option");
591
+ b.setAttribute("aria-selected", String(o.value === value));
592
+ b.innerHTML = '<svg class="tick" width="13" height="13" viewBox="0 0 16 16" fill="none" ' +
593
+ 'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
594
+ '<path d="M3.5 8.5l3 3 6-7"/></svg><span class="txt"></span>';
595
+ b.querySelector(".txt").textContent = o.label;
596
+ b.addEventListener("click", () => { choose(o.value); });
597
+ menu.appendChild(b);
598
+ });
599
+ }
600
+
601
+ function choose(v) {
602
+ value = v;
603
+ close();
604
+ paint();
605
+ opts.onChange(v);
606
+ }
607
+
608
+ function open() {
609
+ if (openDd && openDd !== api) openDd.close();
610
+ openDd = api;
611
+ host.classList.add("open");
612
+ menu.hidden = false;
613
+ btn.setAttribute("aria-expanded", "true");
614
+ active = Math.max(0, options.findIndex((o) => o.value === value));
615
+ paint();
616
+ const sel = menu.children[active];
617
+ if (sel) sel.scrollIntoView({ block: "nearest" });
618
+ }
619
+
620
+ function close() {
621
+ host.classList.remove("open");
622
+ menu.hidden = true;
623
+ btn.setAttribute("aria-expanded", "false");
624
+ if (openDd === api) openDd = null;
625
+ }
626
+
627
+ btn.addEventListener("click", (e) => {
628
+ e.stopPropagation();
629
+ menu.hidden ? open() : close();
630
+ });
631
+
632
+ host.addEventListener("keydown", (e) => {
633
+ if (e.key === "Escape" && !menu.hidden) { e.stopPropagation(); close(); btn.focus(); return; }
634
+ if (menu.hidden) {
635
+ if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") { e.preventDefault(); open(); }
636
+ return;
637
+ }
638
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
639
+ e.preventDefault();
640
+ active = (active + (e.key === "ArrowDown" ? 1 : -1) + options.length) % options.length;
641
+ paint();
642
+ const el = menu.children[active];
643
+ if (el) el.scrollIntoView({ block: "nearest" });
644
+ } else if (e.key === "Enter") {
645
+ e.preventDefault();
646
+ if (options[active]) choose(options[active].value);
647
+ }
648
+ });
649
+
650
+ const api = {
651
+ close,
652
+ setOptions(next) { options = next; paint(); },
653
+ setValue(v) { if (v !== value) { value = v; paint(); } },
654
+ get value() { return value; },
655
+ get count() { return options.length; },
656
+ };
657
+ paint();
658
+ return api;
659
+ }
660
+
661
+ // One outside-click handler for every dropdown, rather than one each.
662
+ document.addEventListener("click", () => { if (openDd) openDd.close(); });
663
+
664
+ const MODES = [
665
+ { value: "default", label: "Ask first" },
666
+ { value: "accept-edits", label: "Accept edits" },
667
+ { value: "plan", label: "Plan (read only)" },
668
+ { value: "bypass", label: "Full access" },
669
+ ];
670
+
671
+ const modelDd = Dropdown(document.getElementById("modelDd"), {
672
+ title: "Model",
673
+ onChange: (v) => post("/config", { model: v }),
674
+ });
675
+ const modeDd = Dropdown(document.getElementById("modeDd"), {
676
+ title: "Permission mode",
677
+ onChange: (v) => post("/config", { mode: v }),
678
+ });
679
+ modeDd.setOptions(MODES);
680
+
681
+ function atBottom() { return log.scrollHeight - log.scrollTop - log.clientHeight < 80; }
682
+ function scroll(force) { if (force || atBottom()) log.scrollTop = log.scrollHeight; }
683
+ function hideHero() { if (hero && hero.parentNode) hero.style.display = "none"; }
684
+
685
+ let streamEl = null;
686
+ let streamRaw = "";
687
+
688
+ function addRow(kind, text) {
689
+ hideHero();
690
+ const stick = atBottom();
691
+ const el = document.createElement("div");
692
+ el.className = "row " + kind;
693
+ el.innerHTML = '<div class="who">' + (kind === "user" ? "you" : "9p") + "</div>" +
694
+ '<div class="bubble">' + render(text) + "</div>";
695
+ log.appendChild(el);
696
+ scroll(stick);
697
+ return el.querySelector(".bubble");
698
+ }
699
+
700
+ function addTool(summary, cls, ms) {
701
+ hideHero();
702
+ const stick = atBottom();
703
+ const el = document.createElement("div");
704
+ el.className = "tool " + (cls || "");
705
+ el.innerHTML = '<span class="dot">' + (cls === "err" ? "✗" : "⚙") + "</span><span>" +
706
+ esc(summary) + "</span>" + (ms ? '<span class="ms">' + ms + "ms</span>" : "");
707
+ log.appendChild(el);
708
+ scroll(stick);
709
+ }
710
+
711
+ function addNote(html, cls) {
712
+ hideHero();
713
+ const stick = atBottom();
714
+ const el = document.createElement("div");
715
+ el.className = "note " + (cls || "");
716
+ el.innerHTML = html;
717
+ log.appendChild(el);
718
+ scroll(stick);
719
+ }
720
+
721
+ function streamText(t) {
722
+ if (!streamEl) { streamEl = addRow("assistant", ""); streamRaw = ""; }
723
+ streamRaw += t;
724
+ const stick = atBottom();
725
+ streamEl.innerHTML = render(streamRaw);
726
+ scroll(stick);
727
+ }
728
+ function endStream() { streamEl = null; streamRaw = ""; }
729
+
730
+ async function post(path, body) {
731
+ const res = await fetch(path, {
732
+ method: "POST",
733
+ headers: { "Content-Type": "application/json" },
734
+ body: JSON.stringify(body),
735
+ });
736
+ if (!res.ok) addNote("error: " + esc(await res.text()), "err");
737
+ return res;
738
+ }
739
+
740
+ function card(html) {
741
+ hideHero();
742
+ const el = document.createElement("div");
743
+ el.className = "card";
744
+ el.innerHTML = html;
745
+ log.appendChild(el);
746
+ scroll(true);
747
+ return el;
748
+ }
749
+
750
+ const MODE_LABEL = {
751
+ "default": "Ask first", "accept-edits": "Accept edits",
752
+ "plan": "Plan (read only)", "bypass": "Full access",
753
+ };
754
+
755
+ function refreshState() {
756
+ fetch("/state").then((r) => r.json()).then((s) => {
757
+ var saved = s.delegated && s.delegated.calls
758
+ ? " · ⇢ " + (s.delegated.contextTokensSaved >= 1000
759
+ ? (s.delegated.contextTokensSaved / 1000).toFixed(1) + "k"
760
+ : s.delegated.contextTokensSaved) + " saved"
761
+ : "";
762
+ meter.textContent = s.usage.requests + " calls · " + s.usage.inputTokens + " in · " +
763
+ s.usage.outputTokens + " out" + saved;
764
+ modeDd.setValue(s.mode);
765
+ modePill.textContent = MODE_LABEL[s.mode] || s.mode;
766
+ modePill.className = "pill" + (s.mode === "bypass" || s.mode === "accept-edits" ? " on" : "");
767
+ const ctx = s.contextTokens || 0;
768
+ ctxPill.textContent = "context " + (ctx >= 1000 ? (ctx / 1000).toFixed(1) + "k" : ctx);
769
+ sendBtn.disabled = s.busy;
770
+
771
+ const base = String(s.cwd).split("/").filter(Boolean).pop() || s.cwd;
772
+ document.getElementById("projName").textContent = base;
773
+ document.getElementById("projLabel").textContent = base;
774
+ document.getElementById("heroName").textContent = base;
775
+ document.getElementById("cwd").textContent = s.cwd;
776
+ document.title = "9th Protocol - " + base;
777
+
778
+ if (s.vault) {
779
+ document.getElementById("vaultWrap").hidden = false;
780
+ document.getElementById("vault").textContent = s.vault;
781
+ }
782
+ if (s.skills && s.skills.length) {
783
+ document.getElementById("skillsWrap").hidden = false;
784
+ document.getElementById("skills").innerHTML =
785
+ s.skills.map((n) => '<span class="pill">/' + esc(n) + "</span>").join("");
786
+ }
787
+ if (s.mcp && s.mcp.length) {
788
+ document.getElementById("mcpWrap").hidden = false;
789
+ document.getElementById("mcp").innerHTML =
790
+ s.mcp.map((n) => '<span class="pill">' + esc(n) + "</span>").join("");
791
+ }
792
+ if (!modelDd.count && s.models && s.models.length) {
793
+ modelDd.setOptions(s.models.map((id) => ({ value: id, label: id })));
794
+ }
795
+ modelDd.setValue(s.model);
796
+ }).catch(() => {});
797
+ }
798
+
799
+ const es = new EventSource("/events");
800
+ es.onmessage = (e) => {
801
+ const ev = JSON.parse(e.data);
802
+ switch (ev.type) {
803
+ case "text_delta": streamText(ev.text); break;
804
+ case "model_selected":
805
+ endStream();
806
+ addNote("auto → <b>" + esc(ev.model) + "</b> (" + esc(ev.reason) + ")");
807
+ break;
808
+ case "compacted":
809
+ endStream();
810
+ addNote("compacted context ~" + ev.beforeTokens + " → ~" + ev.afterTokens + " tokens");
811
+ break;
812
+ case "tool_start": endStream(); addTool(ev.summary); break;
813
+ case "tool_end":
814
+ if (ev.isError) addTool((ev.output || "").split("\\n")[0].slice(0, 160), "err");
815
+ break;
816
+ case "permission_denied": endStream(); addNote("denied: " + esc(ev.summary), "warn"); break;
817
+ case "turn_end": endStream(); refreshState(); break;
818
+ case "error": endStream(); addNote("error: " + esc(ev.message), "err"); break;
819
+ case "busy":
820
+ sendBtn.disabled = ev.value;
821
+ hint.textContent = ev.value ? "working…" : "";
822
+ if (!ev.value) refreshState();
823
+ break;
824
+ case "permission_request": {
825
+ const el = card('<div class="q">' + esc(ev.summary) + "</div>" +
826
+ '<div class="acts"><button class="btn" data-a="1">Allow</button>' +
827
+ '<button class="btn ghost" data-a="0">Deny</button></div>');
828
+ el.querySelectorAll("button").forEach((b) => b.addEventListener("click", () => {
829
+ post("/permission", { id: ev.id, allow: b.dataset.a === "1" });
830
+ el.remove();
831
+ }));
832
+ break;
833
+ }
834
+ case "ask_request": {
835
+ const opts = (ev.options || []).map((o) =>
836
+ '<button class="btn ghost" data-v="' + esc(o) + '">' + esc(o) + "</button>").join("");
837
+ const el = card('<div class="q">' + esc(ev.question) + "</div>" +
838
+ '<div class="acts">' + opts + "</div>" +
839
+ '<div style="display:flex;gap:8px;margin-top:12px;">' +
840
+ '<input id="ans" style="flex:1;background:var(--panel-2);border:1px solid var(--border);border-radius:8px;padding:8px 10px;" placeholder="type an answer" />' +
841
+ '<button class="btn" data-send="1">Reply</button></div>');
842
+ const send = (v) => { post("/answer", { id: ev.id, answer: v }); el.remove(); };
843
+ el.querySelectorAll("[data-v]").forEach((b) =>
844
+ b.addEventListener("click", () => send(b.dataset.v)));
845
+ el.querySelector("[data-send]").addEventListener("click", () =>
846
+ send(el.querySelector("#ans").value));
847
+ break;
848
+ }
849
+ }
850
+ };
851
+ es.onerror = () => { hint.textContent = "reconnecting…"; };
852
+
853
+ form.addEventListener("submit", (e) => {
854
+ e.preventDefault();
855
+ const msg = input.value.trim();
856
+ if (!msg || sendBtn.disabled) return;
857
+ addRow("user", msg);
858
+ post("/send", { message: msg });
859
+ input.value = "";
860
+ input.style.height = "auto";
861
+ });
862
+
863
+ input.addEventListener("keydown", (e) => {
864
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); }
865
+ });
866
+ input.addEventListener("input", () => {
867
+ input.style.height = "auto";
868
+ input.style.height = Math.min(input.scrollHeight, 200) + "px";
869
+ });
870
+
871
+ /* ── views ─────────────────────────────────────────────────── */
872
+ const VIEW_TITLE = { session: "Session", usage: "Usage", settings: "Settings" };
873
+ let currentView = "session";
874
+
875
+ function showView(name) {
876
+ currentView = name;
877
+ for (const v of ["session", "usage", "settings"]) {
878
+ document.getElementById("view-" + v).hidden = v !== name;
879
+ }
880
+ document.querySelectorAll(".srow.nav").forEach((b) =>
881
+ b.classList.toggle("on", b.dataset.view === name));
882
+ document.getElementById("viewName").textContent = VIEW_TITLE[name];
883
+ if (name === "usage") loadUsage();
884
+ if (name === "settings") loadSettings();
885
+ }
886
+ document.querySelectorAll(".srow.nav").forEach((b) =>
887
+ b.addEventListener("click", () => showView(b.dataset.view)));
888
+
889
+ /* ── sidebar collapse ──────────────────────────────────────── */
890
+ const appEl = document.getElementById("app");
891
+ const expandBtn = document.getElementById("expandBtn");
892
+ function setCollapsed(on) {
893
+ appEl.classList.toggle("collapsed", on);
894
+ expandBtn.hidden = !on;
895
+ try { localStorage.setItem("9p.collapsed", on ? "1" : "0"); } catch { /* private mode */ }
896
+ }
897
+ document.getElementById("collapseBtn").addEventListener("click", () => setCollapsed(true));
898
+ expandBtn.addEventListener("click", () => setCollapsed(false));
899
+ try { if (localStorage.getItem("9p.collapsed") === "1") setCollapsed(true); } catch { /* ignore */ }
900
+
901
+ /* ── project switcher ──────────────────────────────────────── */
902
+ /*
903
+ * window.ninep is injected by the desktop preload. A plain browser (9p serve)
904
+ * has no way to open a native folder picker, so the control becomes a static
905
+ * label there rather than a button that cannot do anything.
906
+ */
907
+ const shell = window.ninep;
908
+ const projBtn = document.getElementById("projBtn");
909
+ const recentMenu = document.getElementById("recentMenu");
910
+
911
+ if (!shell) {
912
+ projBtn.disabled = true;
913
+ projBtn.style.cursor = "default";
914
+ projBtn.title = "Run the desktop app to switch projects";
915
+ projBtn.querySelector(".chev").remove();
916
+ } else {
917
+ projBtn.addEventListener("click", async (e) => {
918
+ e.stopPropagation();
919
+ if (!recentMenu.hidden) { recentMenu.hidden = true; return; }
920
+ const recent = await shell.recentProjects().catch(() => []);
921
+ recentMenu.innerHTML = "";
922
+ for (const p of recent) {
923
+ const b = document.createElement("button");
924
+ b.type = "button";
925
+ b.className = "dd-opt";
926
+ b.innerHTML = '<span class="tick"></span><span class="txt"></span>';
927
+ b.querySelector(".txt").textContent = p.split("/").filter(Boolean).pop() || p;
928
+ b.title = p;
929
+ b.addEventListener("click", () => { void shell.openProject(p); });
930
+ recentMenu.appendChild(b);
931
+ }
932
+ const open = document.createElement("button");
933
+ open.type = "button";
934
+ open.className = "dd-opt";
935
+ open.innerHTML = '<span class="tick"></span><span class="txt">Open another folder...</span>';
936
+ open.addEventListener("click", () => { void shell.chooseProject(); });
937
+ recentMenu.appendChild(open);
938
+ recentMenu.hidden = false;
939
+ });
940
+ document.addEventListener("click", () => { recentMenu.hidden = true; });
941
+ }
942
+
943
+ /* ── usage panel ───────────────────────────────────────────── */
944
+ let usageGroup = "model";
945
+ const fmtTok = (n) => (n >= 1e6 ? (n / 1e6).toFixed(1) + "M" : n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n));
946
+
947
+ document.querySelectorAll("#usageTabs .tab").forEach((t) =>
948
+ t.addEventListener("click", () => {
949
+ usageGroup = t.dataset.group;
950
+ document.querySelectorAll("#usageTabs .tab").forEach((x) => x.classList.toggle("on", x === t));
951
+ loadUsage();
952
+ }));
953
+
954
+ async function loadUsage() {
955
+ const cards = document.getElementById("usageCards");
956
+ const tbody = document.querySelector("#usageTable tbody");
957
+ cards.innerHTML = "";
958
+ tbody.innerHTML = '<tr><td class="empty-note">loading...</td></tr>';
959
+
960
+ const me = await fetch("/me").then((r) => r.json()).catch(() => null);
961
+ if (!me || me.byok) {
962
+ cards.innerHTML = '<div class="statcard"><div class="k">Mode</div><div class="v">BYOK</div></div>';
963
+ tbody.innerHTML = '<tr><td class="empty-note">Usage is not metered on your own OpenRouter key.</td></tr>';
964
+ return;
965
+ }
966
+
967
+ const lim = me.limits || {};
968
+ const card = (k, v, sub, spent, cap) => {
969
+ const pct = cap > 0 ? Math.min(100, (spent / cap) * 100) : null;
970
+ return '<div class="statcard"><div class="k">' + k + '</div><div class="v">' + v +
971
+ (sub ? ' <small>' + sub + "</small>" : "") + "</div>" +
972
+ (pct === null ? "" : '<div class="meterbar' + (pct > 80 ? " hot" : "") +
973
+ '"><div style="width:' + pct + '%"></div></div>') + "</div>";
974
+ };
975
+ cards.innerHTML =
976
+ card("Balance", Number(lim.balance ?? 0).toFixed(1), "credits") +
977
+ card("5 hour window", Number(lim.windowSpent ?? 0).toFixed(1),
978
+ "/ " + (lim.windowCap ?? 0), lim.windowSpent, lim.windowCap) +
979
+ card("Weekly", Number(lim.weeklySpent ?? 0).toFixed(1),
980
+ "/ " + (lim.weeklyCap ?? 0), lim.weeklySpent, lim.weeklyCap) +
981
+ card("Plan", String(me.user?.plan ?? "?"));
982
+
983
+ const rep = await fetch("/usage?groupBy=" + usageGroup + "&days=30")
984
+ .then((r) => r.json()).catch(() => null);
985
+ if (!rep || !rep.buckets) {
986
+ tbody.innerHTML = '<tr><td class="empty-note">Could not load usage.</td></tr>';
987
+ return;
988
+ }
989
+ if (!rep.buckets.length) {
990
+ tbody.innerHTML = '<tr><td class="empty-note">Nothing in the last 30 days.</td></tr>';
991
+ return;
992
+ }
993
+ const head = "<tr><th>" + usageGroup + "</th><th>calls</th><th>tokens</th><th>cached</th><th>credits</th></tr>";
994
+ tbody.innerHTML = head + rep.buckets.map((b) => {
995
+ const key = usageGroup === "session" && b.key.length > 12 ? b.key.slice(0, 8) + "..." : b.key;
996
+ return "<tr><td class='mono grow'>" + esc(key) + "</td><td class='mono'>" + b.requests +
997
+ "</td><td class='mono'>" + fmtTok(b.inputTokens) + " / " + fmtTok(b.outputTokens) +
998
+ "</td><td class='mono'>" + Math.round(b.cacheHitRate * 100) + "%</td><td class='mono'>" +
999
+ b.credits.toFixed(2) + "</td></tr>";
1000
+ }).join("");
1001
+ }
1002
+
1003
+ /* ── settings panel ────────────────────────────────────────── */
1004
+ async function loadSettings() {
1005
+ const s = await fetch("/state").then((r) => r.json()).catch(() => ({}));
1006
+ document.querySelectorAll("#biasSeg button").forEach((b) =>
1007
+ b.classList.toggle("on", b.dataset.bias === s.bias));
1008
+ document.querySelectorAll("#modeSeg button").forEach((b) =>
1009
+ b.classList.toggle("on", b.dataset.mode === s.mode));
1010
+
1011
+ const me = await fetch("/me").then((r) => r.json()).catch(() => null);
1012
+ const row = (k, v, mono) =>
1013
+ '<div class="r"><div class="k">' + k + '</div><div class="v' + (mono ? " mono" : "") +
1014
+ '">' + esc(v) + "</div></div>";
1015
+ document.getElementById("accountKv").innerHTML = !me || me.byok
1016
+ ? row("Auth", "BYOK, your own OpenRouter key") + row("Metering", "none")
1017
+ : row("Email", me.user?.email ?? "?") + row("Plan", me.user?.plan ?? "?") +
1018
+ row("Balance", Number(me.limits?.balance ?? 0).toFixed(2) + " credits");
1019
+
1020
+ document.getElementById("projectKv").innerHTML =
1021
+ row("Directory", s.cwd ?? "?", true) +
1022
+ row("Vault", s.vault || "not configured, run 9p init", true) +
1023
+ row("Skills", (s.skills && s.skills.length) ? s.skills.map((n) => "/" + n).join(", ") : "none") +
1024
+ row("MCP servers", (s.mcp && s.mcp.length) ? s.mcp.join(", ") : "none") +
1025
+ row("Context", (s.contextTokens || 0) + " tokens");
1026
+ }
1027
+
1028
+ document.querySelectorAll("#biasSeg button").forEach((b) =>
1029
+ b.addEventListener("click", () => {
1030
+ post("/config", { bias: b.dataset.bias });
1031
+ document.querySelectorAll("#biasSeg button").forEach((x) => x.classList.toggle("on", x === b));
1032
+ }));
1033
+ document.querySelectorAll("#modeSeg button").forEach((b) =>
1034
+ b.addEventListener("click", () => {
1035
+ post("/config", { mode: b.dataset.mode });
1036
+ document.querySelectorAll("#modeSeg button").forEach((x) => x.classList.toggle("on", x === b));
1037
+ modeDd.setValue(b.dataset.mode);
1038
+ }));
1039
+
1040
+ document.getElementById("newBtn").addEventListener("click", () => {
1041
+ post("/send", { message: "/clear" });
1042
+ log.querySelectorAll(".row,.tool,.note,.card").forEach((n) => n.remove());
1043
+ if (hero) hero.style.display = "";
1044
+ refreshState();
1045
+ });
1046
+ document.getElementById("docsBtn").addEventListener("click", () => {
1047
+ window.open("https://docs.9thprotocol.com", "_blank");
1048
+ });
1049
+
1050
+ refreshState();
1051
+ setInterval(refreshState, 5000);
1052
+ </script>
1053
+ </body>
1054
+ </html>`;
1055
+ }
1056
+ //# sourceMappingURL=webui.js.map