rdsh 0.9.1

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
<!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: 14px; --fs-lg: 16px;
  --fs-xl: 20px; --fs-2xl: 30px;
  --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; }
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: 600 var(--fs-sm)/1 var(--font);
  border-radius: var(--r-sm); padding: 9px 14px; min-height: 36px;
  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-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 220px; min-width: 0;
  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.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; }
/* ---------- sessions ---------- */
.session-list { display: flex; flex-direction: column; gap: var(--sp-3); min-width: 0; }
.session-cards {
  display: grid; gap: var(--sp-3); margin: 0; padding: 0; list-style: none;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr));
}
.session-card {
  display: flex; flex-direction: column; gap: 2px; min-width: 0;
  padding: var(--sp-3) var(--sp-4);
  background: var(--bg-elevated); border: 1px solid var(--line); border-radius: var(--r-md);
}
.session-id { margin: 0; font: 600 var(--fs-sm)/1.5 var(--font-mono); overflow-wrap: anywhere; }
.session-project { margin: 0; font-size: var(--fs-xs); }
.session-meta { margin: 0; font-size: var(--fs-xs); font-variant-numeric: tabular-nums; }
.session-projects { margin: 0; font-size: var(--fs-xs); }
.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); }
}
@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">Audit &amp; Performance Monitor for DeepSeek Harness</span></h1>
      <p class="brand-sub" id="ui-subtitle">Local-only monitoring dashboard served by rdsh serve — not the upstream dsh web chat interface.</p>
    </div>
  </div>
  <div class="header-actions">
    <span id="connect-state" class="badge badge-warn" role="status" aria-live="polite">Not connected: enter the token printed in the terminal.</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-help" class="muted">端末に表示された保護済みトークンファイルの内容を入力してください。トークンはアドレスバーに保存されません。</p>
    <div class="connect-row">
      <input id="dashboard-token" type="password" autocomplete="off" spellcheck="false" aria-label="Dashboard token">
      <button type="button" class="btn btn-primary" id="ui-connect" onclick="connectDashboard()">接続</button>
    </div>
    <p id="auth-error" class="error" role="alert"></p>
  </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">接続状態と本家 dsh の場所、最近のセッション数をまとめています。</p>
      </div>
    </div>
    <dl class="glance">
      <div class="tile">
        <dt id="ui-glance-connection">接続</dt>
        <dd><span class="tile-value" id="glance-connection">未接続</span><span class="tile-note" id="glance-connection-note">下の入力欄から接続してください</span></dd>
      </div>
      <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" 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", "DeepSeek Harness の監査・パフォーマンスモニター", "Audit & Performance Monitor for DeepSeek Harness", "DeepSeek Harness 审计与性能监控"],
  ["app.subtitle", "rdsh serve が提供するローカル専用の監視ダッシュボードです。本家 dsh web のチャット画面ではありません。", "Local-only monitoring dashboard served by rdsh serve — not the upstream dsh web chat interface.", "由 rdsh serve 提供的仅限本地的监控仪表板,并非上游 dsh web 的聊天界面。"],
  ["skip.toContent", "本文へスキップ", "Skip to content", "跳到主要内容"],
  ["reload", "再読み込み", "Reload", "重新加载"],
  ["page.updated", "最終更新: {time}", "Last updated: {time}", "最后更新:{time}"],

  ["overview.heading", "概要", "At a glance", "概览"],
  ["overview.help", "接続状態と本家 dsh の場所、最近のセッション数をまとめています。", "Connection, where rdsh finds upstream dsh, and how many sessions are recent.", "连接状态、rdsh 找到的上游 dsh 位置,以及近期会话数量。"],
  ["glance.connection", "接続", "Connection", "连接"],
  ["glance.connection.on", "接続済み", "Connected", "已连接"],
  ["glance.connection.off", "未接続", "Not connected", "未连接"],
  ["glance.connection.note.on", "トークンを確認しました", "Token verified", "令牌已验证"],
  ["glance.connection.note.off", "下の入力欄から接続してください", "Connect from the field below", "请从下方输入框连接"],
  ["glance.dsh", "本家 dsh の場所", "Upstream dsh", "上游 dsh"],
  ["glance.dsh.note", "DSH_HOME: {path}", "DSH_HOME: {path}", "DSH_HOME:{path}"],
  ["glance.dsh.missing", "見つかりません", "Not found", "未找到"],
  ["glance.rdsh", "rdsh", "rdsh", "rdsh"],
  ["glance.rdsh.note", "このダッシュボードを提供している版", "Version serving this dashboard", "提供此仪表板的版本"],
  ["glance.sessions", "最近のセッション", "Recent sessions", "近期会话"],
  ["glance.sessions.note", "直近 {count} 件", "Latest {count}", "最近 {count} 条"],

  ["connection.waiting", "未接続: 端末に表示されたトークンを入力してください。", "Not connected: enter the token printed in the terminal.", "未连接:请输入终端显示的令牌。"],
  ["connection.connected", "接続済み: {time} に更新", "Connected: updated {time}", "已连接:更新于 {time}"],
  ["connect.heading", "ダッシュボードに接続", "Connect to dashboard", "连接到仪表板"],
  ["connect.help", "端末に表示された保護済みトークンファイルの内容を入力してください。トークンはアドレスバーに保存されません。", "Enter the protected token handoff file contents printed in the terminal. The token is not stored in the address bar.", "请输入终端显示的受保护令牌交接文件内容。令牌不会保存在地址栏中。"],
  ["connect.button", "接続", "Connect", "连接"],
  ["token.label", "ダッシュボードのトークン", "Dashboard token", "仪表板令牌"],

  ["sessions.heading", "最近のセッション", "Recent sessions", "近期会话"],
  ["sessions.help", "更新時刻・サイズ・トークン概算を表示します。", "Shows when each session was updated, its size and an estimated token count.", "显示每个会话的更新时间、大小和估算令牌数。"],
  ["sessions.updated", "更新 {time}", "Updated {time}", "更新于 {time}"],
  ["sessions.tokens", "約 {tokens} トークン", "≈ {tokens} tokens", "约 {tokens} 个令牌"],
  ["sessions.projects", "({count} プロジェクト)", "({count} projects)", "({count} 个项目)"],
  ["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", "输入令牌"],
  ["sessions.locked.title", "セッションは接続後に表示されます", "Sessions appear after you connect", "连接后显示会话"],
  ["sessions.locked.help", "トークンを入力すると最新のセッションを読み込みます。", "Enter the token to load the latest sessions.", "输入令牌即可加载最新会话。"],
  ["awaiting", "接続後に自動表示します", "Loads automatically after you connect", "连接后将自动显示"],

  ["advanced.summary", "上級者向けの機能", "Advanced tools", "高级工具"],
  ["advanced.help", "環境診断の詳細・トークン推定・履歴の整理・スキル・性能計測。ふだんは開く必要はありません。", "Diagnostics details, token estimates, history trimming, skills and performance measurement. You normally do not need these.", "诊断详情、令牌估算、历史整理、技能与性能测量。通常无需展开。"],
  ["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", "履歴の整理", "History trimming", "历史整理"], ["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", "トークンを確認できません。ファイルの内容を確認してください。", "Token rejected. Check the handoff file contents.", "令牌被拒绝。请检查交接文件内容。"], ["error.prefix", "エラー: {message}", "Error: {message}", "错误:{message}"], ["retry", "再試行", "Retry", "重试"], ["copy.done", "コピー済み", "Copied", "已复制"], ["copy.none", "コピー対象がありません。先にprune実行してください。", "There is nothing to copy. Run prune 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 and retry.", "无法连接:请确认 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 formatCompact(value) { return new Intl.NumberFormat(RDSH_UI_LOCALE, { notation: "compact", maximumFractionDigits: 1 }).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");
  document.getElementById("glance-connection").textContent = t(connected ? "glance.connection.on" : "glance.connection.off");
  document.getElementById("glance-connection-note").textContent = t(connected ? "glance.connection.note.on" : "glance.connection.note.off");
}
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-connection":"glance.connection", "ui-glance-dsh":"glance.dsh", "ui-glance-rdsh":"glance.rdsh", "ui-glance-rdsh-note":"glance.rdsh.note", "ui-glance-sessions":"glance.sessions", "ui-connect-heading":"connect.heading", "ui-connect-help":"connect.help", "ui-connect":"connect.button", "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("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");
  sessionEmpty("sessions.locked.title", "sessions.locked.help", focusToken);
}
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;
    // 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 target = document.getElementById("glance-dsh"); target.textContent = d && d.original_dsh ? String(d.original_dsh) : t("glance.dsh.missing"); document.getElementById("glance-dsh-note").textContent = d && d.dsh_home ? t("glance.dsh.note", {path: String(d.dsh_home)}) : ""; } catch(e){ fail("doctor", e.message, doctor); } }
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 = formatNumber(list.length);
    document.getElementById("glance-sessions-note").textContent = t("glance.sessions.note", {count: formatNumber(list.length)});
    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-cards";
    for (const s of list) {
      const li = document.createElement("li");
      li.className = "session-card";
      const id = document.createElement("p");
      id.className = "session-id";
      id.textContent = String(s.id || "");
      const project = document.createElement("p");
      project.className = "session-project muted";
      project.textContent = String(s.project || "");
      const meta = document.createElement("p");
      meta.className = "session-meta muted";
      meta.textContent = t("sessions.updated", {time: formatSessionTime(String(s.mtime || ""))}) + " · " + formatBytes(s.bytes) + " · " + t("sessions.tokens", {tokens: formatCompact(Math.round((Number(s.bytes) || 0) / 4))});
      li.append(id, project, meta);
      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>