shadok-ai 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/README.md +196 -0
- package/dist/args.d.ts +8 -0
- package/dist/args.js +17 -0
- package/dist/args.js.map +1 -0
- package/dist/channels.d.ts +61 -0
- package/dist/channels.js +169 -0
- package/dist/channels.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +100 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.js +59 -0
- package/dist/config.js.map +1 -0
- package/dist/detect.d.ts +17 -0
- package/dist/detect.js +41 -0
- package/dist/detect.js.map +1 -0
- package/dist/extract.d.ts +55 -0
- package/dist/extract.js +269 -0
- package/dist/extract.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +90 -0
- package/dist/main.js.map +1 -0
- package/dist/pace.d.ts +33 -0
- package/dist/pace.js +53 -0
- package/dist/pace.js.map +1 -0
- package/dist/retry.d.ts +15 -0
- package/dist/retry.js +40 -0
- package/dist/retry.js.map +1 -0
- package/dist/secrets.d.ts +21 -0
- package/dist/secrets.js +71 -0
- package/dist/secrets.js.map +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +854 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +74 -0
- package/dist/session.js +244 -0
- package/dist/session.js.map +1 -0
- package/dist/setup-prompt.d.ts +6 -0
- package/dist/setup-prompt.js +25 -0
- package/dist/setup-prompt.js.map +1 -0
- package/dist/supervisor.d.ts +55 -0
- package/dist/supervisor.js +61 -0
- package/dist/supervisor.js.map +1 -0
- package/dist/tail.d.ts +66 -0
- package/dist/tail.js +244 -0
- package/dist/tail.js.map +1 -0
- package/dist/telegram.d.ts +48 -0
- package/dist/telegram.js +565 -0
- package/dist/telegram.js.map +1 -0
- package/dist/tmux.d.ts +69 -0
- package/dist/tmux.js +267 -0
- package/dist/tmux.js.map +1 -0
- package/dist/update-flag.d.ts +13 -0
- package/dist/update-flag.js +36 -0
- package/dist/update-flag.js.map +1 -0
- package/dist/updater.d.ts +18 -0
- package/dist/updater.js +45 -0
- package/dist/updater.js.map +1 -0
- package/dist/usage.d.ts +23 -0
- package/dist/usage.js +125 -0
- package/dist/usage.js.map +1 -0
- package/dist/worktree.d.ts +63 -0
- package/dist/worktree.js +153 -0
- package/dist/worktree.js.map +1 -0
- package/package.json +44 -0
- package/public/index.html +2583 -0
|
@@ -0,0 +1,2583 @@
|
|
|
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>shadok-ai — cockpit</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #14161d;
|
|
10
|
+
--bg-raised: #1b1e27;
|
|
11
|
+
--bg-inset: #0e1015;
|
|
12
|
+
--line: #2a2e3b;
|
|
13
|
+
--text: #d8dae2;
|
|
14
|
+
--text-dim: #7d8293;
|
|
15
|
+
--amber: #f0a848;
|
|
16
|
+
--amber-soft: rgba(240, 168, 72, 0.12);
|
|
17
|
+
--phosphor: #ffb45c;
|
|
18
|
+
--ok: #7fc97f;
|
|
19
|
+
--err: #e07a6a;
|
|
20
|
+
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
21
|
+
--sans: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
22
|
+
}
|
|
23
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
24
|
+
[hidden] { display: none !important; }
|
|
25
|
+
html, body { height: 100%; }
|
|
26
|
+
body {
|
|
27
|
+
background: var(--bg);
|
|
28
|
+
color: var(--text);
|
|
29
|
+
font-family: var(--sans);
|
|
30
|
+
font-size: 15px;
|
|
31
|
+
display: flex;
|
|
32
|
+
flex-direction: column;
|
|
33
|
+
}
|
|
34
|
+
.label {
|
|
35
|
+
font-family: var(--mono);
|
|
36
|
+
font-size: 10px;
|
|
37
|
+
letter-spacing: 0.18em;
|
|
38
|
+
text-transform: uppercase;
|
|
39
|
+
color: var(--text-dim);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* ── Instrument bar ──────────────────────────────────── */
|
|
43
|
+
header {
|
|
44
|
+
display: flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
gap: 18px;
|
|
47
|
+
padding: 10px 16px;
|
|
48
|
+
border-bottom: 1px solid var(--line);
|
|
49
|
+
background: var(--bg-raised);
|
|
50
|
+
flex-wrap: wrap;
|
|
51
|
+
}
|
|
52
|
+
header .brand {
|
|
53
|
+
font-family: var(--mono);
|
|
54
|
+
font-size: 14px;
|
|
55
|
+
color: var(--amber);
|
|
56
|
+
letter-spacing: 0.06em;
|
|
57
|
+
}
|
|
58
|
+
header .brand::before { content: "◆ "; }
|
|
59
|
+
.gauge { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
60
|
+
.gauge .value {
|
|
61
|
+
font-family: var(--mono);
|
|
62
|
+
font-size: 12px;
|
|
63
|
+
color: var(--text);
|
|
64
|
+
white-space: nowrap;
|
|
65
|
+
overflow: hidden;
|
|
66
|
+
text-overflow: ellipsis;
|
|
67
|
+
max-width: 260px;
|
|
68
|
+
}
|
|
69
|
+
.led {
|
|
70
|
+
display: inline-block;
|
|
71
|
+
width: 9px; height: 9px;
|
|
72
|
+
border-radius: 50%;
|
|
73
|
+
background: #3a3f4e;
|
|
74
|
+
vertical-align: -1px;
|
|
75
|
+
margin-right: 6px;
|
|
76
|
+
flex: none;
|
|
77
|
+
}
|
|
78
|
+
.led.on { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
|
|
79
|
+
.led.busy { background: var(--amber); box-shadow: 0 0 8px var(--amber); animation: breathe 1.2s ease-in-out infinite; }
|
|
80
|
+
.led.off { background: var(--err); }
|
|
81
|
+
@keyframes breathe { 50% { opacity: 0.35; } }
|
|
82
|
+
@media (prefers-reduced-motion: reduce) { .led.busy { animation: none; } }
|
|
83
|
+
header .spacer { flex: 1; }
|
|
84
|
+
/* Quota gauges (5h / 7d subscription usage).
|
|
85
|
+
Two stacked bars: what has been spent, and how much of the window has
|
|
86
|
+
elapsed. Top bar longer than the bottom one = spending faster than time. */
|
|
87
|
+
.quota { min-width: 92px; }
|
|
88
|
+
.quota .meter {
|
|
89
|
+
height: 3px;
|
|
90
|
+
background: var(--bg-inset);
|
|
91
|
+
border: 1px solid var(--line);
|
|
92
|
+
border-radius: 2px;
|
|
93
|
+
overflow: hidden;
|
|
94
|
+
margin: 1px 0;
|
|
95
|
+
}
|
|
96
|
+
.quota .fill {
|
|
97
|
+
display: block;
|
|
98
|
+
height: 100%;
|
|
99
|
+
width: 0%;
|
|
100
|
+
background: var(--ok);
|
|
101
|
+
transition: width 0.4s ease;
|
|
102
|
+
}
|
|
103
|
+
/* The pace bar is a reference, not a status: it never takes the alert colour. */
|
|
104
|
+
.quota .meter.pace .fill { background: var(--text-dim); }
|
|
105
|
+
/* Colour follows the RATIO, not the raw usage. */
|
|
106
|
+
.quota.warn .meter.usage .fill { background: var(--amber); }
|
|
107
|
+
.quota.crit .meter.usage .fill { background: var(--err); }
|
|
108
|
+
.quota .qpct {
|
|
109
|
+
font-family: var(--mono);
|
|
110
|
+
font-size: 11px;
|
|
111
|
+
color: var(--text-dim);
|
|
112
|
+
white-space: nowrap;
|
|
113
|
+
}
|
|
114
|
+
button {
|
|
115
|
+
font-family: var(--mono);
|
|
116
|
+
font-size: 12px;
|
|
117
|
+
letter-spacing: 0.06em;
|
|
118
|
+
background: transparent;
|
|
119
|
+
color: var(--text);
|
|
120
|
+
border: 1px solid var(--line);
|
|
121
|
+
border-radius: 4px;
|
|
122
|
+
padding: 6px 12px;
|
|
123
|
+
cursor: pointer;
|
|
124
|
+
}
|
|
125
|
+
button:hover { border-color: var(--amber); color: var(--amber); }
|
|
126
|
+
button:focus-visible { outline: 2px solid var(--amber); outline-offset: 2px; }
|
|
127
|
+
button.primary { background: var(--amber); border-color: var(--amber); color: #1a1205; font-weight: 600; }
|
|
128
|
+
button.primary:hover { filter: brightness(1.1); }
|
|
129
|
+
button:disabled { opacity: 0.4; cursor: default; }
|
|
130
|
+
|
|
131
|
+
/* ── Channels (left column) ──────────────────────────── */
|
|
132
|
+
#tabbar {
|
|
133
|
+
width: 220px;
|
|
134
|
+
flex: none;
|
|
135
|
+
display: flex;
|
|
136
|
+
flex-direction: column;
|
|
137
|
+
gap: 2px;
|
|
138
|
+
background: var(--bg-raised);
|
|
139
|
+
border-right: 1px solid var(--line);
|
|
140
|
+
padding: 12px 8px;
|
|
141
|
+
overflow-y: auto;
|
|
142
|
+
}
|
|
143
|
+
#tabbar .side-label { padding: 0 8px 8px; }
|
|
144
|
+
.tab {
|
|
145
|
+
position: relative;
|
|
146
|
+
display: flex;
|
|
147
|
+
align-items: center;
|
|
148
|
+
gap: 8px;
|
|
149
|
+
font-family: var(--mono);
|
|
150
|
+
font-size: 12px;
|
|
151
|
+
color: var(--text-dim);
|
|
152
|
+
border-left: 2px solid transparent;
|
|
153
|
+
border-radius: 4px;
|
|
154
|
+
padding: 9px 10px 11px;
|
|
155
|
+
cursor: pointer;
|
|
156
|
+
user-select: none;
|
|
157
|
+
}
|
|
158
|
+
/* Context-window usage: tiny number + thin bar at the bottom of the tab. */
|
|
159
|
+
.tab .ctxnum { font-size: 9px; color: var(--text-dim); flex: none; }
|
|
160
|
+
.tab .ctxbar {
|
|
161
|
+
position: absolute;
|
|
162
|
+
left: 10px;
|
|
163
|
+
right: 10px;
|
|
164
|
+
bottom: 3px;
|
|
165
|
+
height: 2px;
|
|
166
|
+
background: var(--line);
|
|
167
|
+
border-radius: 1px;
|
|
168
|
+
overflow: hidden;
|
|
169
|
+
}
|
|
170
|
+
.tab .ctxfill {
|
|
171
|
+
display: block;
|
|
172
|
+
height: 100%;
|
|
173
|
+
width: 0%;
|
|
174
|
+
background: var(--ok);
|
|
175
|
+
transition: width 0.4s ease;
|
|
176
|
+
}
|
|
177
|
+
.tab .ctxfill.warn { background: var(--amber); }
|
|
178
|
+
.tab .ctxfill.crit { background: var(--err); }
|
|
179
|
+
.tab:hover { color: var(--text); background: var(--bg-inset); }
|
|
180
|
+
.tab.active {
|
|
181
|
+
color: var(--amber);
|
|
182
|
+
border-left-color: var(--amber);
|
|
183
|
+
background: var(--amber-soft);
|
|
184
|
+
border-radius: 0 4px 4px 0;
|
|
185
|
+
}
|
|
186
|
+
.tab .name {
|
|
187
|
+
flex: 1;
|
|
188
|
+
overflow: hidden;
|
|
189
|
+
text-overflow: ellipsis;
|
|
190
|
+
white-space: nowrap;
|
|
191
|
+
}
|
|
192
|
+
.tab input.rename {
|
|
193
|
+
flex: 1;
|
|
194
|
+
min-width: 0;
|
|
195
|
+
font-family: var(--mono);
|
|
196
|
+
font-size: 12px;
|
|
197
|
+
background: var(--bg-inset);
|
|
198
|
+
border: 1px solid var(--amber);
|
|
199
|
+
border-radius: 3px;
|
|
200
|
+
color: var(--text);
|
|
201
|
+
padding: 2px 5px;
|
|
202
|
+
}
|
|
203
|
+
.tab .close {
|
|
204
|
+
color: var(--text-dim);
|
|
205
|
+
font-size: 13px;
|
|
206
|
+
line-height: 1;
|
|
207
|
+
padding: 0 2px;
|
|
208
|
+
flex: none;
|
|
209
|
+
}
|
|
210
|
+
.tab .close:hover { color: var(--err); }
|
|
211
|
+
/* Per-channel turn timer (thinking time; final time stays dimmed). */
|
|
212
|
+
.tab .timer {
|
|
213
|
+
flex: none;
|
|
214
|
+
font-size: 11px;
|
|
215
|
+
color: var(--amber);
|
|
216
|
+
font-variant-numeric: tabular-nums;
|
|
217
|
+
}
|
|
218
|
+
.tab .timer.done { color: var(--text-dim); }
|
|
219
|
+
/* Channel states: working / waiting for an answer / unread response. */
|
|
220
|
+
.tab.working { background: var(--amber-soft); animation: breathe 1.6s ease-in-out infinite; }
|
|
221
|
+
.tab.needs-answer {
|
|
222
|
+
background: rgba(240, 168, 72, 0.28);
|
|
223
|
+
border-left-color: var(--amber);
|
|
224
|
+
border-radius: 0 4px 4px 0;
|
|
225
|
+
color: var(--text);
|
|
226
|
+
}
|
|
227
|
+
.tab.unread {
|
|
228
|
+
background: rgba(127, 201, 127, 0.14);
|
|
229
|
+
border-left-color: var(--ok);
|
|
230
|
+
border-radius: 0 4px 4px 0;
|
|
231
|
+
color: var(--text);
|
|
232
|
+
}
|
|
233
|
+
@media (prefers-reduced-motion: reduce) { .tab.working { animation: none; } }
|
|
234
|
+
#newTab, #newGroup {
|
|
235
|
+
margin-top: 8px;
|
|
236
|
+
border: 1px dashed var(--line);
|
|
237
|
+
font-size: 12px;
|
|
238
|
+
color: var(--text-dim);
|
|
239
|
+
padding: 8px;
|
|
240
|
+
}
|
|
241
|
+
#newTab:hover, #newGroup:hover { color: var(--amber); border-color: var(--amber); }
|
|
242
|
+
#newGroup { margin-top: 4px; }
|
|
243
|
+
@media (max-width: 900px) { #tabbar { width: 150px; } }
|
|
244
|
+
|
|
245
|
+
/* ── Tab groups ──────────────────────────────────────── */
|
|
246
|
+
#ungrouped, .group-body { display: flex; flex-direction: column; gap: 2px; }
|
|
247
|
+
.group { display: flex; flex-direction: column; gap: 2px; margin-top: 6px; }
|
|
248
|
+
.group-head {
|
|
249
|
+
display: flex;
|
|
250
|
+
align-items: center;
|
|
251
|
+
gap: 6px;
|
|
252
|
+
font-family: var(--mono);
|
|
253
|
+
font-size: 10px;
|
|
254
|
+
letter-spacing: 0.14em;
|
|
255
|
+
text-transform: uppercase;
|
|
256
|
+
color: var(--text-dim);
|
|
257
|
+
border-radius: 4px;
|
|
258
|
+
padding: 5px 8px;
|
|
259
|
+
cursor: pointer;
|
|
260
|
+
user-select: none;
|
|
261
|
+
}
|
|
262
|
+
.group-head:hover { color: var(--text); background: var(--bg-inset); }
|
|
263
|
+
.group-head .caret { flex: none; font-size: 9px; }
|
|
264
|
+
.group-head .gname {
|
|
265
|
+
flex: 1;
|
|
266
|
+
overflow: hidden;
|
|
267
|
+
text-overflow: ellipsis;
|
|
268
|
+
white-space: nowrap;
|
|
269
|
+
}
|
|
270
|
+
.group-head input.rename {
|
|
271
|
+
flex: 1;
|
|
272
|
+
min-width: 0;
|
|
273
|
+
font-family: var(--mono);
|
|
274
|
+
font-size: 11px;
|
|
275
|
+
text-transform: none;
|
|
276
|
+
letter-spacing: normal;
|
|
277
|
+
background: var(--bg-inset);
|
|
278
|
+
border: 1px solid var(--amber);
|
|
279
|
+
border-radius: 3px;
|
|
280
|
+
color: var(--text);
|
|
281
|
+
padding: 2px 5px;
|
|
282
|
+
}
|
|
283
|
+
.group-head .gclose { flex: none; font-size: 13px; padding: 0 2px; line-height: 1; }
|
|
284
|
+
.group-head .gclose:hover { color: var(--err); }
|
|
285
|
+
.group-body { padding-left: 10px; }
|
|
286
|
+
.group.collapsed .group-body { display: none; }
|
|
287
|
+
.tab.dragging, .group.dragging { opacity: 0.4; }
|
|
288
|
+
/* While dragging a tab, empty drop zones become visible targets. */
|
|
289
|
+
body.dragging-tab #ungrouped:empty,
|
|
290
|
+
body.dragging-tab .group-body:empty {
|
|
291
|
+
min-height: 26px;
|
|
292
|
+
border: 1px dashed var(--line);
|
|
293
|
+
border-radius: 4px;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* ── Body ────────────────────────────────────────────── */
|
|
297
|
+
main { flex: 1; display: flex; min-height: 0; }
|
|
298
|
+
.chat-pane { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
|
299
|
+
|
|
300
|
+
/* Start screen */
|
|
301
|
+
#setup {
|
|
302
|
+
margin: auto;
|
|
303
|
+
width: min(520px, 92vw);
|
|
304
|
+
border: 1px solid var(--line);
|
|
305
|
+
border-radius: 8px;
|
|
306
|
+
background: var(--bg-raised);
|
|
307
|
+
padding: 28px;
|
|
308
|
+
display: flex;
|
|
309
|
+
flex-direction: column;
|
|
310
|
+
gap: 18px;
|
|
311
|
+
}
|
|
312
|
+
#setup h1 { font-family: var(--mono); font-size: 16px; color: var(--amber); font-weight: 500; }
|
|
313
|
+
#setup p.hint { color: var(--text-dim); font-size: 13px; line-height: 1.5; }
|
|
314
|
+
.field { display: flex; flex-direction: column; gap: 6px; }
|
|
315
|
+
input[type="text"] {
|
|
316
|
+
font-family: var(--mono);
|
|
317
|
+
font-size: 13px;
|
|
318
|
+
background: var(--bg-inset);
|
|
319
|
+
border: 1px solid var(--line);
|
|
320
|
+
border-radius: 4px;
|
|
321
|
+
color: var(--text);
|
|
322
|
+
padding: 9px 10px;
|
|
323
|
+
}
|
|
324
|
+
input[type="text"]:focus-visible { outline: 2px solid var(--amber); outline-offset: 1px; }
|
|
325
|
+
.radio-row { display: flex; gap: 14px; flex-wrap: wrap; }
|
|
326
|
+
.radio-row label { display: flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; }
|
|
327
|
+
input[type="radio"] { accent-color: var(--amber); }
|
|
328
|
+
|
|
329
|
+
/* Session picker (resume by id) */
|
|
330
|
+
.session-list {
|
|
331
|
+
display: flex;
|
|
332
|
+
flex-direction: column;
|
|
333
|
+
gap: 6px;
|
|
334
|
+
max-height: 230px;
|
|
335
|
+
overflow-y: auto;
|
|
336
|
+
}
|
|
337
|
+
.session-list .empty {
|
|
338
|
+
color: var(--text-dim);
|
|
339
|
+
font-size: 12.5px;
|
|
340
|
+
font-style: italic;
|
|
341
|
+
padding: 4px 0;
|
|
342
|
+
}
|
|
343
|
+
.session-item {
|
|
344
|
+
display: flex;
|
|
345
|
+
flex-direction: column;
|
|
346
|
+
align-items: flex-start;
|
|
347
|
+
gap: 3px;
|
|
348
|
+
text-align: left;
|
|
349
|
+
width: 100%;
|
|
350
|
+
padding: 8px 10px;
|
|
351
|
+
font-family: var(--sans);
|
|
352
|
+
letter-spacing: 0;
|
|
353
|
+
}
|
|
354
|
+
.session-item .sid { font-family: var(--mono); font-size: 11px; color: var(--amber); }
|
|
355
|
+
.session-item .meta { font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
|
|
356
|
+
.session-item .preview {
|
|
357
|
+
font-size: 12.5px;
|
|
358
|
+
color: var(--text);
|
|
359
|
+
max-width: 100%;
|
|
360
|
+
overflow: hidden;
|
|
361
|
+
text-overflow: ellipsis;
|
|
362
|
+
white-space: nowrap;
|
|
363
|
+
}
|
|
364
|
+
.session-item.selected { border-color: var(--amber); background: var(--amber-soft); }
|
|
365
|
+
|
|
366
|
+
/* Transcripts (one per channel) */
|
|
367
|
+
#transcripts { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
|
368
|
+
.transcript {
|
|
369
|
+
flex: 1;
|
|
370
|
+
overflow-y: auto;
|
|
371
|
+
padding: 22px 22px 8px;
|
|
372
|
+
display: flex;
|
|
373
|
+
flex-direction: column;
|
|
374
|
+
}
|
|
375
|
+
.turn { display: flex; flex-direction: column; gap: 4px; max-width: 82%; margin-top: 14px; }
|
|
376
|
+
.turn:first-child { margin-top: 0; }
|
|
377
|
+
/* An activity block sits closer to the text it belongs to than to the next turn. */
|
|
378
|
+
.turn.activity { margin-top: 8px; }
|
|
379
|
+
.turn.activity + .turn.claude { margin-top: 8px; }
|
|
380
|
+
.turn.system + .turn.system { margin-top: 4px; }
|
|
381
|
+
/* One speaker label per run of turns, not one per bubble. */
|
|
382
|
+
.turn.claude + .turn.claude:not(.dialog) > .label,
|
|
383
|
+
.turn.user + .turn.user > .label { display: none; }
|
|
384
|
+
.turn.user { align-self: flex-end; align-items: flex-end; }
|
|
385
|
+
.turn.claude { align-self: flex-start; }
|
|
386
|
+
.bubble {
|
|
387
|
+
padding: 10px 14px;
|
|
388
|
+
border-radius: 6px;
|
|
389
|
+
font-size: 14px;
|
|
390
|
+
line-height: 1.5;
|
|
391
|
+
white-space: pre-wrap;
|
|
392
|
+
word-break: break-word;
|
|
393
|
+
}
|
|
394
|
+
.turn.user .bubble {
|
|
395
|
+
background: var(--amber-soft);
|
|
396
|
+
border: 1px solid rgba(240, 168, 72, 0.35);
|
|
397
|
+
}
|
|
398
|
+
.turn.claude .bubble {
|
|
399
|
+
background: var(--bg-inset);
|
|
400
|
+
border: 1px solid var(--line);
|
|
401
|
+
font-family: var(--mono);
|
|
402
|
+
font-size: 13px;
|
|
403
|
+
/* Wrap long prose lines so nothing is clipped; still preserves the
|
|
404
|
+
TUI's own newlines. (Wide tables come through history as real HTML.) */
|
|
405
|
+
white-space: pre-wrap;
|
|
406
|
+
word-break: break-word;
|
|
407
|
+
overflow-x: auto;
|
|
408
|
+
}
|
|
409
|
+
/* History replayed from the .jsonl: raw markdown → HTML rendering. */
|
|
410
|
+
.turn.hist { opacity: 0.8; }
|
|
411
|
+
.bubble.md {
|
|
412
|
+
white-space: normal;
|
|
413
|
+
word-break: break-word;
|
|
414
|
+
font-family: var(--sans);
|
|
415
|
+
font-size: 14px;
|
|
416
|
+
}
|
|
417
|
+
.bubble.md p { margin: 6px 0; }
|
|
418
|
+
.bubble.md p:first-child { margin-top: 0; }
|
|
419
|
+
.bubble.md p:last-child { margin-bottom: 0; }
|
|
420
|
+
.bubble.md h1, .bubble.md h2, .bubble.md h3, .bubble.md h4 {
|
|
421
|
+
margin: 12px 0 6px;
|
|
422
|
+
font-size: 15px;
|
|
423
|
+
color: var(--amber);
|
|
424
|
+
}
|
|
425
|
+
.bubble.md h1:first-child, .bubble.md h2:first-child,
|
|
426
|
+
.bubble.md h3:first-child, .bubble.md h4:first-child { margin-top: 0; }
|
|
427
|
+
.bubble.md ul, .bubble.md ol { margin: 6px 0 6px 22px; }
|
|
428
|
+
.bubble.md li { margin: 3px 0; }
|
|
429
|
+
.bubble.md li > p { margin: 0; }
|
|
430
|
+
.bubble.md code {
|
|
431
|
+
font-family: var(--mono);
|
|
432
|
+
font-size: 12.5px;
|
|
433
|
+
background: var(--bg-raised);
|
|
434
|
+
border-radius: 3px;
|
|
435
|
+
padding: 1px 5px;
|
|
436
|
+
}
|
|
437
|
+
.bubble.md pre {
|
|
438
|
+
background: var(--bg-raised);
|
|
439
|
+
border: 1px solid var(--line);
|
|
440
|
+
border-radius: 4px;
|
|
441
|
+
padding: 10px 12px;
|
|
442
|
+
margin: 8px 0;
|
|
443
|
+
overflow-x: auto;
|
|
444
|
+
}
|
|
445
|
+
.bubble.md pre code { background: none; padding: 0; }
|
|
446
|
+
.bubble.md table {
|
|
447
|
+
border-collapse: collapse;
|
|
448
|
+
margin: 8px 0;
|
|
449
|
+
font-size: 13px;
|
|
450
|
+
display: block;
|
|
451
|
+
overflow-x: auto;
|
|
452
|
+
max-width: 100%;
|
|
453
|
+
}
|
|
454
|
+
.bubble.md th, .bubble.md td {
|
|
455
|
+
border: 1px solid var(--line);
|
|
456
|
+
padding: 5px 10px;
|
|
457
|
+
text-align: left;
|
|
458
|
+
white-space: nowrap;
|
|
459
|
+
}
|
|
460
|
+
.bubble.md th {
|
|
461
|
+
color: var(--amber);
|
|
462
|
+
font-family: var(--mono);
|
|
463
|
+
font-size: 11px;
|
|
464
|
+
letter-spacing: 0.08em;
|
|
465
|
+
text-transform: uppercase;
|
|
466
|
+
}
|
|
467
|
+
.bubble.md a { color: var(--amber); }
|
|
468
|
+
.bubble.md blockquote {
|
|
469
|
+
border-left: 2px solid var(--amber);
|
|
470
|
+
padding-left: 10px;
|
|
471
|
+
color: var(--text-dim);
|
|
472
|
+
margin: 6px 0;
|
|
473
|
+
}
|
|
474
|
+
.turn.system .bubble {
|
|
475
|
+
background: none;
|
|
476
|
+
border: none;
|
|
477
|
+
color: var(--text-dim);
|
|
478
|
+
font-size: 12.5px;
|
|
479
|
+
font-style: italic;
|
|
480
|
+
padding: 2px 0;
|
|
481
|
+
}
|
|
482
|
+
/* ── Activity block ──────────────────────────────────────
|
|
483
|
+
A run of tool calls between two pieces of assistant text. Folds at three
|
|
484
|
+
levels — output, same-tool run, whole block — each driven by data-folded.
|
|
485
|
+
Anything containing an error carries .has-err and never folds. */
|
|
486
|
+
.turn.activity { max-width: 82%; gap: 0; }
|
|
487
|
+
.act-body, .run-body, .item-out { display: flex; flex-direction: column; }
|
|
488
|
+
[data-folded="true"] > .act-body,
|
|
489
|
+
[data-folded="true"] > .run-body,
|
|
490
|
+
[data-folded="true"] > .item-out { display: none; }
|
|
491
|
+
/* A lone call needs no run header — it is its own row. */
|
|
492
|
+
.act-run[data-count="1"] > .run-head { display: none; }
|
|
493
|
+
|
|
494
|
+
/* Every fold toggle is a bare left-aligned mono line. */
|
|
495
|
+
.act-head, .run-head, .item-head {
|
|
496
|
+
display: flex;
|
|
497
|
+
align-items: baseline;
|
|
498
|
+
gap: 8px;
|
|
499
|
+
width: 100%;
|
|
500
|
+
text-align: left;
|
|
501
|
+
background: none;
|
|
502
|
+
border: none;
|
|
503
|
+
border-left: 2px solid var(--line);
|
|
504
|
+
border-radius: 0;
|
|
505
|
+
color: var(--text-dim);
|
|
506
|
+
font-family: var(--mono);
|
|
507
|
+
font-size: 12px;
|
|
508
|
+
letter-spacing: 0;
|
|
509
|
+
line-height: 1.5;
|
|
510
|
+
padding: 1px 0 1px 10px;
|
|
511
|
+
}
|
|
512
|
+
.act-head:hover, .run-head:hover, .item-head:hover {
|
|
513
|
+
color: var(--text);
|
|
514
|
+
border-left-color: var(--amber);
|
|
515
|
+
}
|
|
516
|
+
.act-head { color: var(--amber); }
|
|
517
|
+
.act-head .count { font-variant-numeric: tabular-nums; }
|
|
518
|
+
/* Tool name column, so a run of calls reads as a list rather than prose. */
|
|
519
|
+
.item-head .tool, .run-head .tool { flex: none; min-width: 62px; color: var(--text); }
|
|
520
|
+
.item-head .arg, .run-head .arg {
|
|
521
|
+
flex: 1;
|
|
522
|
+
min-width: 0;
|
|
523
|
+
overflow: hidden;
|
|
524
|
+
text-overflow: ellipsis;
|
|
525
|
+
white-space: nowrap;
|
|
526
|
+
}
|
|
527
|
+
.caret { flex: none; font-size: 9px; opacity: 0.7; }
|
|
528
|
+
.run-body { padding-left: 12px; }
|
|
529
|
+
/* Inside a run the tool name is already in the header — don't repeat it. */
|
|
530
|
+
.act-run:not([data-count="1"]) .item-head .tool { display: none; }
|
|
531
|
+
.item-head .err-flag { flex: none; color: var(--err); }
|
|
532
|
+
.has-err > .item-head, .has-err > .run-head { border-left-color: var(--err); }
|
|
533
|
+
|
|
534
|
+
/* Tool output. */
|
|
535
|
+
.result-body {
|
|
536
|
+
background: var(--bg-inset);
|
|
537
|
+
border: 1px solid var(--line);
|
|
538
|
+
border-left: 2px solid var(--text-dim);
|
|
539
|
+
border-radius: 0 4px 4px 0;
|
|
540
|
+
color: var(--text-dim);
|
|
541
|
+
font-family: var(--mono);
|
|
542
|
+
font-size: 11.5px;
|
|
543
|
+
line-height: 1.4;
|
|
544
|
+
padding: 8px 10px;
|
|
545
|
+
margin: 2px 0 2px 10px;
|
|
546
|
+
white-space: pre-wrap;
|
|
547
|
+
word-break: break-word;
|
|
548
|
+
overflow-x: auto;
|
|
549
|
+
}
|
|
550
|
+
.has-err > .item-out > .result-body { border-left-color: var(--err); color: var(--err); }
|
|
551
|
+
.result-more {
|
|
552
|
+
align-self: flex-start;
|
|
553
|
+
border: none;
|
|
554
|
+
background: none;
|
|
555
|
+
color: var(--amber);
|
|
556
|
+
font-size: 11px;
|
|
557
|
+
padding: 0 0 2px 10px;
|
|
558
|
+
}
|
|
559
|
+
.result-more:hover { text-decoration: underline; }
|
|
560
|
+
|
|
561
|
+
/* Choice dialog (TUI question rendered clickable) */
|
|
562
|
+
.turn.dialog .bubble {
|
|
563
|
+
background: var(--bg-inset);
|
|
564
|
+
border: 1px solid var(--amber);
|
|
565
|
+
display: flex;
|
|
566
|
+
flex-direction: column;
|
|
567
|
+
gap: 10px;
|
|
568
|
+
}
|
|
569
|
+
.turn.dialog .question { font-size: 14px; line-height: 1.5; }
|
|
570
|
+
.choice {
|
|
571
|
+
display: flex;
|
|
572
|
+
flex-direction: column;
|
|
573
|
+
align-items: flex-start;
|
|
574
|
+
gap: 3px;
|
|
575
|
+
text-align: left;
|
|
576
|
+
width: 100%;
|
|
577
|
+
padding: 9px 12px;
|
|
578
|
+
font-family: var(--sans);
|
|
579
|
+
letter-spacing: 0;
|
|
580
|
+
}
|
|
581
|
+
.choice .num { color: var(--amber); font-family: var(--mono); font-size: 12px; }
|
|
582
|
+
.choice .hint { color: var(--text-dim); font-size: 12.5px; }
|
|
583
|
+
.choice:disabled { opacity: 0.45; }
|
|
584
|
+
/* A pace bubble superseded by a newer one, or already acted on. */
|
|
585
|
+
.turn.dialog.spent .bubble { border-color: var(--line); opacity: 0.5; }
|
|
586
|
+
.turn.dialog.spent .label { text-decoration: line-through; }
|
|
587
|
+
.choice.chosen { border-color: var(--amber); color: var(--amber); opacity: 1; }
|
|
588
|
+
.choice.checked { border-color: var(--amber); }
|
|
589
|
+
.choice.checked .num { color: var(--amber); }
|
|
590
|
+
.validate { align-self: flex-end; margin-top: 2px; }
|
|
591
|
+
.freetext-row { display: flex; gap: 8px; width: 100%; }
|
|
592
|
+
.freetext-row input { flex: 1; }
|
|
593
|
+
#thinking { display: none; align-self: flex-start; color: var(--text-dim); font-family: var(--mono); font-size: 12.5px; padding: 0 22px 6px; }
|
|
594
|
+
#thinking.visible { display: block; }
|
|
595
|
+
|
|
596
|
+
/* Composer */
|
|
597
|
+
#composer {
|
|
598
|
+
display: flex;
|
|
599
|
+
gap: 10px;
|
|
600
|
+
padding: 14px 22px 18px;
|
|
601
|
+
border-top: 1px solid var(--line);
|
|
602
|
+
background: var(--bg-raised);
|
|
603
|
+
}
|
|
604
|
+
#composer textarea {
|
|
605
|
+
flex: 1;
|
|
606
|
+
font-family: var(--sans);
|
|
607
|
+
font-size: 14px;
|
|
608
|
+
background: var(--bg-inset);
|
|
609
|
+
color: var(--text);
|
|
610
|
+
border: 1px solid var(--line);
|
|
611
|
+
border-radius: 6px;
|
|
612
|
+
padding: 10px 12px;
|
|
613
|
+
resize: none;
|
|
614
|
+
min-height: 42px;
|
|
615
|
+
max-height: 160px;
|
|
616
|
+
line-height: 1.45;
|
|
617
|
+
}
|
|
618
|
+
#composer textarea:focus-visible { outline: 2px solid var(--amber); outline-offset: 1px; }
|
|
619
|
+
|
|
620
|
+
/* ── Engine room (live terminal) ─────────────────────── */
|
|
621
|
+
#machine {
|
|
622
|
+
width: 46%;
|
|
623
|
+
max-width: 760px;
|
|
624
|
+
border-left: 1px solid var(--line);
|
|
625
|
+
background: var(--bg-inset);
|
|
626
|
+
display: none;
|
|
627
|
+
flex-direction: column;
|
|
628
|
+
min-width: 0;
|
|
629
|
+
}
|
|
630
|
+
#machine.open { display: flex; }
|
|
631
|
+
#machine .mhead {
|
|
632
|
+
display: flex;
|
|
633
|
+
align-items: center;
|
|
634
|
+
justify-content: space-between;
|
|
635
|
+
padding: 8px 14px;
|
|
636
|
+
border-bottom: 1px solid var(--line);
|
|
637
|
+
}
|
|
638
|
+
#screen {
|
|
639
|
+
flex: 1;
|
|
640
|
+
overflow: auto;
|
|
641
|
+
padding: 14px;
|
|
642
|
+
font-family: var(--mono);
|
|
643
|
+
font-size: 11px;
|
|
644
|
+
line-height: 1.35;
|
|
645
|
+
white-space: pre;
|
|
646
|
+
color: var(--phosphor);
|
|
647
|
+
text-shadow: 0 0 8px rgba(255, 180, 92, 0.25);
|
|
648
|
+
}
|
|
649
|
+
.keys {
|
|
650
|
+
display: flex;
|
|
651
|
+
gap: 6px;
|
|
652
|
+
flex-wrap: wrap;
|
|
653
|
+
padding: 10px 14px;
|
|
654
|
+
border-top: 1px solid var(--line);
|
|
655
|
+
}
|
|
656
|
+
.keys button { padding: 5px 10px; font-size: 11px; background: var(--bg-raised); }
|
|
657
|
+
.keys .settle { margin-left: auto; color: var(--amber); }
|
|
658
|
+
|
|
659
|
+
/* Worktree checkbox */
|
|
660
|
+
.check { display: flex; gap: 8px; align-items: flex-start; font-size: 13px; cursor: pointer; }
|
|
661
|
+
.check input { accent-color: var(--amber); margin-top: 2px; }
|
|
662
|
+
.check-hint { color: var(--text-dim); font-size: 12px; }
|
|
663
|
+
|
|
664
|
+
/* Recover-past-sessions list */
|
|
665
|
+
.recover-list { display: flex; flex-direction: column; gap: 6px; max-height: 260px; overflow-y: auto; }
|
|
666
|
+
.recover-row {
|
|
667
|
+
display: flex;
|
|
668
|
+
align-items: center;
|
|
669
|
+
gap: 10px;
|
|
670
|
+
border: 1px solid var(--line);
|
|
671
|
+
border-radius: 4px;
|
|
672
|
+
padding: 8px 10px;
|
|
673
|
+
background: var(--bg-inset);
|
|
674
|
+
}
|
|
675
|
+
.recover-row .rmeta { flex: 1; min-width: 0; }
|
|
676
|
+
.recover-row .rprompt {
|
|
677
|
+
font-size: 13px;
|
|
678
|
+
color: var(--text);
|
|
679
|
+
white-space: nowrap;
|
|
680
|
+
overflow: hidden;
|
|
681
|
+
text-overflow: ellipsis;
|
|
682
|
+
}
|
|
683
|
+
.recover-row .rsub {
|
|
684
|
+
font-family: var(--mono);
|
|
685
|
+
font-size: 11px;
|
|
686
|
+
color: var(--text-dim);
|
|
687
|
+
/* Long worktree paths must ellipsize, not push the badge over the text. */
|
|
688
|
+
white-space: nowrap;
|
|
689
|
+
overflow: hidden;
|
|
690
|
+
text-overflow: ellipsis;
|
|
691
|
+
}
|
|
692
|
+
.recover-row .badge {
|
|
693
|
+
flex: none;
|
|
694
|
+
font-family: var(--mono);
|
|
695
|
+
font-size: 10px;
|
|
696
|
+
padding: 1px 6px;
|
|
697
|
+
border-radius: 3px;
|
|
698
|
+
border: 1px solid var(--amber);
|
|
699
|
+
color: var(--amber);
|
|
700
|
+
}
|
|
701
|
+
.recover-row .badge.empty { border-color: var(--line); color: var(--text-dim); }
|
|
702
|
+
.recover-row button { flex: none; }
|
|
703
|
+
.recover-empty { color: var(--text-dim); font-size: 12px; font-style: italic; }
|
|
704
|
+
|
|
705
|
+
/* Diff review panel */
|
|
706
|
+
#diffpanel {
|
|
707
|
+
width: 46%;
|
|
708
|
+
max-width: 760px;
|
|
709
|
+
border-left: 1px solid var(--line);
|
|
710
|
+
background: var(--bg-inset);
|
|
711
|
+
display: none;
|
|
712
|
+
flex-direction: column;
|
|
713
|
+
min-width: 0;
|
|
714
|
+
}
|
|
715
|
+
#diffpanel.open { display: flex; }
|
|
716
|
+
#diffpanel .mhead {
|
|
717
|
+
display: flex;
|
|
718
|
+
align-items: center;
|
|
719
|
+
justify-content: space-between;
|
|
720
|
+
padding: 8px 14px;
|
|
721
|
+
border-bottom: 1px solid var(--line);
|
|
722
|
+
}
|
|
723
|
+
#diffpanel .mhead button { margin-left: 6px; }
|
|
724
|
+
#diffStatus {
|
|
725
|
+
font-family: var(--mono);
|
|
726
|
+
font-size: 11.5px;
|
|
727
|
+
color: var(--text-dim);
|
|
728
|
+
padding: 8px 14px;
|
|
729
|
+
border-bottom: 1px solid var(--line);
|
|
730
|
+
white-space: pre-wrap;
|
|
731
|
+
max-height: 25%;
|
|
732
|
+
overflow-y: auto;
|
|
733
|
+
}
|
|
734
|
+
#diffStatus:empty { display: none; }
|
|
735
|
+
#diffBody {
|
|
736
|
+
flex: 1;
|
|
737
|
+
overflow: auto;
|
|
738
|
+
padding: 12px 14px;
|
|
739
|
+
margin: 0;
|
|
740
|
+
font-family: var(--mono);
|
|
741
|
+
font-size: 11.5px;
|
|
742
|
+
line-height: 1.45;
|
|
743
|
+
white-space: pre;
|
|
744
|
+
color: var(--text);
|
|
745
|
+
}
|
|
746
|
+
/* diff coloring */
|
|
747
|
+
#diffBody .add { color: var(--ok); }
|
|
748
|
+
#diffBody .del { color: var(--err); }
|
|
749
|
+
#diffBody .hunk { color: var(--amber); }
|
|
750
|
+
#diffBody .meta { color: var(--text-dim); }
|
|
751
|
+
|
|
752
|
+
@media (max-width: 900px) {
|
|
753
|
+
#machine, #diffpanel { position: fixed; inset: 0; width: auto; max-width: none; z-index: 10; border-left: none; }
|
|
754
|
+
}
|
|
755
|
+
#secretsOverlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; z-index: 50; }
|
|
756
|
+
#secretsPanel { background: var(--panel, #1b1d22); color: var(--fg, #e8e8ea); width: min(520px, 92vw); max-height: 80vh; overflow: auto; border-radius: 10px; padding: 16px 18px; box-shadow: 0 10px 40px rgba(0,0,0,.5); }
|
|
757
|
+
#secretsPanel .sec-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
|
758
|
+
#secretsPanel .sec-head button { background: none; border: none; color: inherit; font-size: 16px; cursor: pointer; }
|
|
759
|
+
#secretsPanel .sec-repo { font-size: 12px; opacity: .8; margin-bottom: 4px; word-break: break-all; }
|
|
760
|
+
#secretsPanel .sec-note { font-size: 12px; opacity: .65; margin: 6px 0 12px; }
|
|
761
|
+
#secretsList { list-style: none; padding: 0; margin: 0 0 12px; }
|
|
762
|
+
#secretsList li { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; border-radius: 6px; }
|
|
763
|
+
#secretsList li:nth-child(odd) { background: rgba(255,255,255,.04); }
|
|
764
|
+
#secretsList code { font-size: 13px; }
|
|
765
|
+
#secretsList button { background: none; border: none; color: #e26; cursor: pointer; font-size: 14px; }
|
|
766
|
+
#secretsAdd { display: flex; gap: 8px; }
|
|
767
|
+
#secretsAdd input { flex: 1; min-width: 0; padding: 6px 8px; border-radius: 6px; border: 1px solid rgba(255,255,255,.15); background: rgba(0,0,0,.25); color: inherit; }
|
|
768
|
+
#secretsAdd button { padding: 6px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,.15); background: rgba(255,255,255,.08); color: inherit; cursor: pointer; }
|
|
769
|
+
#secretsRestart { margin-top: 12px; width: 100%; padding: 8px; border-radius: 6px; border: 1px solid rgba(255,255,255,.15); background: rgba(255,255,255,.06); color: inherit; cursor: pointer; }
|
|
770
|
+
</style>
|
|
771
|
+
</head>
|
|
772
|
+
<body>
|
|
773
|
+
|
|
774
|
+
<header>
|
|
775
|
+
<span class="brand">shadok-ai</span>
|
|
776
|
+
<div class="gauge">
|
|
777
|
+
<span class="label">Status</span>
|
|
778
|
+
<span class="value"><span id="led" class="led"></span><span id="state">—</span></span>
|
|
779
|
+
</div>
|
|
780
|
+
<div class="gauge">
|
|
781
|
+
<span class="label">Directory</span>
|
|
782
|
+
<span class="value" id="cwdView">—</span>
|
|
783
|
+
</div>
|
|
784
|
+
<div class="gauge">
|
|
785
|
+
<span class="label">Session</span>
|
|
786
|
+
<span class="value" id="sessionView">—</span>
|
|
787
|
+
</div>
|
|
788
|
+
<div class="gauge" id="branchGauge" hidden>
|
|
789
|
+
<span class="label">Branch</span>
|
|
790
|
+
<span class="value" id="branchView">—</span>
|
|
791
|
+
</div>
|
|
792
|
+
<span class="spacer"></span>
|
|
793
|
+
<div class="gauge quota" id="quota5h" title="5-hour rolling limit">
|
|
794
|
+
<span class="label">5h</span>
|
|
795
|
+
<div class="meter usage"><span class="fill"></span></div>
|
|
796
|
+
<div class="meter pace"><span class="fill"></span></div>
|
|
797
|
+
<span class="qpct">—</span>
|
|
798
|
+
</div>
|
|
799
|
+
<div class="gauge quota" id="quota7d" title="7-day rolling limit">
|
|
800
|
+
<span class="label">7d</span>
|
|
801
|
+
<div class="meter usage"><span class="fill"></span></div>
|
|
802
|
+
<div class="meter pace"><span class="fill"></span></div>
|
|
803
|
+
<span class="qpct">—</span>
|
|
804
|
+
</div>
|
|
805
|
+
<button id="toggleDiff">Diff</button>
|
|
806
|
+
<button id="toggleMachine">Terminal</button>
|
|
807
|
+
<button id="secretsBtn" title="Repo secrets (injected as env)">Secrets</button>
|
|
808
|
+
<button id="stopBtn">End session</button>
|
|
809
|
+
</header>
|
|
810
|
+
|
|
811
|
+
<div id="secretsOverlay" hidden>
|
|
812
|
+
<div id="secretsPanel">
|
|
813
|
+
<div class="sec-head">
|
|
814
|
+
<strong>Secrets</strong>
|
|
815
|
+
<button id="secretsClose" title="Close">✕</button>
|
|
816
|
+
</div>
|
|
817
|
+
<div class="sec-repo">repo: <code id="secretsRepo">—</code></div>
|
|
818
|
+
<p class="sec-note">Injected as env vars into agents of this repo at spawn (stored 600 outside the repo). Values are write-only. Applies to newly spawned/restarted agents.</p>
|
|
819
|
+
<ul id="secretsList"></ul>
|
|
820
|
+
<form id="secretsAdd">
|
|
821
|
+
<input id="secretKey" placeholder="KEY" autocomplete="off" spellcheck="false" />
|
|
822
|
+
<input id="secretVal" placeholder="value" autocomplete="off" spellcheck="false" />
|
|
823
|
+
<button type="submit">Add</button>
|
|
824
|
+
</form>
|
|
825
|
+
<button id="secretsRestart" title="Restart this agent so it picks up the secrets">↻ Restart this agent to apply</button>
|
|
826
|
+
</div>
|
|
827
|
+
</div>
|
|
828
|
+
|
|
829
|
+
<main>
|
|
830
|
+
<nav id="tabbar" aria-label="Channels">
|
|
831
|
+
<div class="label side-label">Channels</div>
|
|
832
|
+
<div id="ungrouped"></div>
|
|
833
|
+
<div id="groups"></div>
|
|
834
|
+
<button id="newTab" title="New channel">+ new channel</button>
|
|
835
|
+
<button id="newGroup" title="New tab group">+ new group</button>
|
|
836
|
+
</nav>
|
|
837
|
+
|
|
838
|
+
<section class="chat-pane">
|
|
839
|
+
|
|
840
|
+
<div id="setup" hidden>
|
|
841
|
+
<h1>New link</h1>
|
|
842
|
+
<p class="hint">Starts a real Claude Code session, driven inside a
|
|
843
|
+
pseudo-terminal. Each channel is an independent session — open as many
|
|
844
|
+
channels as you want Claudes working in parallel.</p>
|
|
845
|
+
<div class="field">
|
|
846
|
+
<span class="label">Working directory</span>
|
|
847
|
+
<input type="text" id="cwdInput" placeholder="/path/to/project">
|
|
848
|
+
</div>
|
|
849
|
+
<div class="field">
|
|
850
|
+
<span class="label">Resume</span>
|
|
851
|
+
<div class="radio-row">
|
|
852
|
+
<label><input type="radio" name="mode" value="new" checked> new session</label>
|
|
853
|
+
<label><input type="radio" name="mode" value="continue"> latest in directory</label>
|
|
854
|
+
<label><input type="radio" name="mode" value="resume"> by id</label>
|
|
855
|
+
</div>
|
|
856
|
+
</div>
|
|
857
|
+
<div class="field" id="resumeField" hidden>
|
|
858
|
+
<span class="label">Session id</span>
|
|
859
|
+
<input type="text" id="resumeInput" placeholder="5fe046dd-…">
|
|
860
|
+
<span class="label">Sessions in this directory</span>
|
|
861
|
+
<div class="session-list" id="sessionList"></div>
|
|
862
|
+
</div>
|
|
863
|
+
<label class="check" id="worktreeField">
|
|
864
|
+
<input type="checkbox" id="worktreeInput">
|
|
865
|
+
<span>Isolate in a git worktree <span class="check-hint">— the agent edits a fresh branch; review & merge from the Diff panel (new sessions only)</span></span>
|
|
866
|
+
</label>
|
|
867
|
+
<button class="primary" id="startBtn">Start session</button>
|
|
868
|
+
<div class="field" id="liveField" hidden>
|
|
869
|
+
<span class="label">Agents running now</span>
|
|
870
|
+
<div class="recover-list" id="liveList"></div>
|
|
871
|
+
</div>
|
|
872
|
+
<div class="field" id="recoverField" hidden>
|
|
873
|
+
<span class="label">Reopen an unfinished session</span>
|
|
874
|
+
<div class="recover-list" id="recoverList"></div>
|
|
875
|
+
</div>
|
|
876
|
+
</div>
|
|
877
|
+
|
|
878
|
+
<div id="transcripts"></div>
|
|
879
|
+
<div id="thinking">Claude is working…</div>
|
|
880
|
+
|
|
881
|
+
<form id="composer" hidden>
|
|
882
|
+
<textarea id="promptInput" rows="1"
|
|
883
|
+
placeholder="Write your prompt — Enter to send, Shift+Enter for a new line"></textarea>
|
|
884
|
+
<button type="submit" class="primary" id="sendBtn">Send</button>
|
|
885
|
+
</form>
|
|
886
|
+
</section>
|
|
887
|
+
|
|
888
|
+
<aside id="machine" aria-label="Live terminal screen">
|
|
889
|
+
<div class="mhead">
|
|
890
|
+
<span class="label">Engine room — live TUI screen</span>
|
|
891
|
+
<button id="closeMachine">Close</button>
|
|
892
|
+
</div>
|
|
893
|
+
<pre id="screen"></pre>
|
|
894
|
+
<div class="keys" role="group" aria-label="Keys">
|
|
895
|
+
<button data-key="escape">Esc</button>
|
|
896
|
+
<button data-key="enter">Enter</button>
|
|
897
|
+
<button data-key="up">↑</button>
|
|
898
|
+
<button data-key="down">↓</button>
|
|
899
|
+
<button data-key="tab">Tab</button>
|
|
900
|
+
<button data-key="1">1</button>
|
|
901
|
+
<button data-key="2">2</button>
|
|
902
|
+
<button data-key="3">3</button>
|
|
903
|
+
<button data-key="y">y</button>
|
|
904
|
+
<button data-key="n">n</button>
|
|
905
|
+
<button class="settle" id="settleBtn" title="Waits for the current turn to finish and updates the chat">Resync chat</button>
|
|
906
|
+
</div>
|
|
907
|
+
</aside>
|
|
908
|
+
|
|
909
|
+
<aside id="diffpanel" aria-label="Session changes">
|
|
910
|
+
<div class="mhead">
|
|
911
|
+
<span class="label">Changes — <span id="diffBranch">working tree</span></span>
|
|
912
|
+
<span>
|
|
913
|
+
<button id="refreshDiff">Refresh</button>
|
|
914
|
+
<button id="closeDiff">Close</button>
|
|
915
|
+
</span>
|
|
916
|
+
</div>
|
|
917
|
+
<div id="diffStatus"></div>
|
|
918
|
+
<pre id="diffBody"></pre>
|
|
919
|
+
</aside>
|
|
920
|
+
</main>
|
|
921
|
+
|
|
922
|
+
<script src="/vendor/marked.js"></script>
|
|
923
|
+
<script>
|
|
924
|
+
const $ = (id) => document.getElementById(id);
|
|
925
|
+
const tabbar = $("tabbar"), newTabBtn = $("newTab"), newGroupBtn = $("newGroup");
|
|
926
|
+
const ungroupedEl = $("ungrouped"), groupsEl = $("groups");
|
|
927
|
+
const setup = $("setup"), transcripts = $("transcripts"), thinking = $("thinking");
|
|
928
|
+
const composer = $("composer"), promptInput = $("promptInput"), sendBtn = $("sendBtn");
|
|
929
|
+
const machine = $("machine"), screenEl = $("screen");
|
|
930
|
+
|
|
931
|
+
// ── Channels ────────────────────────────────────────────
|
|
932
|
+
// One channel = one WebSocket connection = one claude session server-side
|
|
933
|
+
// (sessions are shared: several clients can attach to the same id).
|
|
934
|
+
let tabs = [];
|
|
935
|
+
let active = null;
|
|
936
|
+
let seq = 0;
|
|
937
|
+
// The server's launch directory — the sensible default for new channels.
|
|
938
|
+
let serverCwd = "";
|
|
939
|
+
fetch("/defaults")
|
|
940
|
+
.then((r) => r.json())
|
|
941
|
+
.then((d) => {
|
|
942
|
+
serverCwd = d.cwd || "";
|
|
943
|
+
if (active && active.status === "setup" && !$("cwdInput").value) refreshChrome();
|
|
944
|
+
})
|
|
945
|
+
.catch(() => {});
|
|
946
|
+
|
|
947
|
+
function basename(p) {
|
|
948
|
+
const parts = p.replace(/\/+$/, "").split("/");
|
|
949
|
+
return parts[parts.length - 1] || p || "session";
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function createTab(groupId) {
|
|
953
|
+
seq++;
|
|
954
|
+
const el = document.createElement("div");
|
|
955
|
+
el.className = "transcript";
|
|
956
|
+
el.hidden = true;
|
|
957
|
+
transcripts.appendChild(el);
|
|
958
|
+
|
|
959
|
+
const btn = document.createElement("div");
|
|
960
|
+
btn.className = "tab";
|
|
961
|
+
btn.draggable = true;
|
|
962
|
+
const led = document.createElement("span");
|
|
963
|
+
led.className = "led";
|
|
964
|
+
const name = document.createElement("span");
|
|
965
|
+
name.className = "name";
|
|
966
|
+
name.textContent = "channel " + seq;
|
|
967
|
+
name.title = "Double-click to rename";
|
|
968
|
+
const timer = document.createElement("span");
|
|
969
|
+
timer.className = "timer";
|
|
970
|
+
timer.title = "Thinking time for the current turn";
|
|
971
|
+
const ctxNum = document.createElement("span");
|
|
972
|
+
ctxNum.className = "ctxnum";
|
|
973
|
+
const close = document.createElement("span");
|
|
974
|
+
close.className = "close";
|
|
975
|
+
close.textContent = "×";
|
|
976
|
+
close.title = "Close this channel";
|
|
977
|
+
btn.append(led, name, timer, ctxNum, close);
|
|
978
|
+
// Context-window usage: a thin progress bar at the bottom of the tab.
|
|
979
|
+
const ctxBar = document.createElement("div");
|
|
980
|
+
ctxBar.className = "ctxbar";
|
|
981
|
+
const ctxFill = document.createElement("span");
|
|
982
|
+
ctxFill.className = "ctxfill";
|
|
983
|
+
ctxBar.appendChild(ctxFill);
|
|
984
|
+
btn.appendChild(ctxBar);
|
|
985
|
+
const group = groupId != null && groups.find((g) => g.id === groupId);
|
|
986
|
+
(group ? group.bodyEl : ungroupedEl).appendChild(btn);
|
|
987
|
+
|
|
988
|
+
const tab = {
|
|
989
|
+
id: seq,
|
|
990
|
+
status: "setup", // setup | connecting | ready | busy | dead
|
|
991
|
+
stateText: "not configured",
|
|
992
|
+
ws: null,
|
|
993
|
+
cwd: "",
|
|
994
|
+
sessionId: null,
|
|
995
|
+
tokens: null,
|
|
996
|
+
screenText: "",
|
|
997
|
+
draft: "",
|
|
998
|
+
turnStart: null, // Date.now() while a turn's thinking timer runs
|
|
999
|
+
contextPct: null,
|
|
1000
|
+
transcriptEl: el,
|
|
1001
|
+
btnEl: btn, ledEl: led, nameEl: name, timerEl: timer,
|
|
1002
|
+
ctxNumEl: ctxNum, ctxFillEl: ctxFill,
|
|
1003
|
+
};
|
|
1004
|
+
btn._tab = tab;
|
|
1005
|
+
btn.addEventListener("click", (e) => {
|
|
1006
|
+
if (e.target === close) closeTab(tab);
|
|
1007
|
+
else activate(tab);
|
|
1008
|
+
});
|
|
1009
|
+
name.addEventListener("dblclick", (e) => {
|
|
1010
|
+
e.stopPropagation();
|
|
1011
|
+
startRename(tab);
|
|
1012
|
+
});
|
|
1013
|
+
btn.addEventListener("dragstart", (e) => {
|
|
1014
|
+
e.dataTransfer.effectAllowed = "move";
|
|
1015
|
+
e.dataTransfer.setData("text/plain", "tab");
|
|
1016
|
+
dragTabEl = btn;
|
|
1017
|
+
document.body.classList.add("dragging-tab");
|
|
1018
|
+
requestAnimationFrame(() => btn.classList.add("dragging"));
|
|
1019
|
+
});
|
|
1020
|
+
btn.addEventListener("dragend", () => endDrag());
|
|
1021
|
+
tabs.push(tab);
|
|
1022
|
+
activate(tab);
|
|
1023
|
+
return tab;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Custom names, remembered by session id.
|
|
1027
|
+
const savedNames = JSON.parse(localStorage.getItem("cp.names") || "{}");
|
|
1028
|
+
function saveName(tab, name) {
|
|
1029
|
+
if (!tab.sessionId) return;
|
|
1030
|
+
savedNames[tab.sessionId] = name;
|
|
1031
|
+
localStorage.setItem("cp.names", JSON.stringify(savedNames));
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Composer drafts, one per channel, remembered by session id.
|
|
1035
|
+
const savedDrafts = JSON.parse(localStorage.getItem("cp.drafts") || "{}");
|
|
1036
|
+
function persistDraft(tab) {
|
|
1037
|
+
if (!tab.sessionId) return;
|
|
1038
|
+
clearTimeout(tab.draftTimer);
|
|
1039
|
+
tab.draftTimer = setTimeout(() => {
|
|
1040
|
+
if (tab.draft) savedDrafts[tab.sessionId] = tab.draft;
|
|
1041
|
+
else delete savedDrafts[tab.sessionId];
|
|
1042
|
+
localStorage.setItem("cp.drafts", JSON.stringify(savedDrafts));
|
|
1043
|
+
}, 300);
|
|
1044
|
+
}
|
|
1045
|
+
function dropDraft(tab) {
|
|
1046
|
+
clearTimeout(tab.draftTimer);
|
|
1047
|
+
tab.draft = "";
|
|
1048
|
+
if (tab.sessionId && tab.sessionId in savedDrafts) {
|
|
1049
|
+
delete savedDrafts[tab.sessionId];
|
|
1050
|
+
localStorage.setItem("cp.drafts", JSON.stringify(savedDrafts));
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Generic inline rename: swaps `nameEl` for a text input inside `hostEl`
|
|
1056
|
+
* (whose dragging is suspended so text selection doesn't start a drag),
|
|
1057
|
+
* calls `onCommit(value)` on Enter/blur.
|
|
1058
|
+
*/
|
|
1059
|
+
function inlineRename(hostEl, nameEl, onCommit) {
|
|
1060
|
+
const wasDraggable = hostEl.draggable;
|
|
1061
|
+
hostEl.draggable = false;
|
|
1062
|
+
const inp = document.createElement("input");
|
|
1063
|
+
inp.type = "text";
|
|
1064
|
+
inp.className = "rename";
|
|
1065
|
+
inp.value = nameEl.textContent;
|
|
1066
|
+
nameEl.replaceWith(inp);
|
|
1067
|
+
inp.focus();
|
|
1068
|
+
inp.select();
|
|
1069
|
+
let done = false;
|
|
1070
|
+
const commit = (cancel) => {
|
|
1071
|
+
if (done) return;
|
|
1072
|
+
done = true;
|
|
1073
|
+
const v = inp.value.trim();
|
|
1074
|
+
if (!cancel && v) onCommit(v);
|
|
1075
|
+
inp.replaceWith(nameEl);
|
|
1076
|
+
hostEl.draggable = wasDraggable;
|
|
1077
|
+
};
|
|
1078
|
+
inp.addEventListener("keydown", (e) => {
|
|
1079
|
+
e.stopPropagation();
|
|
1080
|
+
if (e.key === "Enter") { e.preventDefault(); commit(false); }
|
|
1081
|
+
if (e.key === "Escape") commit(true);
|
|
1082
|
+
});
|
|
1083
|
+
inp.addEventListener("blur", () => commit(false));
|
|
1084
|
+
inp.addEventListener("click", (e) => e.stopPropagation());
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** Inline rename of a channel (double-click on its name). */
|
|
1088
|
+
function startRename(tab) {
|
|
1089
|
+
inlineRename(tab.btnEl, tab.nameEl, (v) => {
|
|
1090
|
+
tab.customName = true;
|
|
1091
|
+
tab.nameEl.textContent = v;
|
|
1092
|
+
saveName(tab, v);
|
|
1093
|
+
persistChannels();
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function closeTab(tab) {
|
|
1098
|
+
// Full close: end the channel everywhere (stop the agent, archive its
|
|
1099
|
+
// Telegram topic, drop it from the registry) — the server does that on
|
|
1100
|
+
// `stop`. Then remove the tab locally. `dismissed` stops the sync poll from
|
|
1101
|
+
// re-adding it in the brief window before the registry updates.
|
|
1102
|
+
if (tab.sessionId) dismissedChannels.add(tab.sessionId);
|
|
1103
|
+
if (tab.ws && tab.ws.readyState === WebSocket.OPEN) {
|
|
1104
|
+
tab.ws.send(JSON.stringify({ type: "stop" }));
|
|
1105
|
+
}
|
|
1106
|
+
removeTabLocal(tab);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Remembers the open channels (session id, directory, name, group) so
|
|
1111
|
+
* they can be restored — each session resumed — on the next page load.
|
|
1112
|
+
* DOM order is the source of truth for tab & group ordering.
|
|
1113
|
+
*/
|
|
1114
|
+
function persistChannels() {
|
|
1115
|
+
const list = [...tabbar.querySelectorAll(".tab")]
|
|
1116
|
+
.map((el) => el._tab)
|
|
1117
|
+
.filter((t) => t && t.sessionId)
|
|
1118
|
+
.map((t) => ({
|
|
1119
|
+
sessionId: t.sessionId,
|
|
1120
|
+
cwd: t.cwd && t.cwd.startsWith("/") ? t.cwd : "",
|
|
1121
|
+
name: t.nameEl.textContent,
|
|
1122
|
+
branch: t.branch || null,
|
|
1123
|
+
repo: serverCwd || "",
|
|
1124
|
+
group: groupIdOf(t.btnEl),
|
|
1125
|
+
}));
|
|
1126
|
+
localStorage.setItem("cp.channels", JSON.stringify(list));
|
|
1127
|
+
persistGroups();
|
|
1128
|
+
pushChannels(list);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// The channel list is mirrored server-side (keyed by the server's launch
|
|
1132
|
+
// directory), so it survives a wiped browser, another device or a reboot.
|
|
1133
|
+
let channelPushTimer = null;
|
|
1134
|
+
let restoring = true; // suppress pushes until the initial restore is done
|
|
1135
|
+
function pushChannels(list) {
|
|
1136
|
+
if (restoring) return; // never overwrite the server list with a partial one
|
|
1137
|
+
clearTimeout(channelPushTimer);
|
|
1138
|
+
channelPushTimer = setTimeout(() => {
|
|
1139
|
+
fetch("/channels", {
|
|
1140
|
+
method: "PUT",
|
|
1141
|
+
headers: { "Content-Type": "application/json" },
|
|
1142
|
+
body: JSON.stringify(list),
|
|
1143
|
+
}).catch(() => {});
|
|
1144
|
+
}, 400);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/** Drops a session id from the remembered channels (invalid / gone). */
|
|
1148
|
+
function forgetChannel(sessionId) {
|
|
1149
|
+
const list = JSON.parse(localStorage.getItem("cp.channels") || "[]").filter(
|
|
1150
|
+
(s) => s.sessionId !== sessionId,
|
|
1151
|
+
);
|
|
1152
|
+
localStorage.setItem("cp.channels", JSON.stringify(list));
|
|
1153
|
+
pushChannels(list);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// ── Tab groups ──────────────────────────────────────────
|
|
1157
|
+
let groups = [];
|
|
1158
|
+
let groupSeq = Number(localStorage.getItem("cp.groupSeq") || 0);
|
|
1159
|
+
|
|
1160
|
+
function groupIdOf(tabEl) {
|
|
1161
|
+
const g = tabEl.closest(".group");
|
|
1162
|
+
return g ? g._group.id : null;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
let groupPushTimer = null;
|
|
1166
|
+
function persistGroups() {
|
|
1167
|
+
const list = [...groupsEl.children].map((el) => ({
|
|
1168
|
+
id: el._group.id,
|
|
1169
|
+
name: el._group.nameEl.textContent,
|
|
1170
|
+
collapsed: el.classList.contains("collapsed"),
|
|
1171
|
+
}));
|
|
1172
|
+
localStorage.setItem("cp.groups", JSON.stringify(list));
|
|
1173
|
+
localStorage.setItem("cp.groupSeq", String(groupSeq));
|
|
1174
|
+
if (restoring) return; // don't overwrite the server set mid-restore
|
|
1175
|
+
clearTimeout(groupPushTimer);
|
|
1176
|
+
groupPushTimer = setTimeout(() => {
|
|
1177
|
+
fetch("/groups", {
|
|
1178
|
+
method: "PUT",
|
|
1179
|
+
headers: { "Content-Type": "application/json" },
|
|
1180
|
+
body: JSON.stringify(list),
|
|
1181
|
+
}).catch(() => {});
|
|
1182
|
+
}, 400);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function createGroup(opts = {}) {
|
|
1186
|
+
const id = opts.id != null ? opts.id : ++groupSeq;
|
|
1187
|
+
groupSeq = Math.max(groupSeq, id);
|
|
1188
|
+
const el = document.createElement("div");
|
|
1189
|
+
el.className = "group" + (opts.collapsed ? " collapsed" : "");
|
|
1190
|
+
const head = document.createElement("div");
|
|
1191
|
+
head.className = "group-head";
|
|
1192
|
+
head.draggable = true;
|
|
1193
|
+
const caret = document.createElement("span");
|
|
1194
|
+
caret.className = "caret";
|
|
1195
|
+
const gname = document.createElement("span");
|
|
1196
|
+
gname.className = "gname";
|
|
1197
|
+
gname.textContent = opts.name || "group " + id;
|
|
1198
|
+
gname.title = "Double-click to rename";
|
|
1199
|
+
const gclose = document.createElement("span");
|
|
1200
|
+
gclose.className = "gclose";
|
|
1201
|
+
gclose.textContent = "×";
|
|
1202
|
+
gclose.title = "Ungroup (tabs are kept)";
|
|
1203
|
+
head.append(caret, gname, gclose);
|
|
1204
|
+
const body = document.createElement("div");
|
|
1205
|
+
body.className = "group-body";
|
|
1206
|
+
el.append(head, body);
|
|
1207
|
+
groupsEl.appendChild(el);
|
|
1208
|
+
|
|
1209
|
+
const group = { id, el, headEl: head, bodyEl: body, nameEl: gname };
|
|
1210
|
+
el._group = group;
|
|
1211
|
+
const paintCaret = () => {
|
|
1212
|
+
caret.textContent = el.classList.contains("collapsed") ? "▸" : "▾";
|
|
1213
|
+
};
|
|
1214
|
+
paintCaret();
|
|
1215
|
+
head.addEventListener("click", (e) => {
|
|
1216
|
+
if (e.target === gclose) return dissolveGroup(group);
|
|
1217
|
+
el.classList.toggle("collapsed");
|
|
1218
|
+
paintCaret();
|
|
1219
|
+
persistGroups();
|
|
1220
|
+
});
|
|
1221
|
+
gname.addEventListener("dblclick", (e) => {
|
|
1222
|
+
e.stopPropagation();
|
|
1223
|
+
inlineRename(head, gname, (v) => {
|
|
1224
|
+
gname.textContent = v;
|
|
1225
|
+
persistGroups();
|
|
1226
|
+
});
|
|
1227
|
+
});
|
|
1228
|
+
head.addEventListener("dragstart", (e) => {
|
|
1229
|
+
e.dataTransfer.effectAllowed = "move";
|
|
1230
|
+
e.dataTransfer.setData("text/plain", "group");
|
|
1231
|
+
dragGroupEl = el;
|
|
1232
|
+
requestAnimationFrame(() => el.classList.add("dragging"));
|
|
1233
|
+
});
|
|
1234
|
+
head.addEventListener("dragend", () => endDrag());
|
|
1235
|
+
groups.push(group);
|
|
1236
|
+
return group;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** Removes a group; its tabs move back to the ungrouped zone. */
|
|
1240
|
+
function dissolveGroup(group) {
|
|
1241
|
+
while (group.bodyEl.firstChild) ungroupedEl.appendChild(group.bodyEl.firstChild);
|
|
1242
|
+
group.el.remove();
|
|
1243
|
+
groups = groups.filter((g) => g !== group);
|
|
1244
|
+
persistChannels();
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// ── Drag & drop (tabs + groups, live-sort while dragging) ──
|
|
1248
|
+
let dragTabEl = null;
|
|
1249
|
+
let dragGroupEl = null;
|
|
1250
|
+
|
|
1251
|
+
function endDrag() {
|
|
1252
|
+
if (dragTabEl) dragTabEl.classList.remove("dragging");
|
|
1253
|
+
if (dragGroupEl) dragGroupEl.classList.remove("dragging");
|
|
1254
|
+
dragTabEl = dragGroupEl = null;
|
|
1255
|
+
document.body.classList.remove("dragging-tab");
|
|
1256
|
+
persistChannels();
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
tabbar.addEventListener("dragover", (e) => {
|
|
1260
|
+
if (!dragTabEl && !dragGroupEl) return;
|
|
1261
|
+
e.preventDefault();
|
|
1262
|
+
e.dataTransfer.dropEffect = "move";
|
|
1263
|
+
if (dragTabEl) {
|
|
1264
|
+
const overTab = e.target.closest(".tab");
|
|
1265
|
+
if (overTab && overTab !== dragTabEl) {
|
|
1266
|
+
const r = overTab.getBoundingClientRect();
|
|
1267
|
+
const after = e.clientY > r.top + r.height / 2;
|
|
1268
|
+
overTab.parentNode.insertBefore(dragTabEl, after ? overTab.nextSibling : overTab);
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
const head = e.target.closest(".group-head");
|
|
1272
|
+
if (head) {
|
|
1273
|
+
const g = head.parentNode;
|
|
1274
|
+
// Dropping on a header files the tab into that group; unfold it so
|
|
1275
|
+
// the tab stays visible.
|
|
1276
|
+
g.classList.remove("collapsed");
|
|
1277
|
+
g._group.headEl.querySelector(".caret").textContent = "▾";
|
|
1278
|
+
if (dragTabEl.parentNode !== g._group.bodyEl)
|
|
1279
|
+
g._group.bodyEl.appendChild(dragTabEl);
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
const zone = e.target.closest("#ungrouped, .group-body");
|
|
1283
|
+
if (zone && dragTabEl.parentNode !== zone) zone.appendChild(dragTabEl);
|
|
1284
|
+
} else if (dragGroupEl) {
|
|
1285
|
+
const overGroup = e.target.closest(".group");
|
|
1286
|
+
if (overGroup && overGroup !== dragGroupEl) {
|
|
1287
|
+
const r = overGroup.getBoundingClientRect();
|
|
1288
|
+
const after = e.clientY > r.top + r.height / 2;
|
|
1289
|
+
groupsEl.insertBefore(dragGroupEl, after ? overGroup.nextSibling : overGroup);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
});
|
|
1293
|
+
tabbar.addEventListener("drop", (e) => {
|
|
1294
|
+
if (dragTabEl || dragGroupEl) e.preventDefault();
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
/** State line shown while the auto-continue waits for the pace to recover. */
|
|
1298
|
+
const PACE_HOLD_STATE = "waiting on pace";
|
|
1299
|
+
|
|
1300
|
+
function setTabState(tab, status, text) {
|
|
1301
|
+
// A pause of the auto-continue can end without a "pace-resumed": a takeover
|
|
1302
|
+
// cancels the pending retry ("auto-retry-cancelled"), and the parked
|
|
1303
|
+
// callback stands down silently when the turn started on its own
|
|
1304
|
+
// ("working") or the session died ("exited"/"stopped"/"gone"). Every one of
|
|
1305
|
+
// those paths sets the tab state, so hanging the pause indication on the
|
|
1306
|
+
// state line — and dropping it here on any other transition — is what keeps
|
|
1307
|
+
// it from being orphaned. No extra server message, no polling.
|
|
1308
|
+
if (tab.paceHeld && text !== PACE_HOLD_STATE) tab.paceHeld = false;
|
|
1309
|
+
tab.status = status;
|
|
1310
|
+
tab.stateText = text;
|
|
1311
|
+
const ledClass = { setup: "", connecting: "busy", ready: "on", busy: "busy", dead: "off" }[status];
|
|
1312
|
+
tab.ledEl.className = "led " + ledClass;
|
|
1313
|
+
if (tab === active) refreshChrome();
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/** Channel highlight: "working" | "needs-answer" | "unread" | null. */
|
|
1317
|
+
function setTabMood(tab, mood) {
|
|
1318
|
+
tab.btnEl.classList.toggle("working", mood === "working");
|
|
1319
|
+
tab.btnEl.classList.toggle("needs-answer", mood === "needs-answer");
|
|
1320
|
+
tab.btnEl.classList.toggle("unread", mood === "unread");
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// ── Thinking timer (one per channel, shown in its tab) ──
|
|
1324
|
+
function fmtElapsed(ms) {
|
|
1325
|
+
const s = Math.floor(ms / 1000);
|
|
1326
|
+
if (s < 60) return s + "s";
|
|
1327
|
+
return Math.floor(s / 60) + "m" + String(s % 60).padStart(2, "0");
|
|
1328
|
+
}
|
|
1329
|
+
/** (Re)starts the timer — called when a prompt leaves for this channel. */
|
|
1330
|
+
function startTurnTimer(tab, startedAt) {
|
|
1331
|
+
// The server's timestamp (when provided) survives page reloads and
|
|
1332
|
+
// covers turns started by other clients.
|
|
1333
|
+
tab.turnStart = startedAt || Date.now();
|
|
1334
|
+
tab.timerEl.classList.remove("done");
|
|
1335
|
+
tab.timerEl.textContent = fmtElapsed(Date.now() - tab.turnStart);
|
|
1336
|
+
}
|
|
1337
|
+
/** Shows a finished turn's duration, dimmed, until the next send. */
|
|
1338
|
+
function showFrozenTime(tab, ms) {
|
|
1339
|
+
tab.timerEl.textContent = fmtElapsed(ms);
|
|
1340
|
+
tab.timerEl.classList.add("done");
|
|
1341
|
+
}
|
|
1342
|
+
/** Freezes the timer; the final time stays dimmed until the next send. */
|
|
1343
|
+
function stopTurnTimer(tab) {
|
|
1344
|
+
if (tab.turnStart) {
|
|
1345
|
+
showFrozenTime(tab, Date.now() - tab.turnStart);
|
|
1346
|
+
tab.turnStart = null;
|
|
1347
|
+
} else tab.timerEl.classList.add("done");
|
|
1348
|
+
}
|
|
1349
|
+
setInterval(() => {
|
|
1350
|
+
for (const t of tabs) {
|
|
1351
|
+
if (t.turnStart) t.timerEl.textContent = fmtElapsed(Date.now() - t.turnStart);
|
|
1352
|
+
}
|
|
1353
|
+
}, 1000);
|
|
1354
|
+
|
|
1355
|
+
// Session ids the user closed here, so the sync poll doesn't re-add them (a
|
|
1356
|
+
// Telegram/live channel survives a client PUT, so it stays in the registry).
|
|
1357
|
+
const dismissedChannels = new Set();
|
|
1358
|
+
|
|
1359
|
+
// Removes a tab locally (no PUT/dismiss) — used when the session ended
|
|
1360
|
+
// elsewhere (another browser, Telegram /end, a closed topic).
|
|
1361
|
+
function removeTabLocal(tab) {
|
|
1362
|
+
if (tab.ws && tab.ws.readyState === WebSocket.OPEN) tab.ws.close();
|
|
1363
|
+
dropDraft(tab);
|
|
1364
|
+
tab.btnEl.remove();
|
|
1365
|
+
tab.transcriptEl.remove();
|
|
1366
|
+
tabs = tabs.filter((t) => t !== tab);
|
|
1367
|
+
refreshTitle();
|
|
1368
|
+
if (active === tab) {
|
|
1369
|
+
if (tabs.length) activate(tabs[tabs.length - 1]);
|
|
1370
|
+
else createTab();
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// Reconcile the open tabs with the server registry every few seconds — the ONE
|
|
1375
|
+
// source of truth. A new session (e.g. a Telegram /spawn) shows up as a tab
|
|
1376
|
+
// live; a renamed channel renames its tab; a channel that left the registry
|
|
1377
|
+
// (ended elsewhere) closes its tab.
|
|
1378
|
+
let lastChannelIds = new Set();
|
|
1379
|
+
async function syncChannels() {
|
|
1380
|
+
if (restoring) return;
|
|
1381
|
+
let chans;
|
|
1382
|
+
try { chans = await (await fetch("/channels")).json(); } catch { return; }
|
|
1383
|
+
if (!Array.isArray(chans)) return;
|
|
1384
|
+
const current = new Set(chans.map((c) => c.sessionId).filter(Boolean));
|
|
1385
|
+
const tabById = new Map(tabs.filter((t) => t.sessionId).map((t) => [t.sessionId, t]));
|
|
1386
|
+
for (const c of chans) {
|
|
1387
|
+
if (!c.sessionId) continue;
|
|
1388
|
+
const t = tabById.get(c.sessionId);
|
|
1389
|
+
if (t) {
|
|
1390
|
+
if (c.name && c.name !== t.nameEl.textContent && !t.btnEl.querySelector("input.rename")) {
|
|
1391
|
+
t.customName = true;
|
|
1392
|
+
t.nameEl.textContent = c.name;
|
|
1393
|
+
saveName(t, c.name);
|
|
1394
|
+
}
|
|
1395
|
+
} else if (!dismissedChannels.has(c.sessionId)) {
|
|
1396
|
+
const nt = createTab(c.group);
|
|
1397
|
+
nt.sessionId = c.sessionId;
|
|
1398
|
+
if (c.branch) nt.branch = c.branch;
|
|
1399
|
+
if (c.name) {
|
|
1400
|
+
nt.customName = true;
|
|
1401
|
+
nt.nameEl.textContent = c.name;
|
|
1402
|
+
}
|
|
1403
|
+
launchTab(nt, {
|
|
1404
|
+
cwd: c.cwd,
|
|
1405
|
+
resume: c.sessionId,
|
|
1406
|
+
branch: c.branch || undefined,
|
|
1407
|
+
repo: c.repo || serverCwd || undefined,
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
// A channel that was in the registry and is now gone was ended elsewhere.
|
|
1412
|
+
for (const t of [...tabs]) {
|
|
1413
|
+
if (t.sessionId && lastChannelIds.has(t.sessionId) && !current.has(t.sessionId)) {
|
|
1414
|
+
removeTabLocal(t);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
lastChannelIds = current;
|
|
1418
|
+
}
|
|
1419
|
+
setInterval(syncChannels, 4000);
|
|
1420
|
+
|
|
1421
|
+
function activate(tab) {
|
|
1422
|
+
if (active && active !== tab) {
|
|
1423
|
+
active.draft = promptInput.value;
|
|
1424
|
+
persistDraft(active);
|
|
1425
|
+
}
|
|
1426
|
+
active = tab;
|
|
1427
|
+
// Viewing the channel clears its "unread response" highlight.
|
|
1428
|
+
if (tab.btnEl.classList.contains("unread")) setTabMood(tab, null);
|
|
1429
|
+
tabs.forEach((t) => {
|
|
1430
|
+
t.btnEl.classList.toggle("active", t === tab);
|
|
1431
|
+
t.transcriptEl.hidden = t !== tab;
|
|
1432
|
+
});
|
|
1433
|
+
promptInput.value = tab.draft;
|
|
1434
|
+
autosizePrompt();
|
|
1435
|
+
refreshChrome();
|
|
1436
|
+
if (tab.status !== "setup") {
|
|
1437
|
+
tab.transcriptEl.scrollTop = tab.transcriptEl.scrollHeight;
|
|
1438
|
+
promptInput.focus();
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// ── Window title: tokens consumed ───────────────────────
|
|
1443
|
+
const BASE_TITLE = document.title;
|
|
1444
|
+
|
|
1445
|
+
function fmtTokens(n) {
|
|
1446
|
+
if (n >= 1e6) return (n / 1e6).toFixed(n >= 1e7 ? 0 : 1) + "M";
|
|
1447
|
+
if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + "k";
|
|
1448
|
+
return String(n);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* Shows the tokens consumed by the open channels in the tab title.
|
|
1453
|
+
* Counts fresh input + cache writes + output; cache READS are excluded —
|
|
1454
|
+
* they re-bill the whole context on every turn and would drown the number.
|
|
1455
|
+
*/
|
|
1456
|
+
function refreshTitle() {
|
|
1457
|
+
let sum = 0;
|
|
1458
|
+
for (const t of tabs) {
|
|
1459
|
+
if (t.tokens) sum += t.tokens.input + t.tokens.cacheCreation + t.tokens.output;
|
|
1460
|
+
}
|
|
1461
|
+
document.title = sum ? fmtTokens(sum) + " tok — shadok-ai" : BASE_TITLE;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/** Keeps header, setup, composer and engine room in sync with the active channel. */
|
|
1465
|
+
function refreshChrome() {
|
|
1466
|
+
const t = active;
|
|
1467
|
+
$("led").className = "led " +
|
|
1468
|
+
({ setup: "", connecting: "busy", ready: "on", busy: "busy", dead: "off" }[t.status]);
|
|
1469
|
+
$("state").textContent = t.stateText;
|
|
1470
|
+
$("cwdView").textContent = t.cwd || "—";
|
|
1471
|
+
$("sessionView").textContent = t.sessionId || "—";
|
|
1472
|
+
$("branchGauge").hidden = !t.branch;
|
|
1473
|
+
$("branchView").textContent = t.branch || "—";
|
|
1474
|
+
setup.hidden = t.status !== "setup";
|
|
1475
|
+
composer.hidden = t.status === "setup";
|
|
1476
|
+
sendBtn.disabled = t.status !== "ready" || !!t.pendingChoices;
|
|
1477
|
+
thinking.classList.toggle("visible", t.status === "busy");
|
|
1478
|
+
screenEl.textContent = t.screenText;
|
|
1479
|
+
if (t.status === "setup") {
|
|
1480
|
+
// Only pre-fill when empty — never clobber what the user is typing.
|
|
1481
|
+
if (!$("cwdInput").value) {
|
|
1482
|
+
$("cwdInput").value =
|
|
1483
|
+
t.cwd || localStorage.getItem("cp.cwd") || serverCwd || "";
|
|
1484
|
+
}
|
|
1485
|
+
const last = localStorage.getItem("cp.session");
|
|
1486
|
+
if (last && !$("resumeInput").value) $("resumeInput").value = last;
|
|
1487
|
+
$("startBtn").disabled = false;
|
|
1488
|
+
refreshRecoverList();
|
|
1489
|
+
refreshLiveList();
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
/** Paints a channel's context-window usage bar + tiny number in its tab. */
|
|
1494
|
+
function renderContext(tab) {
|
|
1495
|
+
const p = tab.contextPct;
|
|
1496
|
+
if (p == null) {
|
|
1497
|
+
tab.ctxNumEl.textContent = "";
|
|
1498
|
+
tab.ctxFillEl.style.width = "0%";
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
tab.ctxNumEl.textContent = p + "%";
|
|
1502
|
+
tab.ctxFillEl.style.width = Math.min(100, p) + "%";
|
|
1503
|
+
tab.ctxFillEl.classList.toggle("warn", p >= 60 && p < 85);
|
|
1504
|
+
tab.ctxFillEl.classList.toggle("crit", p >= 85);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
function addTurn(tab, role, who, text, extraClass) {
|
|
1508
|
+
const turn = document.createElement("div");
|
|
1509
|
+
turn.className = "turn " + role + (extraClass ? " " + extraClass : "");
|
|
1510
|
+
if (role !== "system") {
|
|
1511
|
+
const label = document.createElement("span");
|
|
1512
|
+
label.className = "label";
|
|
1513
|
+
label.textContent = who;
|
|
1514
|
+
turn.appendChild(label);
|
|
1515
|
+
}
|
|
1516
|
+
const bubble = document.createElement("div");
|
|
1517
|
+
bubble.className = "bubble";
|
|
1518
|
+
// Claude content (streamed live or replayed history) is raw markdown from
|
|
1519
|
+
// the .jsonl → render to HTML (tables, code, wrapping).
|
|
1520
|
+
if (role === "claude" && (extraClass === "hist" || extraClass === "live") && window.marked) {
|
|
1521
|
+
bubble.classList.add("md");
|
|
1522
|
+
bubble.innerHTML = marked.parse(text);
|
|
1523
|
+
} else {
|
|
1524
|
+
bubble.textContent = text;
|
|
1525
|
+
}
|
|
1526
|
+
turn.appendChild(bubble);
|
|
1527
|
+
tab.transcriptEl.appendChild(turn);
|
|
1528
|
+
if (tab === active) tab.transcriptEl.scrollTop = tab.transcriptEl.scrollHeight;
|
|
1529
|
+
return bubble;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/* ── Activity log ────────────────────────────────────────
|
|
1533
|
+
Tool traffic is grouped instead of stacked. A *block* is a run of tool
|
|
1534
|
+
events between two pieces of assistant text; inside it, consecutive calls
|
|
1535
|
+
to the same tool condense into a *run*; each call hides its own output.
|
|
1536
|
+
At turn-done every block folds — except any that saw an error.
|
|
1537
|
+
|
|
1538
|
+
All fold state lives on `tab.act` (current block) and `tab.turnBlocks`
|
|
1539
|
+
(blocks awaiting the end-of-turn fold). */
|
|
1540
|
+
|
|
1541
|
+
/** Lines of tool output shown before "+ N more lines". */
|
|
1542
|
+
const RESULT_LINES = 12;
|
|
1543
|
+
|
|
1544
|
+
function setFolded(el, folded) {
|
|
1545
|
+
el.dataset.folded = folded ? "true" : "false";
|
|
1546
|
+
const caret = el.querySelector(":scope > .act-head > .caret, :scope > .run-head > .caret, :scope > .item-head > .caret");
|
|
1547
|
+
if (caret) caret.textContent = folded ? "▸" : "▾";
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
function toggleFold(el) {
|
|
1551
|
+
setFolded(el, el.dataset.folded !== "true");
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/** Marks an element and its ancestors up to the block as containing an error. */
|
|
1555
|
+
function flagError(el) {
|
|
1556
|
+
for (let n = el; n; n = n.parentElement?.closest(".act-run, .turn.activity")) {
|
|
1557
|
+
n.classList.add("has-err");
|
|
1558
|
+
if (n.classList.contains("turn")) break;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/** Opens a fresh activity block at the bottom of the transcript. */
|
|
1563
|
+
function openBlock(tab) {
|
|
1564
|
+
const turn = document.createElement("div");
|
|
1565
|
+
turn.className = "turn claude activity";
|
|
1566
|
+
turn.dataset.folded = "false";
|
|
1567
|
+
const head = document.createElement("button");
|
|
1568
|
+
head.className = "act-head";
|
|
1569
|
+
head.innerHTML = '<span class="caret">▾</span><span class="count"></span>';
|
|
1570
|
+
head.addEventListener("click", () => toggleFold(turn));
|
|
1571
|
+
const body = document.createElement("div");
|
|
1572
|
+
body.className = "act-body";
|
|
1573
|
+
turn.append(head, body);
|
|
1574
|
+
tab.transcriptEl.appendChild(turn);
|
|
1575
|
+
const block = { el: turn, body, countEl: head.querySelector(".count"), calls: 0, errors: 0, run: null };
|
|
1576
|
+
tab.act = block;
|
|
1577
|
+
(tab.turnBlocks ||= []).push(block);
|
|
1578
|
+
return block;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
function renderBlockCount(block) {
|
|
1582
|
+
const ops = block.calls + (block.calls === 1 ? " operation" : " operations");
|
|
1583
|
+
block.countEl.textContent = block.errors ? ops + " · " + block.errors + " error" + (block.errors > 1 ? "s" : "") : ops;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/** Ends the current block so the next tool call starts a new one. */
|
|
1587
|
+
function closeActivity(tab) {
|
|
1588
|
+
tab.act = null;
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/** Folds every block of the finished turn that saw no error. */
|
|
1592
|
+
function sealTurn(tab) {
|
|
1593
|
+
for (const block of tab.turnBlocks || []) {
|
|
1594
|
+
if (!block.el.classList.contains("has-err")) setFolded(block.el, true);
|
|
1595
|
+
}
|
|
1596
|
+
tab.turnBlocks = [];
|
|
1597
|
+
tab.pendingCalls?.clear();
|
|
1598
|
+
tab.lastCall = null;
|
|
1599
|
+
closeActivity(tab);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/** A tool call: appended to the open block, condensed into a run if repeated. */
|
|
1603
|
+
function addToolLine(tab, msg) {
|
|
1604
|
+
const block = tab.act || openBlock(tab);
|
|
1605
|
+
const name = msg.name || "tool";
|
|
1606
|
+
|
|
1607
|
+
if (!block.run || block.run.name !== name) {
|
|
1608
|
+
const runEl = document.createElement("div");
|
|
1609
|
+
runEl.className = "act-run";
|
|
1610
|
+
runEl.dataset.folded = "false";
|
|
1611
|
+
runEl.dataset.count = "1";
|
|
1612
|
+
const head = document.createElement("button");
|
|
1613
|
+
head.className = "run-head";
|
|
1614
|
+
head.innerHTML = '<span class="caret">▾</span><span class="tool"></span><span class="arg"></span>';
|
|
1615
|
+
head.querySelector(".tool").textContent = name;
|
|
1616
|
+
head.addEventListener("click", () => toggleFold(runEl));
|
|
1617
|
+
const body = document.createElement("div");
|
|
1618
|
+
body.className = "run-body";
|
|
1619
|
+
runEl.append(head, body);
|
|
1620
|
+
block.body.appendChild(runEl);
|
|
1621
|
+
block.run = { name, el: runEl, body, argEl: head.querySelector(".arg"), count: 0 };
|
|
1622
|
+
}
|
|
1623
|
+
const run = block.run;
|
|
1624
|
+
run.count += 1;
|
|
1625
|
+
run.el.dataset.count = String(run.count);
|
|
1626
|
+
run.argEl.textContent = "×" + run.count;
|
|
1627
|
+
// A repeated tool folds away; the reader opens it if the detail matters.
|
|
1628
|
+
if (run.count === 2 && !run.el.classList.contains("has-err")) setFolded(run.el, true);
|
|
1629
|
+
|
|
1630
|
+
const item = document.createElement("div");
|
|
1631
|
+
item.className = "act-item";
|
|
1632
|
+
item.dataset.folded = "true";
|
|
1633
|
+
const head = document.createElement("button");
|
|
1634
|
+
head.className = "item-head";
|
|
1635
|
+
head.innerHTML = '<span class="caret">▸</span><span class="tool"></span><span class="arg"></span>';
|
|
1636
|
+
head.querySelector(".tool").textContent = name;
|
|
1637
|
+
head.querySelector(".arg").textContent = msg.summary || "";
|
|
1638
|
+
head.addEventListener("click", () => toggleFold(item));
|
|
1639
|
+
const out = document.createElement("div");
|
|
1640
|
+
out.className = "item-out";
|
|
1641
|
+
item.append(head, out);
|
|
1642
|
+
run.body.appendChild(item);
|
|
1643
|
+
|
|
1644
|
+
block.calls += 1;
|
|
1645
|
+
renderBlockCount(block);
|
|
1646
|
+
(tab.pendingCalls ||= new Map()).set(msg.id || "", item);
|
|
1647
|
+
tab.lastCall = item;
|
|
1648
|
+
if (tab === active) tab.transcriptEl.scrollTop = tab.transcriptEl.scrollHeight;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/**
|
|
1652
|
+
* Tool output, attached to the call that produced it. Paired by id because
|
|
1653
|
+
* one assistant message may fire several tools at once and their results come
|
|
1654
|
+
* back batched — position alone would mismatch them.
|
|
1655
|
+
*/
|
|
1656
|
+
function addResult(tab, msg) {
|
|
1657
|
+
const pending = tab.pendingCalls;
|
|
1658
|
+
let item = msg.toolUseId && pending ? pending.get(msg.toolUseId) : null;
|
|
1659
|
+
if (item) pending.delete(msg.toolUseId);
|
|
1660
|
+
// No id (older server, or an unpaired result): fall back to the last call.
|
|
1661
|
+
else item = tab.lastCall;
|
|
1662
|
+
if (!item || item.querySelector(":scope > .item-out > .result-body")) {
|
|
1663
|
+
// Nothing to attach to — show it on its own rather than dropping it.
|
|
1664
|
+
item = null;
|
|
1665
|
+
}
|
|
1666
|
+
const host = item ? item.querySelector(":scope > .item-out") : orphanItem(tab);
|
|
1667
|
+
const text = msg.text || "";
|
|
1668
|
+
|
|
1669
|
+
const pre = document.createElement("pre");
|
|
1670
|
+
pre.className = "result-body";
|
|
1671
|
+
const lines = text.split("\n");
|
|
1672
|
+
const long = lines.length > RESULT_LINES;
|
|
1673
|
+
const head = () => lines.slice(0, RESULT_LINES).join("\n");
|
|
1674
|
+
pre.textContent = long ? head() : text;
|
|
1675
|
+
host.appendChild(pre);
|
|
1676
|
+
if (long) {
|
|
1677
|
+
const more = document.createElement("button");
|
|
1678
|
+
more.className = "result-more";
|
|
1679
|
+
const label = () => "+ " + (lines.length - RESULT_LINES) + " more lines";
|
|
1680
|
+
more.textContent = label();
|
|
1681
|
+
let expanded = false;
|
|
1682
|
+
more.addEventListener("click", () => {
|
|
1683
|
+
expanded = !expanded;
|
|
1684
|
+
pre.textContent = expanded ? text : head();
|
|
1685
|
+
more.textContent = expanded ? "− collapse" : label();
|
|
1686
|
+
});
|
|
1687
|
+
host.appendChild(more);
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
const target = item || host.closest(".act-item");
|
|
1691
|
+
if (msg.isError) {
|
|
1692
|
+
// Failures stay in the clear: unfolded here, and exempt from turn-done.
|
|
1693
|
+
flagError(target);
|
|
1694
|
+
setFolded(target, false);
|
|
1695
|
+
const run = target.closest(".act-run");
|
|
1696
|
+
if (run) setFolded(run, false);
|
|
1697
|
+
const block = tab.act;
|
|
1698
|
+
if (block) {
|
|
1699
|
+
block.errors += 1;
|
|
1700
|
+
renderBlockCount(block);
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
if (tab === active) tab.transcriptEl.scrollTop = tab.transcriptEl.scrollHeight;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
/** A result with no matching call still deserves a row of its own. */
|
|
1707
|
+
function orphanItem(tab) {
|
|
1708
|
+
const block = tab.act || openBlock(tab);
|
|
1709
|
+
const item = document.createElement("div");
|
|
1710
|
+
item.className = "act-item";
|
|
1711
|
+
item.dataset.folded = "false";
|
|
1712
|
+
const head = document.createElement("button");
|
|
1713
|
+
head.className = "item-head";
|
|
1714
|
+
head.innerHTML = '<span class="caret">▾</span><span class="tool">output</span><span class="arg"></span>';
|
|
1715
|
+
head.addEventListener("click", () => toggleFold(item));
|
|
1716
|
+
const out = document.createElement("div");
|
|
1717
|
+
out.className = "item-out";
|
|
1718
|
+
item.append(head, out);
|
|
1719
|
+
block.body.appendChild(item);
|
|
1720
|
+
block.run = null;
|
|
1721
|
+
return out;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
/**
|
|
1725
|
+
* Renders a TUI dialog clickable in the chat. Single select: one click
|
|
1726
|
+
* validates. Multi-select: clicks toggle checkboxes (the state comes back
|
|
1727
|
+
* from the server and the bubble is re-rendered in place), the Submit
|
|
1728
|
+
* button confirms.
|
|
1729
|
+
*/
|
|
1730
|
+
function renderDialog(tab, msg) {
|
|
1731
|
+
let bubble;
|
|
1732
|
+
if (tab.dialogBubble) {
|
|
1733
|
+
bubble = tab.dialogBubble;
|
|
1734
|
+
bubble.textContent = "";
|
|
1735
|
+
} else {
|
|
1736
|
+
const turn = document.createElement("div");
|
|
1737
|
+
turn.className = "turn claude dialog";
|
|
1738
|
+
const label = document.createElement("span");
|
|
1739
|
+
label.className = "label";
|
|
1740
|
+
label.textContent = msg.multi
|
|
1741
|
+
? "claude — multi-select (check then submit)"
|
|
1742
|
+
: "claude — choice requested";
|
|
1743
|
+
bubble = document.createElement("div");
|
|
1744
|
+
bubble.className = "bubble";
|
|
1745
|
+
turn.append(label, bubble);
|
|
1746
|
+
tab.transcriptEl.appendChild(turn);
|
|
1747
|
+
tab.dialogBubble = bubble;
|
|
1748
|
+
}
|
|
1749
|
+
if (msg.question) {
|
|
1750
|
+
const q = document.createElement("div");
|
|
1751
|
+
q.className = "question";
|
|
1752
|
+
q.textContent = msg.question;
|
|
1753
|
+
bubble.appendChild(q);
|
|
1754
|
+
}
|
|
1755
|
+
const buttons = [];
|
|
1756
|
+
for (const opt of msg.options) {
|
|
1757
|
+
// "Type something" option: free-form text input.
|
|
1758
|
+
if (/^type something/i.test(opt.label)) {
|
|
1759
|
+
const row = document.createElement("div");
|
|
1760
|
+
row.className = "freetext-row";
|
|
1761
|
+
const inp = document.createElement("input");
|
|
1762
|
+
inp.type = "text";
|
|
1763
|
+
inp.placeholder = opt.n + ". Free-form answer…";
|
|
1764
|
+
const go = document.createElement("button");
|
|
1765
|
+
go.type = "button";
|
|
1766
|
+
go.textContent = "Send";
|
|
1767
|
+
const submitFree = () => {
|
|
1768
|
+
const v = inp.value.trim();
|
|
1769
|
+
if (!v || tab.status !== "ready" || !tab.ws) return;
|
|
1770
|
+
buttons.forEach((x) => (x.disabled = true));
|
|
1771
|
+
inp.disabled = true;
|
|
1772
|
+
tab.ws.send(JSON.stringify({ type: "freetext", n: opt.n, text: v }));
|
|
1773
|
+
};
|
|
1774
|
+
go.addEventListener("click", submitFree);
|
|
1775
|
+
inp.addEventListener("keydown", (e) => {
|
|
1776
|
+
if (e.key === "Enter") {
|
|
1777
|
+
e.preventDefault();
|
|
1778
|
+
e.stopPropagation();
|
|
1779
|
+
submitFree();
|
|
1780
|
+
}
|
|
1781
|
+
});
|
|
1782
|
+
buttons.push(go);
|
|
1783
|
+
row.append(inp, go);
|
|
1784
|
+
bubble.appendChild(row);
|
|
1785
|
+
continue;
|
|
1786
|
+
}
|
|
1787
|
+
const b = document.createElement("button");
|
|
1788
|
+
b.type = "button";
|
|
1789
|
+
b.className = "choice" + (opt.checked ? " checked" : "");
|
|
1790
|
+
const num = document.createElement("span");
|
|
1791
|
+
num.className = "num";
|
|
1792
|
+
num.textContent = msg.multi
|
|
1793
|
+
? (opt.checked ? "☑ " : "☐ ") + opt.n + "."
|
|
1794
|
+
: opt.n + ".";
|
|
1795
|
+
const lab = document.createElement("span");
|
|
1796
|
+
lab.textContent = opt.label;
|
|
1797
|
+
b.append(num, lab);
|
|
1798
|
+
if (opt.hint) {
|
|
1799
|
+
const h = document.createElement("span");
|
|
1800
|
+
h.className = "hint";
|
|
1801
|
+
h.textContent = opt.hint;
|
|
1802
|
+
b.appendChild(h);
|
|
1803
|
+
}
|
|
1804
|
+
b.addEventListener("click", () => {
|
|
1805
|
+
if (tab.status !== "ready" || !tab.ws) return;
|
|
1806
|
+
if (msg.multi) {
|
|
1807
|
+
// Toggle — the bubble will be re-rendered with the fresh state.
|
|
1808
|
+
buttons.forEach((x) => (x.disabled = true));
|
|
1809
|
+
tab.ws.send(JSON.stringify({ type: "toggle", n: opt.n }));
|
|
1810
|
+
} else {
|
|
1811
|
+
buttons.forEach((x) => (x.disabled = true));
|
|
1812
|
+
b.classList.add("chosen");
|
|
1813
|
+
tab.ws.send(JSON.stringify({ type: "choose", n: opt.n }));
|
|
1814
|
+
}
|
|
1815
|
+
});
|
|
1816
|
+
buttons.push(b);
|
|
1817
|
+
bubble.appendChild(b);
|
|
1818
|
+
}
|
|
1819
|
+
if (msg.multi) {
|
|
1820
|
+
const v = document.createElement("button");
|
|
1821
|
+
v.type = "button";
|
|
1822
|
+
v.className = "primary validate";
|
|
1823
|
+
v.textContent = "Submit selection";
|
|
1824
|
+
v.addEventListener("click", () => {
|
|
1825
|
+
if (tab.status !== "ready" || !tab.ws) return;
|
|
1826
|
+
buttons.forEach((x) => (x.disabled = true));
|
|
1827
|
+
v.disabled = true;
|
|
1828
|
+
tab.ws.send(JSON.stringify({ type: "confirm" }));
|
|
1829
|
+
});
|
|
1830
|
+
buttons.push(v);
|
|
1831
|
+
bubble.appendChild(v);
|
|
1832
|
+
}
|
|
1833
|
+
tab.pendingChoices = buttons;
|
|
1834
|
+
if (tab === active) tab.transcriptEl.scrollTop = tab.transcriptEl.scrollHeight;
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
/** Disables pending choices (resolved elsewhere or stale). */
|
|
1838
|
+
function retireChoices(tab) {
|
|
1839
|
+
(tab.pendingChoices || []).forEach((b) => (b.disabled = true));
|
|
1840
|
+
tab.pendingChoices = null;
|
|
1841
|
+
tab.dialogBubble = null;
|
|
1842
|
+
if (tab.btnEl.classList.contains("needs-answer")) setTabMood(tab, null);
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// ── Link ────────────────────────────────────────────────
|
|
1846
|
+
/** Starts a channel's session (from the form or a restore). */
|
|
1847
|
+
function launchTab(t, msg) {
|
|
1848
|
+
t.cwd = msg.cwd || "(server directory)";
|
|
1849
|
+
if (!t.customName) t.nameEl.textContent = basename(msg.cwd || "") || "channel " + t.id;
|
|
1850
|
+
setTabState(t, "connecting", "connecting…");
|
|
1851
|
+
|
|
1852
|
+
const ws = new WebSocket(`ws://${location.host}/ws`);
|
|
1853
|
+
t.ws = ws;
|
|
1854
|
+
ws.addEventListener("open", () => ws.send(JSON.stringify({ type: "start", ...msg })));
|
|
1855
|
+
ws.addEventListener("close", () => {
|
|
1856
|
+
if (t.status !== "dead") setTabState(t, "dead", "link lost");
|
|
1857
|
+
});
|
|
1858
|
+
ws.addEventListener("message", (ev) => handleMessage(t, JSON.parse(ev.data)));
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
/**
|
|
1862
|
+
* Lists the agents alive in the server right now — including ones spawned
|
|
1863
|
+
* outside this browser (pilotctl, another tab). They have no transcript
|
|
1864
|
+
* before their first turn, so no other list can surface them. Attaching
|
|
1865
|
+
* shares the same process rather than starting a second one.
|
|
1866
|
+
*/
|
|
1867
|
+
async function refreshLiveList() {
|
|
1868
|
+
const field = $("liveField");
|
|
1869
|
+
const listEl = $("liveList");
|
|
1870
|
+
let live = [];
|
|
1871
|
+
try {
|
|
1872
|
+
live = await (await fetch("/live")).json();
|
|
1873
|
+
} catch { live = []; }
|
|
1874
|
+
if (!Array.isArray(live) || live.length === 0) { field.hidden = true; return; }
|
|
1875
|
+
field.hidden = false;
|
|
1876
|
+
listEl.innerHTML = "";
|
|
1877
|
+
for (const s of live) {
|
|
1878
|
+
const row = document.createElement("div");
|
|
1879
|
+
row.className = "recover-row";
|
|
1880
|
+
const meta = document.createElement("div");
|
|
1881
|
+
meta.className = "rmeta";
|
|
1882
|
+
const p = document.createElement("div");
|
|
1883
|
+
p.className = "rprompt";
|
|
1884
|
+
p.textContent = s.lastPrompt || "(no prompt yet)";
|
|
1885
|
+
const sub = document.createElement("div");
|
|
1886
|
+
sub.className = "rsub";
|
|
1887
|
+
// Id first: it is what identifies the agent, and truncation eats the tail.
|
|
1888
|
+
sub.textContent = s.id.slice(0, 8) + " · " + (s.branch || s.cwd);
|
|
1889
|
+
meta.append(p, sub);
|
|
1890
|
+
const badge = document.createElement("span");
|
|
1891
|
+
badge.className = "badge" + (s.busy ? "" : " empty");
|
|
1892
|
+
badge.textContent = s.busy ? "working" : (s.clients ? "idle" : "detached");
|
|
1893
|
+
const btn = document.createElement("button");
|
|
1894
|
+
btn.textContent = "Attach";
|
|
1895
|
+
btn.addEventListener("click", () => {
|
|
1896
|
+
const t = active;
|
|
1897
|
+
if (!t.customName) { t.customName = true; t.nameEl.textContent = s.id.slice(0, 8); }
|
|
1898
|
+
launchTab(t, { resume: s.id, cwd: s.cwd });
|
|
1899
|
+
});
|
|
1900
|
+
row.append(meta, badge, btn);
|
|
1901
|
+
listEl.appendChild(row);
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
/** Lists past worktree sessions of the setup's repo, each reopenable. */
|
|
1906
|
+
async function refreshRecoverList() {
|
|
1907
|
+
const cwd = $("cwdInput").value.trim();
|
|
1908
|
+
const field = $("recoverField");
|
|
1909
|
+
const listEl = $("recoverList");
|
|
1910
|
+
if (!cwd) { field.hidden = true; return; }
|
|
1911
|
+
let sessions = [];
|
|
1912
|
+
try {
|
|
1913
|
+
sessions = await (await fetch("/recover?repo=" + encodeURIComponent(cwd))).json();
|
|
1914
|
+
} catch { sessions = []; }
|
|
1915
|
+
if (!Array.isArray(sessions) || sessions.length === 0) { field.hidden = true; return; }
|
|
1916
|
+
field.hidden = false;
|
|
1917
|
+
listEl.innerHTML = "";
|
|
1918
|
+
for (const s of sessions) {
|
|
1919
|
+
const row = document.createElement("div");
|
|
1920
|
+
row.className = "recover-row";
|
|
1921
|
+
const meta = document.createElement("div");
|
|
1922
|
+
meta.className = "rmeta";
|
|
1923
|
+
const p = document.createElement("div");
|
|
1924
|
+
p.className = "rprompt";
|
|
1925
|
+
p.textContent = s.preview || "(no prompt)";
|
|
1926
|
+
const sub = document.createElement("div");
|
|
1927
|
+
sub.className = "rsub";
|
|
1928
|
+
sub.textContent = s.branch + (s.dirExists ? "" : " · reclaimed");
|
|
1929
|
+
meta.append(p, sub);
|
|
1930
|
+
const badge = document.createElement("span");
|
|
1931
|
+
const changed = s.commits > 0 || s.hasChanges;
|
|
1932
|
+
badge.className = "badge" + (changed ? "" : " empty");
|
|
1933
|
+
badge.textContent = changed
|
|
1934
|
+
? (s.commits > 0 ? s.commits + " commit" + (s.commits > 1 ? "s" : "") : "changes")
|
|
1935
|
+
: "empty";
|
|
1936
|
+
const btn = document.createElement("button");
|
|
1937
|
+
btn.textContent = "Reopen";
|
|
1938
|
+
btn.disabled = !s.sessionId;
|
|
1939
|
+
btn.addEventListener("click", () => {
|
|
1940
|
+
if (!s.sessionId) return;
|
|
1941
|
+
const t = active;
|
|
1942
|
+
if (!t.customName) { t.customName = true; t.nameEl.textContent = s.branch.replace("shadok-ai/", "wt-"); }
|
|
1943
|
+
launchTab(t, { resume: s.sessionId, cwd: s.cwd, branch: s.branch, repo: cwd });
|
|
1944
|
+
});
|
|
1945
|
+
row.append(meta, badge, btn);
|
|
1946
|
+
listEl.appendChild(row);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
function startActiveTab() {
|
|
1951
|
+
const t = active;
|
|
1952
|
+
if (t.status !== "setup") return;
|
|
1953
|
+
const cwd = $("cwdInput").value.trim();
|
|
1954
|
+
const mode = document.querySelector('input[name="mode"]:checked').value;
|
|
1955
|
+
const msg = { cwd };
|
|
1956
|
+
if (mode === "continue") msg.continue = true;
|
|
1957
|
+
if (mode === "resume") {
|
|
1958
|
+
msg.resume = $("resumeInput").value.trim();
|
|
1959
|
+
if (!msg.resume) return alert("Enter a session id.");
|
|
1960
|
+
}
|
|
1961
|
+
if (mode === "new" && $("worktreeInput").checked) msg.worktree = true;
|
|
1962
|
+
localStorage.setItem("cp.cwd", cwd);
|
|
1963
|
+
$("startBtn").disabled = true;
|
|
1964
|
+
launchTab(t, msg);
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
function handleMessage(t, msg) {
|
|
1968
|
+
switch (msg.type) {
|
|
1969
|
+
case "history":
|
|
1970
|
+
addTurn(t, "system", "", "— resumed session history —");
|
|
1971
|
+
for (const h of msg.turns) {
|
|
1972
|
+
if (h.role === "user") addTurn(t, "user", "pilot", h.text, "hist");
|
|
1973
|
+
else addTurn(t, "claude", "claude", h.text, "hist");
|
|
1974
|
+
}
|
|
1975
|
+
break;
|
|
1976
|
+
case "ready":
|
|
1977
|
+
t.cwd = msg.cwd;
|
|
1978
|
+
t.sessionId = msg.sessionId;
|
|
1979
|
+
t.branch = msg.branch || null;
|
|
1980
|
+
if (t === active) refreshChrome();
|
|
1981
|
+
if (msg.sessionId) {
|
|
1982
|
+
localStorage.setItem("cp.session", msg.sessionId);
|
|
1983
|
+
// Custom name remembered for this session.
|
|
1984
|
+
if (!t.customName && savedNames[msg.sessionId]) {
|
|
1985
|
+
t.customName = true;
|
|
1986
|
+
t.nameEl.textContent = savedNames[msg.sessionId];
|
|
1987
|
+
}
|
|
1988
|
+
// Draft remembered for this session (channels restored on load
|
|
1989
|
+
// only learn their session id here).
|
|
1990
|
+
if (!t.draft && savedDrafts[msg.sessionId]) {
|
|
1991
|
+
t.draft = savedDrafts[msg.sessionId];
|
|
1992
|
+
if (t === active) {
|
|
1993
|
+
promptInput.value = t.draft;
|
|
1994
|
+
autosizePrompt();
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
// launchTab reuses the tab object, so a bubble armed before the session
|
|
1999
|
+
// ended would come back to life against the *new* session and resend
|
|
2000
|
+
// yesterday's text forced. A new session answers no old question.
|
|
2001
|
+
retirePaceConfirm(t);
|
|
2002
|
+
setTabState(t, "ready", "ready");
|
|
2003
|
+
persistChannels();
|
|
2004
|
+
// Reattaching between turns: restore the last turn's time rather than
|
|
2005
|
+
// leaving a blank timer. A "working" message right after overrides it.
|
|
2006
|
+
if (msg.lastTurnMs) showFrozenTime(t, msg.lastTurnMs);
|
|
2007
|
+
addTurn(t, "system", "", "Session established. The pilot has the controls.");
|
|
2008
|
+
if (t === active) promptInput.focus();
|
|
2009
|
+
break;
|
|
2010
|
+
case "working":
|
|
2011
|
+
// Another client may have resolved the pending dialog.
|
|
2012
|
+
retireChoices(t);
|
|
2013
|
+
// Covers turns started elsewhere (other client, dialog answer).
|
|
2014
|
+
if (!t.turnStart) startTurnTimer(t, msg.startedAt);
|
|
2015
|
+
setTabMood(t, "working");
|
|
2016
|
+
setTabState(t, "busy", "responding…");
|
|
2017
|
+
break;
|
|
2018
|
+
case "prompt-echo":
|
|
2019
|
+
// Prompt sent by another tab/interface — or by the server itself
|
|
2020
|
+
// when it auto-retries a turn that died on a transient API error.
|
|
2021
|
+
closeActivity(t);
|
|
2022
|
+
addTurn(t, "user", msg.auto ? "auto-retry" : "pilot (elsewhere)", msg.text);
|
|
2023
|
+
break;
|
|
2024
|
+
case "tokens":
|
|
2025
|
+
t.tokens = msg.tokens;
|
|
2026
|
+
refreshTitle();
|
|
2027
|
+
break;
|
|
2028
|
+
case "context":
|
|
2029
|
+
t.contextPct = msg.pct;
|
|
2030
|
+
renderContext(t);
|
|
2031
|
+
break;
|
|
2032
|
+
case "stream-text":
|
|
2033
|
+
// A complete assistant text block, streamed from the transcript.
|
|
2034
|
+
// Text ends the current activity block: the next tool call opens a new
|
|
2035
|
+
// one, so the transcript keeps the real text/tools interleaving.
|
|
2036
|
+
retireChoices(t);
|
|
2037
|
+
closeActivity(t);
|
|
2038
|
+
addTurn(t, "claude", "claude", msg.text, "live");
|
|
2039
|
+
break;
|
|
2040
|
+
case "stream-tool":
|
|
2041
|
+
// A tool call, grouped into the open activity block.
|
|
2042
|
+
addToolLine(t, msg);
|
|
2043
|
+
break;
|
|
2044
|
+
case "stream-result":
|
|
2045
|
+
// Tool output, attached to the call it belongs to.
|
|
2046
|
+
addResult(t, msg);
|
|
2047
|
+
break;
|
|
2048
|
+
case "turn-done":
|
|
2049
|
+
retireChoices(t);
|
|
2050
|
+
// Activity recedes now the turn is over — errors excepted.
|
|
2051
|
+
sealTurn(t);
|
|
2052
|
+
if (msg.sessionId && msg.sessionId !== t.sessionId) {
|
|
2053
|
+
t.sessionId = msg.sessionId;
|
|
2054
|
+
localStorage.setItem("cp.session", msg.sessionId);
|
|
2055
|
+
persistChannels();
|
|
2056
|
+
}
|
|
2057
|
+
stopTurnTimer(t);
|
|
2058
|
+
setTabMood(t, t === active ? null : "unread");
|
|
2059
|
+
setTabState(t, "ready", "ready");
|
|
2060
|
+
break;
|
|
2061
|
+
case "dialog":
|
|
2062
|
+
// Re-rendered in place when a dialog is already shown (multi toggle).
|
|
2063
|
+
closeActivity(t);
|
|
2064
|
+
renderDialog(t, msg);
|
|
2065
|
+
stopTurnTimer(t);
|
|
2066
|
+
setTabMood(t, "needs-answer");
|
|
2067
|
+
setTabState(t, "ready", "choice pending");
|
|
2068
|
+
break;
|
|
2069
|
+
case "screen":
|
|
2070
|
+
t.screenText = msg.text;
|
|
2071
|
+
if (t === active) screenEl.textContent = msg.text;
|
|
2072
|
+
break;
|
|
2073
|
+
case "gone":
|
|
2074
|
+
// Session's directory is gone (e.g. a removed worktree): stop trying
|
|
2075
|
+
// to restore it and forget it so it doesn't resurrect every reload.
|
|
2076
|
+
addTurn(t, "system", "", msg.message);
|
|
2077
|
+
forgetChannel(msg.sessionId);
|
|
2078
|
+
stopTurnTimer(t);
|
|
2079
|
+
setTabMood(t, null);
|
|
2080
|
+
setTabState(t, "dead", "directory gone");
|
|
2081
|
+
break;
|
|
2082
|
+
case "error":
|
|
2083
|
+
addTurn(t, "system", "", "Error: " + msg.message);
|
|
2084
|
+
// Failed before ever going live (e.g. a bad restore): mark dead so it
|
|
2085
|
+
// doesn't hang on "connecting…".
|
|
2086
|
+
if (t.status === "connecting") setTabState(t, "dead", "failed to start");
|
|
2087
|
+
else if (t.status === "busy") {
|
|
2088
|
+
stopTurnTimer(t);
|
|
2089
|
+
setTabMood(t, null);
|
|
2090
|
+
setTabState(t, "ready", "ready");
|
|
2091
|
+
}
|
|
2092
|
+
break;
|
|
2093
|
+
case "exited":
|
|
2094
|
+
addTurn(t, "system", "", "The claude process exited (code " + msg.code + ").");
|
|
2095
|
+
stopTurnTimer(t);
|
|
2096
|
+
setTabMood(t, null);
|
|
2097
|
+
setTabState(t, "dead", "session ended");
|
|
2098
|
+
break;
|
|
2099
|
+
case "stopped":
|
|
2100
|
+
// Explicit end: the session is gone from the registry — drop the tab.
|
|
2101
|
+
stopTurnTimer(t);
|
|
2102
|
+
if (t.sessionId) dismissedChannels.add(t.sessionId);
|
|
2103
|
+
removeTabLocal(t);
|
|
2104
|
+
break;
|
|
2105
|
+
case "auto-retry":
|
|
2106
|
+
// The server will resend "continue" by itself; nothing to do here.
|
|
2107
|
+
addTurn(t, "system", "",
|
|
2108
|
+
"Transient API error — auto-retry in " + Math.round(msg.delayMs / 1000) +
|
|
2109
|
+
" s (attempt " + msg.attempt + "/" + msg.max + ")");
|
|
2110
|
+
setTabState(t, "ready", "auto-retry in " + Math.round(msg.delayMs / 1000) + " s");
|
|
2111
|
+
break;
|
|
2112
|
+
case "auto-retry-cancelled":
|
|
2113
|
+
// A takeover ends a parked auto-continue too, and no "pace-resumed"
|
|
2114
|
+
// follows — say so rather than leaving the pause line hanging.
|
|
2115
|
+
if (t.paceHeld) {
|
|
2116
|
+
addTurn(t, "system", "", "Auto-continue cancelled — back to manual.");
|
|
2117
|
+
}
|
|
2118
|
+
setTabState(t, "ready", "ready");
|
|
2119
|
+
break;
|
|
2120
|
+
case "auto-retry-gave-up":
|
|
2121
|
+
addTurn(t, "system", "",
|
|
2122
|
+
"Transient API error persists after " + msg.attempts +
|
|
2123
|
+
" auto-retries — send a prompt to retry manually.");
|
|
2124
|
+
setTabMood(t, "needs-answer");
|
|
2125
|
+
break;
|
|
2126
|
+
case "pace-blocked":
|
|
2127
|
+
// The client's own check was stale: roll back the optimistic turn state,
|
|
2128
|
+
// then ask. The refusal lands *before* any "working" — the server checks
|
|
2129
|
+
// the pace before it marks the session busy — so the tab is still
|
|
2130
|
+
// "ready" here and what needs undoing is exactly what submitPrompt
|
|
2131
|
+
// rendered ahead of the server: the turn timer and the working mood.
|
|
2132
|
+
// "ready" is therefore the *only* state this rollback may undo —
|
|
2133
|
+
// submitPrompt starts the timer and sets the mood but never touches the
|
|
2134
|
+
// tab state. Guarding on "!== busy" would also catch "dead": if the
|
|
2135
|
+
// pilot exits while the server awaits getUsage(), "exited" marks the tab
|
|
2136
|
+
// dead and this refusal would resurrect it as ready with a green LED,
|
|
2137
|
+
// with no later message to correct it.
|
|
2138
|
+
const rollback = t.status === "ready";
|
|
2139
|
+
if (rollback) {
|
|
2140
|
+
stopTurnTimer(t);
|
|
2141
|
+
// Nothing here should overwrite a live state line: a parked
|
|
2142
|
+
// auto-continue ("waiting on pace") and a pending auto-retry
|
|
2143
|
+
// ("auto-retry in N s") both outlive the refusal. submitPrompt never
|
|
2144
|
+
// changed the text, so re-asserting the current one is the rollback.
|
|
2145
|
+
setTabState(t, "ready", t.stateText);
|
|
2146
|
+
}
|
|
2147
|
+
paceBlocked = true;
|
|
2148
|
+
if (msg.reason) paceReason = msg.reason;
|
|
2149
|
+
renderPaceConfirm(t, msg.text, msg.reason);
|
|
2150
|
+
// After the render: retiring the superseded bubble drops the highlight,
|
|
2151
|
+
// so the new one has to re-raise it. A refusal is a question — when the
|
|
2152
|
+
// user has moved to another channel the bubble lands in a hidden
|
|
2153
|
+
// transcript, so flag it the way auto-retry-gave-up flags a channel
|
|
2154
|
+
// waiting on the user.
|
|
2155
|
+
// The highlight is not part of the rollback: it belongs to the new
|
|
2156
|
+
// bubble. A channel that died between two refusals takes the `rollback
|
|
2157
|
+
// === false` path, and gating on it there left an armed bubble in a
|
|
2158
|
+
// hidden transcript with nothing pointing at it.
|
|
2159
|
+
if (t !== active) setTabMood(t, "needs-answer");
|
|
2160
|
+
else if (rollback) setTabMood(t, null);
|
|
2161
|
+
break;
|
|
2162
|
+
case "pace-hold":
|
|
2163
|
+
addTurn(t, "system", "",
|
|
2164
|
+
"Auto-continue paused — " + (msg.reason || "pace exceeded") +
|
|
2165
|
+
". Resumes automatically once back under the threshold.");
|
|
2166
|
+
setTabState(t, "ready", PACE_HOLD_STATE);
|
|
2167
|
+
t.paceHeld = true;
|
|
2168
|
+
break;
|
|
2169
|
+
case "pace-resumed":
|
|
2170
|
+
addTurn(t, "system", "", "Pace back under the threshold — auto-continue resumed.");
|
|
2171
|
+
setTabState(t, "ready", "ready");
|
|
2172
|
+
break;
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
// ── Session picker (resume by id) ───────────────────────
|
|
2177
|
+
/** Lists the directory's resumable sessions; a click fills the id field. */
|
|
2178
|
+
async function refreshSessionList() {
|
|
2179
|
+
const listEl = $("sessionList");
|
|
2180
|
+
listEl.innerHTML = '<div class="empty">loading…</div>';
|
|
2181
|
+
let sessions = [];
|
|
2182
|
+
try {
|
|
2183
|
+
const cwd = $("cwdInput").value.trim();
|
|
2184
|
+
const res = await fetch("/sessions?cwd=" + encodeURIComponent(cwd));
|
|
2185
|
+
sessions = await res.json();
|
|
2186
|
+
} catch {
|
|
2187
|
+
/* server unreachable: leave the list empty */
|
|
2188
|
+
}
|
|
2189
|
+
listEl.innerHTML = "";
|
|
2190
|
+
if (!sessions.length) {
|
|
2191
|
+
listEl.innerHTML = '<div class="empty">No sessions found in this directory.</div>';
|
|
2192
|
+
return;
|
|
2193
|
+
}
|
|
2194
|
+
for (const s of sessions) {
|
|
2195
|
+
const b = document.createElement("button");
|
|
2196
|
+
b.type = "button";
|
|
2197
|
+
b.className = "session-item";
|
|
2198
|
+
if (s.id === $("resumeInput").value.trim()) b.classList.add("selected");
|
|
2199
|
+
const sid = document.createElement("span");
|
|
2200
|
+
sid.className = "sid";
|
|
2201
|
+
sid.textContent = (savedNames[s.id] ? savedNames[s.id] + " — " : "") + s.id;
|
|
2202
|
+
const meta = document.createElement("span");
|
|
2203
|
+
meta.className = "meta";
|
|
2204
|
+
meta.textContent = new Date(s.mtime).toLocaleString();
|
|
2205
|
+
b.append(sid, meta);
|
|
2206
|
+
if (s.preview) {
|
|
2207
|
+
const p = document.createElement("span");
|
|
2208
|
+
p.className = "preview";
|
|
2209
|
+
p.textContent = s.preview;
|
|
2210
|
+
b.appendChild(p);
|
|
2211
|
+
}
|
|
2212
|
+
b.addEventListener("click", () => {
|
|
2213
|
+
$("resumeInput").value = s.id;
|
|
2214
|
+
listEl.querySelectorAll(".selected").forEach((x) => x.classList.remove("selected"));
|
|
2215
|
+
b.classList.add("selected");
|
|
2216
|
+
});
|
|
2217
|
+
listEl.appendChild(b);
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
// ── Controls ────────────────────────────────────────────
|
|
2222
|
+
$("startBtn").addEventListener("click", startActiveTab);
|
|
2223
|
+
document.querySelectorAll('input[name="mode"]').forEach((r) =>
|
|
2224
|
+
r.addEventListener("change", () => {
|
|
2225
|
+
const mode = document.querySelector('input[name="mode"]:checked').value;
|
|
2226
|
+
$("resumeField").hidden = mode !== "resume";
|
|
2227
|
+
// Worktree isolation is only meaningful for a brand-new session.
|
|
2228
|
+
$("worktreeField").style.opacity = mode === "new" ? "1" : "0.4";
|
|
2229
|
+
$("worktreeInput").disabled = mode !== "new";
|
|
2230
|
+
if (mode === "resume") refreshSessionList();
|
|
2231
|
+
})
|
|
2232
|
+
);
|
|
2233
|
+
$("cwdInput").addEventListener("change", () => {
|
|
2234
|
+
if (!$("resumeField").hidden) refreshSessionList();
|
|
2235
|
+
refreshRecoverList();
|
|
2236
|
+
});
|
|
2237
|
+
// Agents can appear while the setup screen sits open (pilotctl spawns one,
|
|
2238
|
+
// another tab starts one) — keep the list current without a reload.
|
|
2239
|
+
setInterval(() => {
|
|
2240
|
+
if (active && active.status === "setup") refreshLiveList();
|
|
2241
|
+
}, 5000);
|
|
2242
|
+
|
|
2243
|
+
function submitPrompt(t, text, force) {
|
|
2244
|
+
// Any outstanding pace question is answered by the fact that something is
|
|
2245
|
+
// being sent — whether by clicking its own button or by a fresh submit.
|
|
2246
|
+
retirePaceConfirm(t);
|
|
2247
|
+
closeActivity(t);
|
|
2248
|
+
addTurn(t, "user", "pilot", text);
|
|
2249
|
+
startTurnTimer(t);
|
|
2250
|
+
setTabMood(t, "working");
|
|
2251
|
+
t.ws.send(JSON.stringify({ type: "prompt", text, ...(force ? { force: true } : {}) }));
|
|
2252
|
+
// The composer is shared and shows the active channel's draft. A forced send
|
|
2253
|
+
// can come from a bubble in a channel the user has since left, and clearing
|
|
2254
|
+
// it then would throw away whatever they are typing in the channel they are
|
|
2255
|
+
// actually looking at.
|
|
2256
|
+
if (t === active) {
|
|
2257
|
+
promptInput.value = "";
|
|
2258
|
+
promptInput.style.height = "auto";
|
|
2259
|
+
}
|
|
2260
|
+
dropDraft(t);
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
/**
|
|
2264
|
+
* Stands the channel's pace question down: its button goes dead and the
|
|
2265
|
+
* bubble is struck through, so the thread still shows the question was asked
|
|
2266
|
+
* without leaving it answerable. Kept in tab.paceBubble — a field of its own,
|
|
2267
|
+
* never tab.dialogBubble, which renderDialog re-renders TUI dialogs through.
|
|
2268
|
+
*/
|
|
2269
|
+
function retirePaceConfirm(tab) {
|
|
2270
|
+
const live = tab.paceBubble;
|
|
2271
|
+
if (!live) return;
|
|
2272
|
+
live.btn.disabled = true;
|
|
2273
|
+
live.turn.classList.add("spent");
|
|
2274
|
+
tab.paceBubble = null;
|
|
2275
|
+
// Mirrors retireChoices, but must not steal the highlight of a real TUI
|
|
2276
|
+
// dialog that is still waiting for an answer.
|
|
2277
|
+
if (!tab.pendingChoices && tab.btnEl.classList.contains("needs-answer")) {
|
|
2278
|
+
setTabMood(tab, null);
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
/**
|
|
2283
|
+
* Above the ideal pace, ask before spending. Rendered in the thread like a TUI
|
|
2284
|
+
* question rather than as a modal — the codebase has no floating dialog.
|
|
2285
|
+
* Each blocked send asks again: forcing covers one message, never a session.
|
|
2286
|
+
* Deliberately does NOT touch tab.dialogBubble or tab.pendingChoices: those
|
|
2287
|
+
* belong to renderDialog, and borrowing them would corrupt a real TUI dialog
|
|
2288
|
+
* and lock the composer.
|
|
2289
|
+
*/
|
|
2290
|
+
function renderPaceConfirm(tab, text, reason) {
|
|
2291
|
+
// At most one armed bubble per channel. Without this, a stale bubble stays
|
|
2292
|
+
// clickable forever (clicking yesterday's resends yesterday's text forced),
|
|
2293
|
+
// and two refusals then two quick clicks both clear the "ready" guard —
|
|
2294
|
+
// the tab only turns busy when the server's "working" comes back — so two
|
|
2295
|
+
// forced prompts leave, the second dying as "already in progress".
|
|
2296
|
+
retirePaceConfirm(tab);
|
|
2297
|
+
const turn = document.createElement("div");
|
|
2298
|
+
turn.className = "turn claude dialog";
|
|
2299
|
+
const label = document.createElement("span");
|
|
2300
|
+
label.className = "label";
|
|
2301
|
+
label.textContent = "shadok-ai — pace exceeded";
|
|
2302
|
+
const bubble = document.createElement("div");
|
|
2303
|
+
bubble.className = "bubble";
|
|
2304
|
+
const q = document.createElement("div");
|
|
2305
|
+
q.className = "question";
|
|
2306
|
+
q.textContent = (reason || "Consumption above the ideal pace")
|
|
2307
|
+
+ " — send anyway?";
|
|
2308
|
+
const btn = document.createElement("button");
|
|
2309
|
+
btn.type = "button";
|
|
2310
|
+
btn.className = "choice";
|
|
2311
|
+
btn.textContent = "Force send";
|
|
2312
|
+
btn.addEventListener("click", () => {
|
|
2313
|
+
if (tab.status !== "ready" || !tab.ws) return;
|
|
2314
|
+
// submitPrompt retires this bubble (disabling the button, marking it
|
|
2315
|
+
// spent) — the single-live-bubble rule covers the click too.
|
|
2316
|
+
submitPrompt(tab, text, true);
|
|
2317
|
+
});
|
|
2318
|
+
bubble.append(q, btn);
|
|
2319
|
+
turn.append(label, bubble);
|
|
2320
|
+
tab.transcriptEl.appendChild(turn);
|
|
2321
|
+
tab.paceBubble = { turn, btn };
|
|
2322
|
+
if (tab === active) tab.transcriptEl.scrollTop = tab.transcriptEl.scrollHeight;
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
composer.addEventListener("submit", (e) => {
|
|
2326
|
+
e.preventDefault();
|
|
2327
|
+
const t = active;
|
|
2328
|
+
const text = promptInput.value.trim();
|
|
2329
|
+
if (!text || t.status !== "ready" || t.pendingChoices || !t.ws) return;
|
|
2330
|
+
// Checked here so nothing is optimistically rendered before the server
|
|
2331
|
+
// refuses. The server re-checks anyway — it is the authority.
|
|
2332
|
+
if (paceBlocked) return renderPaceConfirm(t, text, paceReason);
|
|
2333
|
+
submitPrompt(t, text);
|
|
2334
|
+
});
|
|
2335
|
+
promptInput.addEventListener("keydown", (e) => {
|
|
2336
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
2337
|
+
e.preventDefault();
|
|
2338
|
+
composer.requestSubmit();
|
|
2339
|
+
}
|
|
2340
|
+
});
|
|
2341
|
+
function autosizePrompt() {
|
|
2342
|
+
promptInput.style.height = "auto";
|
|
2343
|
+
promptInput.style.height = Math.min(promptInput.scrollHeight, 160) + "px";
|
|
2344
|
+
}
|
|
2345
|
+
promptInput.addEventListener("input", () => {
|
|
2346
|
+
autosizePrompt();
|
|
2347
|
+
active.draft = promptInput.value;
|
|
2348
|
+
persistDraft(active);
|
|
2349
|
+
});
|
|
2350
|
+
|
|
2351
|
+
$("toggleMachine").addEventListener("click", () => machine.classList.toggle("open"));
|
|
2352
|
+
$("closeMachine").addEventListener("click", () => machine.classList.remove("open"));
|
|
2353
|
+
|
|
2354
|
+
// ── Diff panel ──────────────────────────────────────────
|
|
2355
|
+
function colorDiff(text) {
|
|
2356
|
+
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2357
|
+
return text
|
|
2358
|
+
.split("\n")
|
|
2359
|
+
.map((l) => {
|
|
2360
|
+
const e = esc(l);
|
|
2361
|
+
if (/^\+\+\+|^---|^diff |^index /.test(l)) return '<span class="meta">' + e + "</span>";
|
|
2362
|
+
if (/^@@/.test(l)) return '<span class="hunk">' + e + "</span>";
|
|
2363
|
+
if (/^\+/.test(l)) return '<span class="add">' + e + "</span>";
|
|
2364
|
+
if (/^-/.test(l)) return '<span class="del">' + e + "</span>";
|
|
2365
|
+
return e;
|
|
2366
|
+
})
|
|
2367
|
+
.join("\n");
|
|
2368
|
+
}
|
|
2369
|
+
async function loadDiff() {
|
|
2370
|
+
const t = active;
|
|
2371
|
+
if (!t || !t.sessionId) return;
|
|
2372
|
+
$("diffBranch").textContent = t.branch || "working tree";
|
|
2373
|
+
$("diffStatus").textContent = "loading…";
|
|
2374
|
+
$("diffBody").innerHTML = "";
|
|
2375
|
+
try {
|
|
2376
|
+
const d = await (await fetch("/diff?session=" + encodeURIComponent(t.sessionId))).json();
|
|
2377
|
+
$("diffStatus").textContent = d.status || (d.diff ? "" : "no changes");
|
|
2378
|
+
$("diffBody").innerHTML = d.diff ? colorDiff(d.diff) : "";
|
|
2379
|
+
} catch {
|
|
2380
|
+
$("diffStatus").textContent = "diff unavailable";
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
$("toggleDiff").addEventListener("click", () => {
|
|
2384
|
+
const open = $("diffpanel").classList.toggle("open");
|
|
2385
|
+
if (open) loadDiff();
|
|
2386
|
+
});
|
|
2387
|
+
$("closeDiff").addEventListener("click", () => $("diffpanel").classList.remove("open"));
|
|
2388
|
+
$("refreshDiff").addEventListener("click", loadDiff);
|
|
2389
|
+
document.querySelectorAll(".keys button[data-key]").forEach((b) =>
|
|
2390
|
+
b.addEventListener("click", () => {
|
|
2391
|
+
if (active.ws) active.ws.send(JSON.stringify({ type: "key", key: b.dataset.key }));
|
|
2392
|
+
})
|
|
2393
|
+
);
|
|
2394
|
+
$("settleBtn").addEventListener("click", () => {
|
|
2395
|
+
if (active.ws && active.status === "ready")
|
|
2396
|
+
active.ws.send(JSON.stringify({ type: "settle" }));
|
|
2397
|
+
});
|
|
2398
|
+
$("stopBtn").addEventListener("click", () => {
|
|
2399
|
+
if (active.ws) active.ws.send(JSON.stringify({ type: "stop" }));
|
|
2400
|
+
});
|
|
2401
|
+
|
|
2402
|
+
// ── Secrets panel (per-repo env, injected at spawn) ──────────────
|
|
2403
|
+
async function secretsFetch(method, body) {
|
|
2404
|
+
const opts = { method, headers: { "Content-Type": "application/json" } };
|
|
2405
|
+
const q = active && active.sessionId ? "?session=" + encodeURIComponent(active.sessionId) : "";
|
|
2406
|
+
if (body) opts.body = JSON.stringify({ session: active && active.sessionId, ...body });
|
|
2407
|
+
const r = await fetch("/secrets" + (method === "GET" ? q : ""), opts);
|
|
2408
|
+
return r.json();
|
|
2409
|
+
}
|
|
2410
|
+
function renderSecrets(data) {
|
|
2411
|
+
$("secretsRepo").textContent = data.repo || "—";
|
|
2412
|
+
const ul = $("secretsList");
|
|
2413
|
+
ul.innerHTML = "";
|
|
2414
|
+
if (!data.keys || !data.keys.length) {
|
|
2415
|
+
const li = document.createElement("li");
|
|
2416
|
+
li.innerHTML = '<span style="opacity:.6">no secrets for this repo</span>';
|
|
2417
|
+
ul.appendChild(li);
|
|
2418
|
+
}
|
|
2419
|
+
for (const k of data.keys || []) {
|
|
2420
|
+
const li = document.createElement("li");
|
|
2421
|
+
const code = document.createElement("code");
|
|
2422
|
+
code.textContent = k;
|
|
2423
|
+
const del = document.createElement("button");
|
|
2424
|
+
del.textContent = "✕"; del.title = "Remove";
|
|
2425
|
+
del.addEventListener("click", async () => renderSecrets(await secretsFetch("DELETE", { key: k })));
|
|
2426
|
+
li.append(code, del);
|
|
2427
|
+
ul.appendChild(li);
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
async function openSecrets() {
|
|
2431
|
+
renderSecrets(await secretsFetch("GET"));
|
|
2432
|
+
$("secretsOverlay").hidden = false;
|
|
2433
|
+
$("secretKey").focus();
|
|
2434
|
+
}
|
|
2435
|
+
$("secretsBtn").addEventListener("click", openSecrets);
|
|
2436
|
+
$("secretsClose").addEventListener("click", () => { $("secretsOverlay").hidden = true; });
|
|
2437
|
+
$("secretsOverlay").addEventListener("click", (e) => { if (e.target === $("secretsOverlay")) $("secretsOverlay").hidden = true; });
|
|
2438
|
+
$("secretsAdd").addEventListener("submit", async (e) => {
|
|
2439
|
+
e.preventDefault();
|
|
2440
|
+
const key = $("secretKey").value.trim();
|
|
2441
|
+
const value = $("secretVal").value;
|
|
2442
|
+
if (!key) return;
|
|
2443
|
+
const data = await secretsFetch("PUT", { key, value });
|
|
2444
|
+
$("secretKey").value = ""; $("secretVal").value = "";
|
|
2445
|
+
renderSecrets(data);
|
|
2446
|
+
$("secretKey").focus();
|
|
2447
|
+
});
|
|
2448
|
+
$("secretsRestart").addEventListener("click", () => {
|
|
2449
|
+
if (active && active.ws && active.ws.readyState === WebSocket.OPEN) {
|
|
2450
|
+
active.ws.send(JSON.stringify({ type: "restart" }));
|
|
2451
|
+
}
|
|
2452
|
+
$("secretsOverlay").hidden = true;
|
|
2453
|
+
});
|
|
2454
|
+
newTabBtn.addEventListener("click", () => createTab());
|
|
2455
|
+
newGroupBtn.addEventListener("click", () => {
|
|
2456
|
+
createGroup();
|
|
2457
|
+
persistGroups();
|
|
2458
|
+
});
|
|
2459
|
+
|
|
2460
|
+
// ── Quota gauges (5h / 7d) ──────────────────────────────
|
|
2461
|
+
function fmtReset(sec) {
|
|
2462
|
+
if (!sec) return "";
|
|
2463
|
+
const d = sec * 1000 - Date.now();
|
|
2464
|
+
if (d <= 0) return "resets now";
|
|
2465
|
+
const h = Math.floor(d / 3600000);
|
|
2466
|
+
const m = Math.floor((d % 3600000) / 60000);
|
|
2467
|
+
return "resets in " + (h ? h + "h" + (m ? m + "m" : "") : m + "m");
|
|
2468
|
+
}
|
|
2469
|
+
/** Last verdict from /usage — read by the composer before sending. */
|
|
2470
|
+
let paceBlocked = false, paceReason = null;
|
|
2471
|
+
|
|
2472
|
+
function paintGauge(el, w) {
|
|
2473
|
+
const uFill = el.querySelector(".meter.usage .fill");
|
|
2474
|
+
const pFill = el.querySelector(".meter.pace .fill");
|
|
2475
|
+
const pct = el.querySelector(".qpct");
|
|
2476
|
+
const base = el.id === "quota5h" ? "5-hour rolling limit" : "7-day rolling limit";
|
|
2477
|
+
if (!w) {
|
|
2478
|
+
uFill.style.width = "0%";
|
|
2479
|
+
pFill.style.width = "0%";
|
|
2480
|
+
pct.textContent = "—";
|
|
2481
|
+
el.title = base;
|
|
2482
|
+
el.classList.remove("warn", "crit");
|
|
2483
|
+
return;
|
|
2484
|
+
}
|
|
2485
|
+
const used = Math.round(w.usedPercentage);
|
|
2486
|
+
const pace = w.idealPacePct == null ? null : Math.round(w.idealPacePct);
|
|
2487
|
+
const ratio = w.ratioPct == null ? null : Math.round(w.ratioPct);
|
|
2488
|
+
uFill.style.width = Math.min(100, used) + "%";
|
|
2489
|
+
pFill.style.width = (pace === null ? 0 : Math.min(100, pace)) + "%";
|
|
2490
|
+
pct.textContent = used + "%";
|
|
2491
|
+
// Colour tracks the ratio: 90% of the 5h window with 10 min left is fine,
|
|
2492
|
+
// 55% of the 7d window with 6 days left is not.
|
|
2493
|
+
el.classList.toggle("warn", ratio !== null && ratio >= 70 && ratio < 100);
|
|
2494
|
+
el.classList.toggle("crit", ratio !== null && ratio >= 100);
|
|
2495
|
+
// fmtReset returns "" when resetsAt is null — the separator goes with it,
|
|
2496
|
+
// otherwise the tooltip ends on a dangling " · ".
|
|
2497
|
+
const reset = fmtReset(w.resetsAt);
|
|
2498
|
+
el.title = base + " — " + used + "% used"
|
|
2499
|
+
+ (pace === null ? "" : ", ideal pace " + pace + "% (" + ratio + "% of pace)")
|
|
2500
|
+
+ (reset ? " · " + reset : "");
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
async function refreshUsage() {
|
|
2504
|
+
try {
|
|
2505
|
+
const u = await (await fetch("/usage")).json();
|
|
2506
|
+
paintGauge($("quota5h"), u.fiveHour);
|
|
2507
|
+
paintGauge($("quota7d"), u.sevenDay);
|
|
2508
|
+
paceBlocked = !!u.blocked;
|
|
2509
|
+
paceReason = u.reason || null;
|
|
2510
|
+
} catch {
|
|
2511
|
+
// Leave the gauges on their last values, but not the verdict: a stuck
|
|
2512
|
+
// `true` would make the client demand confirmation forever where the
|
|
2513
|
+
// server would let the send through. The server re-checks and is the
|
|
2514
|
+
// authority, so failing open here costs nothing.
|
|
2515
|
+
paceBlocked = false;
|
|
2516
|
+
paceReason = null;
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
refreshUsage();
|
|
2520
|
+
setInterval(refreshUsage, 60_000);
|
|
2521
|
+
|
|
2522
|
+
// On load: restore the remembered groups (server-side, keyed by launch
|
|
2523
|
+
// directory), then the remembered channels by resuming each session (the
|
|
2524
|
+
// server replays the history); otherwise start with a blank channel.
|
|
2525
|
+
(async () => {
|
|
2526
|
+
if (!serverCwd) {
|
|
2527
|
+
try { serverCwd = (await (await fetch("/defaults")).json()).cwd || ""; } catch {}
|
|
2528
|
+
}
|
|
2529
|
+
// Groups first, so channels land in the right one.
|
|
2530
|
+
let savedGroups = [];
|
|
2531
|
+
try {
|
|
2532
|
+
const g = await (await fetch("/groups")).json();
|
|
2533
|
+
if (Array.isArray(g) && g.length) savedGroups = g;
|
|
2534
|
+
} catch {}
|
|
2535
|
+
if (!savedGroups.length) savedGroups = JSON.parse(localStorage.getItem("cp.groups") || "[]");
|
|
2536
|
+
for (const g of savedGroups) createGroup(g);
|
|
2537
|
+
|
|
2538
|
+
let saved = [];
|
|
2539
|
+
try {
|
|
2540
|
+
const fromServer = await (await fetch("/channels")).json();
|
|
2541
|
+
if (Array.isArray(fromServer) && fromServer.length) saved = fromServer;
|
|
2542
|
+
} catch { /* offline: use localStorage */ }
|
|
2543
|
+
if (!saved.length) saved = JSON.parse(localStorage.getItem("cp.channels") || "[]");
|
|
2544
|
+
|
|
2545
|
+
const seenIds = new Set();
|
|
2546
|
+
const uniqueChannels = saved.filter(
|
|
2547
|
+
(s) => s.sessionId && !seenIds.has(s.sessionId) && seenIds.add(s.sessionId),
|
|
2548
|
+
);
|
|
2549
|
+
if (uniqueChannels.length) {
|
|
2550
|
+
for (const s of uniqueChannels) {
|
|
2551
|
+
const t = createTab(s.group);
|
|
2552
|
+
// Know the session id from the start, so a slow or failed reattach
|
|
2553
|
+
// never drops the channel from a persist (which caused the list to
|
|
2554
|
+
// erode to one on repeated reloads).
|
|
2555
|
+
t.sessionId = s.sessionId;
|
|
2556
|
+
if (s.branch) t.branch = s.branch;
|
|
2557
|
+
if (s.name) {
|
|
2558
|
+
t.customName = true;
|
|
2559
|
+
t.nameEl.textContent = s.name;
|
|
2560
|
+
}
|
|
2561
|
+
// Pass branch/repo so a reclaimed worktree checkout is recreated.
|
|
2562
|
+
launchTab(t, {
|
|
2563
|
+
cwd: s.cwd,
|
|
2564
|
+
resume: s.sessionId,
|
|
2565
|
+
branch: s.branch || undefined,
|
|
2566
|
+
repo: s.repo || serverCwd || undefined,
|
|
2567
|
+
});
|
|
2568
|
+
}
|
|
2569
|
+
activate(tabs[0]);
|
|
2570
|
+
// Restore done: re-enable persistence and write the full set once.
|
|
2571
|
+
restoring = false;
|
|
2572
|
+
persistChannels();
|
|
2573
|
+
if (savedGroups.length) persistGroups(); // sync groups (e.g. from localStorage fallback)
|
|
2574
|
+
} else {
|
|
2575
|
+
createTab();
|
|
2576
|
+
// Nothing to restore: enable persistence for user actions, but do NOT
|
|
2577
|
+
// push a blank list (a failed /channels fetch must never wipe the store).
|
|
2578
|
+
restoring = false;
|
|
2579
|
+
}
|
|
2580
|
+
})();
|
|
2581
|
+
</script>
|
|
2582
|
+
</body>
|
|
2583
|
+
</html>
|