@firstpick/pi-package-webui 0.1.4 → 0.1.5

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
@@ -29,6 +29,7 @@
29
29
  <div id="tabBar" class="terminal-tabs" role="tablist" aria-label="Pi terminal tabs">
30
30
  <button id="newTabButton" class="terminal-new-tab-button" type="button" title="Start a separate isolated Pi terminal">+ Tab</button>
31
31
  </div>
32
+ <button id="closeAllTabsButton" class="terminal-close-all-button" type="button" title="Close all terminal tabs">Close all Tabs</button>
32
33
  </header>
33
34
  <div id="widgetArea" class="widget-area"></div>
34
35
  <div id="chat" class="chat" aria-live="polite">
@@ -173,6 +174,8 @@
173
174
  </div>
174
175
  <button id="abortButton" type="button" class="danger">Abort</button>
175
176
  </div>
177
+ <h2>Optional features</h2>
178
+ <div id="optionalFeaturesBox" class="optional-features-box muted">Checking optional features…</div>
176
179
  <h2>Session</h2>
177
180
  <dl id="stateDetails" class="details"></dl>
178
181
  <h2>Queue</h2>
@@ -1,4 +1,4 @@
1
- const CACHE_NAME = "pi-webui-pwa-v10";
1
+ const CACHE_NAME = "pi-webui-pwa-v12";
2
2
  const APP_SHELL = [
3
3
  "/",
4
4
  "/index.html",
package/public/styles.css CHANGED
@@ -63,6 +63,7 @@
63
63
  --visual-viewport-offset-top: 0px;
64
64
  --keyboard-inset-bottom: 0px;
65
65
 
66
+ font-size: 80%;
66
67
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
67
68
  }
68
69
 
@@ -623,6 +624,103 @@ body.side-panel-collapsed .terminal-tabs-shell {
623
624
  .side-panel-controls .danger {
624
625
  margin-top: 0.15rem;
625
626
  }
627
+ .optional-features-box {
628
+ display: grid;
629
+ gap: 0.6rem;
630
+ padding: 0.72rem;
631
+ border: 1px solid rgba(180, 190, 254, 0.16);
632
+ border-radius: 0.85rem;
633
+ background: rgba(var(--ctp-crust-rgb), 0.46);
634
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.035), 0 0 1rem rgba(148, 226, 213, 0.05);
635
+ }
636
+ .optional-feature-row {
637
+ display: grid;
638
+ grid-template-columns: minmax(0, 1fr) auto;
639
+ gap: 0.62rem;
640
+ align-items: start;
641
+ min-width: 0;
642
+ padding: 0.62rem;
643
+ border: 1px solid rgba(180, 190, 254, 0.14);
644
+ border-radius: 0.72rem;
645
+ background: rgba(var(--ctp-crust-rgb), 0.55);
646
+ }
647
+ .optional-feature-row.enabled {
648
+ border-color: rgba(166, 227, 161, 0.28);
649
+ }
650
+ .optional-feature-row.disabled {
651
+ border-color: rgba(249, 226, 175, 0.28);
652
+ }
653
+ .optional-feature-row.missing {
654
+ border-color: rgba(243, 139, 168, 0.25);
655
+ }
656
+ .optional-feature-main {
657
+ display: grid;
658
+ gap: 0.28rem;
659
+ min-width: 0;
660
+ }
661
+ .optional-feature-title {
662
+ display: flex;
663
+ flex-wrap: wrap;
664
+ gap: 0.42rem;
665
+ align-items: center;
666
+ min-width: 0;
667
+ }
668
+ .optional-feature-title strong {
669
+ color: rgba(var(--ctp-text-rgb), 0.94);
670
+ font-size: 0.82rem;
671
+ }
672
+ .optional-feature-pill {
673
+ display: inline-flex;
674
+ align-items: center;
675
+ min-height: 1.15rem;
676
+ padding: 0.12rem 0.42rem;
677
+ border-radius: 999px;
678
+ color: var(--ctp-subtext);
679
+ border: 1px solid rgba(180, 190, 254, 0.18);
680
+ background: rgba(var(--ctp-surface-rgb), 0.55);
681
+ font-size: 0.64rem;
682
+ font-weight: 900;
683
+ letter-spacing: 0.08em;
684
+ text-transform: uppercase;
685
+ }
686
+ .optional-feature-pill.enabled {
687
+ color: var(--ctp-green);
688
+ border-color: rgba(166, 227, 161, 0.32);
689
+ }
690
+ .optional-feature-pill.disabled {
691
+ color: var(--ctp-yellow);
692
+ border-color: rgba(249, 226, 175, 0.32);
693
+ }
694
+ .optional-feature-pill.missing {
695
+ color: var(--ctp-red);
696
+ border-color: rgba(243, 139, 168, 0.32);
697
+ }
698
+ .optional-feature-detail,
699
+ .optional-feature-description {
700
+ color: rgba(var(--ctp-subtext-rgb), 0.74);
701
+ font-size: 0.72rem;
702
+ line-height: 1.35;
703
+ }
704
+ .optional-feature-package {
705
+ width: fit-content;
706
+ max-width: 100%;
707
+ overflow-wrap: anywhere;
708
+ color: rgba(var(--ctp-teal-rgb), 0.86);
709
+ font-size: 0.68rem;
710
+ }
711
+ .optional-feature-action {
712
+ min-width: 5.2rem;
713
+ white-space: nowrap;
714
+ }
715
+ .optional-feature-action.install {
716
+ color: var(--ctp-yellow);
717
+ border-color: rgba(249, 226, 175, 0.32);
718
+ }
719
+ .optional-feature-action.install:not(:disabled):hover {
720
+ color: #11111b;
721
+ border-color: transparent;
722
+ background: linear-gradient(120deg, var(--ctp-yellow), var(--ctp-peach));
723
+ }
626
724
 
