@firstpick/pi-package-webui 0.2.0 → 0.2.2

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/public/styles.css CHANGED
@@ -375,6 +375,65 @@ body.server-offline .layout {
375
375
  filter: blur(2px);
376
376
  pointer-events: none;
377
377
  }
378
+ .server-restart-panel[hidden] {
379
+ display: none !important;
380
+ }
381
+ .server-restart-panel {
382
+ position: fixed;
383
+ inset: 1rem;
384
+ z-index: 62;
385
+ display: grid;
386
+ place-items: center;
387
+ padding: 1rem;
388
+ pointer-events: none;
389
+ }
390
+ .server-restart-card {
391
+ position: relative;
392
+ display: grid;
393
+ justify-items: center;
394
+ width: min(34rem, 100%);
395
+ pointer-events: auto;
396
+ padding: clamp(1.35rem, 4vw, 2.2rem);
397
+ text-align: center;
398
+ border: 1px solid rgba(148, 226, 213, 0.34);
399
+ border-radius: 1.2rem;
400
+ background:
401
+ radial-gradient(circle at 50% 0, rgba(148, 226, 213, 0.18), transparent 18rem),
402
+ linear-gradient(145deg, rgba(var(--ctp-base-rgb), 0.96), rgba(var(--ctp-crust-rgb), 0.98));
403
+ box-shadow: 0 1.2rem 4rem rgba(var(--ctp-crust-rgb), 0.74), 0 0 2rem rgba(148, 226, 213, 0.14), inset 0 1px 0 rgba(255,255,255,0.07);
404
+ }
405
+ .server-restart-spinner {
406
+ width: 2.8rem;
407
+ height: 2.8rem;
408
+ margin-bottom: 0.9rem;
409
+ border: 0.22rem solid rgba(148, 226, 213, 0.18);
410
+ border-top-color: var(--ctp-teal);
411
+ border-radius: 999px;
412
+ animation: server-restart-spin 900ms linear infinite;
413
+ }
414
+ .server-restart-kicker {
415
+ color: var(--ctp-teal);
416
+ font-size: 0.76rem;
417
+ font-weight: 900;
418
+ letter-spacing: 0.12em;
419
+ text-transform: uppercase;
420
+ }
421
+ .server-restart-card h1 {
422
+ margin: 0.35rem 0 0.45rem;
423
+ font-size: clamp(1.35rem, 4vw, 2rem);
424
+ }
425
+ .server-restart-card p {
426
+ margin: 0;
427
+ color: rgba(var(--ctp-subtext-rgb), 0.9);
428
+ }
429
+ body.server-restarting .layout {
430
+ opacity: 0.56;
431
+ filter: blur(1.5px);
432
+ pointer-events: none;
433
+ }
434
+ @keyframes server-restart-spin {
435
+ to { transform: rotate(360deg); }
436
+ }
378
437
  .side-panel-expand-button {
379
438
  position: fixed;
380
439
  top: 1rem;
@@ -504,13 +563,36 @@ body.side-panel-collapsed .terminal-tabs-shell {
504
563
  color: var(--ctp-text);
505
564
  letter-spacing: 0.03em;
506
565
  }
507
- .side-panel-session-line {
508
- margin: 0.34rem 0 0;
509
- max-width: 17rem;
510
- overflow: hidden;
511
- text-overflow: ellipsis;
512
- white-space: nowrap;
513
- font-size: 0.76rem;
566
+ .side-panel-header .side-panel-title {
567
+ display: flex;
568
+ align-items: center;
569
+ gap: 0.46rem;
570
+ flex-wrap: wrap;
571
+ }
572
+ .webui-version-badge,
573
+ .webui-dev-badge {
574
+ display: inline-flex;
575
+ align-items: center;
576
+ min-height: 1.18rem;
577
+ padding: 0.05rem 0.44rem;
578
+ border: 1px solid rgba(180, 190, 254, 0.24);
579
+ border-radius: 999px;
580
+ color: var(--ctp-subtext);
581
+ background: rgba(var(--ctp-surface-rgb), 0.74);
582
+ font-size: 0.68rem;
583
+ font-weight: 800;
584
+ letter-spacing: 0.04em;
585
+ line-height: 1;
586
+ }
587
+ .webui-dev-badge {
588
+ border-color: rgba(249, 226, 175, 0.38);
589
+ color: var(--ctp-yellow);
590
+ background: rgba(249, 226, 175, 0.12);
591
+ box-shadow: 0 0 0.7rem rgba(249, 226, 175, 0.1);
592
+ }
593
+ .webui-version-badge[hidden],
594
+ .webui-dev-badge[hidden] {
595
+ display: none;
514
596
  }
515
597
  .side-panel-kicker {
516
598
  display: block;
@@ -647,6 +729,35 @@ body.side-panel-collapsed .terminal-tabs-shell {
647
729
  gap: 0.42rem;
648
730
  align-items: center;
649
731
  }
732
+ .server-action-row {
733
+ display: grid;
734
+ grid-template-columns: minmax(0, 1fr) auto;
735
+ gap: 0.42rem;
736
+ align-items: center;
737
+ }
738
+ .server-action-row button {
739
+ width: auto;
740
+ min-width: 4.4rem;
741
+ }
742
+ .server-action-status {
743
+ min-height: 1.05rem;
744
+ color: rgba(var(--ctp-subtext-rgb), 0.82);
745
+ font-size: 0.72rem;
746
+ font-weight: 750;
747
+ line-height: 1.35;
748
+ }
749
+ .server-action-status.warn {
750
+ color: var(--ctp-yellow);
751
+ }
752
+ .server-action-status.error {
753
+ color: var(--ctp-red);
754
+ }
755
+ .server-action-status.success {
756
+ color: var(--ctp-green);
757
+ }
758
+ .server-action-status[hidden] {
759
+ display: none;
760
+ }
650
761
  .background-clear-button {
651
762
  width: 44px !important;
652
763
  min-width: 44px !important;
@@ -1311,6 +1422,15 @@ body.side-panel-collapsed .terminal-tabs-shell {
1311
1422
  color: rgba(var(--ctp-text-rgb), 0.78);
1312
1423
  background: linear-gradient(90deg, rgba(203, 166, 247, 0.10), rgba(137, 180, 250, 0.05), rgba(148, 226, 213, 0.08));
1313
1424
  }
1425
+ .widget-area:has(.release-npm-live-widget .release-npm-output-details[open]),
1426
+ .widget-area:has(.release-aur-live-widget .release-npm-output-details[open]) {
1427
+ flex: 0 0 min(44rem, 68dvh);
1428
+ min-height: 0;
1429
+ overflow: auto;
1430
+ overscroll-behavior: contain;
1431
+ scrollbar-gutter: stable;
1432
+ overflow-anchor: none;
1433
+ }
1314
1434
  .statusbar {
1315
1435
  position: relative;
1316
1436
  flex: 0 0 auto;
@@ -1806,6 +1926,52 @@ button.footer-meta {
1806
1926
  text-transform: none;
1807
1927
  white-space: nowrap;
1808
1928
  }
1929
+ .release-npm-output-details {
1930
+ display: grid;
1931
+ gap: 0.58rem;
1932
+ min-width: 0;
1933
+ }
1934
+ .release-npm-output-summary {
1935
+ display: grid;
1936
+ grid-template-columns: auto minmax(0, 1fr);
1937
+ align-items: center;
1938
+ gap: 0.38rem;
1939
+ min-width: 0;
1940
+ color: rgba(var(--ctp-text-rgb), 0.92);
1941
+ cursor: pointer;
1942
+ list-style: none;
1943
+ }
1944
+ .release-npm-output-summary::-webkit-details-marker { display: none; }
1945
+ .release-npm-output-summary:focus-visible {
1946
+ outline: 2px solid rgba(137, 180, 250, 0.72);
1947
+ outline-offset: 0.18rem;
1948
+ border-radius: 0.72rem;
1949
+ }
1950
+ .release-npm-output-summary:hover .release-npm-stream-header,
1951
+ .release-npm-output-summary:focus-visible .release-npm-stream-header {
1952
+ border-color: rgba(137, 180, 250, 0.54);
1953
+ box-shadow: inset 0 0 0 1px rgba(137, 180, 250, 0.08), 0 0 0.8rem rgba(137, 180, 250, 0.08);
1954
+ }
1955
+ .release-npm-output-toggle {
1956
+ display: inline-grid;
1957
+ place-items: center;
1958
+ width: 1rem;
1959
+ height: 1rem;
1960
+ flex: 0 0 auto;
1961
+ color: rgba(var(--ctp-subtext-rgb), 0.78);
1962
+ font-size: 1rem;
1963
+ font-weight: 950;
1964
+ line-height: 1;
1965
+ transition: transform 0.16s ease, color 0.16s ease;
1966
+ }
1967
+ .release-npm-output-details[open] .release-npm-output-toggle {
1968
+ color: var(--ctp-blue);
1969
+ transform: rotate(90deg);
1970
+ }
1971
+ .release-npm-output-summary .release-npm-stream-header {
1972
+ min-width: 0;
1973
+ width: 100%;
1974
+ }
1809
1975
  .release-npm-terminal {
1810
1976
  max-height: min(34rem, 42vh);
1811
1977
  min-height: 5.25rem;
@@ -1825,6 +1991,11 @@ button.footer-meta {
1825
1991
  line-height: 1.5;
1826
1992
  overscroll-behavior: contain;
1827
1993
  }
1994
+ .release-npm-live-widget .release-npm-output-details[open] .release-npm-terminal,
1995
+ .release-aur-live-widget .release-npm-output-details[open] .release-npm-terminal {
1996
+ height: clamp(15rem, 42dvh, 31rem);
1997
+ min-height: 0;
1998
+ }
1828
1999
  .release-npm-line {
1829
2000
  display: block;
1830
2001
  width: max-content;
@@ -3189,6 +3360,28 @@ summary { cursor: pointer; color: var(--warning); }
3189
3360
  text-align: center;
3190
3361
  font-weight: 800;
3191
3362
  }
3363
+ .extension-dialog.release-dialog .dialog-options button.release-publish-disabled-action {
3364
+ color: rgba(var(--ctp-subtext-rgb), 0.72);
3365
+ border-color: rgba(var(--ctp-overlay-rgb), 0.32);
3366
+ background: linear-gradient(180deg, rgba(var(--ctp-surface-rgb), 0.58), rgba(var(--ctp-crust-rgb), 0.72));
3367
+ }
3368
+ .extension-dialog.release-dialog .dialog-options button.release-target-option {
3369
+ text-align: left;
3370
+ border-color: rgba(137, 180, 250, 0.34);
3371
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
3372
+ font-size: 0.78rem;
3373
+ line-height: 1.35;
3374
+ overflow-wrap: anywhere;
3375
+ white-space: normal;
3376
+ }
3377
+ .extension-dialog.release-dialog .dialog-options button.release-target-selected {
3378
+ color: var(--ctp-green);
3379
+ border-color: rgba(166, 227, 161, 0.58);
3380
+ background:
3381
+ linear-gradient(120deg, rgba(166, 227, 161, 0.16), rgba(137, 180, 250, 0.08)),
3382
+ linear-gradient(180deg, rgba(var(--ctp-surface-rgb), 0.80), rgba(var(--ctp-crust-rgb), 0.78));
3383
+ box-shadow: 0 0 1rem rgba(166, 227, 161, 0.14);
3384
+ }
3192
3385
  .extension-dialog.release-dialog .dialog-options button.release-cancel-action {
3193
3386
  border-color: rgba(249, 226, 175, 0.38);
3194
3387
  color: var(--ctp-yellow);
package/start-webui.ps1 CHANGED
@@ -32,7 +32,52 @@ function Get-LaunchCwd {
32
32
  return $cwd
33
33
  }
34
34
 
35
+ function Get-PiManagedPiWebui {
36
+ $node = Get-Command "node" -ErrorAction SilentlyContinue
37
+ if (-not $node) {
38
+ return $null
39
+ }
40
+
41
+ $script = @'
42
+ const { homedir } = require("node:os");
43
+ const { join } = require("node:path");
44
+
45
+ let agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
46
+ if (agentDir === "~") {
47
+ agentDir = homedir();
48
+ } else if (agentDir.startsWith("~/") || (process.platform === "win32" && agentDir.startsWith("~\\"))) {
49
+ agentDir = join(homedir(), agentDir.slice(2));
50
+ }
51
+
52
+ const binName = process.platform === "win32" ? "pi-webui.cmd" : "pi-webui";
53
+ for (const candidate of [
54
+ join(agentDir, "npm", "node_modules", ".bin", binName),
55
+ join(agentDir, "npm", "node_modules", ".bin", "pi-webui"),
56
+ ]) {
57
+ process.stdout.write(`${candidate}\n`);
58
+ }
59
+ '@
60
+
61
+ $candidates = @(& $node.Source -e $script 2>$null)
62
+ if ($LASTEXITCODE -ne 0) {
63
+ return $null
64
+ }
65
+
66
+ foreach ($candidate in $candidates) {
67
+ if (-not [string]::IsNullOrWhiteSpace($candidate) -and (Test-Path -LiteralPath $candidate -PathType Leaf)) {
68
+ return $candidate
69
+ }
70
+ }
71
+
72
+ return $null
73
+ }
74
+
35
75
  function Ensure-PiWebui {
76
+ $managed = Get-PiManagedPiWebui
77
+ if ($managed) {
78
+ return $managed
79
+ }
80
+
36
81
  $command = Get-Command "pi-webui" -ErrorAction SilentlyContinue
37
82
  if ($command) {
38
83
  return $command.Source
package/start-webui.sh CHANGED
@@ -6,6 +6,7 @@ PACKAGE_NAME="@firstpick/pi-package-webui"
6
6
  DEFAULT_HOST="127.0.0.1"
7
7
  DEFAULT_PORT="31415"
8
8
  SERVER_PID=""
9
+ PI_WEBUI_COMMAND=""
9
10
 
10
11
  script_dir() {
11
12
  local source dir
@@ -37,6 +38,44 @@ local_pi_webui_bin() {
37
38
  printf '%s\n' "$candidate"
38
39
  }
39
40
 
41
+ pi_managed_pi_webui_bin() {
42
+ local candidates candidate
43
+
44
+ if ! command -v node >/dev/null 2>&1; then
45
+ return 1
46
+ fi
47
+
48
+ candidates="$(node <<'NODE'
49
+ const { homedir } = require("node:os");
50
+ const { join } = require("node:path");
51
+
52
+ let agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
53
+ if (agentDir === "~") {
54
+ agentDir = homedir();
55
+ } else if (agentDir.startsWith("~/") || (process.platform === "win32" && agentDir.startsWith("~\\"))) {
56
+ agentDir = join(homedir(), agentDir.slice(2));
57
+ }
58
+
59
+ const binName = process.platform === "win32" ? "pi-webui.cmd" : "pi-webui";
60
+ for (const candidate of [
61
+ join(agentDir, "npm", "node_modules", ".bin", "pi-webui"),
62
+ join(agentDir, "npm", "node_modules", ".bin", binName),
63
+ ]) {
64
+ process.stdout.write(`${candidate.replace(/\\/g, "/")}\n`);
65
+ }
66
+ NODE
67
+ )"
68
+
69
+ while IFS= read -r candidate; do
70
+ if [[ -n "$candidate" && -f "$candidate" ]]; then
71
+ printf '%s\n' "$candidate"
72
+ return 0
73
+ fi
74
+ done <<< "$candidates"
75
+
76
+ return 1
77
+ }
78
+
40
79
  cleanup() {
41
80
  if [[ -n "${SERVER_PID:-}" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then
42
81
  kill "$SERVER_PID" 2>/dev/null || true
@@ -66,7 +105,15 @@ choose_cwd() {
66
105
  }
67
106
 
68
107
  ensure_pi_webui() {
108
+ local managed_bin
109
+
110
+ if managed_bin="$(pi_managed_pi_webui_bin 2>/dev/null)" && [[ -n "$managed_bin" ]]; then
111
+ PI_WEBUI_COMMAND="$managed_bin"
112
+ return 0
113
+ fi
114
+
69
115
  if command -v pi-webui >/dev/null 2>&1; then
116
+ PI_WEBUI_COMMAND="$(command -v pi-webui)"
70
117
  return 0
71
118
  fi
72
119
 
@@ -105,6 +152,8 @@ ensure_pi_webui() {
105
152
  echo "Installed, but pi-webui is still not on PATH. Check your npm global bin directory." >&2
106
153
  return 1
107
154
  fi
155
+
156
+ PI_WEBUI_COMMAND="$(command -v pi-webui)"
108
157
  }
109
158
 
110
159
  browser_host_for_url() {
@@ -134,51 +183,10 @@ connect_host_for_port() {
134
183
  esac
135
184
  }
136
185
 
137
- open_url() {
186
+ print_manual_url() {
138
187
  local url="$1"
139
- local platform=""
140
- platform="$(uname -s 2>/dev/null || true)"
141
-
142
- case "$platform" in
143
- MINGW*|MSYS*|CYGWIN*)
144
- if command -v cmd.exe >/dev/null 2>&1; then
145
- cmd.exe /c start "" "$url" </dev/null >/dev/null 2>&1 &
146
- return 0
147
- fi
148
- if command -v powershell.exe >/dev/null 2>&1; then
149
- powershell.exe -NoProfile -Command 'Start-Process -FilePath $args[0]' "$url" </dev/null >/dev/null 2>&1 &
150
- return 0
151
- fi
152
- ;;
153
- Linux*)
154
- if grep -qi microsoft /proc/version 2>/dev/null; then
155
- if command -v wslview >/dev/null 2>&1; then
156
- wslview "$url" </dev/null >/dev/null 2>&1 &
157
- return 0
158
- fi
159
- if command -v cmd.exe >/dev/null 2>&1; then
160
- cmd.exe /c start "" "$url" </dev/null >/dev/null 2>&1 &
161
- return 0
162
- fi
163
- fi
164
- ;;
165
- esac
166
188
 
167
- if command -v xdg-open >/dev/null 2>&1; then
168
- xdg-open "$url" </dev/null >/dev/null 2>&1 &
169
- elif command -v open >/dev/null 2>&1; then
170
- open "$url" </dev/null >/dev/null 2>&1 &
171
- elif command -v wslview >/dev/null 2>&1; then
172
- wslview "$url" </dev/null >/dev/null 2>&1 &
173
- elif command -v cmd.exe >/dev/null 2>&1; then
174
- cmd.exe /c start "" "$url" </dev/null >/dev/null 2>&1 &
175
- elif command -v powershell.exe >/dev/null 2>&1; then
176
- powershell.exe -NoProfile -Command 'Start-Process -FilePath $args[0]' "$url" </dev/null >/dev/null 2>&1 &
177
- else
178
- echo "Could not find a browser opener. Open manually:" >&2
179
- echo " $url" >&2
180
- return 1
181
- fi
189
+ echo "Open manually: $url"
182
190
  }
183
191
 
184
192
  http_ok() {
@@ -407,8 +415,7 @@ main() {
407
415
  if [[ "$dev_mode" -eq 1 ]]; then
408
416
  echo "--dev only affects newly started servers; stop the existing server first to run this checkout."
409
417
  fi
410
- echo "Opening: $target_url"
411
- open_url "$target_url" || true
418
+ print_manual_url "$target_url"
412
419
  exit 0
413
420
  fi
414
421
 
@@ -425,10 +432,12 @@ main() {
425
432
  if [[ "$dev_mode" -eq 1 ]]; then
426
433
  local_webui_bin="$(local_pi_webui_bin)"
427
434
  webui_cmd=(node "$local_webui_bin")
435
+ export PI_WEBUI_DEV=1
428
436
  echo "Dev mode: using local Pi Web UI server: $local_webui_bin"
429
437
  else
430
438
  ensure_pi_webui
431
- webui_cmd=(pi-webui)
439
+ webui_cmd=("$PI_WEBUI_COMMAND")
440
+ unset PI_WEBUI_DEV
432
441
  fi
433
442
 
434
443
  echo "Starting Pi Web UI in: $cwd"
@@ -442,7 +451,8 @@ main() {
442
451
  trap 'cleanup; exit 143' TERM
443
452
 
444
453
  if wait_until_ready "$url" "$SERVER_PID"; then
445
- open_url "$url" || true
454
+ echo "Pi Web UI is ready."
455
+ print_manual_url "$url"
446
456
  else
447
457
  ready_status="$?"
448
458
  if [[ "$ready_status" -eq 2 ]]; then
@@ -451,8 +461,8 @@ main() {
451
461
  exit $?
452
462
  fi
453
463
 
454
- echo "Server did not respond yet; opening the URL anyway." >&2
455
- open_url "$url" || true
464
+ echo "Server did not respond yet; not opening a browser automatically." >&2
465
+ print_manual_url "$url"
456
466
  fi
457
467
 
458
468
  wait "$SERVER_PID"
@@ -41,11 +41,15 @@ assert.match(html, /<link rel="apple-touch-icon" href="\/apple-touch-icon\.png"
41
41
  assert.match(html, /id="terminalTabsToggleButton"/, "mobile should expose a compact terminal-tabs toggle");
42
42
  assert.match(html, /id="closeAllTabsButton"[\s\S]*?>Close all Tabs<\/button>/, "tab header should expose a top-right close-all tabs action");
43
43
  assert.match(html, /id="sidePanelBackdrop"/, "mobile side panel needs an overlay/backdrop close target");
44
+ assert.match(html, /<strong class="side-panel-title">[\s\S]*Control Deck[\s\S]*id="webuiVersionBadge"[\s\S]*id="webuiDevBadge"/, "Control Deck title should expose Web UI version and dev badges");
45
+ assert.doesNotMatch(html, /id="sessionLine"/, "Control Deck title should not show verbose session status metadata");
44
46
  assert.match(html, /id="themeSelect"/, "side panel should expose a theme selector");
45
47
  assert.match(html, /<label for="themeSelect">Theme<\/label>/, "theme selector should be labeled in side-panel controls");
46
48
  assert.match(html, /id="backgroundInput"[^>]*type="file"[^>]*accept="image\/png,image\/jpeg,image\/webp,image\/gif"/, "side panel should expose an image picker for custom backgrounds");
47
49
  assert.match(html, /id="backgroundClearButton"[\s\S]*?>×<\/button>/, "side-panel background control should expose an X remove button");
48
- assert.match(html, /id="stopServerButton"[^>]*?>Stop Server<\/button>/, "side panel should expose a Stop Server button");
50
+ assert.match(html, /id="serverActionSelect"[\s\S]*<option value="restart">Restart Server<\/option>[\s\S]*<option value="stop">Stop Server<\/option>/, "side panel should expose restart and stop server actions in a dropdown");
51
+ assert.match(html, /id="runServerActionButton"[^>]*disabled[^>]*>Run<\/button>/, "side panel should expose a guarded button for selected server actions");
52
+ assert.match(html, /id="serverActionStatus"[^>]*aria-live="polite"/, "server actions should expose visible restart feedback");
49
53
  assert.match(html, /id="agentDoneNotificationsToggle"/, "side panel should expose an agent-done notifications toggle");
50
54
  assert.match(html, /id="agentDoneNotificationsStatus"/, "agent-done notifications toggle should expose status text");
51
55
  assert.match(html, /id="thinkingVisibilityToggle"/, "side panel should expose a thinking-output visibility toggle");
@@ -56,6 +60,7 @@ assert.match(html, /id="optionalFeaturesBox"/, "side panel should expose optiona
56
60
  assert.match(html, /id="codexUsageBox"/, "side panel should expose Codex subscription usage status");
57
61
  assert.match(html, /data-side-panel-section="codex-usage"/, "Codex usage should live in a collapsible side-panel section");
58
62
  assert.match(html, /id="serverOfflinePanel"/, "PWA/offline shell should expose a backend-offline recovery panel");
63
+ assert.match(html, /id="serverRestartPanel"[\s\S]*id="serverRestartMessage"/, "server restart should expose a loading overlay instead of the generic offline shell");
59
64
  assert.match(html, /id="copyServerCommandButton"/, "backend-offline recovery panel should expose a start-command copy button");
60
65
  assert.match(html, /id="retryServerConnectionButton"/, "backend-offline recovery panel should expose a retry button");
61
66
  assert.match(html, /data-side-panel-section="controls"/, "side panel controls should live in a collapsible section");
@@ -135,6 +140,10 @@ assert.match(css, /\.message-collapse\[open\] \+ \.tool-result-preview \{[\s\S]*
135
140
  assert.match(css, /\.run-indicator-pulse \{[\s\S]*?animation:\s*run-indicator-pulse/, "active agent run indicator should have an animated pulse");
136
141
  assert.match(css, /\.optional-features-box \{[\s\S]*?display:\s*grid/, "optional features should render as a side-panel feature list");
137
142
  assert.match(css, /\.side-panel-section-toggle \{[\s\S]*?justify-content:\s*space-between/, "side panel section toggles should align labels and chevrons");
143
+ assert.match(css, /\.server-restart-panel \{[\s\S]*?z-index:\s*62/, "server restart overlay should render above the offline shell");
144
+ assert.match(css, /@keyframes server-restart-spin/, "server restart overlay should show a loading spinner");
145
+ assert.match(css, /\.webui-version-badge,\n\.webui-dev-badge \{[\s\S]*?border-radius:\s*999px/, "Web UI version and dev indicators should render as compact title badges");
146
+ assert.match(css, /\.webui-dev-badge \{[\s\S]*?color:\s*var\(--ctp-yellow\)/, "Web UI dev indicator should have distinct warning styling");
138
147
  assert.match(css, /\.side-panel-section\.collapsed \.side-panel-section-content,\n\.side-panel-section-content\[hidden\] \{\n\s+display:\s*none;/, "collapsed side panel section content should be hidden");
139
148
  assert.match(css, /\.side-panel-section:not\(\.collapsed\) \.side-panel-section-chevron/, "expanded side panel sections should rotate the chevron");
140
149
  assert.match(css, /\.optional-feature-pill\.enabled/, "optional features should visually distinguish enabled state");
@@ -145,6 +154,10 @@ assert.match(css, /\.todo-widget-item\.partial \.todo-widget-marker/, "todo-prog
145
154
  assert.match(css, /\.todo-widget-item\.done \.todo-widget-text[\s\S]*?text-decoration:\s*line-through/, "todo-progress completed items should be visually crossed out");
146
155
  assert.match(css, /\.release-npm-widget \{[\s\S]*?border-left:\s*0\.28rem solid/, "release-npm output should stand apart from the page background");
147
156
  assert.match(css, /\.release-npm-stream-header \{[\s\S]*?text-transform:\s*uppercase/, "release-npm output should label the output stream clearly");
157
+ assert.match(css, /\.release-npm-output-summary \{[\s\S]*?cursor:\s*pointer/, "release-npm output should expose a local expand/collapse summary");
158
+ assert.match(css, /\.release-npm-output-details\[open\] \.release-npm-output-toggle/, "release-npm expanded output should rotate the summary chevron");
159
+ assert.match(css, /\.widget-area:has\(\.release-npm-live-widget \.release-npm-output-details\[open\]\)[\s\S]*?flex:\s*0 0 min\(44rem, 68dvh\)/, "live release output should reserve a stable widget slot instead of resizing the transcript while streaming");
160
+ assert.match(css, /\.release-npm-live-widget \.release-npm-output-details\[open\] \.release-npm-terminal,[\s\S]*?height:\s*clamp\(15rem, 42dvh, 31rem\)/, "live release terminals should keep a fixed viewport height while output streams");
148
161
  assert.match(css, /\.release-npm-terminal \{[\s\S]*?rgba\(3, 4, 10, 0\.98\)/, "release-npm terminal should use a high-contrast stream panel");
149
162
  assert.match(css, /\.release-aur-widget \{[\s\S]*?border-color/, "release-aur output should render as a specialized Web UI widget variant");
150
163
  assert.match(css, /\.widget-area \.widget:not\(\.todo-widget\):not\(\.release-npm-widget\)/, "mobile widget filtering should keep release workflow output visible");
@@ -239,8 +252,22 @@ assert.match(app, /Restart Web UI to load themes/, "frontend should explain when
239
252
  assert.match(app, /themeSelect\.addEventListener\("change"/, "side-panel theme selector should switch themes immediately");
240
253
  assert.match(app, /open \? "Close for network" : "Open to network"/, "network button should toggle from open to close action");
241
254
  assert.match(app, /api\("\/api\/network\/close", \{ method: "POST"/, "network close action should call the close endpoint");
242
- assert.match(app, /stopServerButton\.addEventListener\("click", stopServer\)/, "Stop Server button should be wired to the shutdown handler");
255
+ assert.match(app, /webuiVersionBadge: \$\("#webuiVersionBadge"\)/, "frontend should bind the Control Deck version badge");
256
+ assert.match(app, /webuiDevBadge: \$\("#webuiDevBadge"\)/, "frontend should bind the Control Deck dev badge");
257
+ assert.match(app, /function refreshWebuiVersion\(\)[\s\S]*api\("\/api\/health", \{ scoped: false \}\)[\s\S]*setWebuiVersion\(health\.webuiVersion\)[\s\S]*setWebuiDevServer\(isWebuiDevMetadata\(health\)\)/, "frontend should load Web UI version and dev mode from health metadata");
258
+ assert.match(app, /case "webui_connected":[\s\S]*setWebuiVersion\(event\.version\)[\s\S]*setWebuiDevServer\(isWebuiDevMetadata\(event\)\)/, "frontend should refresh Web UI version and dev mode from reconnect events");
259
+ assert.match(server, /const webuiDevServer = isTruthyEnv\(process\.env\.PI_WEBUI_DEV\) \|\| isSourceCheckout\(packageRoot\)/, "server should derive dev mode from PI_WEBUI_DEV or a source checkout");
260
+ assert.match(server, /webuiDev: webuiDevServer,[\s\S]*webuiMode: webuiDevServer \? "dev" : "production"/, "server status should expose Web UI dev mode");
261
+ assert.match(server, /type: "webui_connected",[\s\S]*webuiDev: webuiDevServer,[\s\S]*webuiMode: webuiDevServer \? "dev" : "production"/, "SSE connect event should expose Web UI dev mode");
262
+ assert.match(app, /serverActionSelect\.addEventListener\("change", updateServerActionButton\)/, "Server action dropdown should control the guarded run button");
263
+ assert.match(app, /runServerActionButton\.addEventListener\("click"[\s\S]*runSelectedServerAction/, "Server action run button should execute the selected action");
264
+ assert.match(app, /api\("\/api\/restart", \{ method: "POST", scoped: false \}\)/, "Restart Server action should call the unscoped restart endpoint");
265
+ assert.match(app, /setServerActionStatus\(message, "warn"\);\n\s+setServerRestartOverlay\(true, message\)/, "Restart Server action should show reconnect progress in the side panel and loading overlay");
266
+ assert.match(app, /const showOfflinePanel = backendOffline && !serverRestartInProgress/, "intentional restart should suppress the generic offline shell while reconnecting");
243
267
  assert.match(app, /api\("\/api\/shutdown", \{ method: "POST", scoped: false \}\)/, "Stop Server action should call the unscoped shutdown endpoint");
268
+ assert.match(server, /url\.pathname === "\/api\/restart" && req\.method === "POST"/, "server should expose restart endpoint");
269
+ assert.match(server, /PI_WEBUI_RESTORE_TABS: JSON\.stringify\(restorableTabs \|\| \[\]\)/, "server restart should preserve restorable tab metadata");
270
+ assert.match(server, /if \(webuiDevServer\) env\.PI_WEBUI_DEV = "1";/, "server restart should explicitly preserve dev mode");
244
271
  assert.match(server, /async function closeNetworkAccess\(\)/, "server should expose a local-only rebind helper for closing network access");
245
272
  assert.match(server, /url\.pathname === "\/api\/network\/close" && req\.method === "POST"/, "server should route network close requests");
246
273
  assert.match(server, /server\.closeAllConnections\?\.\(\)/, "network rebind should force-close long-lived clients so close-to-localhost can complete");
@@ -324,7 +351,10 @@ assert.match(app, /function bindGitWorkflowToActiveTab\(\) \{\n\s+gitWorkflow =
324
351
  assert.match(app, /function setGitWorkflow\(patch, \{ tabId = activeTabId \} = \{\}\)[\s\S]*if \(tabId === activeTabId\) \{[\s\S]*renderGitWorkflow\(\);/, "guided git workflow should not render inactive terminal workflows globally");
325
352
  assert.doesNotMatch(app, /gitWorkflowVisibleTabId|Workflow belongs to/, "guided git workflow should not pin or show workflows outside their owning terminal tab");
326
353
  assert.match(app, /function renderReleaseNpmOutputWidget\(\)/, "release-npm live output should use a specialized Web UI renderer");
354
+ assert.match(app, /const releaseNpmOutputExpandedByTab = new Map\(\)/, "release-npm output collapse state should be tracked per browser tab");
355
+ assert.match(app, /function renderReleaseNpmOutputDetails\(key, streamHeader, terminal, controls = null\)[\s\S]*node\.open = releaseNpmOutputExpandedByTab\.get\(stateKey\) !== false[\s\S]*release-npm-output-toggle/, "release-npm output should render as a browser-side details expander");
327
356
  assert.match(app, /releaseNpmStreamHeader\("Live output stream", outputLines\.length, \{ live: true \}\)/, "release-npm live output should expose a clear stream heading");
357
+ assert.match(app, /renderReleaseNpmOutputDetails\("release-npm:output", streamHeader, terminal, controls\)/, "release-npm live stream should be wrapped in the local expander");
328
358
  assert.match(app, /function renderReleaseAurOutputWidget\(\)/, "release-aur live output should use a specialized Web UI renderer");
329
359
  assert.match(app, /releaseNpmActionButton\("Abort", "\/release-abort", "danger"\)/, "release-npm Web UI output should expose an abort action");
330
360
  assert.match(app, /releaseNpmActionButton\("Abort", "\/release-aur abort", "danger"\)/, "release-aur Web UI output should expose an abort action");
@@ -566,7 +596,7 @@ assert.equal(manifest.start_url, "/", "PWA manifest should start at the web UI r
566
596
  assert.ok(manifest.icons?.some((icon) => icon.src === "/apple-touch-icon.png" && icon.sizes === "180x180"), "PWA manifest should include a conventional 180px apple touch icon");
567
597
  assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-192.png" && icon.sizes === "192x192"), "PWA manifest should include a 192px icon");
568
598
  assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-512.png" && icon.sizes === "512x512"), "PWA manifest should include a 512px icon");
569
- assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v16"/, "PWA service worker should define an app-shell cache");
599
+ assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v18"/, "PWA service worker should define an app-shell cache");
570
600
  assert.match(serviceWorker, /self\.addEventListener\("notificationclick"/, "PWA service worker should focus Web UI when blocked-tab notifications are clicked");
571
601
  assert.match(serviceWorker, /event\.notification\.data\?\.url/, "blocked-tab notifications should carry a URL for service-worker click handling");
572
602
  assert.match(serviceWorker, /"\/apple-touch-icon\.png"/, "PWA service worker should cache the apple touch icon");
@@ -715,6 +745,7 @@ assert.match(readme, /\.\/start-webui\.sh --dev --cwd \/path\/to\/project/, "REA
715
745
  assert.match(startScript, /--dev\)/, "start-webui.sh should accept a --dev flag");
716
746
  assert.match(startScript, /local_pi_webui_bin\(\)/, "start-webui.sh should resolve this checkout's local server entrypoint");
717
747
  assert.match(startScript, /webui_cmd=\(node "\$local_webui_bin"\)/, "start-webui.sh --dev should run the local bin with node");
748
+ assert.match(startScript, /export PI_WEBUI_DEV=1/, "start-webui.sh --dev should mark the Web UI server as dev mode");
718
749
  assert.match(startScript, /"\$\{webui_cmd\[@\]\}" --cwd "\$cwd" --host "\$host" --port "\$port" "\$\{pass_args\[@\]\}"/, "start-webui.sh should launch through the selected server command without forwarding --dev");
719
750
 
720
751
  assert.match(pkg.scripts?.test || "", /node tests\/mobile-static\.test\.mjs/, "package test script should run the mobile static harness");