@firstpick/pi-package-webui 0.2.4 → 0.2.6

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/index.html CHANGED
@@ -12,7 +12,7 @@
12
12
  <link rel="manifest" href="/manifest.webmanifest" />
13
13
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
14
14
  <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
15
- <link rel="stylesheet" href="/styles.css?v=20" />
15
+ <link rel="stylesheet" href="/styles.css?v=23" />
16
16
  </head>
17
17
  <body>
18
18
  <button id="sidePanelExpandButton" class="side-panel-expand-button" type="button" aria-controls="sidePanel" aria-expanded="false" aria-label="Expand side panel" title="Expand side panel">
@@ -133,6 +133,27 @@
133
133
  </button>
134
134
  </div>
135
135
  </div>
136
+ <div class="composer-publish-menu composer-native-command-menu">
137
+ <button
138
+ id="nativeCommandMenuButton"
139
+ class="composer-icon-button composer-publish-button composer-native-command-button"
140
+ type="button"
141
+ title="Open skills and tools commands"
142
+ aria-label="Open /skills and /tools commands"
143
+ aria-haspopup="menu"
144
+ aria-expanded="false"
145
+ aria-controls="nativeCommandMenu"
146
+ data-tooltip="Skills/tools setup: open skill or tool setup."
147
+ ><svg class="composer-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 5h16v14H4z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="m7 10 2.5 2L7 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 15h5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>
148
+ <div id="nativeCommandMenu" class="composer-publish-menu-panel composer-native-command-menu-panel" role="menu" aria-label="Skills and tools setup">
149
+ <button id="nativeSkillsButton" class="composer-publish-menu-item composer-native-command-menu-item" type="button" role="menuitem" data-command="/skills">
150
+ <span>Skills Setup</span>
151
+ </button>
152
+ <button id="nativeToolsButton" class="composer-publish-menu-item composer-native-command-menu-item" type="button" role="menuitem" data-command="/tools">
153
+ <span>Tools Setup</span>
154
+ </button>
155
+ </div>
156
+ </div>
136
157
  </div>
137
158
  <div class="spacer"></div>
138
159
  <button
@@ -364,6 +385,6 @@
364
385
  </form>
365
386
  </dialog>
366
387
 
367
- <script type="module" src="/app.js?v=20"></script>
388
+ <script type="module" src="/app.js?v=23"></script>
368
389
  </body>
369
390
  </html>
