@firstpick/pi-package-webui 0.1.5 → 0.1.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.
@@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
6
  const root = join(dirname(fileURLToPath(import.meta.url)), "..");
7
- const [pkgRaw, html, css, app, server, extension, readme, manifestRaw, serviceWorker, appleIcon, icon192, icon512] = await Promise.all([
7
+ const [pkgRaw, html, css, app, server, extension, readme, manifestRaw, serviceWorker, appleIcon, icon192, icon512, matrixBackground, mochaBackground] = await Promise.all([
8
8
  readFile(join(root, "package.json"), "utf8"),
9
9
  readFile(join(root, "public", "index.html"), "utf8"),
10
10
  readFile(join(root, "public", "styles.css"), "utf8"),
@@ -17,6 +17,8 @@ const [pkgRaw, html, css, app, server, extension, readme, manifestRaw, serviceWo
17
17
  readFile(join(root, "public", "apple-touch-icon.png")),
18
18
  readFile(join(root, "public", "icon-192.png")),
19
19
  readFile(join(root, "public", "icon-512.png")),
20
+ readFile(join(root, "public", "matrix-background.webp")),
21
+ readFile(join(root, "public", "catppuccin-mocha-background.png")),
20
22
  ]);
21
23
  const pkg = JSON.parse(pkgRaw);
22
24
  const manifest = JSON.parse(manifestRaw);
@@ -40,9 +42,19 @@ assert.match(html, /id="closeAllTabsButton"[\s\S]*?>Close all Tabs<\/button>/, "
40
42
  assert.match(html, /id="sidePanelBackdrop"/, "mobile side panel needs an overlay/backdrop close target");
41
43
  assert.match(html, /id="themeSelect"/, "side panel should expose a theme selector");
42
44
  assert.match(html, /<label for="themeSelect">Theme<\/label>/, "theme selector should be labeled in side-panel controls");
45
+ 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");
46
+ assert.match(html, /id="backgroundClearButton"[\s\S]*?>×<\/button>/, "side-panel background control should expose an X remove button");
43
47
  assert.match(html, /id="agentDoneNotificationsToggle"/, "side panel should expose an agent-done notifications toggle");
44
48
  assert.match(html, /id="agentDoneNotificationsStatus"/, "agent-done notifications toggle should expose status text");
49
+ assert.match(html, /id="thinkingVisibilityToggle"/, "side panel should expose a thinking-output visibility toggle");
50
+ assert.match(html, /id="thinkingVisibilityStatus"/, "thinking-output visibility toggle should expose status text");
51
+ assert.match(html, /id="nativeCommandDialog"/, "native slash selector UI should have a dedicated dialog");
52
+ assert.match(html, /id="nativeCommandSearch"[^>]*type="search"/, "native slash selector dialog should expose a filter box");
45
53
  assert.match(html, /id="optionalFeaturesBox"/, "side panel should expose optional feature status and controls");
54
+ assert.match(html, /data-side-panel-section="controls"/, "side panel controls should live in a collapsible section");
55
+ assert.match(html, /data-side-panel-section="commands"/, "side panel commands should live in a collapsible section");
56
+ assert.match(html, /class="side-panel-section-toggle"[^>]*aria-controls="sidePanelSectionControls"/, "side panel section toggles should target their content panels");
57
+ assert.match(html, /class="side-panel-section-label">Events<\/span>/, "side panel events should expose a section toggle label");
46
58
  assert.match(html, /id="jumpToLatestButton"/, "chat should expose a jump-to-latest control for non-forced streaming");
47
59
  assert.match(html, /id="stickyUserPromptButton"/, "chat should expose a fixed last-user-prompt jump control");
48
60
  assert.match(html, /id="feedbackTray"/, "chat should expose a queued action-feedback tray");
@@ -50,6 +62,9 @@ assert.match(html, /id="sendFeedbackButton"/, "action feedback should be submitt
50
62
  assert.match(html, /<textarea id="promptInput"[^>]*rows="1"[^>]*enterkeyhint="enter"/, "prompt textarea should start at one row and hint that Return inserts a newline");
51
63
  assert.match(html, /id="composerActionsButton"/, "mobile composer should expose a compact actions trigger");
52
64
  assert.match(html, /id="composerActionsPanel"/, "secondary composer controls should live in a mobile actions panel");
65
+ assert.match(html, /<div class="composer-row">[\s\S]*id="abortButton"[\s\S]*id="sendButton"/, "Abort should live in the bottom composer row beside Send");
66
+ assert.match(html, /id="abortButton"[^>]*Esc or hold/, "Abort should advertise Esc and long-press affordances");
67
+ assert.doesNotMatch(html, /class="side-panel-controls"[\s\S]*id="abortButton"/, "Abort should not be buried in the side-panel controls");
53
68
  assert.match(html, /id="publishButton"[\s\S]*?aria-controls="publishMenu"/, "composer should expose a Publish workflow menu button");
54
69
  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");
55
70
  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");
@@ -67,9 +82,16 @@ assert.match(css, /--visual-viewport-height:\s*100dvh/, "CSS should define a vis
67
82
  assert.match(css, /color-scheme:\s*var\(--theme-color-scheme\)/, "CSS should allow JS-selected themes to update browser color-scheme");
68
83
  assert.match(css, /font-size:\s*80%/, "Web UI should render at 80% base scale for denser layout");
69
84
  assert.match(css, /--background-glow-pink/, "CSS should expose theme-controlled page glow colors");
85
+ assert.match(css, /--theme-background-image:\s*none/, "CSS should expose a theme-controlled page background image variable");
86
+ assert.match(css, /var\(--theme-background-image\)/, "body background should include the selected theme background image layer");
87
+ assert.match(css, /\.background-control-row \{[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\) auto/, "side-panel background controls should keep the remove button beside the picker");
88
+ assert.match(css, /\.background-clear-button \{[\s\S]*?color:\s*var\(--ctp-red\)/, "background remove button should be visually destructive");
70
89
  assert.match(css, /height:\s*var\(--visual-viewport-height, 100dvh\)/, "layout should consume visual viewport height");
71
90
  assert.match(css, /button, select, input \{ min-height: 44px; \}/, "base controls should meet 44px touch-target height");
72
91
  assert.match(css, /\.composer-row button[\s\S]*?min-height:\s*44px/, "mobile composer buttons should keep 44px touch targets");
92
+ assert.match(css, /\.composer-abort-button,\n\.composer-row button\.primary \{[\s\S]*?min-width:/, "Abort and Send should share stable bottom-row sizing");
93
+ assert.match(css, /\.composer-abort-button\.long-pressing::after[\s\S]*?animation:\s*abort-long-press-fill 700ms linear forwards/, "Abort should expose a visible long-press progress affordance");
94
+ assert.match(css, /body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-abort-button:not\(\[hidden\]\) \{ grid-column: span 2; \}/, "active mobile runs should keep Abort beside Send in the bottom controls");
73
95
  assert.match(css, /#promptInput \{[\s\S]*?min-height:\s*calc\(1\.5em \+ 1\.8rem\)/, "prompt input should default to a compact single-line height");
74
96
  assert.match(css, /#promptInput \{[\s\S]*?overflow-y:\s*hidden/, "prompt input should be JS-resized instead of showing a scrollbar by default");
75
97
  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");
@@ -79,10 +101,17 @@ assert.match(css, /\.message\.action-enter \{[\s\S]*?action-card-slide-in/, "new
79
101
  assert.match(css, /@keyframes action-card-slide-in \{[\s\S]*?translateY\(0\.42rem\)/, "action-card entry animation should start below the final position");
80
102
  assert.match(css, /\.message\.thinking,\n\.message\.toolCall,\n\.message\.assistantEvent/, "thinking and assistant events should have non-assistant transcript card styling");
81
103
  assert.match(css, /\.message\.toolResult, \.message\.bashExecution, \.message\.compactionSummary/, "compaction summaries should render as compact collapsible transcript cards");
104
+ assert.match(css, /\.message\.toolExecution \{[\s\S]*?border-color/, "paired tool executions should render as distinct TUI-like action cards");
105
+ assert.match(css, /\.tool-diff \{[\s\S]*?font-family:/, "edit tool diffs should have a dedicated monospace renderer");
106
+ assert.match(css, /\.markdown-body \{[\s\S]*?line-height:/, "assistant Markdown output should have dedicated readable styling");
107
+ assert.match(css, /\.markdown-table-wrapper \{[\s\S]*?overflow-x:\s*auto/, "assistant Markdown tables should be horizontally scrollable on narrow screens");
82
108
  assert.match(css, /\.tool-result-preview \{[\s\S]*?padding:/, "collapsed tool results should show a preview area by default");
83
109
  assert.match(css, /\.message-collapse\[open\] \+ \.tool-result-preview \{[\s\S]*?display:\s*none/, "tool result preview should hide when full output is expanded");
84
110
  assert.match(css, /\.run-indicator-pulse \{[\s\S]*?animation:\s*run-indicator-pulse/, "active agent run indicator should have an animated pulse");
85
111
  assert.match(css, /\.optional-features-box \{[\s\S]*?display:\s*grid/, "optional features should render as a side-panel feature list");
112
+ assert.match(css, /\.side-panel-section-toggle \{[\s\S]*?justify-content:\s*space-between/, "side panel section toggles should align labels and chevrons");
113
+ 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");
114
+ assert.match(css, /\.side-panel-section:not\(\.collapsed\) \.side-panel-section-chevron/, "expanded side panel sections should rotate the chevron");
86
115
  assert.match(css, /\.optional-feature-pill\.enabled/, "optional features should visually distinguish enabled state");
87
116
  assert.match(css, /\.todo-widget \{[\s\S]*?display:\s*grid/, "todo-progress widget should render as a styled checklist card");
88
117
  assert.match(css, /\.todo-widget-item\.partial \.todo-widget-marker/, "todo-progress partial items should have distinct styling");
@@ -128,9 +157,9 @@ assert.match(css, /\.terminal-tab\.activity-blocked \.terminal-tab-activity-indi
128
157
  assert.match(css, /\.terminal-tab\.activity-done/, "completed unseen work should have a distinct tab style");
129
158
  assert.match(css, /\.terminal-tabs[\s\S]*?position:\s*absolute/, "expanded mobile tabs should overlay instead of consuming transcript space");
130
159
  assert.match(css, /body\.mobile-keyboard-open \.terminal-tabs-shell,[\s\S]*?body\.mobile-keyboard-open \.widget-area,[\s\S]*?body\.mobile-keyboard-open \.statusbar/, "mobile keyboard mode should hide header, widgets, and footer");
131
- assert.match(css, /body\.mobile-keyboard-open \.composer-actions-button,[\s\S]*?body\.mobile-keyboard-open #steerButton,[\s\S]*?body\.mobile-keyboard-open #followUpButton/, "mobile keyboard mode should hide secondary composer buttons");
160
+ assert.match(css, /body\.mobile-keyboard-open \.composer-actions-button,[\s\S]*?body\.mobile-keyboard-open \.composer-actions-panel/, "mobile keyboard mode should hide the secondary actions sheet while keeping active-run controls available");
132
161
  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");
133
- assert.match(css, /\.composer-actions-panel > #followUpButton,[\s\S]*?\.composer-actions-panel > #steerButton/, "idle Steer and Follow-up should fit inside the Actions sheet");
162
+ assert.match(css, /button\[hidden\] \{ display: none !important; \}/, "hidden bottom-row controls should not occupy layout space");
134
163
  assert.match(css, /\.footer-details-toggle \{ display: none; \}/, "footer details toggle should be hidden outside mobile CSS");
135
164
  assert.match(css, /\.footer-workspace,\n\s+\.footer-context \{ display: grid !important; \}/, "collapsed mobile footer should primarily show cwd and context");
136
165
  assert.match(css, /\.footer-model \{ order: 7; \}/, "model should move into expanded footer details on mobile");
@@ -144,6 +173,9 @@ assert.match(css, /(?:^|\n)\s*\.side-panel\s*\{[\s\S]*?position:\s*fixed/, "mobi
144
173
  assert.match(css, /\.extension-dialog[\s\S]*?max-height:\s*calc\(var\(--visual-viewport-height/, "dialogs should fit the visual viewport on mobile");
145
174
  assert.match(css, /\.extension-dialog[\s\S]*?inset:\s*auto 0 0 0/, "mobile dialogs should behave like bottom sheets");
146
175
  assert.match(css, /#dialogMessage \{[\s\S]*?white-space:\s*pre-wrap/, "extension dialog messages should preserve multiline prompts");
176
+ assert.match(css, /\.native-command-dialog \{[\s\S]*?width:\s*min\(56rem/, "native slash selector dialog should have roomy desktop layout");
177
+ assert.match(css, /\.native-selector-item \{[\s\S]*?--tree-depth/, "native slash selector choices should support tree indentation");
178
+ 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");
147
179
  assert.match(css, /\.extension-dialog\.guardrail-dialog[\s\S]*?border-color:\s*rgba\(249, 226, 175/, "guardrail dialogs should have warning-specific styling");
148
180
  assert.match(css, /\.extension-dialog\.release-dialog[\s\S]*?width:\s*min\(64rem/, "release confirmation dialogs should have more horizontal room");
149
181
  assert.match(css, /\.extension-dialog\.release-dialog #dialogMessage[\s\S]*?max-height:\s*min\(56vh, 34rem\)/, "release confirmation summaries should scroll in a roomy panel");
@@ -152,10 +184,27 @@ assert.match(css, /\.release-dialog-danger \{ color: var\(--ctp-red\); \}/, "rel
152
184
 
153
185
  assert.match(app, /const MOBILE_VIEW_QUERY = "\(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\)"/, "mobile detection should include phones that report desktop-like layout widths");
154
186
  assert.match(app, /const THEME_STORAGE_KEY = "pi-webui-theme"/, "theme selection should be persisted in browser storage");
187
+ assert.match(app, /const CUSTOM_BACKGROUND_STORAGE_KEY = "pi-webui-custom-background"/, "custom backgrounds should keep a legacy persistent browser storage key for migration");
188
+ assert.match(app, /const CUSTOM_BACKGROUNDS_STORAGE_KEY = "pi-webui-custom-backgrounds"/, "custom backgrounds should be persisted per theme in browser storage");
189
+ assert.match(app, /const CUSTOM_BACKGROUND_IDB_NAME = "pi-webui-custom-background"/, "custom backgrounds should prefer IndexedDB persistence for large images");
190
+ assert.match(app, /const SIDE_PANEL_SECTION_STORAGE_KEY = "pi-webui-side-panel-sections-collapsed"/, "side-panel section collapse state should be persisted in browser storage");
155
191
  assert.match(app, /const AGENT_DONE_NOTIFICATIONS_STORAGE_KEY = "pi-webui-agent-done-notifications"/, "agent-done notification preference should be persisted in browser storage");
156
192
  assert.match(app, /async function initializeThemes\(\)/, "frontend should initialize bundled themes");
157
193
  assert.match(app, /api\("\/api\/themes", \{ scoped: false \}\)/, "theme loading should use the unscoped themes endpoint");
158
194
  assert.match(app, /function applyTheme\(theme/, "frontend should apply a selected theme to CSS variables");
195
+ assert.match(app, /const LOCAL_BACKGROUND_IMAGE_PATTERN = /, "frontend should restrict theme background images to local static URLs");
196
+ assert.match(app, /"--theme-background-image": themeExportCssValue\(theme, "backgroundImage", "none", LOCAL_BACKGROUND_IMAGE_PATTERN\)/, "frontend should apply theme export background images to CSS variables");
197
+ assert.match(app, /applyCustomBackgroundOverride\(\{ render: false \}\);/, "theme changes should preserve the user's custom background override");
198
+ assert.match(app, /function customBackgroundThemeKey\(themeName = currentThemeName\)/, "custom background persistence should be keyed by the active theme");
199
+ assert.match(app, /async function setCustomBackgroundFromFile\(file\)/, "frontend should load side-panel background image files");
200
+ assert.match(app, /persistCustomBackground\(background, themeName\)/, "side-panel background changes should save under the selected theme");
201
+ assert.match(app, /clearStoredCustomBackground\(themeName, \{ includeLegacy: true \}\)/, "side-panel X button should remove only the selected theme background while clearing legacy state");
202
+ assert.match(app, /await loadCustomBackgroundForTheme\(theme\.name/, "theme switching should load that theme's saved custom background");
203
+ assert.match(app, /URL\.createObjectURL\(file\)/, "custom backgrounds should apply through short blob URLs instead of huge CSS data URLs");
204
+ assert.match(app, /function dataUrlToBlob\(dataUrl\)/, "saved custom backgrounds should be restored from persisted data URLs into blob URLs");
205
+ assert.match(app, /async function clearCustomBackground\(\)/, "frontend should support removing the custom background");
206
+ assert.match(app, /backgroundClearButton\.addEventListener\("click"/, "side-panel X button should clear the custom background");
207
+ assert.match(app, /initializeCustomBackground\(\)\.catch/, "startup should restore the saved custom background when theme loading fails");
159
208
  assert.match(app, /Restart Web UI to load themes/, "frontend should explain when a stale server cannot serve the themes endpoint");
160
209
  assert.match(app, /themeSelect\.addEventListener\("change"/, "side-panel theme selector should switch themes immediately");
161
210
  assert.match(app, /open \? "Close for network" : "Open to network"/, "network button should toggle from open to close action");
@@ -200,11 +249,18 @@ assert.match(app, /function parseTodoProgressWidget\(lines\)/, "todo-progress wi
200
249
  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
250
  assert.match(app, /function resetOptionalFeatureAvailability\(\)/, "optional feature state should reset across active-tab and reload boundaries");
202
251
  assert.match(app, /function renderOptionalFeaturePanel\(\)/, "side panel should render optional feature installed/enabled state");
252
+ assert.match(app, /function setSidePanelSectionCollapsed\(record, collapsed/, "side panel sections should have explicit collapse/expand behavior");
253
+ assert.match(app, /restoreSidePanelSectionState\(\);\nbindSidePanelSectionToggles\(\);/, "side panel section state should restore before toggles are bound");
203
254
  assert.match(app, /OPTIONAL_FEATURES_STORAGE_KEY/, "optional feature disable toggles should persist in browser storage");
255
+ 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");
256
+ 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");
257
+ 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");
258
+ assert.match(app, /function renderCommands\(\)/, "side-panel commands should be re-renderable from current optional feature state");
204
259
  assert.match(app, /function installOptionalFeature\(featureId\)/, "optional features should expose an install action");
205
260
  assert.match(app, /api\("\/api\/optional-feature-install"/, "optional feature install action should call the backend installer endpoint");
206
261
  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
262
  assert.match(app, /if \(!isOptionalFeatureEnabled\("todoProgressWidget"\)\) return String\(text \|\| ""\)/, "todo progress line stripping should only run when the todo feature is detected and enabled");
263
+ assert.match(app, /const releasePrompt = detectedReleasePrompt && isOptionalFeatureEnabled\(detectedReleasePrompt\.featureId\) \? detectedReleasePrompt : null/, "release confirmation dialogs should use specialized rendering only when their release optional feature is enabled");
208
264
  assert.match(app, /case "webui_tab_reloaded":[\s\S]*resetOptionalFeatureAvailability\(\)/, "optional feature state should reset when the RPC tab reloads resources");
209
265
  assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*hasAvailableCommand\(commandName\)/, "publish workflow launch should guard on loaded slash commands");
210
266
  assert.match(app, /if \(!isOptionalFeatureEnabled\("gitWorkflow"\)\)/, "guided git workflow should guard on enabled /git-staged-msg feature");
@@ -217,18 +273,26 @@ assert.match(app, /let transientMessages = \[\]/, "frontend should keep transien
217
273
  assert.match(app, /function orderedTranscriptItems\(\)/, "frontend should merge persisted and transient messages chronologically");
218
274
  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");
219
275
  assert.match(app, /const ACTION_FEEDBACK_REACTIONS = \{/, "frontend should define direct feedback reactions");
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");
276
+ assert.match(app, /message\?\.role === "assistant" \|\| message\?\.role === "toolExecution" \|\| message\?\.role === "toolResult" \|\| message\?\.role === "bashExecution"/, "frontend should allow reactions on final assistant output as well as actions");
221
277
  assert.match(app, /function renderActionFeedbackControls\(/, "frontend should render per-message reaction controls");
222
278
  assert.match(app, /function toolResultPreviewText\(message, lineLimit = 10\)/, "tool results should derive a ten-line collapsed preview");
279
+ assert.match(app, /function renderToolExecution\(parent, message\)[\s\S]*?WEBUI_TOOL_RENDERERS/, "paired tool cards should use the browser-side built-in tool renderer registry");
280
+ assert.match(app, /appendToolRawDetails\(parent, tool\)/, "paired tool cards should keep a safe raw-data expander for debugging renderer mismatches");
281
+ assert.match(app, /function toolStateMeta\(tool\)/, "tool cards should expose consistent status and elapsed metadata across built-in renderers");
282
+ assert.match(app, /function handleToolExecutionUpdate\(event\)[\s\S]*?event\.partialResult/, "live tool_execution_update events should update transcript-visible tool cards");
223
283
  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");
224
284
  assert.match(app, /function assistantDisplayMessages\(message\)/, "assistant history should split thinking and tool-call parts out of the final Assistant output card");
225
285
  assert.match(app, /function assistantHasToolCallAfter\(content, index\)/, "assistant text that precedes a tool call should be detectable and suppressible");
226
286
  assert.match(app, /if \(!assistantHasToolCallAfter\(content, index\)\) finalParts\.push\(finalPart\);/, "assistant history should not render pre-tool-call assistant text as final output");
227
- assert.match(app, /return content\.trim\(\) \? \[\{ \.\.\.message, title: "Assistant" \}\] : \[\]/, "assistant messages with stripped empty text should not render empty Assistant cards");
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");
229
- assert.match(app, /displayMessage\.role === "assistant" \? messageIndex : -1/, "only final Assistant output cards should keep the assistant message index for feedback");
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");
287
+ assert.match(app, /return content\.trim\(\) \? \[\{ \.\.\.message, title: "final output" \}\] : \[\]/, "assistant messages with stripped empty text should not render empty final-output cards");
288
+ assert.match(app, /function isEmptyAssistantTextPart\(part\)[\s\S]*?part\.type === "text"[\s\S]*?!assistantTextPartText\(part\)\.trim\(\)/, "empty assistant text parts should be recognized as skippable provider metadata");
289
+ assert.match(app, /if \(isEmptyAssistantTextPart\(part\)\) continue;/, "empty assistant text parts should not render as assistant-event cards");
290
+ assert.match(app, /function assistantFinalOutputPart\(part\)[\s\S]*?if \(part\.type === "text"\) \{[\s\S]*?const text = assistantTextPartText\(part\);[\s\S]*?return text\.trim\(\) \? \{ \.\.\.part, type: "text", text \} : null;/, "assistant text parts should normalize supported text payload shapes");
291
+ assert.match(app, /\["assistant", "toolExecution"\]\.includes\(transcriptMessage\.role\) \? messageIndex : -1/, "final Assistant output and paired tool action cards should keep the source message index for feedback");
292
+ assert.match(app, /function ensureStreamingThinkingBubble\(\)[\s\S]*if \(!thinkingOutputVisible\) return false/, "live thinking should respect the show/hide thinking-output toggle");
293
+ assert.match(app, /if \(thinkingOutputVisible && \(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");
294
+ assert.match(app, /const THINKING_VISIBILITY_STORAGE_KEY = "pi-webui-thinking-visible"/, "thinking visibility should persist in browser storage");
295
+ assert.match(app, /function setThinkingOutputVisible\(visible[\s\S]*renderAllMessages\(\{ preserveScroll: true \}\)/, "thinking visibility changes should immediately re-render the transcript");
232
296
  assert.match(app, /function assistantStreamingMessage\(event\)/, "live streaming should read the authoritative partial assistant message from RPC events like the TUI");
233
297
  assert.match(app, /assistantThinkingTextFromMessage\(assistantStreamingMessage\(event\)\) \|\| thinkingDeltaText\(update\)/, "live thinking end should replace deltas with the final partial-message thinking content");
234
298
  assert.match(app, /if \(typeof partialText === "string"\) streamRawText = partialText;/, "live assistant text should synchronize from partial messages instead of relying only on deltas");
@@ -238,12 +302,16 @@ assert.match(app, /function renderStreamingAssistantText\(\)[\s\S]*?const assist
238
302
  assert.match(app, /const STREAM_OUTPUT_HIDE_DELAY_MS = 300/, "stream output hiding should be debounced to prevent rapid flicker");
239
303
  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");
240
304
  assert.match(app, /function scheduleStreamBubbleHide\([\s\S]*?STREAM_OUTPUT_MIN_VISIBLE_MS/, "stream output cards should observe a minimum visible duration before hiding");
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");
305
+ assert.match(app, /if \(assistantText\) \{[\s\S]*?renderMarkdown\(streamText, assistantText\);[\s\S]*?\} else \{\n\s+scheduleStreamBubbleHide\(\);/, "empty filtered stream output should schedule hide while visible stream output renders as Markdown");
242
306
  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
307
  assert.match(app, /streamToolCallSeen = true;\n\s+suppressStreamingAssistantTextBeforeToolCall\(\);/, "tool-call starts should remove pending assistant text from the live transcript");
244
- assert.match(app, /const created = appendMessage\(\{ role: "assistant", title: "Assistant"/, "live Assistant cards should be created only for final output text");
308
+ assert.match(app, /const created = appendMessage\(\{ role: "assistant", title: "final output"/, "live Assistant cards should be created only for final output text without a noisy Assistant label");
309
+ assert.match(app, /function renderMarkdownInto\(parent, text\)/, "assistant output should have a browser-native Markdown renderer");
310
+ assert.match(app, /safeMarkdownLinkHref\(url\)/, "Markdown links should be sanitized before rendering");
311
+ assert.match(app, /renderContent\(body, message\.content, \{ markdown: message\.role === "assistant" \}\)/, "final assistant output should render through the Markdown path");
312
+ assert.match(app, /const hideMessageHeader = message\.role === "assistant" && !isCollapsibleOutput/, "assistant final-output cards should hide the redundant role header");
245
313
  assert.match(app, /api\("\/api\/action-feedback", \{ method: "POST"/, "queued action feedback should post to the server after the run is idle");
246
- assert.match(app, /function postQueuedFeedback\(tabId, items\)/, "queued feedback should have a backward-compatible submit path");
314
+ assert.match(app, /function postQueuedFeedback\(tabId, items, tabContext = activeTabContext\(tabId\)\)/, "queued feedback should have a backward-compatible submit path");
247
315
  assert.match(app, /\/api\/action-feedback not found; falling back to a normal prompt/, "new frontend should gracefully handle older running Web UI servers");
248
316
  assert.match(app, /actionFeedbackSteerMessage\(item\)/, "live action feedback should be sent as steering while the agent is running");
249
317
  assert.match(app, /function addTransientMessage\(\{ role = "notice"/, "frontend should render transient command output into the transcript");
@@ -254,18 +322,25 @@ assert.doesNotMatch(app, /"agent running"/, "active agent indicator should not r
254
322
  assert.doesNotMatch(app, /runIndicatorTimestamp/, "active agent indicator should not render a separate live timestamp header");
255
323
  assert.match(app, /runIndicatorBubble = make\("article", "message runIndicator run-indicator-message streaming"\)/, "active agent indicator should use a dedicated streaming transcript card");
256
324
  assert.match(app, /runIndicatorShowsElapsed\(\) \? `\$\{detail\} · run time/, "active agent indicator should label elapsed run time instead of showing a bare counter");
257
- assert.match(app, /Abort requested; checking whether Pi stopped/, "abort feedback should clarify that Web UI is checking stop status");
325
+ assert.match(app, /Abort requested/, "abort feedback should clarify that Web UI is checking stop status");
326
+ assert.match(app, /const ABORT_LONG_PRESS_MS = 700/, "Abort long-press timing should be explicit");
327
+ assert.match(app, /async function abortActiveRun\(\{ source = "button" \} = \{\}\)/, "Abort should be centralized for button, Esc, and long-press triggers");
328
+ assert.match(app, /elements\.abortButton\.addEventListener\("pointerdown", startAbortLongPress\)/, "Abort should support pointer long-press");
329
+ assert.match(app, /abortActiveRun\(\{ source: "escape" \}\)/, "Escape should trigger the abort action when available");
258
330
  assert.match(app, /function addAbortTranscriptNotice\(/, "abort button should render a transcript-visible aborted notice");
259
331
  assert.match(app, /this transcript marks the run as aborted/, "abort notice should clearly mark the agent output as aborted");
260
332
  assert.match(app, /await api\("\/api\/abort"[\s\S]*?addAbortTranscriptNotice\(\{ activeRun: hadActiveRun \}\)/, "abort button should add the aborted transcript notice after the abort request succeeds");
261
333
  assert.match(app, /let runIndicatorGraceCheckTimer = null/, "local-only run indicators should have a grace-check timer");
262
334
  assert.match(app, /const RUN_INDICATOR_STATE_RECHECK_MS = 5000/, "active run indicators should periodically re-check state");
263
335
  assert.match(app, /function scheduleRunIndicatorGraceCheck\(/, "local-only run indicators should schedule a follow-up state check");
264
- assert.match(app, /function maybeRefreshRunIndicatorState\([\s\S]*?refreshState\(\)/, "active run indicators should poll state so missed completion events do not leave stale cards");
336
+ assert.match(app, /function maybeRefreshRunIndicatorState\([\s\S]*?refreshState\(tabContext\)/, "active run indicators should poll state so missed completion events do not leave stale cards");
265
337
  assert.match(app, /function clearRunIndicatorActivity[\s\S]*?clearRunIndicatorGraceCheck\(\)[\s\S]*?runIndicatorLastStateCheckAt = 0/, "clearing the active agent indicator should cancel pending grace checks and reset state polling");
266
- assert.match(app, /scheduleRunIndicatorGraceCheck\(\)[\s\S]*?refreshState\(\)/, "stale local-only run indicators should re-check state after the start grace period");
338
+ assert.match(app, /scheduleRunIndicatorGraceCheck\(tabContext = activeTabContext\(\)\)[\s\S]*?refreshState\(tabContext\)/, "stale local-only run indicators should re-check state after the start grace period");
267
339
  assert.match(app, /function scheduleAbortStateChecks\(/, "abort handling should poll state so the active indicator can clear after stop confirmation");
268
- assert.match(app, /case "tool_execution_start":[\s\S]*?setRunIndicatorActivity\(`Running tool:/, "tool execution should update the active agent transcript indicator");
340
+ assert.match(app, /case "tool_execution_start":[\s\S]*?suppressStreamingAssistantTextBeforeToolCall\(\)[\s\S]*?handleToolExecutionStart\(event\)[\s\S]*?setRunIndicatorActivity\(`Running tool:/, "tool execution should suppress pre-tool assistant text, then update the active agent indicator and live tool card");
341
+ assert.match(app, /case "tool_execution_update":[\s\S]*?handleToolExecutionUpdate\(event\)/, "tool execution updates should be handled as transcript output");
342
+ assert.match(app, /case "auto_retry_start":[\s\S]*?addTransientMessage\(\{ role: "warn", title: "auto retry"/, "auto-retry starts should be transcript-visible warnings");
343
+ assert.match(app, /case "extension_error":[\s\S]*?addTransientMessage\(\{ role: "error", title: "extension error"/, "extension errors should be transcript-visible error cards");
269
344
  assert.match(app, /setRunIndicatorActivity\("Requesting context compaction…"\);\n\s+scrollChatToBottom\(\{ force: true \}\);/, "manual compaction should force-follow the transcript to the bottom status card");
270
345
  assert.match(app, /case "agent_end":[\s\S]*?clearRunIndicatorActivity\(\)/, "agent completion should remove the active agent transcript indicator");
271
346
  assert.match(app, /case "agent_end":[\s\S]*?notifyAgentDone\(event\.tabId \|\| activeTabId/, "agent completion should trigger optional done notifications");
@@ -283,8 +358,11 @@ assert.doesNotMatch(app, /addEventListener\("mouseenter", \(\) => setActiveComma
283
358
  assert.match(app, /function resizePromptInput\(\)/, "prompt textarea should auto-resize from a one-line default");
284
359
  assert.match(app, /elements\.promptInput\.addEventListener\("input", \(\) => \{\n\s+resizePromptInput\(\);/, "prompt textarea should resize whenever the user edits it");
285
360
  assert.match(app, /function updateComposerModeButtons\(\)/, "composer should relocate Steer and Follow-up based on run state");
286
- assert.match(app, /const target = runActive \? elements\.composerRow : elements\.composerActionsPanel/, "Steer and Follow-up should live in Actions unless a run is active");
287
- assert.match(app, /document\.body\.classList\.toggle\("pi-run-active", runActive\)/, "run-active state should be reflected in CSS for mobile composer layout");
361
+ assert.match(app, /const target = runActive \? elements\.composerRow : elements\.composerActionsPanel/, "Steer and Follow-up should move into the bottom row only while an agent run is active");
362
+ assert.match(app, /const before = runActive \? elements\.abortButton : null/, "active Steer and Follow-up controls should sit before Abort and Send");
363
+ assert.match(app, /button\.hidden = !runActive;\n\s+button\.disabled = !runActive;/, "Steer and Follow-up should be hidden and disabled when the agent is not running");
364
+ assert.match(app, /elements\.abortButton\.hidden = !abortAvailable;\n\s+elements\.abortButton\.disabled = !abortAvailable \|\| abortRequestInFlight;/, "Abort should only be exposed in the bottom bar while a run can be aborted");
365
+ assert.match(app, /document\.body\.classList\.toggle\("pi-run-active", runActive \|\| abortAvailable\)/, "run-active or abort-available state should be reflected in CSS for mobile composer layout");
288
366
  assert.match(app, /function showComposerButtonTooltip\(button\)/, "empty mode-button taps should show the usage tooltip");
289
367
  assert.match(app, /sendPromptFromModeButton\("steer", elements\.steerButton\)/, "Steer should show tooltip instead of silently doing nothing when input is empty");
290
368
  assert.match(app, /sendPromptFromModeButton\("follow-up", elements\.followUpButton\)/, "Follow-up should show tooltip instead of silently doing nothing when input is empty");
@@ -295,8 +373,18 @@ assert.match(app, /releaseNpmButton\.addEventListener\("click", \(\) => runPubli
295
373
  assert.match(app, /releaseAurButton\.addEventListener\("click", \(\) => runPublishWorkflow\("\/release-aur"\)\)/, "Publish menu should launch /release-aur");
296
374
  assert.match(app, /async function sendPrompt\(kind = "prompt", explicitMessage\)/, "prompt sending should accept direct messages that bypass the input field");
297
375
  assert.match(app, /const rawMessage = usesPromptInput \? elements\.promptInput\.value : explicitMessage/, "direct prompt sends should not read the input textarea");
298
- assert.match(app, /if \(usesPromptInput\) \{\n\s+elements\.promptInput\.value = "";/, "direct prompt sends should preserve the input textarea draft");
376
+ assert.match(app, /if \(usesPromptInput\) \{[\s\S]*?if \(targetStillActive\) \{[\s\S]*?elements\.promptInput\.value = "";/, "direct prompt sends should preserve the input textarea draft");
299
377
  assert.match(app, /make\("button", "command-item"\)[\s\S]*?sendPrompt\("prompt", `\/\$\{command\.name\}`\)/, "side-panel command clicks should send the slash command directly");
378
+ assert.match(app, /const NATIVE_SELECTOR_COMMANDS = new Set\(\["model", "settings", "theme", "fork", "clone", "resume", "tree", "login", "logout", "scoped-models"\]\)/, "frontend should route native slash commands into selector UIs");
379
+ assert.match(app, /async function handleNativeSlashSelectorCommand\(message/, "frontend should intercept exact native slash commands before prompt forwarding");
380
+ assert.match(app, /kind === "prompt" && attachments\.length === 0 && await handleNativeSlashSelectorCommand/, "prompt sending should open native selector dialogs before marking a run active");
381
+ assert.match(app, /function openNativeModelSelector\(\)[\s\S]*?nativeCommandApi\("\/api\/models"\)/, "native /model selector should load models through the active tab API");
382
+ assert.match(app, /function openNativeSettingsDialog\(\)[\s\S]*?\/api\/steering-mode[\s\S]*?\/api\/follow-up-mode[\s\S]*?\/api\/auto-compaction/, "native /settings selector should expose queue and compaction controls");
383
+ assert.match(app, /function openNativeForkSelector\(\)[\s\S]*?\/api\/fork-messages[\s\S]*?\/api\/fork/, "native /fork selector should pair fork-point loading with the fork action");
384
+ assert.match(app, /function openNativeResumeSelector\(scope = "current"\)[\s\S]*?\/api\/sessions\?scope=\$\{encodeURIComponent\(selectedScope\)\}/, "native /resume selector should list current-cwd or all sessions");
385
+ assert.match(app, /function openNativeTreeSelector\(\)[\s\S]*?\/api\/session-tree[\s\S]*?\/api\/tree-navigate/, "native /tree selector should list tree entries and navigate through the backend helper");
386
+ assert.match(app, /Provider credential entry is intentionally not implemented in the browser yet/, "native /login should remain a safe non-secret guidance dialog");
387
+ assert.match(app, /const HIDDEN_COMMAND_NAMES = new Set\(\["webui-tree-navigate"\]\)/, "internal Web UI helper commands should stay out of command pickers");
300
388
  assert.match(app, /function shouldSendPromptFromEnter\(event\)/, "prompt keyboard handling should be centralized");
301
389
  assert.match(app, /return !isMobileView\(\);/, "plain Enter should send only outside mobile view so mobile Return can insert newlines");
302
390
  assert.match(app, /mobile-keyboard-open/, "JS should toggle mobile keyboard mode from viewport/focus state");
@@ -323,6 +411,10 @@ assert.match(app, /WARNING: \$\{activeAgentTabs\.length\}[\s\S]*?still running o
323
411
  assert.match(app, /elements\.closeAllTabsButton\.addEventListener\("click", \(\) => closeAllTerminalTabs\(\)\)/, "close-all tabs button should be wired in JS");
324
412
  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");
325
413
  assert.match(app, /let tabSeenCompletionSerials = new Map\(\)/, "frontend should track which tab completions have been seen");
414
+ assert.match(app, /let activeTabGeneration = 0/, "frontend should version active-tab UI state to reject stale async work");
415
+ assert.match(app, /function isCurrentTabContext\(context\)/, "frontend should identify stale active-tab refresh contexts");
416
+ assert.match(app, /function connectEvents\(tabContext = activeTabContext\(\)\)[\s\S]*?eventSource !== source/, "frontend should ignore stale SSE messages from old active tabs");
417
+ assert.match(app, /async function refreshMessages\(tabContext = activeTabContext\(\)\)[\s\S]*?if \(!isCurrentTabContext\(tabContext\)\) return;/, "message refreshes should not render after the user switches tabs");
326
418
  assert.match(app, /function tabIndicator\(tab\)/, "frontend should derive idle, working, blocked, and work-done tab indicator states");
327
419
  assert.match(app, /pendingBlockerCount > 0[\s\S]*?state: "blocked"/, "frontend should show blocked tabs when extension UI blockers are pending");
328
420
  assert.match(app, /const EXTENSION_UI_BLOCKING_METHODS = new Set\(\["select", "confirm", "input", "editor"\]\)/, "frontend should share blocking extension UI method detection for dialogs and notifications");
@@ -351,7 +443,7 @@ assert.match(app, /mobileFooterExpanded = false;[\s\S]*?document\.body\.classLis
351
443
  assert.match(app, /footerMeta\("context", contextLabel, "footer-context"\)/, "footer should render context as a primary mobile meta item");
352
444
  assert.match(app, /footerMeta\("model", modelLine, "footer-model", \{/, "footer model item should be clickable");
353
445
  assert.match(app, /function renderFooterModelPicker\(\)/, "footer should render a scoped-model picker dropdown");
354
- assert.match(app, /api\("\/api\/scoped-models"\)/, "footer model picker should load scoped models instead of all available models");
446
+ assert.match(app, /api\("\/api\/scoped-models", \{ tabId: tabContext\.tabId \}\)/, "footer model picker should load scoped models instead of all available models");
355
447
  assert.match(app, /for \(const model of footerScopedModels\)/, "footer model picker should render only scoped models");
356
448
  assert.match(app, /api\("\/api\/model", \{ method: "POST"/, "footer model picker should apply selected model through the model API");
357
449
  assert.match(app, /footer-details-toggle/, "footer details toggle should be rendered by JS");
@@ -361,7 +453,7 @@ assert.match(app, /case "webui_tab_reloaded":/, "frontend should handle native /
361
453
  assert.match(app, /addTransientMessage\(\{ role: "native", title: "\/reload"/, "native /reload should produce visible transcript output");
362
454
  assert.match(app, /navigator\.clipboard\.writeText\(response\.data\.copyText\)/, "native /copy should use the browser clipboard when available");
363
455
  assert.match(app, /Clipboard access failed:[\s\S]*?response\.data\.copyText/, "native /copy should show text in transcript when clipboard access fails");
364
- assert.match(app, /setTimeout\(\(\) => refreshAll\(\)\.catch/, "frontend should refresh state after native /reload restarts the RPC process");
456
+ assert.match(app, /setTimeout\(\(\) => \{[\s\S]*?refreshAll\(tabContext\)\.catch/, "frontend should refresh state after native /reload restarts the RPC process");
365
457
  assert.match(app, /api\("\/api\/path-fast-picks"/, "frontend should load/save fast picks through the server API");
366
458
  assert.match(app, /loadLegacyFastPicks\(/, "frontend should migrate existing browser-local fast picks");
367
459
 
@@ -370,14 +462,18 @@ assert.equal(manifest.start_url, "/", "PWA manifest should start at the web UI r
370
462
  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");
371
463
  assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-192.png" && icon.sizes === "192x192"), "PWA manifest should include a 192px icon");
372
464
  assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-512.png" && icon.sizes === "512x512"), "PWA manifest should include a 512px icon");
373
- assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v12"/, "PWA service worker should define an app-shell cache");
465
+ assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v16"/, "PWA service worker should define an app-shell cache");
374
466
  assert.match(serviceWorker, /self\.addEventListener\("notificationclick"/, "PWA service worker should focus Web UI when blocked-tab notifications are clicked");
375
467
  assert.match(serviceWorker, /event\.notification\.data\?\.url/, "blocked-tab notifications should carry a URL for service-worker click handling");
376
468
  assert.match(serviceWorker, /"\/apple-touch-icon\.png"/, "PWA service worker should cache the apple touch icon");
469
+ assert.match(serviceWorker, /"\/matrix-background\.webp"/, "PWA service worker should cache the Matrix background image");
470
+ assert.match(serviceWorker, /"\/catppuccin-mocha-background\.png"/, "PWA service worker should cache the Catppuccin Mocha background image");
377
471
  assert.match(serviceWorker, /url\.pathname\.startsWith\("\/api\/"\)/, "PWA service worker should not cache live API or SSE calls");
378
472
  assert.ok(appleIcon.length > 1000, "PWA apple touch icon should be present");
379
473
  assert.ok(icon192.length > 1000, "PWA 192px icon should be present");
380
474
  assert.ok(icon512.length > icon192.length, "PWA 512px icon should be present and larger than 192px icon");
475
+ assert.ok(matrixBackground.length > 100000, "Matrix background image should be present as an optimized WebP asset");
476
+ assert.ok(mochaBackground.length > 8000, "Catppuccin Mocha background image should be present as a compact PNG asset");
381
477
 
382
478
  assert.match(server, /const NATIVE_SLASH_COMMANDS = \[/, "server should define Pi native slash commands for autocomplete");
383
479
  assert.match(server, /\{ name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes" \}/, "native /reload should be advertised for autocomplete");
@@ -408,21 +504,28 @@ assert.match(server, /const restoreTabs = readRestoreTabsFromEnv\(\)/, "server s
408
504
  assert.match(server, /delete process\.env\.PI_WEBUI_RESTORE_TABS/, "server should avoid leaking restore descriptors into spawned Pi RPC processes");
409
505
  assert.match(server, /if \(sessionFile && !options\.noSession\) piArgs\.push\("--session", sessionFile\)/, "restored tabs should resume previous session files");
410
506
  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");
411
- assert.match(server, /const closedRestorableTabs = \[\]/, "server should remember recently closed tabs for slash-command restarts");
412
- assert.match(server, /async function closeTab\(id\)[\s\S]*?rememberClosedRestorableTab\(tab, restorableState\)/, "closing a tab should capture its restorable session before stopping RPC");
507
+ assert.match(server, /const closedRestorableTabs = \[\]/, "server should track recently closed tabs separately from restart restore descriptors");
508
+ assert.match(server, /async function closeTab\(id\)[\s\S]*?rememberClosedRestorableTab\(tab, restorableState\)/, "closing a tab should capture its session before stopping RPC for detailed closed-tab status");
413
509
  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
510
  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");
415
511
  assert.match(server, /function rememberTabState\(tab, state\)/, "server should cache last-known tab state for restart-safe session restoration");
416
512
  assert.match(server, /sessionFile: tabRestorableSessionFile\(tab\)/, "tab metadata should expose cached session files for health/status restore descriptors");
417
- assert.match(server, /restorableTabs: mergeRestorableTabDescriptors\(statusTabs, closedRestorableTabs\)/, "status should expose open plus recently closed restorable tabs");
418
- assert.match(server, /data\.restorableTabs = mergeRestorableTabDescriptors\(detailedTabs, closedRestorableTabs\)/, "detailed status should include session-aware closed tab restore descriptors");
513
+ assert.match(server, /restorableTabs: mergeRestorableTabDescriptors\(statusTabs\)/, "status should expose only currently open tabs as restart restore descriptors");
514
+ assert.match(server, /data\.restorableTabs = mergeRestorableTabDescriptors\(detailedTabs\)/, "detailed status should keep restart restore descriptors limited to open tabs");
515
+ assert.match(server, /data\.closedTabs = closedRestorableTabs\.slice\(\)/, "detailed status should expose recently closed tabs separately");
419
516
  assert.match(server, /const stateData = stateResult\.ok \? stateResult\.data : tab\.lastState \|\| null/, "detailed status should fall back to cached state when live RPC state is temporarily unavailable");
420
517
  assert.match(server, /const initialTabs = await createInitialTabs\(\)/, "server should recreate restored tabs before listening");
421
518
  assert.match(extension, /api\/webui-status\?detailed=1&events=0/, "launcher should capture detailed tab status before restarting an existing Web UI");
422
- assert.match(extension, /function mergeRestorableTabsFromStatusSources\(sources: unknown\[\], options: StartWebuiOptions\)/, "launcher should merge all available restore sources instead of trusting only the first one");
423
- assert.match(extension, /statusData\?\.restorableTabs, statusData\?\.tabs, existing\.restorableTabs, existing\.tabs/, "launcher should combine detailed, health, open, and closed restore descriptors");
519
+ assert.match(extension, /function mergeRestorableTabsFromStatusSources\(sources: unknown\[\], options: StartWebuiOptions\)/, "launcher should merge available restore sources instead of trusting only the first one");
520
+ assert.match(extension, /const openTabSources: unknown\[\] = \[\]/, "launcher should collect explicit open-tab sources for restart restore");
521
+ assert.match(extension, /const detailedTabs = statusData\?\.tabs;\n\s+if \(Array\.isArray\(detailedTabs\)\) openTabSources\.push\(detailedTabs\)/, "launcher should prefer detailed open tabs over restorableTabs that may include closed tabs");
522
+ assert.match(extension, /if \(openTabSources\.length > 0\) return mergeRestorableTabsFromStatusSources\(openTabSources, options\)/, "launcher should restore only open tabs when open tab lists are available");
523
+ assert.match(extension, /return mergeRestorableTabsFromStatusSources\(\[statusData\?\.restorableTabs, existing\.restorableTabs\], options\)/, "launcher should use restorableTabs only as a legacy fallback");
424
524
  assert.match(extension, /env\.PI_WEBUI_RESTORE_TABS = JSON\.stringify\(restoreTabs\)/, "launcher should pass restorable tabs to the new detached server");
425
- assert.match(extension, /pi\.registerCommand\("start-webui"/, "extension should expose /start-webui as an alias for users who invoke that command name");
525
+ assert.match(extension, /pi\.registerCommand\("webui-start"/, "extension should expose the canonical /webui-start command");
526
+ assert.match(extension, /pi\.registerCommand\("webui-tree-navigate"/, "extension should expose the internal Web UI tree navigation command");
527
+ assert.match(extension, /ctx\.navigateTree\(payload\.entryId/, "internal Web UI tree command should call the native session tree navigation API");
528
+ assert.doesNotMatch(extension, /pi\.registerCommand\("start-webui"/, "extension should not expose the older /start-webui alias");
426
529
  assert.match(server, /if \(state\.data\?\.sessionFile && !options\.noSession\) piArgs\.push\("--session", state\.data\.sessionFile\)/, "native /reload should resume the same session file when restarting the RPC tab");
427
530
  assert.match(server, /case "reload": \{[\s\S]*?restartTabRpc\(tab, "slash-command"\)/, "native /reload should restart the active RPC tab");
428
531
  assert.match(server, /message: "Reloaded keybindings, extensions, skills, prompts, and themes\."/, "native /reload should return visible command output");
@@ -447,6 +550,16 @@ assert.match(server, /url\.pathname === "\/api\/path-suggestions" && req\.method
447
550
  assert.match(server, /url\.pathname === "\/api\/path-fast-picks" && req\.method === "GET"/, "server should expose GET /api/path-fast-picks");
448
551
  assert.match(server, /url\.pathname === "\/api\/path-fast-picks" && req\.method === "POST"/, "server should expose POST /api/path-fast-picks");
449
552
  assert.match(server, /url\.pathname === "\/api\/scoped-models" && req\.method === "GET"/, "server should expose GET /api/scoped-models");
553
+ assert.match(server, /url\.pathname === "\/api\/fork-messages" && req\.method === "GET"/, "server should expose fork-point data for the native /fork selector");
554
+ assert.match(server, /url\.pathname === "\/api\/sessions" && req\.method === "GET"/, "server should expose session lists for the native /resume selector");
555
+ assert.match(server, /url\.pathname === "\/api\/session-tree" && req\.method === "GET"/, "server should expose session-tree data for the native /tree selector");
556
+ assert.match(server, /url\.pathname === "\/api\/switch-session" && req\.method === "POST"/, "server should expose session switching for the native /resume selector");
557
+ assert.match(server, /url\.pathname === "\/api\/tree-navigate" && req\.method === "POST"/, "server should expose tree navigation through the Web UI helper command");
558
+ assert.match(server, /function configuredSessionDir\(\)/, "server should honor forwarded --session-dir for session selectors");
559
+ assert.match(server, /SessionManager\.listAll\(sessionDir\) : await SessionManager\.list\(tab\.cwd, sessionDir\)/, "server should support current-cwd and all-session resume scopes");
560
+ assert.match(server, /type: "set_steering_mode"/, "server should expose steering queue-mode changes for native /settings");
561
+ assert.match(server, /type: "set_follow_up_mode"/, "server should expose follow-up queue-mode changes for native /settings");
562
+ assert.match(server, /type: "set_auto_compaction"/, "server should expose auto-compaction changes for native /settings");
450
563
  assert.match(server, /@firstpick\/pi-themes-bundle/, "server should discover themes from the optional theme package");
451
564
  assert.match(server, /const OPTIONAL_FEATURE_PACKAGES = new Map/, "server should whitelist optional feature packages for install actions");
452
565
  assert.match(server, /function installOptionalFeaturePackage\(featureId\)/, "server should provide optional feature package installation helper");
@@ -455,9 +568,10 @@ assert.match(server, /Installing optional Web UI features is only allowed from l
455
568
  assert.match(server, /url\.pathname === "\/api\/themes" && req\.method === "GET"/, "server should expose GET /api/themes");
456
569
  assert.match(server, /readBundledThemes\(\)/, "server should read bundled theme JSON files for the browser");
457
570
  assert.match(server, /"apple-touch-icon\.png", "icon-192\.png"/, "server should serve the conventional apple touch icon path");
458
- assert.match(server, /"manifest\.webmanifest", "service-worker\.js"/, "server should serve PWA manifest and service worker as static assets");
571
+ assert.match(server, /"catppuccin-mocha-background\.png", "matrix-background\.webp", "manifest\.webmanifest", "service-worker\.js"/, "server should serve theme background images as static assets");
459
572
  assert.match(server, /\["\.webmanifest", "application\/manifest\+json; charset=utf-8"\]/, "server should serve manifest with the correct MIME type");
460
573
  assert.match(server, /\["\.png", "image\/png"\]/, "server should serve PWA PNG icons with the correct MIME type");
574
+ assert.match(server, /\["\.webp", "image\/webp"\]/, "server should serve Matrix WebP backgrounds with the correct MIME type");
461
575
  assert.match(server, /function configuredScopedModelPatterns\(cwd = options\.cwd\)/, "server should read Pi configured scoped-model patterns for the active tab cwd");
462
576
  assert.match(server, /readJsonFileIfExists\(path\.join\(cwd, "\.pi", "settings\.json"\)\)/, "server should read project-local scoped-model settings from active tab cwd");
463
577
  assert.match(server, /resolveScopedModelsFromPatterns\(patterns, response\.data\?\.models/, "server should resolve scoped patterns against available models");