627
725
  .terminal-tabs-shell {
628
726
  position: relative;
@@ -640,6 +738,23 @@ body.side-panel-collapsed .terminal-tabs-shell {
640
738
  box-shadow: inset 0 -1px 0 rgba(255,255,255,0.035), 0 0.45rem 1rem rgba(var(--ctp-crust-rgb), 0.20);
641
739
  }
642
740
  .terminal-tabs-toggle-button { display: none; }
741
+ .terminal-close-all-button {
742
+ flex: 0 0 auto;
743
+ min-height: 2.35rem;
744
+ padding: 0.38rem 0.7rem;
745
+ color: var(--ctp-red);
746
+ white-space: nowrap;
747
+ border-color: rgba(243, 139, 168, 0.32);
748
+ background:
749
+ linear-gradient(120deg, rgba(243, 139, 168, 0.12), rgba(250, 179, 135, 0.08)),
750
+ rgba(var(--ctp-crust-rgb), 0.62);
751
+ }
752
+ .terminal-close-all-button:hover,
753
+ .terminal-close-all-button:focus-visible {
754
+ color: #11111b;
755
+ border-color: transparent;
756
+ background: linear-gradient(120deg, var(--ctp-red), var(--ctp-peach));
757
+ }
643
758
  .terminal-tabs {
644
759
  display: flex;
645
760
  align-items: center;
@@ -918,6 +1033,9 @@ body.side-panel-collapsed .terminal-tabs-shell {
918
1033
  color: #11111b;
919
1034
  background: linear-gradient(120deg, var(--ctp-red), var(--ctp-peach));
920
1035
  }
1036
+ .terminal-tab-group-close {
1037
+ border-left-color: rgba(243, 139, 168, 0.18);
1038
+ }
921
1039
  .terminal-new-tab-button {
922
1040
  flex: 0 0 auto;
923
1041
  padding: 0.45rem 0.72rem;
@@ -1629,6 +1747,13 @@ button.footer-meta {
1629
1747
  background: linear-gradient(145deg, rgba(var(--ctp-surface-rgb), 0.66), rgba(var(--ctp-mantle-rgb), 0.7));
1630
1748
  box-shadow: 0 0.8rem 1.8rem rgba(var(--ctp-crust-rgb), 0.34), inset 0 1px 0 rgba(255,255,255,0.045);
1631
1749
  }
1750
+ .message.action-enter {
1751
+ animation: action-card-slide-in 180ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
1752
+ }
1753
+ @keyframes action-card-slide-in {
1754
+ from { opacity: 0; transform: translateY(0.42rem); }
1755
+ to { opacity: 1; transform: translateY(0); }
1756
+ }
1632
1757
  .message::before {
1633
1758
  content: "";
1634
1759
  position: absolute;
@@ -1779,6 +1904,18 @@ button.footer-meta {
1779
1904
  padding: 0 0.9rem 0.85rem;
1780
1905
  border-top: 1px solid rgba(249, 226, 175, 0.12);
1781
1906
  }
1907
+ .tool-result-preview {
1908
+ padding: 0 0.9rem 0.85rem;
1909
+ border-top: 1px solid rgba(249, 226, 175, 0.10);
1910
+ }
1911
+ .message-collapse[open] + .tool-result-preview {
1912
+ display: none;
1913
+ }
1914
+ .tool-result-preview-text {
1915
+ max-height: none;
1916
+ margin: 0;
1917
+ color: rgba(var(--ctp-text-rgb), 0.78);
1918
+ }
1782
1919
  .message.toolResult:not(.error) .message-collapse:not([open]) > summary.message-header,
1783
1920
  .message.bashExecution .message-collapse:not([open]) > summary.message-header,
1784
1921
  .message.compactionSummary .message-collapse:not([open]) > summary.message-header {
@@ -2537,7 +2674,7 @@ summary { cursor: pointer; color: var(--warning); }
2537
2674
  body.side-panel-collapsed .terminal-tabs-shell { padding-right: 2.75rem; }
2538
2675
  .terminal-tabs-toggle-button {
2539
2676
  display: block;
2540
- width: min(14rem, calc(100vw - 4rem));
2677
+ width: min(14rem, calc(100vw - 8.8rem));
2541
2678
  min-height: 28px;
2542
2679
  padding: 0.16rem 0.58rem;
2543
2680
  overflow: hidden;
@@ -2565,8 +2702,14 @@ summary { cursor: pointer; color: var(--warning); }
2565
2702
  top: calc(0.12rem + env(safe-area-inset-top));
2566
2703
  right: 0.42rem;
2567
2704
  }
2705
+ .terminal-close-all-button {
2706
+ min-height: 28px;
2707
+ padding: 0.16rem 0.46rem;
2708
+ border-radius: 0.58rem;
2709
+ font-size: 0.72rem;
2710
+ line-height: 1.1;
2711
+ }
2568
2712
  .terminal-tabs {
2569
- position: absolute;
2570
2713
  left: 0.55rem;
2571
2714
  right: 0.55rem;
2572
2715
  top: calc(100% + 0.35rem);
@@ -20,6 +20,15 @@ const [pkgRaw, html, css, app, server, extension, readme, manifestRaw, serviceWo
20
20
  ]);
21
21
  const pkg = JSON.parse(pkgRaw);
22
22
  const manifest = JSON.parse(manifestRaw);
23
+ const companionDependencies = {
24
+ "@firstpick/pi-extension-git-footer-status": "^0.2.1",
25
+ "@firstpick/pi-extension-release-aur": "^0.1.3",
26
+ "@firstpick/pi-extension-release-npm": "^0.3.3",
27
+ "@firstpick/pi-extension-stats": "^0.2.0",
28
+ "@firstpick/pi-extension-todo-progress": "^0.1.7",
29
+ "@firstpick/pi-prompts-git-pr": "^0.1.0",
30
+ "@firstpick/pi-themes-bundle": "^0.1.1",
31
+ };
23
32
 
24
33
  assert.match(html, /viewport-fit=cover/, "viewport should opt into safe-area-aware full-screen layout");
25
34
  assert.match(html, /interactive-widget=resizes-content/, "viewport should request keyboard-driven content resizing where supported");
@@ -27,11 +36,13 @@ assert.match(html, /<meta name="theme-color" content="#11111b" \/>/, "PWA should
27
36
  assert.match(html, /<link rel="manifest" href="\/manifest\.webmanifest" \/>/, "PWA should expose a web app manifest");
28
37
  assert.match(html, /<link rel="apple-touch-icon" href="\/apple-touch-icon\.png" \/>/, "PWA should expose the conventional iOS home-screen icon path");
29
38
  assert.match(html, /id="terminalTabsToggleButton"/, "mobile should expose a compact terminal-tabs toggle");
39
+ assert.match(html, /id="closeAllTabsButton"[\s\S]*?>Close all Tabs<\/button>/, "tab header should expose a top-right close-all tabs action");
30
40
  assert.match(html, /id="sidePanelBackdrop"/, "mobile side panel needs an overlay/backdrop close target");
31
41
  assert.match(html, /id="themeSelect"/, "side panel should expose a theme selector");
32
42
  assert.match(html, /<label for="themeSelect">Theme<\/label>/, "theme selector should be labeled in side-panel controls");
33
43
  assert.match(html, /id="agentDoneNotificationsToggle"/, "side panel should expose an agent-done notifications toggle");
34
44
  assert.match(html, /id="agentDoneNotificationsStatus"/, "agent-done notifications toggle should expose status text");
45
+ assert.match(html, /id="optionalFeaturesBox"/, "side panel should expose optional feature status and controls");
35
46
  assert.match(html, /id="jumpToLatestButton"/, "chat should expose a jump-to-latest control for non-forced streaming");
36
47
  assert.match(html, /id="stickyUserPromptButton"/, "chat should expose a fixed last-user-prompt jump control");
37
48
  assert.match(html, /id="feedbackTray"/, "chat should expose a queued action-feedback tray");
@@ -54,6 +65,7 @@ assert.ok(
54
65
 
55
66
  assert.match(css, /--visual-viewport-height:\s*100dvh/, "CSS should define a visual viewport height fallback");
56
67
  assert.match(css, /color-scheme:\s*var\(--theme-color-scheme\)/, "CSS should allow JS-selected themes to update browser color-scheme");
68
+ assert.match(css, /font-size:\s*80%/, "Web UI should render at 80% base scale for denser layout");
57
69
  assert.match(css, /--background-glow-pink/, "CSS should expose theme-controlled page glow colors");
58
70
  assert.match(css, /height:\s*var\(--visual-viewport-height, 100dvh\)/, "layout should consume visual viewport height");
59
71
  assert.match(css, /button, select, input \{ min-height: 44px; \}/, "base controls should meet 44px touch-target height");
@@ -63,9 +75,15 @@ assert.match(css, /#promptInput \{[\s\S]*?overflow-y:\s*hidden/, "prompt input s
63
75
  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");
64
76
  assert.match(css, /\.message\.extension,[\s\S]*?\.message\.native/, "extension and native command output should have visible transcript styling");
65
77
  assert.match(css, /\.message\.run-indicator-message \{[\s\S]*?border-color/, "active agent runs should render a visible transcript indicator card");
78
+ assert.match(css, /\.message\.action-enter \{[\s\S]*?action-card-slide-in/, "new action cards should subtly slide in from the bottom");
79
+ assert.match(css, /@keyframes action-card-slide-in \{[\s\S]*?translateY\(0\.42rem\)/, "action-card entry animation should start below the final position");
66
80
  assert.match(css, /\.message\.thinking,\n\.message\.toolCall,\n\.message\.assistantEvent/, "thinking and assistant events should have non-assistant transcript card styling");
67
81
  assert.match(css, /\.message\.toolResult, \.message\.bashExecution, \.message\.compactionSummary/, "compaction summaries should render as compact collapsible transcript cards");
82
+ assert.match(css, /\.tool-result-preview \{[\s\S]*?padding:/, "collapsed tool results should show a preview area by default");
83
+ assert.match(css, /\.message-collapse\[open\] \+ \.tool-result-preview \{[\s\S]*?display:\s*none/, "tool result preview should hide when full output is expanded");
68
84
  assert.match(css, /\.run-indicator-pulse \{[\s\S]*?animation:\s*run-indicator-pulse/, "active agent run indicator should have an animated pulse");
85
+ assert.match(css, /\.optional-features-box \{[\s\S]*?display:\s*grid/, "optional features should render as a side-panel feature list");
86
+ assert.match(css, /\.optional-feature-pill\.enabled/, "optional features should visually distinguish enabled state");
69
87
  assert.match(css, /\.todo-widget \{[\s\S]*?display:\s*grid/, "todo-progress widget should render as a styled checklist card");
70
88
  assert.match(css, /\.todo-widget-item\.partial \.todo-widget-marker/, "todo-progress partial items should have distinct styling");
71
89
  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");
@@ -95,6 +113,8 @@ assert.match(css, /\.composer-actions-panel > \.composer-publish-menu[\s\S]*?gri
95
113
  assert.match(css, /\.composer-actions-panel[\s\S]*?bottom:\s*calc\(100% \+ 0\.42rem\)/, "mobile composer actions should open as an above-composer sheet");
96
114
  assert.match(css, /body\.composer-actions-open \.composer-actions-panel \{ display: grid; \}/, "composer actions panel should only open when toggled");
97
115
  assert.match(css, /\.terminal-tabs-toggle-button \{ display: none; \}/, "terminal tab toggle should be hidden outside mobile CSS");
116
+ assert.match(css, /\.terminal-close-all-button \{[\s\S]*?color:\s*var\(--ctp-red\)/, "close-all tabs action should render as a top-right destructive tab action");
117
+ assert.match(css, /\.terminal-tab-group-close \{[\s\S]*?border-left-color/, "terminal tab groups should style their close button distinctly");
98
118
  assert.match(css, /body\.mobile-tabs-expanded \.terminal-tabs \{ display: flex; \}/, "mobile tabs should expand only when toggled");
99
119
  assert.match(css, /\.terminal-tab-activity-indicator/, "terminal tabs should expose per-tab agent activity indicators");
100
120
  assert.match(css, /\.terminal-tab-group-item \{[\s\S]*?background:\s*var\(--ctp-crust\)/, "grouped terminal tab items should use opaque backgrounds");
@@ -177,31 +197,50 @@ assert.match(app, /function hasQueuedDialogRequest\(id\)/, "frontend should dedu
177
197
  assert.match(app, /if \(request\.replayed\) addEvent\(`recovered pending \$\{request\.method\} request`, "warn"\)/, "frontend should surface replayed extension UI blockers");
178
198
  assert.match(app, /case "webui_extension_ui_cancelled":/, "frontend should close dialogs cancelled by backend abort handling");
179
199
  assert.match(app, /function parseTodoProgressWidget\(lines\)/, "todo-progress widgets should be parsed from extension widget lines");
200
+ assert.match(app, /Optional feature detection intentionally checks loaded Pi capabilities/, "optional Web UI features should be detected through loaded capabilities, not package folders");
201
+ assert.match(app, /function resetOptionalFeatureAvailability\(\)/, "optional feature state should reset across active-tab and reload boundaries");
202
+ assert.match(app, /function renderOptionalFeaturePanel\(\)/, "side panel should render optional feature installed/enabled state");
203
+ assert.match(app, /OPTIONAL_FEATURES_STORAGE_KEY/, "optional feature disable toggles should persist in browser storage");
204
+ assert.match(app, /function installOptionalFeature\(featureId\)/, "optional features should expose an install action");
205
+ assert.match(app, /api\("\/api\/optional-feature-install"/, "optional feature install action should call the backend installer endpoint");
206
+ assert.match(app, /function updateOptionalFeatureAvailability\(\)[\s\S]*hasAvailableCommand\("git-staged-msg"\)[\s\S]*hasAvailableCommand\("release-npm"\)[\s\S]*hasAvailableCommand\("release-aur"\)[\s\S]*hasAvailableCommand\("todo-progress-status"\)/, "optional feature detection should call RPC-visible commands directly");
207
+ assert.match(app, /if \(!isOptionalFeatureEnabled\("todoProgressWidget"\)\) return String\(text \|\| ""\)/, "todo progress line stripping should only run when the todo feature is detected and enabled");
208
+ assert.match(app, /case "webui_tab_reloaded":[\s\S]*resetOptionalFeatureAvailability\(\)/, "optional feature state should reset when the RPC tab reloads resources");
209
+ assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*hasAvailableCommand\(commandName\)/, "publish workflow launch should guard on loaded slash commands");
210
+ assert.match(app, /if \(!isOptionalFeatureEnabled\("gitWorkflow"\)\)/, "guided git workflow should guard on enabled /git-staged-msg feature");
180
211
  assert.match(app, /function renderReleaseNpmOutputWidget\(\)/, "release-npm live output should use a specialized Web UI renderer");
181
212
  assert.match(app, /function renderReleaseAurOutputWidget\(\)/, "release-aur live output should use a specialized Web UI renderer");
182
213
  assert.match(app, /releaseNpmActionButton\("Abort", "\/release-abort", "danger"\)/, "release-npm Web UI output should expose an abort action");
183
214
  assert.match(app, /releaseNpmActionButton\("Abort", "\/release-aur abort", "danger"\)/, "release-aur Web UI output should expose an abort action");
184
- assert.match(app, /key === "todo-progress" \? renderTodoProgressWidget\(key, lines\) : null/, "todo-progress should use the specialized widget renderer");
215
+ assert.match(app, /key === "todo-progress" && isOptionalFeatureEnabled\("todoProgressWidget"\) \? renderTodoProgressWidget\(key, lines\) : null/, "todo-progress should use the specialized widget renderer only when enabled");
185
216
  assert.match(app, /let transientMessages = \[\]/, "frontend should keep transient Web UI/extension output messages");
186
217
  assert.match(app, /function orderedTranscriptItems\(\)/, "frontend should merge persisted and transient messages chronologically");
187
218
  assert.match(app, /items\.sort\(\(a, b\) => a\.timestampMs - b\.timestampMs \|\| a\.order - b\.order\)/, "transient extension output should not pin itself below newer persisted messages");
188
219
  assert.match(app, /const ACTION_FEEDBACK_REACTIONS = \{/, "frontend should define direct feedback reactions");
189
220
  assert.match(app, /message\?\.role === "assistant" \|\| message\?\.role === "toolResult" \|\| message\?\.role === "bashExecution"/, "frontend should allow reactions on final assistant output as well as actions");
190
221
  assert.match(app, /function renderActionFeedbackControls\(/, "frontend should render per-message reaction controls");
222
+ assert.match(app, /function toolResultPreviewText\(message, lineLimit = 10\)/, "tool results should derive a ten-line collapsed preview");
223
+ assert.match(app, /appendText\(preview, toolResultPreviewText\(message, 10\), "code-block tool-result-preview-text"\)/, "collapsed tool results should render the first ten preview lines by default");
191
224
  assert.match(app, /function assistantDisplayMessages\(message\)/, "assistant history should split thinking and tool-call parts out of the final Assistant output card");
225
+ assert.match(app, /function assistantHasToolCallAfter\(content, index\)/, "assistant text that precedes a tool call should be detectable and suppressible");
226
+ assert.match(app, /if \(!assistantHasToolCallAfter\(content, index\)\) finalParts\.push\(finalPart\);/, "assistant history should not render pre-tool-call assistant text as final output");
192
227
  assert.match(app, /return content\.trim\(\) \? \[\{ \.\.\.message, title: "Assistant" \}\] : \[\]/, "assistant messages with stripped empty text should not render empty Assistant cards");
193
228
  assert.match(app, /part\.type === "text"\) return typeof part\.text === "string" && part\.text\.trim\(\) \? part : null/, "empty assistant text parts should be filtered after todo/widget extraction");
194
229
  assert.match(app, /displayMessage\.role === "assistant" \? messageIndex : -1/, "only final Assistant output cards should keep the assistant message index for feedback");
195
230
  assert.match(app, /function ensureStreamingThinkingBubble\(\)/, "live thinking should render in a dedicated non-assistant streaming card");
231
+ assert.match(app, /if \(thinkingText \|\| !streaming\) appendText\(body, thinkingText \|\| "No thinking content was exposed by the provider\.", "thinking-text"\);/, "empty live thinking cards should not render the no-thinking fallback before deltas arrive");
196
232
  assert.match(app, /function assistantStreamingMessage\(event\)/, "live streaming should read the authoritative partial assistant message from RPC events like the TUI");
197
233
  assert.match(app, /assistantThinkingTextFromMessage\(assistantStreamingMessage\(event\)\) \|\| thinkingDeltaText\(update\)/, "live thinking end should replace deltas with the final partial-message thinking content");
198
234
  assert.match(app, /if \(typeof partialText === "string"\) streamRawText = partialText;/, "live assistant text should synchronize from partial messages instead of relying only on deltas");
199
235
  assert.match(app, /const TODO_PROGRESS_LINE_REGEX = /, "frontend should recognize live todo progress lines that will be moved into the todo widget");
200
236
  assert.match(app, /function stripTodoProgressLines\(text, \{ streaming = false \} = \{\}\)/, "live Assistant output should strip todo-progress lines before rendering final-output text");
201
- assert.match(app, /streamRawText \+= delta;[\s\S]*?const assistantText = stripTodoProgressLines\(streamRawText, \{ streaming: true \}\)/, "streamed Assistant text should classify from accumulated output without flashing partial todo-progress lines");
237
+ assert.match(app, /function renderStreamingAssistantText\(\)[\s\S]*?const assistantText = stripTodoProgressLines\(streamRawText, \{ streaming: true \}\)/, "streamed Assistant text should classify from accumulated output without flashing partial todo-progress lines");
202
238
  assert.match(app, /const STREAM_OUTPUT_HIDE_DELAY_MS = 300/, "stream output hiding should be debounced to prevent rapid flicker");
239
+ assert.match(app, /const STREAM_OUTPUT_TOOLCALL_GUARD_MS = 220/, "live assistant text should be briefly guarded so pre-tool-call text can be suppressed");
203
240
  assert.match(app, /function scheduleStreamBubbleHide\([\s\S]*?STREAM_OUTPUT_MIN_VISIBLE_MS/, "stream output cards should observe a minimum visible duration before hiding");
204
241
  assert.match(app, /if \(assistantText\) \{[\s\S]*?streamText\.textContent = assistantText;[\s\S]*?\} else \{\n\s+scheduleStreamBubbleHide\(\);/, "empty filtered stream output should schedule hide instead of immediately removing the card");
242
+ assert.match(app, /if \(streamToolCallSeen \|\| streamBubble\) renderStreamingAssistantText\(\);\n\s+else scheduleStreamingAssistantTextRender\(\);/, "live assistant text should wait briefly before showing unless it is already visible or follows a tool call");
243
+ assert.match(app, /streamToolCallSeen = true;\n\s+suppressStreamingAssistantTextBeforeToolCall\(\);/, "tool-call starts should remove pending assistant text from the live transcript");
205
244
  assert.match(app, /const created = appendMessage\(\{ role: "assistant", title: "Assistant"/, "live Assistant cards should be created only for final output text");
206
245
  assert.match(app, /api\("\/api\/action-feedback", \{ method: "POST"/, "queued action feedback should post to the server after the run is idle");
207
246
  assert.match(app, /function postQueuedFeedback\(tabId, items\)/, "queued feedback should have a backward-compatible submit path");
@@ -210,7 +249,7 @@ assert.match(app, /actionFeedbackSteerMessage\(item\)/, "live action feedback sh
210
249
  assert.match(app, /function addTransientMessage\(\{ role = "notice"/, "frontend should render transient command output into the transcript");
211
250
  assert.match(app, /addTransientMessage\(\{ role: "extension", title: "extension output"/, "extension notify output should appear in the transcript, not only the event log");
212
251
  assert.match(app, /function renderRunIndicator\(/, "frontend should render a transcript-level active agent indicator");
213
- assert.match(app, /return "Agent is still runing: ";/, "active agent indicator should use the requested headline wording");
252
+ assert.match(app, /return "Agent is running: ";/, "active agent indicator should use the requested headline wording");
214
253
  assert.doesNotMatch(app, /"agent running"/, "active agent indicator should not render a separate title/header label");
215
254
  assert.doesNotMatch(app, /runIndicatorTimestamp/, "active agent indicator should not render a separate live timestamp header");
216
255
  assert.match(app, /runIndicatorBubble = make\("article", "message runIndicator run-indicator-message streaming"\)/, "active agent indicator should use a dedicated streaming transcript card");
@@ -278,7 +317,11 @@ assert.match(app, /function updateTerminalTabGroupOpenState\(\)/, "frontend shou
278
317
  assert.match(app, /wrapper\.addEventListener\("pointerenter", \(\) => setOpenTerminalTabGroup\(group\.key\)\)/, "terminal tab groups should mark themselves open while hovered");
279
318
  assert.match(app, /if \(openTerminalTabGroupKey\) \{\n\s+scheduleRefreshTabs\(600\);/, "tab polling should defer full tab refreshes while a group menu is open");
280
319
  assert.match(app, /function shouldRenderTerminalTabGroup\(group, groupCount\) \{\n\s+return groupCount > 1 && group\.tabs\.length > 1 && Boolean\(group\.cwd\);\n\}/, "terminal tabs should only collapse cwd groups when multiple groups are available");
281
- assert.match(app, /const groups = tabCwdGroups\(\);[\s\S]*?for \(const group of groups\) \{\n\s+if \(shouldRenderTerminalTabGroup\(group, groups\.length\)\)[\s\S]*?for \(const tab of group\.tabs\) elements\.tabBar\.append\(renderTerminalTab\(tab\)\);/, "terminal tabs should render every tab ungrouped when grouping is skipped");
320
+ assert.match(app, /function closeTerminalTabGroup\(group\)[\s\S]*?closeTerminalTabs\(group\.tabs\.map\(\(tab\) => tab\.id\)/, "terminal tab groups should be closable as a batch");
321
+ assert.match(app, /function closeAllTerminalTabs\(\)[\s\S]*?closeTerminalTabs\(tabs\.map\(\(tab\) => tab\.id\)/, "tab header should close all terminal tabs as a batch");
322
+ assert.match(app, /WARNING: \$\{activeAgentTabs\.length\}[\s\S]*?still running or waiting for input/, "tab close confirmations should warn when agents are still running");
323
+ assert.match(app, /elements\.closeAllTabsButton\.addEventListener\("click", \(\) => closeAllTerminalTabs\(\)\)/, "close-all tabs button should be wired in JS");
324
+ assert.match(app, /const groups = tabCwdGroups\(\);[\s\S]*?for \(const group of groups\) \{\n\s+if \(shouldRenderTerminalTabGroup\(group, groups\.length\)\)[\s\S]*?renderTerminalTabGroup\(group, groups\.length\)[\s\S]*?for \(const tab of group\.tabs\) elements\.tabBar\.append\(renderTerminalTab\(tab\)\);/, "terminal tabs should render groups with group count and ungrouped tabs when grouping is skipped");
282
325
  assert.match(app, /let tabSeenCompletionSerials = new Map\(\)/, "frontend should track which tab completions have been seen");
283
326
  assert.match(app, /function tabIndicator\(tab\)/, "frontend should derive idle, working, blocked, and work-done tab indicator states");
284
327
  assert.match(app, /pendingBlockerCount > 0[\s\S]*?state: "blocked"/, "frontend should show blocked tabs when extension UI blockers are pending");
@@ -327,7 +370,7 @@ assert.equal(manifest.start_url, "/", "PWA manifest should start at the web UI r
327
370
  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");
328
371
  assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-192.png" && icon.sizes === "192x192"), "PWA manifest should include a 192px icon");
329
372
  assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-512.png" && icon.sizes === "512x512"), "PWA manifest should include a 512px icon");
330
- assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v10"/, "PWA service worker should define an app-shell cache");
373
+ assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v12"/, "PWA service worker should define an app-shell cache");
331
374
  assert.match(serviceWorker, /self\.addEventListener\("notificationclick"/, "PWA service worker should focus Web UI when blocked-tab notifications are clicked");
332
375
  assert.match(serviceWorker, /event\.notification\.data\?\.url/, "blocked-tab notifications should carry a URL for service-worker click handling");
333
376
  assert.match(serviceWorker, /"\/apple-touch-icon\.png"/, "PWA service worker should cache the apple touch icon");
@@ -364,8 +407,11 @@ assert.match(server, /async function handleNativeSlashCommand\(tab, body\)/, "se
364
407
  assert.match(server, /const restoreTabs = readRestoreTabsFromEnv\(\)/, "server should accept restart tab restore descriptors from the launcher environment");
365
408
  assert.match(server, /delete process\.env\.PI_WEBUI_RESTORE_TABS/, "server should avoid leaking restore descriptors into spawned Pi RPC processes");
366
409
  assert.match(server, /if \(sessionFile && !options\.noSession\) piArgs\.push\("--session", sessionFile\)/, "restored tabs should resume previous session files");
410
+ assert.doesNotMatch(server, /args\.push\("--name"/, "Web UI tab titles should not be forwarded as Pi CLI --name flags because older bundled Pi CLIs reject them");
367
411
  assert.match(server, /const closedRestorableTabs = \[\]/, "server should remember recently closed tabs for slash-command restarts");
368
412
  assert.match(server, /async function closeTab\(id\)[\s\S]*?rememberClosedRestorableTab\(tab, restorableState\)/, "closing a tab should capture its restorable session before stopping RPC");
413
+ assert.match(server, /async function closeTabs\(ids\)[\s\S]*?if \(targetTabs\.length >= tabs\.size\) \{\n\s+await createTab/, "bulk tab close should create a replacement before closing every current tab");
414
+ assert.match(server, /url\.pathname === "\/api\/tabs\/close" && req\.method === "POST"[\s\S]*?closedIds: closed\.map\(\(tab\) => tab\.id\)/, "server should expose a bulk close-tabs endpoint");
369
415
  assert.match(server, /function rememberTabState\(tab, state\)/, "server should cache last-known tab state for restart-safe session restoration");
370
416
  assert.match(server, /sessionFile: tabRestorableSessionFile\(tab\)/, "tab metadata should expose cached session files for health/status restore descriptors");
371
417
  assert.match(server, /restorableTabs: mergeRestorableTabDescriptors\(statusTabs, closedRestorableTabs\)/, "status should expose open plus recently closed restorable tabs");
@@ -387,6 +433,8 @@ assert.match(server, /case "session": \{[\s\S]*?formatSessionOutput\(tab, state\
387
433
  assert.match(server, /case "copy": \{[\s\S]*?get_last_assistant_text[\s\S]*?copyText: text/, "native /copy should return text for browser clipboard handling");
388
434
  assert.match(server, /case "hotkeys": \{[\s\S]*?webuiHotkeysOutput\(\)/, "native /hotkeys should return Web UI hotkey output");
389
435
  assert.match(server, /url\.pathname === "\/api\/commands" && req\.method === "GET"[\s\S]*?getCommandData\(tab\)/, "GET /api/commands should merge native and RPC-visible commands");
436
+ assert.match(server, /function safeRpcResponse\(tab, command/, "server should provide stopped-RPC fallbacks for refresh endpoints");
437
+ assert.match(server, /function primeTabRpc\(tab\)/, "server should prime new terminal RPC state before returning created tabs");
390
438
  assert.match(server, /specific Web UI action or final-output cards/, "server feedback-learning prompt should cover final outputs as well as actions");
391
439
  assert.match(server, /function formatActionFeedbackLearningPrompt\(items\)/, "server should convert feedback into a LEARNING prompt");
392
440
  assert.match(server, /url\.pathname === "\/api\/action-feedback" && req\.method === "POST"[\s\S]*?handleActionFeedback\(tab, body\)/, "POST /api/action-feedback should trigger the feedback-learning prompt");
@@ -399,7 +447,11 @@ assert.match(server, /url\.pathname === "\/api\/path-suggestions" && req\.method
399
447
  assert.match(server, /url\.pathname === "\/api\/path-fast-picks" && req\.method === "GET"/, "server should expose GET /api/path-fast-picks");
400
448
  assert.match(server, /url\.pathname === "\/api\/path-fast-picks" && req\.method === "POST"/, "server should expose POST /api/path-fast-picks");
401
449
  assert.match(server, /url\.pathname === "\/api\/scoped-models" && req\.method === "GET"/, "server should expose GET /api/scoped-models");
402
- assert.match(server, /@firstpick\/pi-themes-bundle/, "server should discover themes from the bundled theme package");
450
+ assert.match(server, /@firstpick\/pi-themes-bundle/, "server should discover themes from the optional theme package");
451
+ assert.match(server, /const OPTIONAL_FEATURE_PACKAGES = new Map/, "server should whitelist optional feature packages for install actions");
452
+ assert.match(server, /function installOptionalFeaturePackage\(featureId\)/, "server should provide optional feature package installation helper");
453
+ assert.match(server, /url\.pathname === "\/api\/optional-feature-install" && req\.method === "POST"/, "server should expose optional feature install endpoint");
454
+ assert.match(server, /Installing optional Web UI features is only allowed from localhost/, "optional feature install endpoint should be localhost-only");
403
455
  assert.match(server, /url\.pathname === "\/api\/themes" && req\.method === "GET"/, "server should expose GET /api/themes");
404
456
  assert.match(server, /readBundledThemes\(\)/, "server should read bundled theme JSON files for the browser");
405
457
  assert.match(server, /"apple-touch-icon\.png", "icon-192\.png"/, "server should serve the conventional apple touch icon path");
@@ -415,13 +467,42 @@ assert.match(readme, /Feedback reactions \(`👍`, `👎`, `\?`\) on final assis
415
467
  assert.match(readme, /POST \/api\/action-feedback\?tab=<tabId>/, "README should document the action-feedback endpoint");
416
468
  assert.match(readme, /`@` file\/path references with live suggestions/, "README should describe @ file/path reference autocomplete");
417
469
  assert.match(readme, /GET \/api\/path-suggestions\?tab=<tabId>&query=<path>/, "README should document the path-suggestions endpoint");
470
+ assert.match(readme, /POST \/api\/optional-feature-install/, "README should document optional feature install endpoint");
418
471
  assert.match(readme, /server-persisted fast picks/, "README should describe server-persisted fast picks");
419
472
  assert.match(readme, /browser notifications when a tab needs an extension UI response and an optional side-panel toggle for agent-done notifications/, "README should describe blocked-tab and agent-done notifications");
420
473
  assert.match(readme, /blocked-tab browser notifications, and optional agent-done notifications require browser service-worker\/notification support/, "README should document notification requirements");
421
- assert.match(readme, /Side-panel theme picker backed by the bundled `@firstpick\/pi-themes-bundle` themes/, "README should describe bundled theme selection");
474
+ assert.match(readme, /Side-panel theme picker backed by optional `@firstpick\/pi-themes-bundle` themes when loaded/, "README should describe optional theme selection");
475
+ assert.match(readme, /## Optional companion packages/, "README should document optional Web UI companion packages");
476
+ assert.match(readme, /checks loaded Pi capabilities directly through RPC-visible commands and live widget events/, "README should document capability-based startup checks");
477
+ assert.match(readme, /side panel shows each optional feature as enabled, disabled, or install-needed/, "README should document optional feature side-panel controls");
478
+ assert.match(readme, /Installing a missing feature is an explicit, warned action/, "README should document optional feature install warning behavior");
422
479
 
423
480
  assert.equal(pkg.scripts?.test, "node tests/mobile-static.test.mjs", "package test script should run the mobile static harness");
424
- assert.equal(pkg.dependencies?.["@firstpick/pi-themes-bundle"], "^0.1.0", "webui package should depend on the bundled theme package");
481
+ for (const [name, range] of Object.entries(companionDependencies)) {
482
+ assert.equal(pkg.optionalDependencies?.[name], range, `webui package should optionally depend on ${name}`);
483
+ assert.equal(pkg.dependencies?.[name], undefined, `webui package should not require optional companion ${name}`);
484
+ }
485
+ assert.equal(pkg.bundledDependencies, undefined, "webui optional companion packages should not be bundled into the tarball");
486
+ for (const extensionPath of [
487
+ "../pi-extension-git-footer-status/index.ts",
488
+ "../pi-extension-release-aur/index.ts",
489
+ "../pi-extension-release-npm/index.ts",
490
+ "../pi-extension-stats/index.ts",
491
+ "../pi-extension-todo-progress/index.ts",
492
+ "node_modules/@firstpick/pi-extension-git-footer-status/index.ts",
493
+ "node_modules/@firstpick/pi-extension-release-aur/index.ts",
494
+ "node_modules/@firstpick/pi-extension-release-npm/index.ts",
495
+ "node_modules/@firstpick/pi-extension-stats/index.ts",
496
+ "node_modules/@firstpick/pi-extension-todo-progress/index.ts",
497
+ ]) {
498
+ assert.ok(pkg.pi?.extensions?.includes(extensionPath), `webui Pi manifest should load ${extensionPath} when present`);
499
+ }
500
+ assert.ok(pkg.pi?.skills?.includes("../pi-extension-release-aur/skills"), "webui Pi manifest should load release-aur sibling skills when present");
501
+ assert.ok(pkg.pi?.skills?.includes("node_modules/@firstpick/pi-extension-release-aur/skills"), "webui Pi manifest should load release-aur nested skills when present");
502
+ assert.ok(pkg.pi?.prompts?.includes("../pi-package-prompts-git-pr/prompts"), "webui Pi manifest should load guided-git sibling prompts when present");
503
+ assert.ok(pkg.pi?.prompts?.includes("node_modules/@firstpick/pi-prompts-git-pr/prompts"), "webui Pi manifest should load guided-git nested prompts when present");
504
+ assert.ok(pkg.pi?.themes?.includes("../pi-package-themes-bundle/themes"), "webui Pi manifest should load sibling bundled themes when present");
505
+ assert.ok(pkg.pi?.themes?.includes("node_modules/@firstpick/pi-themes-bundle/themes"), "webui Pi manifest should load nested bundled themes when present");
425
506
  assert.ok(pkg.scripts?.check?.includes("node --check public/app.js"), "check script should syntax-check app.js");
426
507
  assert.ok(pkg.scripts?.check?.includes("node tests/mobile-static.test.mjs"), "check script should include mobile static assertions");
427
508