@@ -1,4 +1,4 @@
1
- const CACHE_NAME = "pi-webui-pwa-v20";
1
+ const CACHE_NAME = "pi-webui-pwa-v23";
2
2
  const APP_SHELL = [
3
3
  "/",
4
4
  "/index.html",
package/public/styles.css CHANGED
@@ -1458,6 +1458,17 @@ body.side-panel-collapsed .terminal-tabs-shell {
1458
1458
  opacity: 0.62;
1459
1459
  box-shadow: 0 0 1rem var(--glow-mauve);
1460
1460
  }
1461
+ .statusbar-tui-footer {
1462
+ gap: 0;
1463
+ padding-block: 0.58rem;
1464
+ }
1465
+ .statusbar-tui-footer::before {
1466
+ opacity: 0.32;
1467
+ box-shadow: none;
1468
+ }
1469
+ .statusbar-git-footer {
1470
+ gap: 0.58rem;
1471
+ }
1461
1472
  .footer-line {
1462
1473
  position: relative;
1463
1474
  z-index: 1;
@@ -1475,6 +1486,51 @@ body.side-panel-collapsed .terminal-tabs-shell {
1475
1486
  gap: 0.5rem;
1476
1487
  color: rgba(var(--ctp-subtext-rgb), 0.76);
1477
1488
  }
1489
+ .footer-line-tui {
1490
+ align-items: center;
1491
+ gap: 0.5rem;
1492
+ overflow: hidden;
1493
+ color: rgba(var(--ctp-subtext-rgb), 0.78);
1494
+ white-space: nowrap;
1495
+ }
1496
+ .footer-tui-item {
1497
+ min-width: 0;
1498
+ overflow: hidden;
1499
+ padding: 0;
1500
+ color: inherit;
1501
+ font: inherit;
1502
+ font-weight: 700;
1503
+ text-overflow: ellipsis;
1504
+ white-space: nowrap;
1505
+ }
1506
+ button.footer-tui-item {
1507
+ appearance: none;
1508
+ border: 0;
1509
+ background: transparent;
1510
+ cursor: pointer;
1511
+ }
1512
+ .footer-tui-action:hover,
1513
+ .footer-tui-action:focus-visible {
1514
+ color: var(--ctp-teal);
1515
+ outline: none;
1516
+ }
1517
+ .footer-tui-cwd {
1518
+ flex: 0 1 auto;
1519
+ max-width: 38%;
1520
+ color: rgba(var(--ctp-text-rgb), 0.86);
1521
+ }
1522
+ .footer-tui-status { color: var(--ctp-yellow); }
1523
+ .footer-tui-stat { flex: 0 0 auto; }
1524
+ .footer-tui-spacer {
1525
+ flex: 1 1 auto;
1526
+ min-width: 1.2rem;
1527
+ }
1528
+ .footer-tui-model {
1529
+ flex: 0 1 auto;
1530
+ max-width: 46%;
1531
+ color: rgba(var(--ctp-text-rgb), 0.86);
1532
+ text-align: right;
1533
+ }
1478
1534
  .footer-details-toggle { display: none; }
1479
1535
  .footer-metric,
1480
1536
  .footer-meta {
@@ -2423,6 +2479,49 @@ button.footer-meta {
2423
2479
  color: rgba(var(--ctp-subtext-rgb), 0.78);
2424
2480
  font-size: 0.78rem;
2425
2481
  }
2482
+ .message.has-copy-action:not(.toolResult):not(.bashExecution):not(.compactionSummary) > .message-header,
2483
+ .message.has-copy-action:not(.toolResult):not(.bashExecution):not(.compactionSummary) > .message-body {
2484
+ padding-right: 3.1rem;
2485
+ }
2486
+ .message.has-copy-action > .message-collapse > summary.message-header {
2487
+ padding-right: 3.4rem;
2488
+ }
2489
+ .message-copy-button {
2490
+ position: absolute;
2491
+ top: 0.48rem;
2492
+ right: 0.54rem;
2493
+ z-index: 9;
2494
+ display: inline-flex;
2495
+ align-items: center;
2496
+ justify-content: center;
2497
+ width: 2.15rem;
2498
+ min-width: 2.15rem;
2499
+ min-height: 2.05rem;
2500
+ padding: 0;
2501
+ border-radius: 0.64rem;
2502
+ color: rgba(var(--ctp-text-rgb), 0.82);
2503
+ background: linear-gradient(180deg, rgba(var(--ctp-surface-rgb), 0.82), rgba(var(--ctp-crust-rgb), 0.90));
2504
+ border-color: rgba(148, 226, 213, 0.26);
2505
+ box-shadow: 0 0.45rem 1rem rgba(var(--ctp-crust-rgb), 0.30), inset 0 1px 0 rgba(255,255,255,0.055);
2506
+ line-height: 1;
2507
+ opacity: 0.72;
2508
+ }
2509
+ .message-copy-button:hover,
2510
+ .message-copy-button:focus-visible,
2511
+ .message-copy-button.copied {
2512
+ color: #11111b;
2513
+ border-color: transparent;
2514
+ background: linear-gradient(120deg, var(--ctp-teal), var(--ctp-blue));
2515
+ box-shadow: 0 0 0.9rem rgba(148, 226, 213, 0.28), 0 0.45rem 1rem rgba(var(--ctp-crust-rgb), 0.34);
2516
+ opacity: 1;
2517
+ }
2518
+ .message-copy-button.copied {
2519
+ background: linear-gradient(120deg, var(--ctp-green), var(--ctp-teal));
2520
+ }
2521
+ .message-copy-icon {
2522
+ font-size: 1.18rem;
2523
+ line-height: 1;
2524
+ }
2426
2525
  .message-collapse {
2427
2526
  margin: 0;
2428
2527
  padding: 0;
@@ -2597,7 +2696,7 @@ button.footer-meta {
2597
2696
  }
2598
2697
  .action-feedback-controls {
2599
2698
  position: absolute;
2600
- top: calc(100% - 0.18rem);
2699
+ bottom: 0.48rem;
2601
2700
  right: 0.55rem;
2602
2701
  z-index: 8;
2603
2702
  display: flex;
@@ -2605,7 +2704,7 @@ button.footer-meta {
2605
2704
  gap: 0.35rem;
2606
2705
  opacity: 0;
2607
2706
  pointer-events: auto;
2608
- transform: translateY(-0.12rem) scale(0.98);
2707
+ transform: translateY(0.12rem) scale(0.98);
2609
2708
  transition: opacity 140ms ease, transform 140ms ease;
2610
2709
  }
2611
2710
  .action-feedback-controls:hover,
@@ -3092,6 +3191,22 @@ summary { cursor: pointer; color: var(--warning); }
3092
3191
  .composer-publish-button.menu-open {
3093
3192
  background: linear-gradient(120deg, var(--ctp-peach), var(--ctp-yellow), var(--ctp-mauve));
3094
3193
  }
3194
+ .composer-native-command-button {
3195
+ color: var(--ctp-mauve);
3196
+ border-color: rgba(203, 166, 247, 0.40);
3197
+ background:
3198
+ linear-gradient(120deg, rgba(203, 166, 247, 0.15), rgba(137, 180, 250, 0.10)),
3199
+ linear-gradient(180deg, rgba(var(--ctp-surface-rgb), 0.88), rgba(var(--ctp-crust-rgb), 0.88));
3200
+ }
3201
+ .composer-native-command-button:hover,
3202
+ .composer-native-command-button.menu-open {
3203
+ color: #11111b;
3204
+ background: linear-gradient(120deg, var(--ctp-mauve), var(--ctp-blue), var(--ctp-teal));
3205
+ border-color: transparent;
3206
+ }
3207
+ .composer-native-command-menu.open .composer-native-command-button {
3208
+ border-color: rgba(203, 166, 247, 0.62);
3209
+ }
3095
3210
  .composer-publish-menu-panel {
3096
3211
  position: absolute;
3097
3212
  z-index: 100;
@@ -3141,6 +3256,20 @@ summary { cursor: pointer; color: var(--warning); }
3141
3256
  background: linear-gradient(120deg, var(--ctp-peach), var(--ctp-yellow));
3142
3257
  box-shadow: 0 0 1rem rgba(250, 179, 135, 0.20);
3143
3258
  }
3259
+ .composer-native-command-menu-item {
3260
+ color: var(--ctp-mauve);
3261
+ border-color: rgba(203, 166, 247, 0.32);
3262
+ background:
3263
+ linear-gradient(120deg, rgba(203, 166, 247, 0.12), rgba(137, 180, 250, 0.08)),
3264
+ var(--ctp-crust);
3265
+ }
3266
+ .composer-native-command-menu-item:hover,
3267
+ .composer-native-command-menu-item:focus-visible {
3268
+ color: #11111b;
3269
+ border-color: transparent;
3270
+ background: linear-gradient(120deg, var(--ctp-mauve), var(--ctp-blue));
3271
+ box-shadow: 0 0 1rem rgba(203, 166, 247, 0.20);
3272
+ }
3144
3273
  .composer button[data-tooltip] {
3145
3274
  position: relative;
3146
3275
  }
@@ -3213,6 +3342,15 @@ summary { cursor: pointer; color: var(--warning); }
3213
3342
  .composer-input-row button[data-tooltip].tooltip-open::before {
3214
3343
  transform: translate(-1.2rem, 0) rotate(45deg);
3215
3344
  }
3345
+ .composer-publish-menu:hover > .composer-publish-button[data-tooltip]::before,
3346
+ .composer-publish-menu:hover > .composer-publish-button[data-tooltip]::after,
3347
+ .composer-publish-menu:focus-within > .composer-publish-button[data-tooltip]::before,
3348
+ .composer-publish-menu:focus-within > .composer-publish-button[data-tooltip]::after,
3349
+ .composer-publish-menu.open > .composer-publish-button[data-tooltip]::before,
3350
+ .composer-publish-menu.open > .composer-publish-button[data-tooltip]::after {
3351
+ display: none !important;
3352
+ opacity: 0 !important;
3353
+ }
3216
3354
 
3217
3355
  .details {
3218
3356
  display: grid;
@@ -3489,6 +3627,18 @@ summary { cursor: pointer; color: var(--warning); }
3489
3627
  color: #ff9f43 !important;
3490
3628
  background: rgba(255, 159, 67, 0.10);
3491
3629
  }
3630
+ .native-selector-badge.native-selector-badge-pi-native,
3631
+ .native-command-body:has(.native-resource-summary) .native-selector-badge.native-selector-badge-pi-native {
3632
+ border-color: rgba(137, 180, 250, 0.48);
3633
+ color: var(--ctp-blue);
3634
+ background: rgba(137, 180, 250, 0.10);
3635
+ }
3636
+ .native-selector-badge.native-selector-badge-external,
3637
+ .native-command-body:has(.native-resource-summary) .native-selector-badge.native-selector-badge-external {
3638
+ border-color: rgba(203, 166, 247, 0.46);
3639
+ color: var(--ctp-mauve);
3640
+ background: rgba(203, 166, 247, 0.10);
3641
+ }
3492
3642
  .native-selector-detail,
3493
3643
  .native-selector-meta,
3494
3644
  .native-settings-hint {
@@ -3943,6 +4093,19 @@ summary { cursor: pointer; color: var(--warning); }
3943
4093
  padding: 0.68rem 0.7rem;
3944
4094
  border-radius: 0.82rem;
3945
4095
  }
4096
+ .message-copy-button {
4097
+ top: 0.38rem;
4098
+ right: 0.42rem;
4099
+ width: 2rem;
4100
+ min-width: 2rem;
4101
+ min-height: 1.92rem;
4102
+ }
4103
+ .message-copy-icon { font-size: 1.12rem; }
4104
+ .message.has-copy-action:not(.toolResult):not(.bashExecution):not(.compactionSummary) > .message-header,
4105
+ .message.has-copy-action:not(.toolResult):not(.bashExecution):not(.compactionSummary) > .message-body,
4106
+ .message.has-copy-action > .message-collapse > summary.message-header {
4107
+ padding-right: 2.55rem;
4108
+ }
3946
4109
  .message-header {
3947
4110
  gap: 0.5rem;
3948
4111
  margin-bottom: 0.38rem;
@@ -3956,7 +4119,7 @@ summary { cursor: pointer; color: var(--warning); }
3956
4119
  }
3957
4120
  .feedback-tray button { width: 100%; }
3958
4121
  .action-feedback-controls {
3959
- top: calc(100% - 0.12rem);
4122
+ bottom: 0.38rem;
3960
4123
  right: 0.34rem;
3961
4124
  gap: 0.28rem;
3962
4125
  }
@@ -3981,6 +4144,19 @@ summary { cursor: pointer; color: var(--warning); }
3981
4144
  overflow: visible;
3982
4145
  }
3983
4146
  body.footer-model-picker-open .footer-line { z-index: 1; }
4147
+ .footer-line-tui {
4148
+ flex-wrap: wrap;
4149
+ gap: 0.22rem 0.48rem;
4150
+ white-space: normal;
4151
+ }
4152
+ .footer-tui-item { white-space: nowrap; }
4153
+ .footer-tui-cwd,
4154
+ .footer-tui-model {
4155
+ flex-basis: 100%;
4156
+ max-width: 100%;
4157
+ text-align: left;
4158
+ }
4159
+ .footer-tui-spacer { display: none; }
3984
4160
  .footer-model-picker {
3985
4161
  position: fixed;
3986
4162
  left: max(0.55rem, env(safe-area-inset-left));
@@ -98,6 +98,10 @@ assert.doesNotMatch(html, /class="side-panel-controls"[\s\S]*id="abortButton"/,
98
98
  assert.match(html, /id="publishButton"[\s\S]*?aria-controls="publishMenu"/, "composer should expose a Publish workflow menu button");
99
99
  assert.match(html, /id="releaseNpmButton"[^>]*data-command="\/release-npm"[\s\S]*?<span>NPM Release<\/span>/, "Publish menu should include the npm release workflow by label");
100
100
  assert.match(html, /id="releaseAurButton"[^>]*data-command="\/release-aur"[\s\S]*?<span>AUR Release<\/span>/, "Publish menu should include the AUR release workflow by label");
101
+ assert.match(html, /id="nativeCommandMenuButton"[\s\S]*?aria-controls="nativeCommandMenu"/, "composer should expose a /skills and /tools command menu button");
102
+ assert.ok(html.indexOf('id="publishButton"') < html.indexOf('id="nativeCommandMenuButton"'), "skills/tools command menu should render immediately after the Publish workflow button");
103
+ assert.match(html, /id="nativeSkillsButton"[^>]*data-command="\/skills"[\s\S]*?<span>Skills Setup<\/span>/, "skills/tools command menu should include Skills Setup");
104
+ assert.match(html, /id="nativeToolsButton"[^>]*data-command="\/tools"[\s\S]*?<span>Tools Setup<\/span>/, "skills/tools command menu should include Tools Setup");
101
105
  assert.doesNotMatch(html, /<code>\/release-(?:npm|aur)<\/code>/, "Publish menu should not show slash command names as option labels");
102
106
  assert.doesNotMatch(html, /data-tooltip="[^"]*\/release-(?:npm|aur)/, "Publish tooltip should not show slash command names");
103
107
  assert.match(html, /id="steerButton"[\s\S]*?data-tooltip="Steer usage:/, "Steer should explain type-first usage in a tooltip");
@@ -127,6 +131,8 @@ assert.match(css, /#promptInput \{[\s\S]*?overflow-y:\s*hidden/, "prompt input s
127
131
  assert.match(css, /\.sticky-user-prompt-button \{[\s\S]*?grid-template-columns:\s*auto minmax\(0, 1fr\) auto/, "last-user-prompt jump control should render as a fixed transcript header");
128
132
  assert.match(css, /\.message\.extension,[\s\S]*?\.message\.native/, "extension and native command output should have visible transcript styling");
129
133
  assert.match(css, /\.message\.run-indicator-message \{[\s\S]*?border-color/, "active agent runs should render a visible transcript indicator card");
134
+ assert.match(css, /\.message-copy-button \{[\s\S]*?position:\s*absolute/, "transcript messages should expose a top-right copy button");
135
+ assert.match(css, /\.message\.has-copy-action[\s\S]*?padding-right:\s*3\.1rem/, "copy buttons should reserve space in message cards");
130
136
  assert.match(css, /\.message\.action-enter \{[\s\S]*?action-card-slide-in 340ms/, "new action cards should visibly slide in from the bottom");
131
137
  assert.match(css, /@keyframes action-card-slide-in \{[\s\S]*?translate3d\(0, 1\.45rem, 0\)/, "action-card entry animation should start well below the final position");
132
138
  assert.match(css, /\.message\.thinking,\n\.message\.toolCall,\n\.message\.assistantEvent/, "thinking and assistant events should have non-assistant transcript card styling");
@@ -173,16 +179,19 @@ assert.match(css, /\.command-suggest-item:hover \{\n\s+box-shadow: none;\n\s+tra
173
179
  assert.doesNotMatch(css, /\.command-suggest-item:hover,\n\.command-suggest-item\.active/, "autocomplete hover and active selection styles should stay separate");
174
180
  assert.match(css, /\.feedback-tray\[hidden\] \{ display: none; \}/, "queued action-feedback tray should hide when empty");
175
181
  assert.match(css, /\.action-feedback-controls \{[\s\S]*?position:\s*absolute/, "action reactions should be absolutely positioned so they do not expand cards");
176
- assert.match(css, /\.action-feedback-controls \{[\s\S]*?top:\s*calc\(100% - 0\.18rem\)/, "action reactions should sit outside the message box by default");
182
+ assert.match(css, /\.action-feedback-controls \{[\s\S]*?bottom:\s*0\.48rem/, "action reactions should sit inside the message box by default");
177
183
  assert.match(css, /\.action-feedback-controls \{[\s\S]*?opacity:\s*0/, "action reactions should stay hidden until hovered or focused");
178
184
  assert.match(css, /\.action-feedback-controls:hover,[\s\S]*?\.action-feedback-controls:focus-within/, "action reactions should reveal on hover or keyboard focus");
179
185
  assert.match(css, /\.action-feedback-controls:not\(:hover\):not\(:focus-within\) \.action-feedback-button/, "hidden action reactions should not expose button hit targets until the hover area is reached");
180
186
  assert.match(css, /\.action-feedback-button\.feedback-question\.active/, "question-mark reaction should have selected styling");
181
187
  assert.match(css, /\.composer-row button\[data-tooltip\]::after/, "composer button tooltips should be shared across Git, Steer, and Follow-up buttons");
182
188
  assert.match(css, /\.composer-row button\[data-tooltip\]\.tooltip-open::after/, "composer button tooltips should be triggerable from JS for empty mobile taps");
189
+ assert.match(css, /\.composer-publish-menu:hover > \.composer-publish-button\[data-tooltip\]::before,[\s\S]*?\.composer-publish-menu\.open > \.composer-publish-button\[data-tooltip\]::after \{[\s\S]*?display:\s*none !important;[\s\S]*?opacity:\s*0 !important;/, "dropdown button tooltips should hide while publish or setup menus are open");
183
190
  assert.match(css, /\.composer-publish-menu-panel \{[\s\S]*?display:\s*none;[\s\S]*?flex-direction:\s*column/, "Publish workflow menu should hide when closed and expand like grouped tabs");
184
191
  assert.match(css, /\.composer-publish-menu:hover \.composer-publish-menu-panel,[\s\S]*?\.composer-publish-menu:focus-within \.composer-publish-menu-panel,[\s\S]*?\.composer-publish-menu\.open \.composer-publish-menu-panel \{\n\s+display:\s*flex;/, "Publish workflow menu should open on hover, focus, or explicit open state");
185
- assert.match(css, /\.composer-actions-panel > \.composer-publish-menu[\s\S]*?grid-column: span 1/, "Publish workflow button should fit beside Git workflow in mobile actions");
192
+ assert.match(css, /\.composer-native-command-button \{[\s\S]*?color:\s*var\(--ctp-mauve\)/, "skills/tools command menu should have a distinct slash-command button style");
193
+ assert.match(css, /\.composer-native-command-menu-item \{[\s\S]*?color:\s*var\(--ctp-mauve\)/, "skills/tools command menu items should be styled separately from publish actions");
194
+ assert.match(css, /\.composer-actions-panel > \.composer-publish-menu[\s\S]*?grid-column: span 1/, "Publish and command menu buttons should fit beside Git workflow in mobile actions");
186
195
  assert.match(css, /\.composer-actions-panel[\s\S]*?bottom:\s*calc\(100% \+ 0\.42rem\)/, "mobile composer actions should open as an above-composer sheet");
187
196
  assert.match(css, /body\.composer-actions-open \.composer-actions-panel \{ display: grid; \}/, "composer actions panel should only open when toggled");
188
197
  assert.match(css, /\.terminal-tabs-toggle-button \{ display: none; \}/, "terminal tab toggle should be hidden outside mobile CSS");
@@ -205,14 +214,16 @@ assert.match(css, /body\.mobile-keyboard-open \.composer-actions-button,[\s\S]*?
205
214
  assert.match(css, /\.server-offline-panel/, "PWA/offline shell should style a backend-offline recovery panel");
206
215
  assert.match(css, /body:not\(\.pi-run-active\):not\(\.mobile-keyboard-open\) \.composer-row button\.primary \{ grid-column: span 4; \}/, "idle mobile composer should keep Actions and Send on one compact row");
207
216
  assert.match(css, /button\[hidden\] \{ display: none !important; \}/, "hidden bottom-row controls should not occupy layout space");
208
- assert.match(css, /\.footer-details-toggle \{ display: none; \}/, "footer details toggle should be hidden outside mobile CSS");
209
- assert.match(css, /\.footer-workspace,\n\s+\.footer-context \{ display: grid !important; \}/, "collapsed mobile footer should primarily show cwd and context");
210
- assert.match(css, /\.footer-model \{ order: 7; \}/, "model should move into expanded footer details on mobile");
217
+ assert.match(css, /\.statusbar-tui-footer \{[\s\S]*?gap:\s*0/, "default TUI-like footer should reduce statusbar chrome around the compact line");
218
+ assert.match(css, /\.statusbar-git-footer \{[\s\S]*?gap:\s*0\.58rem/, "enabled git-footer extension should keep the styled Web UI footer spacing");
219
+ assert.match(css, /\.footer-line-tui \{[\s\S]*?white-space:\s*nowrap/, "default Web UI footer should use a minimal TUI-like line");
220
+ assert.match(css, /\.footer-tui-cwd[\s\S]*?max-width:\s*38%/, "TUI-like footer should keep cwd compact on desktop");
221
+ assert.match(css, /\.footer-tui-model[\s\S]*?text-align:\s*right/, "TUI-like footer should right-align model information on desktop");
211
222
  assert.match(css, /\.footer-model-picker[\s\S]*?position:\s*absolute/, "footer model picker should render as a dropdown/popover");
212
223
  assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\)[\s\S]*?\.footer-model-picker \{[\s\S]*?position:\s*fixed/, "mobile footer model picker should escape footer-details stacking as a fixed overlay on narrow, device-width-narrow, or touch-only devices");
213
224
  assert.match(css, /bottom:\s*var\(--footer-model-picker-bottom/, "mobile footer model picker should be anchored by a JS-computed viewport offset");
214
225
  assert.match(css, /\.footer-model-option\.active/, "footer model picker should style the selected scoped model");
215
- assert.match(css, /body\.footer-details-expanded \.footer-line-meta[\s\S]*?display:\s*grid/, "mobile footer metadata should be expandable");
226
+ assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\)[\s\S]*?\.footer-line-tui \{[\s\S]*?flex-wrap:\s*wrap/, "mobile footer should wrap the minimal TUI-like line instead of using expanded metadata chips");
216
227
  assert.match(css, /(?:^|\n)\s*\.side-panel-backdrop\s*\{[\s\S]*?position:\s*fixed/, "mobile side panel backdrop should be fixed overlay UI");
217
228
  assert.match(css, /(?:^|\n)\s*\.side-panel\s*\{[\s\S]*?position:\s*fixed/, "mobile side panel should be an overlay drawer instead of stacked content");
218
229
  assert.match(css, /\.extension-dialog[\s\S]*?max-height:\s*calc\(var\(--visual-viewport-height/, "dialogs should fit the visual viewport on mobile");
@@ -220,6 +231,8 @@ assert.match(css, /\.extension-dialog[\s\S]*?inset:\s*auto 0 0 0/, "mobile dialo
220
231
  assert.match(css, /#dialogMessage \{[\s\S]*?white-space:\s*pre-wrap/, "extension dialog messages should preserve multiline prompts");
221
232
  assert.match(css, /\.native-command-dialog \{[\s\S]*?width:\s*min\(56rem/, "native slash selector dialog should have roomy desktop layout");
222
233
  assert.match(css, /\.native-selector-item \{[\s\S]*?--tree-depth/, "native slash selector choices should support tree indentation");
234
+ assert.match(css, /\.native-selector-badge\.native-selector-badge-pi-native[\s\S]*?color:\s*var\(--ctp-blue\)/, "Tools Setup should distinguish Pi native tools with a Pi Native tag");
235
+ assert.match(css, /\.native-selector-badge\.native-selector-badge-external[\s\S]*?color:\s*var\(--ctp-mauve\)/, "Tools Setup should distinguish external tools with an External tag");
223
236
  assert.match(css, /\.native-settings-grid,[\s\S]*?\.native-tree-options \{[\s\S]*?grid-template-columns:/, "native settings and tree selector options should use responsive grids");
224
237
  assert.match(css, /\.extension-dialog\.guardrail-dialog[\s\S]*?border-color:\s*rgba\(249, 226, 175/, "guardrail dialogs should have warning-specific styling");
225
238
  assert.match(css, /\.extension-dialog\.release-dialog[\s\S]*?width:\s*min\(64rem/, "release confirmation dialogs should have more horizontal room");
@@ -287,6 +300,10 @@ assert.match(app, /navigator\.serviceWorker\.register\("\/service-worker\.js"\)/
287
300
  assert.match(app, /function serverStartCommandText\(\)[\s\S]*pi-webui --cwd/, "PWA/offline shell should build a pi-webui --cwd recovery command");
288
301
  assert.match(app, /Pi Web UI server is offline/, "PWA/offline shell should clearly report backend-down state");
289
302
  assert.match(app, /navigator\.clipboard\.writeText\(text\)/, "backend-offline recovery panel should copy the start command when possible");
303
+ assert.match(app, /function messageCopyText\(message, body = null\)/, "frontend should derive copy text from transcript messages");
304
+ assert.match(app, /function attachMessageCopyButton\(bubble, message, body\)/, "frontend should add copy controls to rendered transcript cards");
305
+ assert.match(app, /button\.append\(make\("span", "message-copy-icon", "⧉"\)\)/, "message copy buttons should render as icon-only controls");
306
+ assert.match(app, /copyMessageBubble\(button\)/, "message copy buttons should copy through the shared clipboard helper");
290
307
  assert.match(app, /retryServerConnectionButton.*retryServerConnection/s, "backend-offline recovery panel should wire a retry action");
291
308
  assert.match(app, /function isChatNearBottom\(/, "chat should detect whether the user is reading above the bottom");
292
309
  assert.match(app, /function scheduleChatFollowScroll\(/, "chat auto-follow should retry after layout settles during fast streaming");
@@ -336,9 +353,33 @@ assert.match(app, /function renderCodexUsage\(\)/, "frontend should render Codex
336
353
  assert.match(app, /api\(`\/api\/codex-usage\$\{suffix\}`, \{ scoped: false \}\)/, "Codex usage should load through a server endpoint without browser credentials");
337
354
  assert.match(app, /restoreSidePanelSectionState\(\);\nbindSidePanelSectionToggles\(\);/, "side panel section state should restore before toggles are bound");
338
355
  assert.match(app, /OPTIONAL_FEATURES_STORAGE_KEY/, "optional feature disable toggles should persist in browser storage");
356
+ assert.match(app, /GIT_FOOTER_WEBUI_STATUS_KEY = "git-footer-webui"/, "git footer Web UI data should be received as an extension-owned status payload");
357
+ assert.match(app, /function parseGitFooterWebuiPayloadRaw\(raw\)[\s\S]*GIT_FOOTER_WEBUI_PAYLOAD_TYPE[\s\S]*GIT_FOOTER_WEBUI_PAYLOAD_VERSION/, "Web UI footer should parse the structured payload emitted by git-footer-status");
358
+ assert.match(app, /function renderFooter\(\)[\s\S]*parseGitFooterWebuiPayload\(\)[\s\S]*renderGitFooterPayload\(gitFooterPayload\)/, "detailed footer rendering should prefer the git-footer-status extension payload");
359
+ assert.match(app, /function renderGitFooterPayload\(payload\)[\s\S]*classList\.remove\("statusbar-tui-footer"\)[\s\S]*classList\.add\("statusbar-git-footer"\)[\s\S]*payload\.main\.map\(renderGitFooterPayloadMetric\)[\s\S]*payload\.meta\.map/, "enabled git footer payload should use the styled extension chip renderer, not the default TUI line");
360
+ assert.match(app, /function renderGitFooterPayloadMetric\(chip\)[\s\S]*footerMetric\(chip\.icon/, "git footer main payload chips should render as styled metrics");
361
+ assert.match(app, /function renderGitFooterPayloadMeta\(chip, tab\)[\s\S]*footerMeta\(chip\.label, chip\.value, footerMetaClassForPayload\(chip\)/, "git footer meta payload chips should render as styled metadata");
362
+ assert.match(app, /let latestStats = null/, "default footer should retain session stats for token and context display");
363
+ assert.match(app, /async function refreshStats\(tabContext = activeTabContext\(\)\)[\s\S]*api\("\/api\/stats"/, "default footer should fetch session stats");
364
+ assert.match(app, /function renderMinimalFooter\(\)[\s\S]*stats: fallbackFooterStats\(\)/, "minimal default footer should include token, cost, and context stats");
365
+ assert.match(app, /function footerStatsTokensDisplay\(stats = latestStats\)[\s\S]*`↑\$\{formatFooterTokenCount\(tokens\.input\)\} ↓\$\{formatFooterTokenCount\(tokens\.output\)\}`/, "fallback footer stats should include input/output tokens");
366
+ assert.match(app, /function footerStatsCostDisplay\(stats = latestStats\)[\s\S]*footerCostAuthLabel\(\)/, "fallback footer stats should include api\/sub cost mode");
367
+ assert.doesNotMatch(app, /Git footer status disabled/, "disabled git footer should show only the minimal footer metadata");
368
+ assert.doesNotMatch(app, /footerMeta\("runtime"/, "minimal Web UI footer should not render runtime metadata");
369
+ assert.match(app, /statusEntries\.has\(GIT_FOOTER_WEBUI_STATUS_KEY\)/, "optional feature detection should recognize the git-footer-status Web UI payload");
370
+ assert.match(app, /\/git-footer-refresh --webui-silent/, "Web UI should quietly request the extension-owned footer payload when idle and missing");
371
+ assert.match(app, /Loading git footer status…/, "missing git footer payload should show a loading state before declaring the extension unavailable");
372
+ assert.match(app, /GIT_FOOTER_WEBUI_PAYLOAD_CACHE_KEY/, "git footer payloads should be cached across Web UI reloads");
373
+ assert.match(app, /function setOptionalFeatureDisabled\(featureId, disabled\)[\s\S]*clearGitFooterWebuiPayloadCache\(\)/, "changing the git footer feature toggle should invalidate the cached footer payload");
374
+ const workspaceInfoSource = server.match(/async function getWorkspaceInfo[\s\S]*?\n}\n\nlet activeGitWorkflowProcess/)?.[0] || "";
375
+ assert.ok(workspaceInfoSource, "server workspace info source should be inspectable");
376
+ assert.doesNotMatch(workspaceInfoSource, /runCommand\("git"|branchStatus|isRepo/, "Web UI workspace endpoint should not duplicate git footer status collection");
339
377
  assert.match(app, /function renderOptionalFeatureDependentDisplays\(\)[\s\S]*renderOptionalFeatureControls\(\);[\s\S]*renderThemeSelect\(\);[\s\S]*renderWidgets\(\);[\s\S]*renderStatus\(\);[\s\S]*renderCommands\(\);[\s\S]*renderAllMessages\(\{ preserveScroll: true \}\);[\s\S]*if \(streamRawText\) renderStreamingAssistantText\(\);/, "optional feature toggles should immediately refresh visible controls, commands, transcript, and live stream displays");
340
378
  assert.match(app, /function setOptionalFeatureDisabled\(featureId, disabled\)[\s\S]*renderOptionalFeatureDependentDisplays\(\);[\s\S]*const tabContext = activeTabContext\(\);[\s\S]*refreshCommands\(tabContext\)/, "optional feature enable/disable should re-render the GUI and then refresh command capabilities");
341
379
  assert.match(app, /function setOptionalControlState\(button, available, unavailableTitle\)[\s\S]*setAttribute\("aria-label", nextAriaLabel\)[\s\S]*setAttribute\("data-tooltip", nextTooltip\)/, "optional feature button disabled state should update accessible labels and visible tooltips");
380
+ assert.match(app, /\["skills", "tuiSkillsCommand"\][\s\S]*\["tools", "tuiToolsCommand"\]/, "optional feature toggles should gate /skills and /tools command surfaces");
381
+ assert.match(app, /function setNativeCommandMenuOpen\(open\)/, "frontend should track the skills/tools command menu open state separately from Publish");
382
+ assert.match(app, /nativeSkillsButton\.hidden = !isOptionalFeatureEnabled\("tuiSkillsCommand"\)[\s\S]*nativeToolsButton\.hidden = !isOptionalFeatureEnabled\("tuiToolsCommand"\)/, "skills/tools menu items should be hidden by their optional feature toggles");
342
383
  assert.match(app, /function renderCommands\(\)/, "side-panel commands should be re-renderable from current optional feature state");
343
384
  assert.match(app, /function installOptionalFeature\(featureId\)/, "optional features should expose an install action");
344
385
  assert.match(app, /api\("\/api\/optional-feature-install"/, "optional feature install action should call the backend installer endpoint");
@@ -486,10 +527,18 @@ assert.match(app, /function showComposerButtonTooltip\(button\)/, "empty mode-bu
486
527
  assert.match(app, /sendPromptFromModeButton\("steer", elements\.steerButton\)/, "Steer should show tooltip instead of silently doing nothing when input is empty");
487
528
  assert.match(app, /sendPromptFromModeButton\("follow-up", elements\.followUpButton\)/, "Follow-up should show tooltip instead of silently doing nothing when input is empty");
488
529
  assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*?sendPrompt\("prompt", command\)/, "Publish workflows should send slash commands directly without replacing the draft");
489
- assert.match(app, /publishMenuContainer\?\.addEventListener\("pointerenter", \(\) => setPublishMenuOpen\(true\)\)/, "Publish menu should expand on hover");
530
+ assert.match(app, /async function runNativeCommandMenu\(command\)[\s\S]*?await handleNativeSlashSelectorCommand\(command\)/, "skills/tools command menu should open native selector dialogs directly");
531
+ assert.match(app, /function nativeToolOriginTag\(resource\)[\s\S]*?sourceInfo\?\.source === "builtin"[\s\S]*?label: "Pi Native"[\s\S]*?label: "External"/, "Tools Setup should classify built-in Pi tools separately from external tools");
532
+ assert.match(app, /renderNativeResourceToggles\(tools, \{[\s\S]*?getResourceTag: nativeToolOriginTag/, "Tools Setup should render Pi Native\/External tags");
533
+ assert.match(app, /const tags = Array\.isArray\(item\.tags\)[\s\S]*?item\.badge, \.\.\.tags/, "native selector filtering should include extra resource tags");
534
+ assert.match(app, /publishMenuContainer\?\.addEventListener\("pointerenter", \(\) => \{[\s\S]*?setPublishMenuOpen\(true\);[\s\S]*?\}\)/, "Publish menu should expand on hover");
490
535
  assert.match(app, /publishMenuContainer\?\.addEventListener\("pointerleave", \(\) => setPublishMenuOpen\(false\)\)/, "Publish menu should collapse after hover leaves");
536
+ assert.match(app, /nativeCommandMenuContainer\?\.addEventListener\("pointerenter", \(\) => \{[\s\S]*?setNativeCommandMenuOpen\(true\);[\s\S]*?\}\)/, "skills/tools command menu should expand on hover");
537
+ assert.match(app, /nativeCommandMenuContainer\?\.addEventListener\("pointerleave", \(\) => setNativeCommandMenuOpen\(false\)\)/, "skills/tools command menu should collapse after hover leaves");
491
538
  assert.match(app, /releaseNpmButton\.addEventListener\("click", \(\) => runPublishWorkflow\("\/release-npm"\)\)/, "Publish menu should launch /release-npm");
492
539
  assert.match(app, /releaseAurButton\.addEventListener\("click", \(\) => runPublishWorkflow\("\/release-aur"\)\)/, "Publish menu should launch /release-aur");
540
+ assert.match(app, /nativeSkillsButton\.addEventListener\("click", \(\) => runNativeCommandMenu\("\/skills"\)\)/, "skills/tools command menu should launch /skills");
541
+ assert.match(app, /nativeToolsButton\.addEventListener\("click", \(\) => runNativeCommandMenu\("\/tools"\)\)/, "skills/tools command menu should launch /tools");
493
542
  assert.match(app, /async function sendPrompt\(kind = "prompt", explicitMessage\)/, "prompt sending should accept direct messages that bypass the input field");
494
543
  assert.match(app, /const rawMessage = usesPromptInput \? elements\.promptInput\.value : explicitMessage/, "direct prompt sends should not read the input textarea");
495
544
  assert.match(app, /if \(usesPromptInput\) \{[\s\S]*?if \(targetStillActive\) \{[\s\S]*?elements\.promptInput\.value = "";/, "direct prompt sends should preserve the input textarea draft");
@@ -575,16 +624,16 @@ assert.match(app, /function applyResponseTab\(response\)/, "frontend should merg
575
624
  assert.match(app, /case "webui_tab_renamed":/, "frontend should update tab labels from backend rename events");
576
625
  assert.match(app, /terminalTabsToggleButton\.addEventListener\("click"/, "terminal tabs trigger should be wired in JS");
577
626
  assert.match(app, /composerActionsButton\.addEventListener\("click"/, "composer actions trigger should be wired in JS");
578
- assert.match(app, /function setMobileFooterExpanded\(/, "mobile footer should have an expandable details state");
627
+ assert.match(app, /function setMobileFooterExpanded\(/, "mobile footer should preserve expansion state for compatibility");
579
628
  assert.match(app, /function updateFooterModelPickerPosition\(\)/, "mobile model picker should compute a fixed overlay position above the footer");
580
- assert.match(app, /mobileFooterExpanded = false;[\s\S]*?document\.body\.classList\.remove\("footer-details-expanded"\)/, "opening mobile model picker should collapse footer details so details cannot cover the dropdown");
581
- assert.match(app, /footerMeta\("context", contextLabel, "footer-context"\)/, "footer should render context as a primary mobile meta item");
582
- assert.match(app, /footerMeta\("model", modelLine, "footer-model", \{/, "footer model item should be clickable");
629
+ assert.match(app, /mobileFooterExpanded = false;[\s\S]*?document\.body\.classList\.remove\("footer-details-expanded"\)/, "opening mobile model picker should collapse legacy footer details so they cannot cover the dropdown");
630
+ assert.match(app, /function renderTuiFooterLine\([\s\S]*footer-line footer-line-tui/, "footer should render a minimal TUI-like line instead of metadata chips");
631
+ assert.match(app, /footerTuiItem\(model, "footer-tui-model", \{[\s\S]*setFooterModelPickerOpen\(!footerModelPickerOpen\)/, "footer model item should be clickable");
583
632
  assert.match(app, /function renderFooterModelPicker\(\)/, "footer should render a scoped-model picker dropdown");
584
633
  assert.match(app, /api\("\/api\/scoped-models", \{ tabId: tabContext\.tabId \}\)/, "footer model picker should load scoped models instead of all available models");
585
634
  assert.match(app, /for \(const model of footerScopedModels\)/, "footer model picker should render only scoped models");
586
635
  assert.match(app, /api\("\/api\/model", \{ method: "POST"/, "footer model picker should apply selected model through the model API");
587
- assert.match(app, /footer-details-toggle/, "footer details toggle should be rendered by JS");
636
+ assert.doesNotMatch(app.match(/function renderMinimalFooter\(\)[\s\S]*?\n\}/)?.[0] || "", /footer-details-toggle/, "minimal default footer should not render a details toggle chip");
588
637
  assert.match(app, /bindMobileViewChanges\(/, "side panel state should react to mobile breakpoint changes");
589
638
  assert.match(app, /function restoreSidePanelState\(\) \{\n\s+if \(isMobileView\(\)\)/, "mobile should start with side panel collapsed even if desktop state was expanded");
590
639
  assert.match(app, /case "webui_tab_reloaded":/, "frontend should handle native /reload tab restart events");
@@ -600,7 +649,7 @@ assert.equal(manifest.start_url, "/", "PWA manifest should start at the web UI r
600
649
  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");
601
650
  assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-192.png" && icon.sizes === "192x192"), "PWA manifest should include a 192px icon");
602
651
  assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-512.png" && icon.sizes === "512x512"), "PWA manifest should include a 512px icon");
603
- assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v20"/, "PWA service worker should define an app-shell cache");
652
+ assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v23"/, "PWA service worker should define an app-shell cache");
604
653
  assert.match(serviceWorker, /self\.addEventListener\("notificationclick"/, "PWA service worker should focus Web UI when blocked-tab notifications are clicked");
605
654
  assert.match(serviceWorker, /event\.notification\.data\?\.url/, "blocked-tab notifications should carry a URL for service-worker click handling");
606
655
  assert.match(serviceWorker, /"\/apple-touch-icon\.png"/, "PWA service worker should cache the apple touch icon");
@@ -632,10 +681,14 @@ assert.match(server, /tabActivity: tabActivitySnapshot\(tab\)/, "server should e
632
681
  assert.match(server, /const EXTENSION_UI_BLOCKING_METHODS = new Set\(\["select", "confirm", "input", "editor"\]\)/, "server should know which extension UI requests can block Pi runs");
633
682
  assert.match(server, /function trackPendingExtensionUiRequest\(tab, event\)/, "server should track blocking extension UI requests per tab");
634
683
  assert.match(server, /pendingExtensionUiRequests: new Map\(\)/, "new tabs should initialize pending extension UI request storage");
684
+ assert.match(server, /extensionStatuses: new Map\(\)/, "new tabs should initialize replayable extension status storage");
685
+ assert.match(server, /function rememberExtensionStatusEvent\(tab, event\)[\s\S]*event\.method !== "setStatus"[\s\S]*statuses\.set\(String\(event\.statusKey\), String\(event\.statusText\)\)/, "server should retain extension status events for reconnects");
686
+ assert.match(server, /rememberExtensionStatusEvent\(tab, scopedEvent\)[\s\S]*trackPendingExtensionUiRequest\(tab, scopedEvent\)/, "RPC events should retain extension statuses before broadcasting");
635
687
  assert.match(server, /trackPendingExtensionUiRequest\(tab, scopedEvent\)/, "RPC events should populate pending extension UI storage before broadcasting");
636
688
  assert.match(server, /scopedEvent = \{ \.\.\.scopedEvent,[\s\S]*?pendingExtensionUiRequestCount: pendingExtensionUiRequests\(tab\)\.length \}/, "RPC events should broadcast pending blocker counts for tab indicators");
689
+ assert.match(server, /function replayExtensionStatuses\(tab, res\)[\s\S]*method: "setStatus"/, "server should replay latest extension statuses on SSE reconnect");
637
690
  assert.match(server, /function replayPendingExtensionUiRequests\(tab, res\)/, "server should be able to replay missed extension UI requests on SSE reconnect");
638
- assert.match(server, /replayPendingExtensionUiRequests\(tab, res\)/, "SSE connections should replay pending extension UI blockers");
691
+ assert.match(server, /replayExtensionStatuses\(tab, res\);\n\s+replayPendingExtensionUiRequests\(tab, res\)/, "SSE connections should replay extension statuses before pending blockers");
639
692
  assert.match(server, /pendingExtensionUiRequests: pendingExtensionUiRequestSummaries\(tab\)/, "detailed Web UI status should expose pending extension UI blockers");
640
693
  assert.match(server, /resolvePendingExtensionUiRequest\(tab, payload\.id\)/, "extension UI responses should clear the pending blocker cache");
641
694
  assert.match(server, /type: "webui_extension_ui_resolved"[\s\S]*?pendingExtensionUiRequestCount/, "extension UI responses should notify clients that a blocker resolved");