rdsh 0.10.0

Rust port/shim for dsh: fast launcher, token saver, zero behavior breakage via passthrough
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
<!DOCTYPE html>
<html lang="__RDSH_LOCALE__" data-rdsh-locale="__RDSH_LOCALE__">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="robots" content="noindex">
<title id="ui-page-title">rdsh dashboard</title>
<link rel="icon" type="image/png" href="/icon.png">
<link rel="apple-touch-icon" href="/icon.png">
<style>
/* ---------- design tokens ---------- */
:root {
  color-scheme: light dark;
  --bg: #f4f6f9;
  --bg-elevated: #ffffff;
  --card: #ffffff;
  --line: #dbe1e8;
  --line-strong: #c2ccd6;
  --fg: #1b2129;
  --fg-muted: #55606d;
  --acc: #0a5fc0;
  --acc-fg: #ffffff;
  --ok: #176e30;
  --warn: #8a5a00;
  --danger: #b3261e;
  --code-bg: #eef1f5;
  --brand-bg: #1f6feb;
  --brand-fg: #ffffff;
  --shadow-1: 0 1px 2px rgba(20, 26, 34, .07), 0 1px 3px rgba(20, 26, 34, .05);
  --shadow-2: 0 6px 20px rgba(20, 26, 34, .09);
  --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
  --sp-5: 24px; --sp-6: 32px; --sp-7: 48px;
  --r-sm: 6px; --r-md: 10px; --r-lg: 16px; --r-pill: 999px;
  --fs-xs: 12px; --fs-sm: 13px; --fs-md: 16px; --fs-lg: 20px;
  --fs-xl: 22px; --fs-2xl: 34px;
  --font: system-ui, -apple-system, "Segoe UI", "Hiragino Sans", "Noto Sans JP", sans-serif;
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  --measure: 1120px;
}
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0b0f14;
    --bg-elevated: #121820;
    --card: #141a22;
    --line: #29313c;
    --line-strong: #3a4451;
    --fg: #e9eef4;
    --fg-muted: #9aa5b4;
    --acc: #6cb6ff;
    --acc-fg: #06121f;
    --ok: #4ac26b;
    --warn: #d9a72c;
    --danger: #ff837a;
    --code-bg: #0d1319;
    --brand-bg: #2264c9;
    --brand-fg: #ffffff;
    --shadow-1: 0 1px 2px rgba(0, 0, 0, .45);
    --shadow-2: 0 8px 24px rgba(0, 0, 0, .45);
  }
}
/* ---------- base ---------- */
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html { -webkit-text-size-adjust: 100%; }
body {
  margin: 0;
  background: var(--bg);
  color: var(--fg);
  font: var(--fs-md)/1.65 var(--font);
  overflow-wrap: break-word;
}
h1, h2 { letter-spacing: -.005em; }
a { color: var(--acc); }
a:hover { text-decoration-thickness: 2px; }
svg { max-width: 100%; }
:focus-visible { outline: 2px solid var(--acc); outline-offset: 2px; border-radius: var(--r-sm); }
.skip-link {
  position: absolute; left: -9999px; top: 0; z-index: 20;
  background: var(--card); color: var(--fg);
  padding: var(--sp-2) var(--sp-3); border: 1px solid var(--line-strong); border-radius: var(--r-sm);
}
.skip-link:focus { left: var(--sp-3); top: var(--sp-3); }
/* ---------- header ---------- */
.site-header {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between;
  gap: var(--sp-3) var(--sp-4);
  padding: var(--sp-4) clamp(var(--sp-4), 4vw, var(--sp-6));
  background: var(--card);
  border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: var(--sp-3); min-width: 0; }
.brand-mark {
  display: inline-flex; flex: 0 0 auto; align-items: center; justify-content: center;
  width: 40px; height: 40px; border-radius: 12px;
  background: var(--brand-bg); color: var(--brand-fg);
}
.brand-copy { min-width: 0; }
.brand-title { margin: 0; display: flex; flex-direction: column; gap: 1px; }
.brand-name { font-size: var(--fs-xl); font-weight: 700; line-height: 1.2; }
.brand-role { font-size: var(--fs-sm); font-weight: 500; color: var(--fg-muted); line-height: 1.35; }
.brand-sub { margin: 2px 0 0; font-size: var(--fs-xs); color: var(--fg-muted); max-width: 68ch; }
.header-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-3); margin-left: auto; }
.badge {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 3px 10px; border-radius: var(--r-pill);
  border: 1px solid currentColor; background: transparent;
  font-size: var(--fs-xs); font-weight: 600; white-space: nowrap;
}
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.badge-ok { color: var(--ok); }
.badge-warn { color: var(--warn); }
#ver { font: var(--fs-xs)/1.4 var(--font-mono); color: var(--fg-muted); white-space: nowrap; }
.updated { font-size: var(--fs-xs); color: var(--fg-muted); white-space: nowrap; }
/* ---------- buttons ---------- */
.btn {
  font: 700 var(--fs-md)/1 var(--font);
  border-radius: var(--r-md); padding: 12px 18px; min-height: 44px;
  border: 1px solid transparent; cursor: pointer;
  transition: background-color .12s ease, border-color .12s ease, filter .12s ease;
}
.btn-primary { background: var(--acc); color: var(--acc-fg); }
.btn-primary:hover { filter: brightness(1.08); }
.btn-connect { min-height: 52px; padding: 0 30px; font-size: var(--fs-lg); font-weight: 800; }
.btn-ghost { background: transparent; color: var(--fg); border-color: var(--line-strong); }
.btn-ghost:hover { background: var(--code-bg); border-color: var(--acc); }
.btn:disabled { opacity: .55; cursor: not-allowed; }
/* ---------- layout ---------- */
.layout {
  max-width: var(--measure); margin: 0 auto;
  padding: var(--sp-5) clamp(var(--sp-4), 4vw, var(--sp-6)) var(--sp-6);
  display: flex; flex-direction: column; gap: var(--sp-5);
}
.panel {
  background: var(--card); border: 1px solid var(--line);
  border-radius: var(--r-lg); padding: var(--sp-5); box-shadow: var(--shadow-1);
  min-width: 0;
}
.panel-head {
  display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between;
  gap: var(--sp-3); margin-bottom: var(--sp-4);
}
.panel h2, .card h2 { margin: 0 0 var(--sp-1); font-size: var(--fs-lg); }
.muted { margin: 0; color: var(--fg-muted); font-size: var(--fs-sm); }
/* ---------- connect ---------- */
.connect-card { border-color: var(--acc); box-shadow: var(--shadow-2); }
.connect-row { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-3); }
#dashboard-token {
  flex: 1 1 240px; min-width: 0; min-height: 52px;
  background: var(--code-bg); color: var(--fg);
  border: 2px solid var(--line-strong); border-radius: var(--r-md);
  padding: 12px 14px; font: 15px/1.4 var(--font-mono);
}
.error { margin: var(--sp-2) 0 0; min-height: 1.2em; color: var(--danger); font-size: var(--fs-sm); }
/* ---------- at a glance ---------- */
.glance {
  display: grid; gap: var(--sp-3); margin: 0;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr));
}
.glance .tile {
  display: flex; flex-direction: column; gap: var(--sp-1); min-width: 0;
  margin: 0; padding: var(--sp-3) var(--sp-4);
  background: var(--bg-elevated); border: 1px solid var(--line); border-radius: var(--r-md);
}
.glance dt { font-size: var(--fs-xs); font-weight: 600; color: var(--fg-muted); letter-spacing: .02em; }
.glance dd { margin: 0; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.tile-value { font-size: var(--fs-2xl); font-weight: 700; line-height: 1.15; letter-spacing: -.01em; overflow-wrap: anywhere; }
.tile-value-path { font: 600 var(--fs-sm)/1.5 var(--font-mono); word-break: break-all; }
.tile-note { font-size: var(--fs-xs); color: var(--fg-muted); overflow-wrap: anywhere; }
/* ---------- status hero ---------- */
.hero { display: flex; align-items: center; gap: var(--sp-4); border-left: 6px solid var(--ok); color: var(--ok); }
.hero-warn { border-left-color: var(--warn); color: var(--warn); }
.hero-icon { flex: 0 0 auto; }
.hero-big { margin: 0; font-size: var(--fs-2xl); font-weight: 700; line-height: 1.25; letter-spacing: -.01em; color: var(--fg); }
.hero-sub { margin: 2px 0 0; color: var(--fg-muted); font-size: var(--fs-md); }
/* ---------- connect steps ---------- */
.steps { list-style: none; counter-reset: step; margin: 0 0 var(--sp-4); padding: 0; display: flex; flex-direction: column; gap: var(--sp-4); }
.step { counter-increment: step; display: flex; gap: var(--sp-3); align-items: flex-start; }
.step::before { content: counter(step); flex: 0 0 auto; width: 34px; height: 34px; border-radius: 50%; background: var(--acc); color: var(--acc-fg); font-weight: 700; font-size: var(--fs-lg); display: flex; align-items: center; justify-content: center; }
.step h3 { margin: 2px 0; font-size: var(--fs-lg); }
.step p { margin: 0; color: var(--fg-muted); font-size: var(--fs-md); }
.step .chip { display: inline-block; margin-top: var(--sp-2); padding: 8px 12px; background: var(--code-bg); border: 1px solid var(--line-strong); border-radius: var(--r-sm); font: var(--fs-sm)/1.4 var(--font-mono); overflow-wrap: anywhere; }
.faq { margin-top: var(--sp-4); border-top: 1px dashed var(--line-strong); padding-top: var(--sp-3); }
.faq summary { cursor: pointer; font-weight: 600; color: var(--fg-muted); font-size: var(--fs-md); }
.faq summary::-webkit-details-marker { display: none; }
.faq ul { margin: var(--sp-2) 0 0; padding-left: var(--sp-5); color: var(--fg-muted); font-size: var(--fs-md); }
.faq li { margin-top: var(--sp-2); }
/* ---------- locked teaser ---------- */
.locked { display: flex; align-items: center; gap: var(--sp-4); border: 1.5px dashed var(--line-strong); border-radius: var(--r-md); padding: var(--sp-5); color: var(--fg-muted); }
.locked b { color: var(--fg); }
.locked .locked-help { margin: 2px 0 0; font-size: var(--fs-md); }
/* ---------- sessions ---------- */
.session-list { display: flex; flex-direction: column; gap: var(--sp-3); min-width: 0; }
.session-rows { display: flex; flex-direction: column; margin: 0; padding: 0; list-style: none; }
.session-row { display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-2); border-top: 1px solid var(--line); }
.session-row:first-child { border-top: none; }
.proj { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; font-weight: 700; font-size: var(--fs-lg); min-width: 0; }
.proj .sw { width: 10px; height: 10px; border-radius: 50%; background: var(--acc); flex: 0 0 auto; }
.when { flex: 1 1 auto; min-width: 0; font-size: var(--fs-md); }
.size-pill { flex: 0 0 auto; font-size: var(--fs-sm); font-weight: 600; color: var(--fg-muted); border: 1px solid var(--line-strong); border-radius: var(--r-pill); padding: 5px 12px; }
.session-projects { margin: 0; font-size: var(--fs-sm); color: var(--fg-muted); }
.empty {
  display: flex; flex-direction: column; align-items: flex-start; gap: var(--sp-2);
  padding: var(--sp-5);
  background: var(--bg-elevated);
  border: 1px dashed var(--line-strong); border-radius: var(--r-md);
}
.empty-title { margin: 0; font-weight: 600; }
/* ---------- advanced ---------- */
.advanced summary {
  display: flex; align-items: center; gap: var(--sp-2);
  font-size: var(--fs-lg); font-weight: 600; cursor: pointer;
  list-style: none; padding: var(--sp-1) 0;
}
.advanced summary::-webkit-details-marker { display: none; }
.advanced summary::before {
  content: ""; flex: 0 0 auto; width: 8px; height: 8px;
  border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
  transform: rotate(-45deg); transition: transform .15s ease;
}
.advanced[open] summary::before { transform: rotate(45deg); }
.advanced > .muted { margin: var(--sp-1) 0 var(--sp-4); }
.advanced-grid {
  display: grid; gap: var(--sp-4);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
}
.card {
  min-width: 0; padding: var(--sp-4);
  background: var(--bg-elevated); border: 1px solid var(--line); border-radius: var(--r-md);
}
.card > .muted { margin-bottom: var(--sp-2); }
textarea, input[type=number] {
  width: 100%; background: var(--code-bg); color: var(--fg);
  border: 1px solid var(--line-strong); border-radius: var(--r-sm);
  padding: 9px 10px; font: var(--fs-sm)/1.5 var(--font-mono);
}
textarea { min-height: 110px; resize: vertical; }
.row { display: flex; align-items: center; gap: var(--sp-2); margin-top: var(--sp-2); }
.row label { color: var(--fg-muted); font-size: var(--fs-sm); white-space: nowrap; }
.row input { width: 96px; }
pre {
  margin: var(--sp-3) 0 0; padding: var(--sp-3);
  background: var(--code-bg); border: 1px solid var(--line); border-radius: var(--r-sm);
  font: var(--fs-xs)/1.6 var(--font-mono);
  white-space: pre-wrap; overflow-wrap: anywhere; overflow: auto; max-height: 240px;
}
/* ---------- footer ---------- */
.site-footer {
  max-width: var(--measure); margin: 0 auto;
  padding: 0 clamp(var(--sp-4), 4vw, var(--sp-6)) var(--sp-6);
  color: var(--fg-muted); font-size: var(--fs-xs);
  display: flex; flex-direction: column; gap: var(--sp-1);
}
.site-footer p { margin: 0; }
.footer-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1) var(--sp-2); }
.footer-meta code { font-family: var(--font-mono); }
/* ---------- responsive ---------- */
@media (max-width: 560px) {
  .header-actions { width: 100%; margin-left: 0; }
  .tile-value { font-size: var(--fs-xl); }
  .panel { padding: var(--sp-4); }
  .session-row { flex-wrap: wrap; }
  .proj { flex: 1 1 100%; }
  .size-pill { margin-left: var(--sp-2); }
}
@media (prefers-reduced-motion: reduce) {
  * { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
}
</style>
</head>
<body>
<a class="skip-link" id="ui-skip" href="#main">本文へスキップ</a>
<header class="site-header">
  <div class="brand">
    <span class="brand-mark" aria-hidden="true"><svg viewBox="0 0 24 24" width="22" height="22" focusable="false"><path d="M3 15.5h3.4l2.3-6.8 3.2 9.6 2.6-7 1.7 4.2H21" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
    <div class="brand-copy">
      <h1 class="brand-title"><span class="brand-name">rdsh</span><span class="brand-role" id="ui-title">dsh のようすを見るページ(このパソコンの中だけで開けます)</span></h1>
      <p class="brand-sub" id="ui-subtitle">見るだけのダッシュボードです。本家 dsh のチャット画面ではありません。</p>
    </div>
  </div>
  <div class="header-actions">
    <span id="connect-state" class="badge badge-warn" role="status" aria-live="polite">まだつながっていません</span>
    <span id="ver">rdsh __RDSH_VERSION__</span>
    <span class="updated" id="glance-updated"></span>
    <button type="button" class="btn btn-ghost" id="ui-reload" onclick="reloadData()">再読み込み</button>
  </div>
</header>
<main id="main" class="layout">
  <section id="auth" class="panel connect-card" hidden aria-labelledby="ui-connect-heading">
    <h2 id="ui-connect-heading">ダッシュボードに接続</h2>
    <p id="ui-connect-lede" class="muted">やることは3つだけです。</p>
    <ol class="steps">
      <li class="step">
        <div>
          <h3 id="ui-step1-title">端末にあいことばの入ったファイルが出ています</h3>
          <p id="ui-step1-help">rdsh serve を動かしている画面を見てください。こんな行があります。</p>
          <span class="chip" aria-hidden="true">cat /var/tmp/rdsh-token-xxxx….txt</span>
        </div>
      </li>
      <li class="step">
        <div>
          <h3 id="ui-step2-title">その中身をぜんぶコピーする</h3>
          <p id="ui-step2-help">長い文字ですが、全部が必要です。</p>
        </div>
      </li>
      <li class="step">
        <div>
          <h3 id="ui-step3-title">下の箱に貼り付けて「つなぐ」を押す</h3>
          <p id="ui-step3-help">あいことは、このページを開ける人を確かめるための文字です。URLには保存されません。</p>
        </div>
      </li>
    </ol>
    <div class="connect-row">
      <input id="dashboard-token" type="password" autocomplete="off" spellcheck="false" aria-label="あいことば">
      <button type="button" class="btn btn-primary btn-connect" id="ui-connect" onclick="connectDashboard()">つなぐ</button>
    </div>
    <p id="auth-error" class="error" role="alert"></p>
    <details class="faq">
      <summary id="ui-faq-summary">わからないときは(よくある質問)</summary>
      <ul>
        <li id="ui-faq-1">「端末」ってどこ? — rdsh serve を実行した、文字が流れる黒い画面のことです。</li>
        <li id="ui-faq-2">ファイルが見つからない — 一度止めて(Ctrl-C)、もう一度 rdsh serve を実行すると新しいファイルが出ます。</li>
        <li id="ui-faq-3">ほかのパソコンからは開けません。あやしいときは、URLが 127.0.0.1 で始まるか確認してください。</li>
      </ul>
    </details>
  </section>

  <section class="locked" id="locked" hidden>
    <svg width="34" height="34" viewBox="0 0 34 34" aria-hidden="true" focusable="false"><rect x="7" y="15" width="20" height="13" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><path d="M11.5 15v-4a5.5 5.5 0 0111 0v4" fill="none" stroke="currentColor" stroke-width="2"/></svg>
    <div>
      <b id="ui-locked-title">つなぐと見られるもの</b>
      <p class="locked-help" id="ui-locked-help">最近のセッション ・ dsh本体の場所 ・ くわしい情報</p>
    </div>
  </section>

  <section class="panel hero" id="status-hero" hidden aria-live="polite">
    <svg class="hero-icon" width="46" height="46" viewBox="0 0 46 46" aria-hidden="true" focusable="false"><circle cx="23" cy="23" r="22" fill="currentColor" opacity=".16"/><path d="M14 23.5l6.5 6.5L32 16.5" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
    <div>
      <p class="hero-big" id="hero-title"></p>
      <p class="hero-sub" id="hero-sub"></p>
    </div>
  </section>

  <section class="panel" id="overview" aria-labelledby="ui-overview-heading">
    <div class="panel-head">
      <div>
        <h2 id="ui-overview-heading">いまのようす</h2>
        <p id="ui-overview-help" class="muted">よく見る3つだけを示します。</p>
      </div>
    </div>
    <dl class="glance">
      <div class="tile">
        <dt id="ui-glance-dsh">本家 dsh の場所</dt>
        <dd><span class="tile-value tile-value-path" id="glance-dsh">—</span><span class="tile-note" id="glance-dsh-note"></span></dd>
      </div>
      <div class="tile">
        <dt id="ui-glance-rdsh">rdsh</dt>
        <dd><span class="tile-value" id="glance-rdsh">__RDSH_VERSION__</span><span class="tile-note" id="ui-glance-rdsh-note">このダッシュボードを提供している版</span></dd>
      </div>
      <div class="tile">
        <dt id="ui-glance-sessions">最近のセッション</dt>
        <dd><span class="tile-value" id="glance-sessions">—</span><span class="tile-note" id="glance-sessions-note"></span></dd>
      </div>
    </dl>
  </section>

  <section class="panel" id="sessions-panel" aria-labelledby="ui-sessions-heading">
    <div class="panel-head">
      <div>
        <h2 id="ui-sessions-heading">最近のセッション</h2>
        <p id="ui-sessions-help" class="muted">更新時刻・サイズ・トークン概算を表示します。</p>
      </div>
      <button type="button" class="btn btn-ghost" id="ui-refresh-sessions" onclick="sessions()">更新</button>
    </div>
    <div id="sess" class="session-list">接続後に自動表示します</div>
  </section>

  <details class="panel advanced" id="advanced">
    <summary><span id="ui-advanced-summary">上級者向けの機能</span></summary>
    <p id="ui-advanced-help" class="muted">環境診断の詳細・トークン推定・履歴の整理・スキル・性能計測。ふだんは開く必要はありません。</p>
    <div class="advanced-grid">
      <section class="card" aria-labelledby="ui-doctor-heading">
        <h2 id="ui-doctor-heading">環境診断</h2>
        <p id="ui-doctor-help" class="muted">本家 dsh の場所・DSH_HOME・slim 設定を確認します(doctor 相当)。</p>
        <button type="button" class="btn btn-ghost" id="ui-refresh-doctor" onclick="doctor()">更新</button>
        <pre id="doctor">接続後に自動表示します</pre>
      </section>
      <section class="card" aria-labelledby="ui-tokens-heading">
        <h2 id="ui-tokens-heading">トークン推定</h2>
        <p id="ui-tokens-help" class="muted">入力テキストのトークン数を概算します(約4文字=1トークン)。</p>
        <textarea id="ttext" placeholder="ここにテキストを貼り付け"></textarea>
        <button type="button" class="btn btn-ghost" id="ui-estimate" onclick="tokens()">推定する</button>
        <pre id="tout">-</pre>
      </section>
      <section class="card" aria-labelledby="ui-prune-heading">
        <h2 id="ui-prune-heading">履歴の整理</h2>
        <p id="ui-prune-help" class="muted">長い出力の先頭と末尾を残して予算内に収めます。</p>
        <textarea id="ptext" placeholder="長いツール出力を貼り付け"></textarea>
        <div class="row"><label id="ui-budget" for="pmax">予算</label><input id="pmax" type="number" value="4000" min="100"></div>
        <button type="button" class="btn btn-ghost" id="ui-prune" onclick="prune()">prune実行</button>
        <button type="button" class="btn btn-ghost" id="pcopy" onclick="copyPrune()">コピー</button>
        <pre id="pout">-</pre>
      </section>
      <section class="card" aria-labelledby="ui-skills-heading">
        <h2 id="ui-skills-heading">スキル</h2>
        <p id="ui-skills-help" class="muted">導入済みのスキル一覧。</p>
        <button type="button" class="btn btn-ghost" id="ui-refresh-skills" onclick="skills()">更新</button>
        <pre id="sk">接続後に自動表示します</pre>
      </section>
      <section class="card" aria-labelledby="ui-bench-heading">
        <h2 id="ui-bench-heading">性能計測</h2>
        <p id="ui-bench-help" class="muted">rdsh と本家 dsh の起動時間を比べます。</p>
        <button type="button" class="btn btn-ghost" id="ui-bench" onclick="bench()">計測 (n=3)</button>
        <pre id="bench">未計測</pre>
      </section>
    </div>
  </details>
</main>
<footer class="site-footer">
  <p id="ui-footer">rdsh は本家 dsh を壊さない安全な Rust ランチャーです。</p>
  <p class="footer-meta">
    <span id="ui-footer-version">rdsh __RDSH_VERSION__</span>
    <span aria-hidden="true">·</span>
    <a href="https://github.com/jimoto-no-llm/rustdsh" rel="noopener noreferrer">GitHub</a>
    <span aria-hidden="true">·</span>
    <span id="ui-footer-license">MIT ライセンス</span>
    <span aria-hidden="true">·</span>
    <span id="ui-footer-note">serve は 127.0.0.1 でのみ待ち受けます。</span>
  </p>
</footer>
<script>
const RDSH_UI_LOCALE = ["en", "zh-CN", "ja"].includes(document.documentElement.dataset.rdshLocale) ? document.documentElement.dataset.rdshLocale : "en";
const RDSH_UI_CATALOG = {};
for (const [key, ja, en, zh] of [
  ["page.title", "rdsh ダッシュボード", "rdsh dashboard", "rdsh 仪表板"],
  ["app.role", "dsh のようすを見るページ(このパソコンの中だけで開けます)", "See what dsh is doing (opens only on this computer)", "查看 dsh 的动态(仅在本机打开)"],
  ["app.subtitle", "見るだけのダッシュボードです。本家 dsh のチャット画面ではありません。", "A read-only view — this is not the dsh chat.", "仅供查看的仪表板,并非 dsh 聊天界面。"],
  ["skip.toContent", "本文へスキップ", "Skip to content", "跳到主要内容"],
  ["reload", "最新にする", "Refresh data", "刷新数据"],
  ["page.updated", "最終更新: {time}", "Last updated: {time}", "最后更新:{time}"],

  ["overview.heading", "いまのようす", "Status", "当前状态"],
  ["overview.help", "よく見る3つだけを示します。", "The three things you normally need.", "只显示常用的三项。"],
  ["glance.dsh", "dsh 本体の場所", "Where dsh lives", "dsh 程序位置"],
  ["glance.dsh.note.found", "ちゃんと見つかっています", "Found and ready", "已找到,可正常使用"],
  ["glance.dsh.note.missing", "見つかりません", "Not found", "未找到"],
  ["glance.rdsh", "このページの版", "This page's version", "本页面版本"],
  ["glance.rdsh.note", "更新の案内が出たら、押すだけで新しくなります。", "When an update lands, one press installs it.", "出现更新提示后,一键即可更新。"],
  ["glance.sessions", "最近のセッション", "Recent sessions", "近期会话"],
  ["glance.sessions.count", "{count} 件", "{count}", "{count} 条"],
  ["glance.sessions.note", "このパソコンでの会話の記録", "Conversations on this computer", "本机上的对话记录"],

  ["connection.waiting", "まだつながっていません", "Not connected yet", "尚未连接"],
  ["connection.connected", "つながっています({time} 更新)", "Connected (updated {time})", "已连接({time} 更新)"],
  ["connect.heading", "3つやるだけで使えます", "Just three quick steps", "只需三步即可使用"],
  ["connect.lede", "1分もかかりません。", "It takes under a minute.", "不到一分钟。"],
  ["step1.title", "端末にあいことばの入ったファイルが出ています", "The terminal shows a file with your access phrase", "终端里显示了一个写有口令的文件"],
  ["step1.help", "rdsh serve を動かしている画面を見てください。こんな行があります。", "Look at the window running rdsh serve. You will see a line like this.", "查看运行 rdsh serve 的窗口,会看到类似这样的一行。"],
  ["step2.title", "その中身をぜんぶコピーする", "Copy the whole file contents", "完整复制其中的内容"],
  ["step2.help", "長い文字ですが、全部が必要です。", "It is long, but every character is needed.", "文字很长,但全部都需要。"],
  ["step3.title", "下の箱に貼り付けて「つなぐ」を押す", "Paste it below and press Connect", "粘贴到下方并点击“连接”"],
  ["step3.help", "あいことば(トークン)は、このページを開ける人を確かめるための文字です。URLには保存されません。", "The phrase (token) proves it is you; it is never stored in the URL.", "口令(令牌)用于确认是本人打开页面;不会保存到网址中。"],
  ["connect.button", "つなぐ", "Connect", "连接"],
  ["token.label", "あいことば(トークン)", "Access phrase (token)", "口令(令牌)"],
  ["token.placeholder", "ここに貼り付け", "Paste it here", "粘贴到此处"],
  ["faq.summary", "わからないときは(よくある質問)", "Trouble? Common questions", "遇到问题时(常见问答)"],
  ["faq.terminal", "「端末」ってどこ? — rdsh serve を実行した、文字が流れる黒い画面のことです。", "Where is the terminal? — the black window where you ran rdsh serve.", "终端在哪里?— 即运行 rdsh serve 的黑色窗口。"],
  ["faq.file", "ファイルが見つからない — 一度止めて(Ctrl-C)、もう一度 rdsh serve を実行すると新しいファイルが出ます。", "Cannot find the file? — stop with Ctrl-C and run rdsh serve again for a fresh one.", "找不到文件?— 用 Ctrl-C 停止后再次运行 rdsh serve 可生成新文件。"],
  ["faq.local", "ほかのパソコンからは開けません。あやしいときは、URLが 127.0.0.1 で始まるか確認してください。", "It opens on this computer only; if in doubt, check the URL starts with 127.0.0.1.", "其他电脑无法打开;如有疑虑,请确认网址以 127.0.0.1 开头。"],
  ["hero.ok.title", "dsh は動いています", "dsh is up and running", "dsh 正在运行"],
  ["hero.ok.sub", "{time} に確認しました。くわしい点検は下の「くわしい情報」にあります。", "Checked {time}. A detailed check lives under Details below.", "已于 {time} 确认。详细检查见下方“详细信息”。"],
  ["hero.missing.title", "dsh 本体が見つかりません", "dsh was not found", "未找到 dsh 程序"],
  ["hero.missing.sub", "端末で dsh doctor を実行すると原因がわかります。", "Run dsh doctor in the terminal to see why.", "在终端运行 dsh doctor 可查看原因。"],
  ["hero.fail.title", "状態を確認できませんでした", "Could not check the status", "无法确认状态"],
  ["hero.fail.sub", "「最新にする」を押すか、serve を再起動してください。", "Press Refresh data, or restart rdsh serve.", "请点击“刷新数据”,或重启 rdsh serve。"],

  ["sessions.heading", "最近のセッション", "Recent sessions", "近期会话"],
  ["sessions.help", "いつ更新したかと、大きさを示します。", "Shows when each was updated and how big it is.", "显示各项的更新时间与大小。"],
  ["sessions.updated", "{time} に更新", "Updated {time}", "更新于 {time}"],
  ["sessions.projects", "{count} プロジェクトの記録", "From {count} projects", "来自 {count} 个项目"],
  ["sessions.project.fallback", "プロジェクト", "project", "项目"],
  ["sessions.empty.title", "まだセッションがありません", "No sessions yet", "还没有会话"],
  ["sessions.empty.help", "dsh で会話を始めると、最新のセッションがここに並びます。", "Start a conversation with dsh and the latest sessions appear here.", "使用 dsh 开始对话后,最新会话会显示在这里。"],
  ["sessions.empty.connect", "トークンを入力", "Enter the token", "输入令牌"],
  ["locked.title", "つなぐと見られるもの", "What you will see once connected", "连接后可查看的内容"],
  ["locked.help", "最近のセッション ・ dsh本体の場所 ・ くわしい情報", "Recent sessions, where dsh lives, and details", "近期会话、dsh 程序位置、详细信息"],
  ["awaiting", "接続後に自動表示します", "Loads automatically after you connect", "连接后将自动显示"],

  ["advanced.summary", "くわしい情報(ふだんは開かなくて大丈夫)", "Details (safe to leave closed)", "详细信息(平时无需展开)"],
  ["advanced.help", "環境の点検・文字数のはかり・長い出力を短くする道具・スキル一覧・起動時間の測定。", "Environment checks, a character counter, long-output trimming, skills and startup timing.", "环境检查、字数估算、长输出修剪、技能列表与启动计时。"],
  ["doctor.heading", "環境診断", "Environment check", "环境诊断"], ["doctor.help", "本家 dsh の場所・DSH_HOME・slim 設定を確認します(doctor 相当)。", "Checks where upstream dsh lives, DSH_HOME and slim settings (the doctor view).", "确认上游 dsh 的位置、DSH_HOME 与 slim 设置(相当于 doctor)。"], ["refresh", "更新", "Refresh", "刷新"],
  ["tokens.heading", "トークン推定", "Token estimate", "令牌估算"], ["tokens.help", "入力テキストのトークン数を概算します(約4文字=1トークン)。", "Estimates input tokens (about 4 characters = 1 token).", "估算输入文本的令牌数(约 4 个字符 = 1 个令牌)。"], ["tokens.placeholder", "ここにテキストを貼り付け", "Paste text here", "在此粘贴文本"], ["tokens.estimate", "推定する", "Estimate", "估算"], ["tokens.result", "トークン: {tokens} / 文字数: {chars}", "tokens: {tokens} / chars: {chars}", "令牌:{tokens} / 字符:{chars}"],
  ["prune.heading", "長い出力を短くする", "Trim long output", "修剪长输出"], ["prune.help", "長い出力の先頭と末尾を残して予算内に収めます。", "Keeps the head and tail of long output within a token budget.", "保留长输出的开头和结尾,并控制在预算内。"], ["prune.placeholder", "長いツール出力を貼り付け", "Paste long tool output", "粘贴较长的工具输出"], ["budget", "予算", "Budget", "预算"], ["prune.run", "prune実行", "Run prune", "执行裁剪"], ["prune.result", "[処理前 {before} → 処理後 {after}]({chars}文字)", "[before {before} -> after {after}] ({chars} chars)", "[处理前 {before} → 处理后 {after}]({chars} 个字符)"], ["copy", "コピー", "Copy", "复制"],
  ["skills.heading", "スキル", "Skills", "技能"], ["skills.help", "導入済みのスキル一覧。", "Installed skills.", "已安装的技能。"], ["bench.heading", "性能計測", "Performance", "性能测量"], ["bench.help", "rdsh と本家 dsh の起動時間を比べます。", "Compares rdsh and upstream dsh startup time.", "比较 rdsh 与上游 dsh 的启动时间。"], ["bench.measure", "計測 (n=3)", "Measure (n=3)", "测量 (n=3)"], ["bench.pending", "計測中…", "Measuring…", "正在测量…"], ["bench.none", "未計測", "Not measured", "未测量"],

  ["footer", "rdsh は本家 dsh を壊さない安全な Rust ランチャーです。", "rdsh is a safe Rust launcher that leaves upstream dsh intact.", "rdsh 是不会破坏上游 dsh 的安全 Rust 启动器。"],
  ["footer.note", "serve は 127.0.0.1 でのみ待ち受けます。", "serve listens only on 127.0.0.1.", "serve 仅监听 127.0.0.1。"],
  ["footer.license", "MIT ライセンス", "MIT License", "MIT 许可证"],

  ["auth.rejected", "あいことばが違うみたいです。ファイルの中身を「ぜんぶ」コピーできていますか?", "That phrase does not match. Did you copy the whole file contents?", "口令似乎不对。是否完整复制了文件内容?"], ["error.prefix", "エラー: {message}", "Error: {message}", "错误:{message}"], ["retry", "再試行", "Retry", "重试"], ["copy.done", "コピー済み", "Copied", "已复制"], ["copy.none", "コピーするものがありません。先に「短くする」を押してください。", "Nothing to copy yet. Trim first.", "尚无可复制内容。请先执行修剪。"],
  ["response.invalid", "応答の解析に失敗: サーバーから不正な応答が返されました。再試行してください。", "Could not parse the response: the server returned an invalid response. Retry.", "无法解析响应:服务器返回了无效响应。请重试。"],
  ["connection.failed", "つながりません。rdsh serve が動いているか確認して、もう一度「つなぐ」を押してください。", "Could not connect. Check that rdsh serve is running, then try again.", "无法连接。请确认 rdsh serve 正在运行后重试。"],
  ["http.400", "不正なリクエスト (400): {path} への送信内容を確認してください。", "Bad request (400): check the data sent to {path}.", "请求无效 (400):请检查发送到 {path} 的数据。"], ["http.401", "認証エラー (401): ダッシュボードのトークンが必要です。", "Authentication error (401): dashboard token required.", "认证错误 (401):需要仪表板令牌。"], ["http.403", "アクセス拒否 (403): 信頼できないホスト/オリジンです。127.0.0.1 のダッシュボードURLから直接開いてください。", "Access denied (403): untrusted host/origin. Open directly from the 127.0.0.1 dashboard URL.", "访问被拒绝 (403):主机或来源不受信任。请直接从 127.0.0.1 仪表板 URL 打开。"], ["http.404", "見つかりません (404): {path} は存在しません。", "Not found (404): {path} does not exist.", "未找到 (404):{path} 不存在。"], ["http.413", "送信データが大きすぎます (413): テキストを短くして再試行してください。", "Request data is too large (413): shorten the text and retry.", "发送数据过大 (413):请缩短文本后重试。"], ["http.429", "混雑しています (429): しばらく待って再試行してください。", "Busy (429): wait and retry.", "繁忙 (429):请稍候重试。"], ["http.500", "サーバー内部エラー (500): 再試行してください。直らない場合は serve を再起動してください。", "Internal server error (500): retry. If it continues, restart serve.", "服务器内部错误 (500):请重试。若持续发生,请重启 serve。"], ["http.503", "サーバーが混み合っています (503): しばらく待って再試行してください。", "Server is busy (503): wait and retry.", "服务器繁忙 (503):请稍候重试。"], ["http.default", "リクエスト失敗 ({status}): {path}。再試行してください。", "Request failed ({status}): {path}. Retry.", "请求失败 ({status}):{path}。请重试。"],
]) { (RDSH_UI_CATALOG[key] ||= {}) .ja = ja; RDSH_UI_CATALOG[key].en = en; RDSH_UI_CATALOG[key]["zh-CN"] = zh; }
function t(key, values = {}) { return (RDSH_UI_CATALOG[key]?.[RDSH_UI_LOCALE] || key).replace(/\{(\w+)\}/g, (_, name) => String(values[name] ?? "{" + name + "}")); }
function formatNumber(value) { return new Intl.NumberFormat(RDSH_UI_LOCALE).format(value); }
function formatBytes(value) {
  const bytes = Number(value) || 0;
  if (bytes <= 0) return "0 B";
  const units = ["B", "KB", "MB", "GB"];
  let size = bytes, unit = 0;
  while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1; }
  return new Intl.NumberFormat(RDSH_UI_LOCALE, { maximumFractionDigits: unit === 0 ? 0 : 1 }).format(size) + " " + units[unit];
}
function formatSessionTime(value) {
  if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(value)) return value;
  // The sessions API supplies UTC minutes; keep its timezone and precision.
  const date = new Date(value.replace(" ", "T") + ":00Z");
  return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat(RDSH_UI_LOCALE, {timeZone:"UTC",year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",timeZoneName:"short"}).format(date);
}
function setConnectState(key, values) {
  const connected = key === "connection.connected";
  const badge = document.getElementById("connect-state");
  badge.textContent = t(key, values);
  badge.className = "badge " + (connected ? "badge-ok" : "badge-warn");
}
// One plain sentence about dsh itself: running, missing, or unknown.
function setHero(kind, values) {
  const hero = document.getElementById("status-hero");
  const ok = kind === "ok";
  hero.className = "panel hero" + (ok ? "" : " hero-warn");
  const keys = { ok: ["hero.ok.title", "hero.ok.sub"], missing: ["hero.missing.title", "hero.missing.sub"], fail: ["hero.fail.title", "hero.fail.sub"] };
  document.getElementById("hero-title").textContent = t(keys[kind][0], values);
  document.getElementById("hero-sub").textContent = t(keys[kind][1], values);
  hero.hidden = false;
}
function setUpdatedAt(value) {
  const time = value instanceof Date ? value.toLocaleTimeString(RDSH_UI_LOCALE) : String(value);
  document.getElementById("glance-updated").textContent = t("page.updated", { time });
}
for (const [id, key] of Object.entries({ "ui-skip":"skip.toContent", "ui-reload":"reload", "ui-title":"app.role", "ui-page-title":"page.title", "ui-subtitle":"app.subtitle", "ui-overview-heading":"overview.heading", "ui-overview-help":"overview.help", "connect-state":"connection.waiting", "ui-glance-dsh":"glance.dsh", "ui-glance-rdsh":"glance.rdsh", "ui-glance-rdsh-note":"glance.rdsh.note", "ui-glance-sessions":"glance.sessions", "ui-locked-title":"locked.title", "ui-locked-help":"locked.help", "ui-connect-heading":"connect.heading", "ui-connect-lede":"connect.lede", "ui-step1-title":"step1.title", "ui-step1-help":"step1.help", "ui-step2-title":"step2.title", "ui-step2-help":"step2.help", "ui-step3-title":"step3.title", "ui-step3-help":"step3.help", "ui-connect":"connect.button", "ui-faq-summary":"faq.summary", "ui-faq-1":"faq.terminal", "ui-faq-2":"faq.file", "ui-faq-3":"faq.local", "ui-sessions-heading":"sessions.heading", "ui-sessions-help":"sessions.help", "ui-refresh-sessions":"refresh", "ui-advanced-summary":"advanced.summary", "ui-advanced-help":"advanced.help", "ui-doctor-heading":"doctor.heading", "ui-doctor-help":"doctor.help", "ui-refresh-doctor":"refresh", "ui-tokens-heading":"tokens.heading", "ui-tokens-help":"tokens.help", "ui-estimate":"tokens.estimate", "ui-prune-heading":"prune.heading", "ui-prune-help":"prune.help", "ui-budget":"budget", "ui-prune":"prune.run", pcopy:"copy", "ui-skills-heading":"skills.heading", "ui-skills-help":"skills.help", "ui-refresh-skills":"refresh", "ui-bench-heading":"bench.heading", "ui-bench-help":"bench.help", "ui-bench":"bench.measure", "ui-footer":"footer", "ui-footer-license":"footer.license", "ui-footer-note":"footer.note", "doctor":"awaiting", "sess":"awaiting", "sk":"awaiting", "bench":"bench.none" })) document.getElementById(id).textContent = t(key);
document.getElementById("dashboard-token").setAttribute("aria-label", t("token.label"));
document.getElementById("dashboard-token").placeholder = t("token.placeholder");
document.getElementById("ttext").placeholder = t("tokens.placeholder");
document.getElementById("ptext").placeholder = t("prune.placeholder");
setUpdatedAt("—");
let dashboardToken = new URLSearchParams(location.hash.slice(1)).get("key") || sessionStorage.getItem("rdsh_dashboard_token") || "";
if (dashboardToken) sessionStorage.setItem("rdsh_dashboard_token", dashboardToken);
history.replaceState(null, "", location.pathname);
function focusToken() {
  const card = document.getElementById("auth");
  card.hidden = false;
  const input = document.getElementById("dashboard-token");
  input.focus();
  if (input.scrollIntoView) input.scrollIntoView({ block: "center", behavior: "smooth" });
}
function reloadData() {
  if (dashboardToken) connectDashboard(dashboardToken);
  else focusToken();
}
function sessionEmpty(titleKey, helpKey, action) {
  const box = document.getElementById("sess");
  box.textContent = "";
  const wrap = document.createElement("div");
  wrap.className = "empty";
  const title = document.createElement("p");
  title.className = "empty-title";
  title.textContent = t(titleKey);
  const help = document.createElement("p");
  help.className = "muted";
  help.textContent = t(helpKey);
  wrap.append(title, help);
  if (action) {
    const button = document.createElement("button");
    button.type = "button";
    button.className = "btn btn-ghost";
    button.textContent = t("sessions.empty.connect");
    button.onclick = action;
    wrap.append(button);
  }
  box.append(wrap);
}
function showAuth(message) {
  dashboardToken = "";
  sessionStorage.removeItem("rdsh_dashboard_token");
  document.getElementById("auth").hidden = false;
  document.getElementById("auth-error").textContent = message || "";
  setConnectState("connection.waiting");
  // Until connected, show only the steps and a teaser; the data panels stay closed.
  document.getElementById("locked").hidden = false;
  document.getElementById("status-hero").hidden = true;
  document.getElementById("overview").hidden = true;
  document.getElementById("sessions-panel").hidden = true;
  document.getElementById("advanced").hidden = true;
}
async function connectDashboard(candidateInput){
  const candidate = typeof candidateInput === "string" ? candidateInput : document.getElementById("dashboard-token").value.trim();
  const error = document.getElementById("auth-error");
  try {
    const response = await fetch("/api/doctor", {headers:{"X-RDSH-Token":candidate}});
    if (!response.ok) throw new Error(t("auth.rejected"));
    dashboardToken = candidate;
    sessionStorage.setItem("rdsh_dashboard_token", dashboardToken);
    document.getElementById("dashboard-token").value = "";
    document.getElementById("auth").hidden = true;
    document.getElementById("locked").hidden = true;
    document.getElementById("overview").hidden = false;
    document.getElementById("sessions-panel").hidden = false;
    document.getElementById("advanced").hidden = false;
    // Render the connect state first, then fill every section in parallel so the
    // first connected paint shows diagnostics, sessions and skills together.
    setConnectState("connection.connected", { time: new Date().toLocaleTimeString(RDSH_UI_LOCALE) });
    setUpdatedAt(new Date());
    const [version] = await Promise.all([jget("/api/version"), doctor(), sessions(), skills()]);
    document.getElementById("ver").textContent = version.name + " " + version.version;
    document.getElementById("glance-rdsh").textContent = version.version;
  } catch(e) { showAuth(e.message); error.textContent = e.message; }
}
function statusMessage(status, path){
  switch (status){
    case 400: return t("http.400", { path });
    case 401: showAuth(t("auth.rejected")); return t("http.401");
    case 403: return t("http.403");
    case 404: return t("http.404", { path });
    case 413: return t("http.413");
    case 429: return t("http.429");
    case 500: return t("http.500");
    case 503: return t("http.503");
    default: return t("http.default", { status, path });
  }
}
async function checkRes(r, path){
  if (r.ok){
    try { return await r.json(); }
    catch(e){ throw new Error(t("response.invalid")); }
  }
  let detail = "";
  try { const b = await r.json(); if (b && b.error) detail = "(" + b.error + ")"; } catch(e){}
  throw new Error(statusMessage(r.status, path) + detail);
}
async function jget(p){
  let r;
  try { r = await fetch(p, {headers:{"X-RDSH-Token":dashboardToken}}); }
  catch(e){ throw new Error(t("connection.failed")); }
  return checkRes(r, p);
}
async function jpost(p, b){
  let r;
  try { r = await fetch(p, {method:"POST", headers:{"Content-Type":"application/json","X-RDSH-Token":dashboardToken}, body: JSON.stringify(b)}); }
  catch(e){ throw new Error(t("connection.failed")); }
  return checkRes(r, p);
}
function fail(outId, message, retry){
  const out = document.getElementById(outId);
  out.textContent = t("error.prefix", { message });
  let btn = document.getElementById(outId + "-retry");
  if (!btn){
    btn = document.createElement("button");
    btn.id = outId + "-retry";
    btn.type = "button";
    btn.className = "btn btn-ghost";
    btn.textContent = t("retry");
    out.after(btn);
  }
  btn.onclick = retry;
  btn.hidden = false;
}
function clearRetry(outId){
  const btn = document.getElementById(outId + "-retry");
  if (btn) btn.hidden = true;
}
if (dashboardToken) connectDashboard(dashboardToken); else showAuth("");
async function doctor(){ try { clearRetry("doctor"); const d = await jget("/api/doctor"); document.getElementById("doctor").textContent = JSON.stringify(d, null, 2); const found = d && d.original_dsh; const target = document.getElementById("glance-dsh"); target.textContent = found ? String(d.original_dsh) : t("glance.dsh.note.missing"); document.getElementById("glance-dsh-note").textContent = found ? t("glance.dsh.note.found") : ""; if (found) { setHero("ok", {time: new Date().toLocaleTimeString(RDSH_UI_LOCALE)}); } else { setHero("missing"); } } catch(e){ fail("doctor", e.message, doctor); setHero("fail"); } }
async function tokens(){ try { clearRetry("tout"); const text = document.getElementById("ttext").value; const r = await jpost("/api/tokens", {text}); document.getElementById("tout").textContent = t("tokens.result", { tokens: formatNumber(r.tokens), chars: formatNumber(r.chars) }); } catch(e){ fail("tout", e.message, tokens); } }
let lastPruned = "";
async function prune(){
  try {
    clearRetry("pout");
    const text = document.getElementById("ptext").value;
    const m = +document.getElementById("pmax").value || 4000;
    const r = await jpost("/api/prune", {text, max_tokens: m});
    lastPruned = r.pruned;
    document.getElementById("pout").textContent = t("prune.result", {before:formatNumber(r.before),after:formatNumber(r.after),chars:formatNumber(r.pruned.length)}) + "\n" + r.pruned;
  } catch(e){ fail("pout", e.message, prune); }
}
async function copyPrune(){
  const btn = document.getElementById("pcopy");
  try {
    if (!lastPruned) throw new Error(t("copy.none"));
    await navigator.clipboard.writeText(lastPruned);
    const old = btn.textContent;
    btn.textContent = t("copy.done");
    setTimeout(() => { btn.textContent = old; }, 1500);
  } catch(e){ fail("pout", e.message, prune); }
}
async function bench(){ try { clearRetry("bench"); document.getElementById("bench").textContent = t("bench.pending"); const r = await jget("/api/bench?n=3"); document.getElementById("bench").textContent = JSON.stringify(r, null, 2); } catch(e){ fail("bench", e.message, bench); } }
async function sessions(){
  try {
    clearRetry("sess");
    const r = await jget("/api/sessions?limit=10");
    const list = Array.isArray(r.sessions) ? r.sessions : [];
    const projects = Number(r.projects) || 0;
    document.getElementById("glance-sessions").textContent = t("glance.sessions.count", {count: formatNumber(list.length)});
    document.getElementById("glance-sessions-note").textContent = t("glance.sessions.note");
    if (!list.length) { sessionEmpty("sessions.empty.title", "sessions.empty.help", null); return; }
    const box = document.getElementById("sess");
    box.textContent = "";
    const ul = document.createElement("ul");
    ul.className = "session-rows";
    for (const s of list) {
      const li = document.createElement("li");
      li.className = "session-row";
      // Human name first: the project folder, not the opaque session id.
      const name = String(s.project || "").split("-").filter(Boolean).pop() || t("sessions.project.fallback");
      li.title = String(s.id || "") + (s.project ? " · " + s.project : "");
      const proj = document.createElement("span");
      proj.className = "proj";
      const sw = document.createElement("span");
      sw.className = "sw";
      sw.setAttribute("aria-hidden", "true");
      proj.append(sw, document.createTextNode(name));
      const when = document.createElement("span");
      when.className = "when";
      when.textContent = t("sessions.updated", {time: formatSessionTime(String(s.mtime || ""))});
      const size = document.createElement("span");
      size.className = "size-pill";
      size.textContent = formatBytes(s.bytes);
      li.append(proj, when, size);
      ul.append(li);
    }
    box.append(ul);
    const foot = document.createElement("p");
    foot.className = "session-projects muted";
    foot.textContent = t("sessions.projects", {count: formatNumber(projects)});
    box.append(foot);
  } catch(e){ fail("sess", e.message, sessions); }
}
async function skills(){ try { clearRetry("sk"); const r = await jget("/api/skills"); document.getElementById("sk").textContent = r.names.join("\n"); } catch(e){ fail("sk", e.message, skills); } }
</script>
</body>
</html>