@firstpick/pi-package-webui 0.4.2 → 0.4.4

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.
@@ -12,7 +12,7 @@ const [pkgRaw, html, css, app, server, extension, readme, startScript, manifestR
12
12
  readFile(join(root, "bin", "pi-webui.mjs"), "utf8"),
13
13
  readFile(join(root, "index.ts"), "utf8"),
14
14
  readFile(join(root, "README.md"), "utf8"),
15
- readFile(join(root, "start-webui.sh"), "utf8"),
15
+ readFile(join(root, "dev", "scripts", "start-webui.sh"), "utf8"),
16
16
  readFile(join(root, "public", "manifest.webmanifest"), "utf8"),
17
17
  readFile(join(root, "public", "service-worker.js"), "utf8"),
18
18
  readFile(join(root, "public", "apple-touch-icon.png")),
@@ -24,6 +24,7 @@ const [pkgRaw, html, css, app, server, extension, readme, startScript, manifestR
24
24
  const pkg = JSON.parse(pkgRaw);
25
25
  const manifest = JSON.parse(manifestRaw);
26
26
  const companionDependencies = {
27
+ "@firstpick/pi-extension-btw": "^0.1.0",
27
28
  "@firstpick/pi-extension-git-footer-status": "^0.3.3",
28
29
  "@firstpick/pi-extension-release-aur": "^0.1.6",
29
30
  "@firstpick/pi-extension-release-npm": "^0.4.0",
@@ -66,11 +67,13 @@ assert.match(html, /id="terminalTabsLayoutSelect"[\s\S]*<option value="left">Lef
66
67
  assert.match(html, /id="terminalTabsLayoutStatus"/, "terminal-tabs layout selector should expose status text");
67
68
  assert.match(html, /id="nativeCommandDialog"/, "native slash selector UI should have a dedicated dialog");
68
69
  assert.match(html, /id="nativeCommandSearch"[^>]*type="search"/, "native slash selector dialog should expose a filter box");
70
+ assert.match(html, /id="commandPaletteCloseButton"[^>]*aria-label="Close command palette"[^>]*>Close<\/button>/, "command palette should expose a visible accessible close button");
69
71
  assert.match(html, /id="pathPickerCreateNameInput"[^>]*placeholder="New directory name"/, "cwd picker should expose a new-directory name input");
70
72
  assert.match(html, /id="pathPickerCreateButton"[^>]*>Create directory<\/button>/, "cwd picker should expose a create-directory action");
71
73
  assert.match(html, /id="pathPickerSearchInput"[^>]*type="search"[^>]*placeholder="Search current directory…"/, "cwd picker should expose a current-directory search box");
72
74
  assert.match(html, /id="pathPickerClearSearchButton"[^>]*hidden[^>]*>Clear<\/button>/, "cwd picker should expose a clear-search action");
73
75
  assert.match(html, /id="optionalFeaturesBox"/, "side panel should expose optional feature status and controls");
76
+ assert.doesNotMatch(html, /id="btwOverlayDialog"/, "/btw should not use a blocking modal overlay");
74
77
  assert.match(html, /id="codexUsageBox"/, "side panel should expose Codex subscription usage status");
75
78
  assert.match(html, /data-side-panel-section="codex-usage"/, "Codex usage should live in a collapsible side-panel section");
76
79
  assert.match(html, /data-side-panel-section="queue"[\s\S]*id="createPromptListButton"[\s\S]*>Create prompt list<\/button>/, "Queue section should expose prompt-list creation");
@@ -124,8 +127,10 @@ assert.match(app, /attachmentTextDialog\?\.addEventListener\("keydown"[\s\S]*eve
124
127
  assert.match(css, /\.attachment-text-dialog[\s\S]*\.attachment-text-editor/, "text attachment editor should have dedicated dialog styling");
125
128
  assert.match(html, /id="composerActionsButton"/, "mobile composer should expose a compact actions trigger");
126
129
  assert.match(html, /id="composerActionsPanel"/, "secondary composer controls should live in a mobile actions panel");
127
- 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");
128
- assert.match(html, /id="abortButton"[^>]*Esc or hold/, "Abort should advertise Esc and long-press affordances");
130
+ assert.match(html, /<div class="composer-row">[\s\S]*id="abortButton"[\s\S]*id="btwButton"[\s\S]*id="sendButton"/, "Abort and /btw should live in the bottom composer row beside Send");
131
+ assert.match(html, /id="btwButton"[\s\S]*class="composer-icon-button composer-btw-button"[\s\S]*?<svg class="composer-icon"/, "composer should expose an icon-only /btw side-question button");
132
+ assert.doesNotMatch(html, /id="btwButton"[\s\S]*?<span>\/btw<\/span>[\s\S]*?id="sendButton"/, "/btw composer button should not show a text label");
133
+ assert.match(html, /id="abortButton"[^>]*Hold Esc or the Abort button for 3 seconds/, "Abort should advertise guarded Esc and long-press affordances");
129
134
  assert.doesNotMatch(html, /class="side-panel-controls"[\s\S]*id="abortButton"/, "Abort should not be buried in the side-panel controls");
130
135
  assert.match(html, /id="publishButton"[\s\S]*?aria-controls="publishMenu"/, "composer should expose a Publish workflow menu button");
131
136
  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");
@@ -137,6 +142,14 @@ assert.match(html, /id="nativeToolsButton"[^>]*data-command="\/tools"[\s\S]*?<sp
137
142
  assert.match(html, /id="appRunnerInfoButton"[\s\S]*?aria-controls="appRunnerInfoDialog"/, "detected app-runner controls should expose an explanation popup button");
138
143
  assert.match(html, /id="appRunnerMenuButton"[\s\S]*?aria-controls="appRunnerMenuPanel"/, "composer should expose a detected app-runner dropdown button");
139
144
  assert.ok(html.indexOf('id="optionsMenuButton"') < html.indexOf('id="appRunnerMenuButton"'), "app-runner dropdown should render to the right of the settings/options button");
145
+ assert.match(html, /id="optionsRemoteButton"[^>]*data-command="\/remote"[^>]*hidden[\s\S]*?<span>Open Remote<\/span>/, "Options menu should include the Remote WebUI launcher by label");
146
+ const optionsRemoteIndex = html.indexOf('id="optionsRemoteButton"');
147
+ const optionsReloadIndex = html.indexOf('id="optionsReloadButton"');
148
+ const optionsNameIndex = html.indexOf('id="optionsNameButton"');
149
+ assert.ok(
150
+ Math.min(optionsReloadIndex, optionsNameIndex) < optionsRemoteIndex && optionsRemoteIndex < Math.max(optionsReloadIndex, optionsNameIndex),
151
+ "Open Remote should render between Reload Pi and Name Session",
152
+ );
140
153
  assert.match(html, /id="appRunnerMenuPanel"[^>]*aria-label="Detected app runners"/, "app-runner dropdown should render detected runner choices only from JS data");
141
154
  assert.match(html, /id="appRunnerInfoDialog"[\s\S]*id="appRunnerInfoBody"/, "app-runner explanation popup should have a dynamic details body");
142
155
  assert.match(app, /\.pi-webui-runners\.json/, "app-runner popup should explain the project-local custom runner config file");
@@ -175,11 +188,11 @@ assert.match(css, /@media \(max-width: 1050px\)[\s\S]*?body:not\(\.side-panel-co
175
188
  assert.match(css, /@media \(max-width: 1050px\)[\s\S]*?\.side-panel-backdrop \{[\s\S]*?z-index:\s*110;[\s\S]*?\.side-panel \{[\s\S]*?z-index:\s*111;[\s\S]*?body\.side-panel-collapsed \.terminal-tabs-shell \{[\s\S]*?padding-right:\s*4\.85rem;[\s\S]*?\.side-panel-expand-button \{[\s\S]*?z-index:\s*120/, "narrow side-panel overlay and expand button should stay above and reserve space from terminal header controls");
176
189
  assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\)[\s\S]*?body\.side-panel-collapsed \.terminal-tabs-shell \{ padding-right:\s*calc\(44px \+ 0\.8rem\); \}[\s\S]*?\.side-panel-expand-button \{[\s\S]*?z-index:\s*120[\s\S]*?\.side-panel-backdrop \{[\s\S]*?z-index:\s*110;[\s\S]*?\.side-panel \{[\s\S]*?z-index:\s*111;/, "mobile side-panel controls should not hide behind terminal header buttons");
177
190
  assert.match(css, /button, select, input \{ min-height: 44px; \}/, "base controls should meet 44px touch-target height");
178
- assert.match(css, /\.composer-row button[\s\S]*?min-height:\s*44px/, "mobile composer buttons should keep 44px touch targets");
191
+ assert.match(css, /\.composer-row button \{\n\s+width:\s*100%;\n\s+min-height:\s*40px/, "mobile composer buttons should use compact 40px footer controls");
179
192
  assert.match(css, /\.composer-abort-button,\n\.composer-row button\.primary \{[\s\S]*?min-width:/, "Abort and Send should share stable bottom-row sizing");
180
- 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");
193
+ assert.match(css, /\.composer-abort-button\.long-pressing::after[\s\S]*?animation:\s*abort-long-press-fill var\(--abort-long-press-duration, 3000ms\) linear forwards/, "Abort should expose a visible 3-second long-press progress affordance");
181
194
  assert.match(css, /body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-abort-button:not\(\[hidden\]\) \{\n\s+order:\s*1;\n\s+grid-column:\s*span 2;/, "active mobile runs should move Abort to the top row");
182
- assert.match(css, /body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-actions-button \{ order:\s*4; \}[\s\S]*?body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-row button\.primary \{\n\s+order:\s*5;\n\s+grid-column:\s*span 4;/, "active mobile runs should keep Actions beside Send on the bottom row");
195
+ assert.match(css, /body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-actions-button \{ order:\s*4; \}[\s\S]*?body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-btw-button:not\(\[hidden\]\) \{\n\s+order:\s*5;\n\s+grid-column:\s*span 2;[\s\S]*?body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-row button\.primary \{\n\s+order:\s*6;\n\s+grid-column:\s*span 2;/, "active mobile runs should keep Actions, /btw, and Send on the bottom row");
183
196
  assert.match(css, /#promptInput \{[\s\S]*?min-height:\s*calc\(1\.5em \+ 1\.8rem\)/, "prompt input should default to a compact single-line height");
184
197
  assert.match(css, /#promptInput \{[\s\S]*?overflow-y:\s*hidden/, "prompt input should be JS-resized instead of showing a scrollbar by default");
185
198
  assert.match(css, /\.composer-context-tags \{[\s\S]*?top:\s*-0\.48rem;[\s\S]*?left:\s*0\.75rem;/, "busy prompt behavior and skill tags should sit at the top-left of the input frame");
@@ -191,7 +204,11 @@ assert.match(css, /\.skill-editor-dialog form \{[\s\S]*?height:\s*100%;[\s\S]*?m
191
204
  assert.match(css, /\.skill-editor-text \{[\s\S]*?overflow-x:\s*hidden;[\s\S]*?overflow-wrap:\s*anywhere;[\s\S]*?white-space:\s*pre-wrap/, "skill editor text should wrap long lines instead of horizontal scrolling");
192
205
  assert.match(css, /\.composer-busy-mode-menu \{[\s\S]*?bottom:\s*calc\(100% \+ 0\.22rem\);[\s\S]*?background:\s*var\(--ctp-crust\)/, "busy prompt behavior dropdown should expand above the tag with an opaque background");
193
206
  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");
207
+ assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\)[\s\S]*?\.sticky-user-prompt-button \{\n\s+grid-template-columns:\s*minmax\(0, 1fr\) auto;\n\s+min-height:\s*36px;[\s\S]*?\.sticky-user-prompt-text \{[\s\S]*?font-size:\s*0\.72rem/, "mobile last-user-prompt card should use compact height and text");
194
208
  assert.match(css, /\.message\.extension,[\s\S]*?\.message\.native/, "extension and native command output should have visible transcript styling");
209
+ assert.match(app, /function mirrorRemoteWebuiWidgetToTranscript\(widgetKey, lines = \[\], request = \{\}\)[\s\S]*?widgetKey !== "pi-remote-webui"[\s\S]*?addTransientMessage\(\{ role: "extension", title: "\/remote"/, "remote WebUI QR widget events should mirror into the active tab transcript");
210
+ assert.match(app, /if \(widgetKey === "pi-remote-webui"\) \{[\s\S]*?widgets\.delete\(widgetKey\);[\s\S]*?mirrorRemoteWebuiWidgetToTranscript/, "remote WebUI QR widget events should not render a Web UI overlay widget");
211
+ assert.doesNotMatch(app, /function renderRemoteWebuiWidget/, "remote WebUI QR should only render in the transcript");
195
212
  assert.match(css, /\.message\.run-indicator-message \{[\s\S]*?border-color/, "active agent runs should render a visible transcript indicator card");
196
213
  assert.match(css, /\.message-copy-button \{[\s\S]*?position:\s*absolute/, "transcript messages should expose a top-right copy button");
197
214
  assert.match(css, /\.message\.has-copy-action[\s\S]*?padding-right:\s*3\.1rem/, "copy buttons should reserve space in message cards");
@@ -211,6 +228,7 @@ assert.match(css, /\.message\.toolResult \.message-collapse\[open\] > \.message-
211
228
  assert.match(css, /\.tool-output-details\[open\] > \.tool-output-code \{[\s\S]*?max-height:\s*min\(34rem, 52dvh\);[\s\S]*?overflow:\s*auto/, "expanded live tool output should get an internal scrollbar");
212
229
  assert.match(css, /\.run-indicator-pulse \{[\s\S]*?animation:\s*run-indicator-pulse/, "active agent run indicator should have an animated pulse");
213
230
  assert.match(css, /\.optional-features-box \{[\s\S]*?display:\s*grid/, "optional features should render as a side-panel feature list");
231
+ assert.match(css, /\.btw-widget \{[\s\S]*?\.btw-widget-composer \{[\s\S]*?\.btw-transfer-action \{[\s\S]*?\.btw-live-widget \.release-npm-output-details\[open\] \.release-npm-terminal \{[\s\S]*?height:\s*clamp/, "/btw should render as a non-blocking release-style output widget with its own input and transfer action");
214
232
  assert.match(css, /\.prompt-list-controls \{[\s\S]*?display:\s*grid/, "Queue prompt-list controls should render as a side-panel control group");
215
233
  assert.match(css, /\.prompt-list-dialog \{[\s\S]*?width:\s*min\(58rem/, "prompt-list editor dialog should have a wider prompt-friendly layout");
216
234
  assert.match(css, /\.prompt-list-editor-rows \{[\s\S]*?max-height:/, "prompt-list dialog should scroll long follow-up lists inside the editor");
@@ -268,11 +286,18 @@ assert.match(css, /\.composer-publish-menu-panel \{[\s\S]*?display:\s*none;[\s\S
268
286
  assert.match(css, /\.composer-publish-menu:hover \.composer-publish-menu-panel,[\s\S]*?\.composer-publish-menu:focus-within \.composer-publish-menu-panel,[\s\S]*?\.composer-publish-menu\.open \.composer-publish-menu-panel \{\n\s+display:\s*flex;/, "Publish workflow menu should open on hover, focus, or explicit open state");
269
287
  assert.match(css, /\.composer-native-command-button \{[\s\S]*?color:\s*var\(--ctp-mauve\)/, "skills/tools command menu should have a distinct slash-command button style");
270
288
  assert.match(css, /\.composer-options-menu-panel \{[\s\S]*?max-height:\s*min\(calc\(var\(--visual-viewport-height, 100dvh\) - 2rem\), 44rem\)/, "Options menu should be tall enough for common commands without scrolling on normal viewports");
289
+ assert.match(css, /\.composer-publish-menu-panel \{[\s\S]*?overscroll-behavior:\s*contain;[\s\S]*?-webkit-overflow-scrolling:\s*touch/, "dropdown panels should use contained momentum scrolling when their options overflow");
290
+ assert.match(css, /\.composer-actions-panel > \.composer-publish-menu \.composer-publish-menu-panel \{[\s\S]*?max-height:\s*min\(var\(--mobile-dropdown-max-height, 34dvh\), calc\(var\(--visual-viewport-height, 100dvh\) - 2rem\)\)/, "mobile composer dropdowns should clamp to an in-viewport scroll height");
291
+ assert.match(css, /\.composer-actions-panel > \.composer-options-menu \.composer-publish-menu-panel,\n\s+\.composer-actions-panel > \.composer-app-runner-menu \.composer-publish-menu-panel \{\n\s+inset-inline:\s*auto 0;/, "mobile Options and app-runner dropdowns should share the clamped scrollable popover placement");
271
292
  assert.match(css, /\.composer-native-command-menu-item \{[\s\S]*?color:\s*var\(--ctp-mauve\)/, "skills/tools command menu items should be styled separately from publish actions");
272
293
  assert.match(css, /\.composer-actions-panel > \.composer-publish-menu[\s\S]*?grid-column: span 1/, "Publish and command menu buttons should fit beside Git workflow in mobile actions");
273
294
  assert.match(css, /\.composer-actions-panel[\s\S]*?bottom:\s*calc\(100% \+ 0\.42rem\)/, "mobile composer actions should open as an above-composer sheet");
274
295
  assert.match(css, /body\.composer-actions-open \.composer-actions-panel \{ display: grid; \}/, "composer actions panel should only open when toggled");
275
296
  assert.match(css, /\.terminal-tabs-toggle-button \{ display: none; \}/, "terminal tab toggle should be hidden outside mobile CSS");
297
+ assert.match(css, /@media \(max-width: 720px\) \{[\s\S]*?\.terminal-command-palette-button,\n\s+\.terminal-dashboard-button \{[\s\S]*?display:\s*inline-grid;/, "mobile header should keep the command palette button visible beside the dashboard button");
298
+ assert.match(css, /\.command-palette-close-button \{[\s\S]*?min-width:\s*44px;[\s\S]*?min-height:\s*44px/, "command palette close button should meet touch-target sizing by default");
299
+ assert.match(css, /@media \(max-width: 720px\) \{[\s\S]*?\.command-palette-dialog \{[\s\S]*?width:\s*min\(100vw - 0\.5rem, 42rem\)[\s\S]*?\.command-palette-list \{[\s\S]*?scrollbar-gutter:\s*auto;[\s\S]*?\.command-palette-item \{[\s\S]*?grid-template-columns:\s*minmax\(3\.4rem, 0\.26fr\) minmax\(0, 1fr\);[\s\S]*?min-height:\s*2\.72rem;/, "mobile command palette results should use compact two-column cards instead of tall one-column cards");
300
+ assert.match(css, /@media \(max-width: 720px\) \{[\s\S]*?\.command-palette-item-kind \{[\s\S]*?font-size:\s*0\.56rem;[\s\S]*?\.command-palette-item-label \{ font-size:\s*0\.82rem; \}[\s\S]*?\.command-palette-item-description \{ font-size:\s*0\.6rem; \}/, "mobile command palette result text should be scaled down to fit compact cards");
276
301
  assert.match(css, /body\.terminal-tabs-left \.chat-panel \{[\s\S]*?grid-template-columns:\s*clamp\(13rem, 18vw, 19rem\) minmax\(0, 1fr\)/, "terminal tabs left layout should split the chat panel into a sidebar and transcript area");
277
302
  assert.match(css, /body\.terminal-tabs-left \.terminal-tabs-shell \{[\s\S]*?grid-column:\s*1;[\s\S]*?grid-row:\s*1 \/ -1;[\s\S]*?flex-direction:\s*column/, "terminal tabs left layout should turn the top tab strip into a vertical sidebar");
278
303
  assert.match(css, /body\.terminal-tabs-left \.terminal-tabs \{[\s\S]*?flex-direction:\s*column/, "terminal tabs left layout should stack tabs vertically");
@@ -284,6 +309,7 @@ assert.match(css, /\.terminal-close-all-button \{[\s\S]*?color:\s*var\(--ctp-red
284
309
  assert.match(css, /\.terminal-tabs\.terminal-tabs-dense \{[\s\S]*?flex-wrap:\s*wrap/, "large terminal tab sets should wrap into a readable dense tab strip");
285
310
  assert.match(css, /\.terminal-tab-group-close \{[\s\S]*?border-left-color/, "terminal tab groups should style their close button distinctly");
286
311
  assert.match(css, /body\.mobile-tabs-expanded \.terminal-tabs \{ display: flex; \}/, "mobile tabs should expand only when toggled");
312
+ assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\) \{[\s\S]*?\.terminal-tabs \{\n\s+position:\s*absolute;[\s\S]*?top:\s*calc\(100% \+ 0\.35rem\);[\s\S]*?overscroll-behavior:\s*contain;[\s\S]*?-webkit-overflow-scrolling:\s*touch;/, "expanded mobile tabs should overlay below the header with contained touch scrolling");
287
313
  assert.match(css, /\.terminal-tab-activity-indicator/, "terminal tabs should expose per-tab agent activity indicators");
288
314
  assert.match(css, /\.terminal-tab-group-item \{[\s\S]*?flex:\s*0 0 auto[\s\S]*?background:\s*var\(--ctp-crust\)/, "grouped terminal tab items should keep readable height and use opaque backgrounds");
289
315
  assert.match(css, /\.terminal-tab-group-add \{[\s\S]*?flex:\s*0 0 auto/, "grouped terminal tab menus should scroll instead of shrinking the add-tab action");
@@ -301,7 +327,7 @@ assert.match(css, /\.terminal-tabs[\s\S]*?position:\s*absolute/, "expanded mobil
301
327
  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");
302
328
  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");
303
329
  assert.match(css, /\.server-offline-panel/, "PWA/offline shell should style a backend-offline recovery panel");
304
- 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");
330
+ assert.match(css, /body:not\(\.pi-run-active\):not\(\.mobile-keyboard-open\) \.composer-row button\.primary \{ grid-column: span 2; \}[\s\S]*?body:not\(\.pi-run-active\):not\(\.mobile-keyboard-open\) \.composer-btw-button\[hidden\] \+ button\.primary \{ grid-column: span 4; \}/, "idle mobile composer should keep Actions, /btw, and Send on one compact row with a hidden-button fallback");
305
331
  assert.match(css, /button\[hidden\] \{ display: none !important; \}/, "hidden bottom-row controls should not occupy layout space");
306
332
  assert.match(css, /\.statusbar-tui-footer \{[\s\S]*?gap:\s*0/, "default TUI-like footer should reduce statusbar chrome around the compact line");
307
333
  assert.match(css, /\.statusbar-git-footer \{[\s\S]*?--footer-chip-min-width:\s*7\.6rem;[\s\S]*?gap:\s*0\.58rem/, "enabled git-footer extension should keep styled spacing and one shared minimal chip width token");
@@ -331,6 +357,7 @@ assert.match(server, /url\.pathname === "\/api\/git-changes\/untracked-file" &&
331
357
  assert.match(server, /async function readGitChanges\(cwd\)[\s\S]*?const diffArgs = \["diff", "--no-ext-diff"[\s\S]*?\["diff", "--cached"/, "server should collect both staged and unstaged git diffs for the changes modal");
332
358
  assert.match(server, /url\.pathname === "\/api\/git-changes" && req\.method === "GET"/, "server should expose GET /api/git-changes for the changes modal");
333
359
  assert.match(css, /@media \(max-width: 1050px\)[\s\S]*?\.footer-line-meta \{[\s\S]*?display:\s*flex;[\s\S]*?flex-wrap:\s*wrap;[\s\S]*?\.footer-line-meta \.footer-meta \{[\s\S]*?flex:\s*1 1 var\(--footer-chip-min-width\);[\s\S]*?width:\s*auto;[\s\S]*?\.footer-workspace,\n\s+\.footer-model,\n\s+\.footer-thinking \{ grid-column:\s*auto; \}/, "narrow git-footer metadata should wrap like the top metric row instead of forcing a two-column grid");
360
+ assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\)[\s\S]*?\.context-meter-bar \{ display:\s*none !important; \}/, "mobile should hide the WebUI context meter that appears after high context usage");
334
361
  assert.match(css, /\.footer-line-tui \{[\s\S]*?white-space:\s*nowrap/, "default Web UI footer should use a minimal TUI-like line");
335
362
  assert.match(css, /\.footer-tui-cwd[\s\S]*?max-width:\s*38%/, "TUI-like footer should keep cwd compact on desktop");
336
363
  assert.match(css, /\.footer-tui-model[\s\S]*?text-align:\s*right/, "TUI-like footer should right-align model information on desktop");
@@ -341,6 +368,8 @@ assert.match(css, /\.footer-model-option\.active/, "footer model picker should s
341
368
  assert.match(app, /async function createPathPickerDirectory\(\)/, "cwd picker should implement create-directory behavior in the browser");
342
369
  assert.match(app, /function renderPathPickerDirectoryList\(\)[\s\S]*pathPickerDirectoryMatchesSearch/, "cwd picker should filter current-directory entries in the browser");
343
370
  assert.match(app, /elements\.pathPickerSearchInput\.addEventListener\("input", renderPathPickerDirectoryList\)/, "cwd picker should update directory matches as the user types");
371
+ assert.match(app, /function shouldOpenCwdChangeInNewTab\(tab\) \{[\s\S]*!!tab\?\.conversationStarted[\s\S]*activeTabHasConversationMessages\(tab\)[\s\S]*stateHasVisibleWork\(currentState\)[\s\S]*tabHasActiveAgent\(tab\)/, "cwd changes for started conversations should be routed to a new tab");
372
+ assert.match(app, /if \(shouldOpenCwdChangeInNewTab\(tab\)\) \{[\s\S]*await createTerminalTab\(cwd, \{ triggerButton: null \}\);[\s\S]*return;[\s\S]*window\.confirm\(`Restart/, "footer cwd changes should open a new tab before destructive cwd restarts once a session is active");
344
373
  assert.match(server, /async function createDirectoryPickerDirectory\(parentPath, nameValue, activeCwd\)/, "server should implement cwd picker directory creation");
345
374
  assert.match(server, /function directoryPickerActiveCwd\(req, url, body = \{\}\)/, "server should let the cwd picker run before any Pi tabs exist");
346
375
  assert.match(server, /url\.pathname === "\/api\/directories" && req\.method === "POST"/, "server should expose POST /api/directories for cwd picker directory creation");
@@ -401,6 +430,13 @@ assert.match(app, /themeSelect\.addEventListener\("change"/, "side-panel theme s
401
430
  assert.match(app, /open \? "Close for network" : "Open to network"/, "network button should toggle from open to close action");
402
431
  assert.match(app, /remoteAuthToggle: \$\("#remoteAuthToggle"\)/, "Controls should expose the remote PIN auth toggle");
403
432
  assert.match(app, /api\("\/api\/remote-auth\/settings", \{ method: "POST"/, "remote PIN auth toggle should call the settings endpoint");
433
+ assert.match(server, /function webuiSettingsFile\(\)[\s\S]*pi-webui[\s\S]*settings\.json/, "server should persist Web UI settings under a pi-webui settings file");
434
+ assert.match(server, /let persistedRemoteAuthEnabled = await readPersistedRemoteAuthEnabled\(\)/, "server should load the saved Remote PIN auth preference before startup");
435
+ assert.match(server, /if \(remoteAuthStartupEnabled\(\)\) enableRemoteAuth\(remoteAuthStartupReason\(\)\)/, "saved Remote PIN auth preference should enable auth on startup");
436
+ assert.match(server, /await saveRemoteAuthPreference\(true\)/, "enabling Remote PIN auth should persist the on preference");
437
+ assert.match(server, /await saveRemoteAuthPreference\(false\)/, "disabling Remote PIN auth should persist the off preference");
438
+ assert.match(server, /function pinFromHash\(\)[\s\S]*new URLSearchParams\(String\(window\.location\.hash \|\| ""\)\.replace\(\/\^#\/, ""\)\)/, "remote auth page should read QR-provided PINs from the URL fragment");
439
+ assert.match(server, /window\.history\.replaceState\(null, "", window\.location\.pathname \+ \(window\.location\.search \|\| ""\)\)/, "remote auth page should scrub fragment PINs from the address bar before authenticating");
404
440
  assert.match(app, /api\("\/api\/network\/close", \{ method: "POST"/, "network close action should call the close endpoint");
405
441
  assert.match(app, /webuiVersionBadge: \$\("#webuiVersionBadge"\)/, "frontend should bind the Control Deck version badge");
406
442
  assert.match(app, /webuiDevBadge: \$\("#webuiDevBadge"\)/, "frontend should bind the Control Deck dev badge");
@@ -435,6 +471,9 @@ assert.match(readme, /toggles to "Close for network"/, "README should document t
435
471
  assert.match(app, /window\.visualViewport/, "app should listen to VisualViewport for keyboard/viewport updates");
436
472
  assert.match(html, /<textarea id="promptInput"[^>]*autofocus/, "prompt composer should autofocus for new Web UI/app launches");
437
473
  assert.match(app, /function syncMobileChatToBottomForInput\(\)/, "mobile input focus should force the output view to the latest message");
474
+ assert.match(app, /function updateMobileDropdownScrollBounds\(\)[\s\S]*--mobile-dropdown-max-height/, "mobile dropdowns should compute a viewport-bounded scroll height when opened");
475
+ assert.match(app, /function setAppRunnerMenuOpen\(open\)[\s\S]*scheduleMobileDropdownScrollBoundsUpdate\(\)/, "app-runner dropdown opening should refresh mobile scroll bounds");
476
+ assert.match(app, /function setOptionsMenuOpen\(open\)[\s\S]*scheduleMobileDropdownScrollBoundsUpdate\(\)/, "Options dropdown opening should refresh mobile scroll bounds");
438
477
  assert.match(app, /function focusPromptInput\(\{ defer = false \} = \{\}\)/, "frontend should focus the prompt composer programmatically after tab/app startup");
439
478
  assert.match(app, /async function switchTab\(tabId\)[\s\S]*?restoreActiveDraft\(\);\n\s+focusPromptInput\(\{ defer: true \}\);/, "switching to a newly opened tab should focus the prompt input immediately");
440
479
  assert.match(app, /async function initializeTabs\(\)[\s\S]*?restoreActiveDraft\(\);[\s\S]*if \(!loadedTabs\.length\)[\s\S]*focusPromptInput\(\{ defer: true \}\);/, "starting the Web UI should prompt for cwd when needed and focus active tabs");
@@ -473,6 +512,8 @@ assert.match(app, /guardrail-safe-action/, "guardrail dialogs should distinguish
473
512
  assert.match(app, /function hasQueuedDialogRequest\(id\)/, "frontend should deduplicate replayed extension UI dialogs by request id");
474
513
  assert.match(app, /if \(request\.replayed\) addEvent\(`recovered pending \$\{request\.method\} request`, "warn"\)/, "frontend should surface replayed extension UI blockers");
475
514
  assert.match(app, /case "webui_extension_ui_cancelled":/, "frontend should close dialogs cancelled by backend abort handling");
515
+ assert.match(app, /case "webui_extension_ui_resolved":[\s\S]*?removeQueuedDialogRequests\(\[event\.id\]\)/, "frontend should close dialogs resolved by another connected browser");
516
+ assert.match(app, /if \(responseId && activeDialog && String\(activeDialog\.id \|\| ""\) !== responseId\) return;/, "dialog response cleanup should not close the next queued dialog after a resolve-event race");
476
517
  assert.match(app, /function parseTodoProgressWidget\(lines\)/, "todo-progress widgets should be parsed from extension widget lines");
477
518
  assert.match(app, /const todoProgressWidgetExpandedByTab = new Map\(\)/, "todo-progress expansion state should survive widget re-renders per tab");
478
519
  assert.match(app, /const node = make\("details", "widget todo-widget"\)/, "todo-progress widget should render collapsed by default as expandable details");
@@ -504,6 +545,8 @@ assert.match(app, /GIT_FOOTER_WEBUI_STATUS_KEY = "git-footer-webui"/, "git foote
504
545
  assert.match(app, /function parseGitFooterWebuiPayloadRaw\(raw\)[\s\S]*GIT_FOOTER_WEBUI_PAYLOAD_TYPE[\s\S]*GIT_FOOTER_WEBUI_PAYLOAD_VERSION/, "Web UI footer should parse the structured payload emitted by git-footer-status");
505
546
  assert.match(app, /function normalizeFooterPayloadChangedFile\(value\)[\s\S]*FOOTER_CHANGED_FILE_KINDS\.has\(value\.kind\)[\s\S]*oldPath/, "git footer payload parsing should preserve changed-file details for changes popovers");
506
547
  assert.match(app, /const files = value\.files\.map\(normalizeFooterPayloadChangedFile\)\.filter\(Boolean\)\.slice\(0, 80\);[\s\S]*chip\.files = files;/, "git footer payload chips should retain bounded changed-file lists");
548
+ assert.match(app, /FOOTER_PAYLOAD_ACTIONS = new Set\(\["calibrate-current", "calibrate-probe"\]\)[\s\S]*chip\.action = value\.action;/, "git footer payload chips should preserve allowlisted actions such as PI calibration");
549
+ assert.match(app, /async function runGitFooterPiCalibration\(mode = "current", tabContext = activeTabContext\(\)\)[\s\S]*resolveAvailableCommandName\("calibrate", \{ rpcOnly: true \}\)[\s\S]*mode === "probe" \? `\/\$\{commandName\}` : `\/\$\{commandName\} current`[\s\S]*scheduleGitFooterPiCalibrationRefresh\(tabContext, mode === "probe" \? \[5000, 14000\] : \[600, 1600\]\)/, "clicking an uncalibrated PI footer chip should run current/probe calibration and refresh the git footer value");
507
550
  assert.match(app, /title: cleanFooterPayloadText\(value\.title, "", 4000\)/, "git footer tooltip titles should preserve long cwd paths instead of truncating at chip display length");
508
551
  assert.match(app, /const sourceTitle = cleanFooterPayloadText\(chip\?\.title, "", 4000\)/, "git footer tooltip rendering should keep full source titles for long cwd paths");
509
552
  assert.match(app, /function renderFooter\(\)[\s\S]*parseGitFooterWebuiPayload\(\)[\s\S]*renderGitFooterPayload\(footerPayloadWithLiveModel\(gitFooterPayload\)\)/, "detailed footer rendering should prefer the git-footer-status extension payload");
@@ -568,10 +611,16 @@ assert.match(app, /api\("\/api\/optional-features"/, "optional feature panel sho
568
611
  assert.match(app, /packageStatus\?\.updateAvailable[\s\S]*action\.textContent = "Update…"/, "optional feature package drift should turn the install action into an update action");
569
612
  assert.match(app, /optionalFeatureInstallMessages\.set\(featureId[\s\S]*waiting for package-manager output/, "optional feature installs should show running feedback while npm is active");
570
613
  assert.match(app, /api\("\/api\/optional-feature-install"/, "optional feature install action should call the backend installer endpoint");
614
+ assert.match(app, /id: "btwCommand"[\s\S]*?@firstpick\/pi-extension-btw/, "optional features should include the /btw companion");
615
+ assert.match(app, /BTW_OUTPUT_WIDGET_KEY = "btw:output"[\s\S]*function renderBtwOutputWidget/, "Web UI should render structured /btw output widgets");
616
+ assert.match(app, /if \(key\.startsWith\("btw:"\)\) return "btwCommand"/, "extension widget routing should associate /btw widgets with the optional feature");
571
617
  assert.match(app, /id: "safetyGuard"[\s\S]*?@firstpick\/pi-extension-safety-guard/, "optional features should include the safety guard companion");
572
618
  assert.match(app, /id: "tuiSkillsCommand"[\s\S]*?@firstpick\/pi-extension-setup-skills/, "optional features should include the TUI skills command companion");
573
619
  assert.match(app, /id: "tuiToolsCommand"[\s\S]*?@firstpick\/pi-extension-tools/, "optional features should include the TUI tools command companion");
574
- assert.match(app, /function updateOptionalFeatureAvailability\(\)[\s\S]*hasAvailableCommand\("git-staged-msg"\)[\s\S]*hasAvailableCommand\("release-npm"\)[\s\S]*hasAvailableCommand\("release-aur"\)[\s\S]*hasAvailableCommand\("safety-guard"\)[\s\S]*hasLoadedRpcCommand\("skills"\)[\s\S]*hasAvailableCommand\("todo-progress-status"\)[\s\S]*hasLoadedRpcCommand\("tools"\)/, "optional feature detection should call RPC-visible commands directly and distinguish native resource selectors from TUI companions");
620
+ assert.match(app, /id: "remoteWebui"[\s\S]*?@firstpick\/pi-package-remote-webui/, "optional features should include the Remote WebUI companion");
621
+ assert.match(app, /function updateOptionalFeatureAvailability\(\)[\s\S]*hasAvailableCommand\("git-staged-msg"\)[\s\S]*hasAvailableCommand\("release-npm"\)[\s\S]*hasAvailableCommand\("release-aur"\)[\s\S]*hasAvailableCommand\("safety-guard"\)[\s\S]*hasLoadedRpcCommand\("skills"\)[\s\S]*hasAvailableCommand\("todo-progress-status"\)[\s\S]*hasLoadedRpcCommand\("tools"\)[\s\S]*hasAvailableCommand\("remote"\)/, "optional feature detection should call RPC-visible commands directly and distinguish native resource selectors from TUI companions");
622
+ assert.match(app, /hasRemoteWebuiCommand = isOptionalFeatureEnabled\("remoteWebui"\) && hasAvailableCommand\("remote"\)[\s\S]*optionsRemoteButton\.hidden = !hasRemoteWebuiCommand/, "Options menu should show Open Remote only when /remote is loaded and enabled");
623
+ assert.match(app, /if \(key === "pi-remote-webui"\) return "remoteWebui"/, "optional feature handling should recognize Remote WebUI widget events without rendering them as overlays");
575
624
  assert.match(app, /function combineIdenticalDuplicateCommands\(commands\)[\s\S]*duplicateGroups[\s\S]*duplicateCount: group\.length/, "identical duplicate RPC commands should be combined into one visible command entry");
576
625
  assert.match(app, /if \(kind === "prompt" && attachments\.length === 0\) message = resolveRpcSlashCommandMessage\(message\)/, "manual slash prompts should resolve combined duplicate command aliases before reaching Pi RPC");
577
626
  assert.match(app, /if \(!isOptionalFeatureEnabled\("todoProgressWidget"\)\) return String\(text \|\| ""\)/, "todo progress line stripping should only run when the todo feature is detected and enabled");
@@ -712,10 +761,19 @@ assert.match(app, /const headline = runIndicatorHeadline\(\);\n\s+if \(runIndica
712
761
  assert.match(app, /const meta = runIndicatorShowsElapsed\(\) \? `\$\{detail\} · run time[\s\S]*?if \(runIndicatorMeta\.textContent !== meta\) runIndicatorMeta\.textContent = meta;/, "active agent indicator should avoid redundant metadata DOM writes except elapsed changes");
713
762
  assert.match(app, /runIndicatorShowsElapsed\(\) \? `\$\{detail\} · run time/, "active agent indicator should label elapsed run time instead of showing a bare counter");
714
763
  assert.match(app, /Abort requested/, "abort feedback should clarify that Web UI is checking stop status");
715
- assert.match(app, /const ABORT_LONG_PRESS_MS = 700/, "Abort long-press timing should be explicit");
764
+ assert.match(app, /const ABORT_LONG_PRESS_MS = 3000/, "Abort long-press timing should be explicit");
765
+ assert.match(app, /const ABORT_LONG_PRESS_TICK_MS = 100/, "Abort hold countdown should update visibly while held");
766
+ assert.match(app, /const ABORT_LONG_PRESS_RELEASE_GRACE_MS = 350/, "Escape release cancellation should be debounced to ignore spurious keyup during key repeat");
767
+ assert.match(app, /function isAbortLongPressActive\(\) \{\n\s+return abortLongPressStartedAt > 0;\n\}/, "Abort hold state should stay active from its monotonic start time, not timer id truthiness");
716
768
  assert.match(app, /async function abortActiveRun\(\{ source = "button" \} = \{\}\)/, "Abort should be centralized for button, Esc, and long-press triggers");
717
769
  assert.match(app, /elements\.abortButton\.addEventListener\("pointerdown", startAbortLongPress\)/, "Abort should support pointer long-press");
718
- assert.match(app, /abortActiveRun\(\{ source: "escape" \}\)/, "Escape should trigger the abort action when available");
770
+ assert.match(app, /else if \(!event\.repeat\) startAbortLongPress\(event, \{ source: "escape" \}\)/, "Escape should arm the guarded abort hold only on the initial keydown");
771
+ assert.match(app, /if \(isAbortLongPressActive\(\)\) \{\n\s+resumeAbortLongPressAffordance\(\);\n\s+return true;\n\s+\}\n\s+resetAbortLongPressAffordance\(\);/, "repeat or duplicate start events should resume instead of restart an in-progress abort countdown");
772
+ assert.match(app, /abortLongPressDeadlineAt = abortLongPressStartedAt \+ ABORT_LONG_PRESS_MS/, "Abort hold countdown should use an immutable deadline for display and completion");
773
+ assert.match(app, /function completeAbortLongPress\(\)[\s\S]*?if \(abortLongPressReleasePending\) return;[\s\S]*?if \(isAbortAvailable\(\)\) abortActiveRun\(\{ source \}\);[\s\S]*?else \{\n\s+resetAbortLongPressAffordance\(\);\n\s+updateComposerModeButtons\(\);\n\s+\}/, "completed abort holds should abort only when no release is pending and reset cleanly if the run already stopped");
774
+ assert.match(app, /if \(event\.repeat\) \{\n\s+event\.preventDefault\(\);\n\s+return;\n\s+\}\n\s+if \(document\.activeElement === elements\.promptInput[\s\S]*doubleEscapeAction/, "held Escape key-repeat should not trigger the double-Escape action");
775
+ assert.match(app, /window\.addEventListener\("keyup"[\s\S]*abortLongPressSource === "escape"[\s\S]*scheduleAbortLongPressReleaseReset/, "releasing Escape should debounce-cancel a pending guarded abort hold");
776
+ assert.match(app, /function resumeAbortLongPressAffordance\(\)[\s\S]*clearAbortLongPressResetTimer\(\);\n\s+abortLongPressReleasePending = false;\n\s+tickAbortLongPressAffordance\(\);/, "new Escape keydown events should cancel pending release resets without restarting countdown");
719
777
  assert.match(app, /function addAbortTranscriptNotice\(/, "abort button should render a transcript-visible aborted notice");
720
778
  assert.match(app, /this transcript marks the run as aborted/, "abort notice should clearly mark the agent output as aborted");
721
779
  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");
@@ -729,6 +787,9 @@ assert.match(app, /function scheduleAbortStateChecks\(/, "abort handling should
729
787
  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");
730
788
  assert.match(app, /case "tool_execution_update":[\s\S]*?handleToolExecutionUpdate\(event\)/, "tool execution updates should be handled as transcript output");
731
789
  assert.match(app, /case "auto_retry_start":[\s\S]*?addTransientMessage\(\{ role: "warn", title: "auto retry"/, "auto-retry starts should be transcript-visible warnings");
790
+ assert.match(app, /function trackAutoRetryStateFromEvent\(event\)[\s\S]*?event\.type === "auto_retry_start"[\s\S]*?autoRetryingTabs\.add\(tabId\)[\s\S]*?suppressPendingAgentDoneNotificationsForTab\(tabId\)[\s\S]*?markTabWorkingLocally\(tabId\)/, "auto-retry starts should suppress misleading done notifications and keep the tab working");
791
+ assert.match(app, /function notifyAgentDone[\s\S]*?agentDoneNotificationKeys\.add\(key\);\n\s+if \(isAutoRetryingTab\(tabId\)\) return;/, "agent-done notifications should be ignored while a tab is auto-retrying");
792
+ assert.match(app, /function queueAgentDoneBrowserNotification[\s\S]*?setTimeout\([\s\S]*?if \(isAutoRetryingTab\(tabId\)\) return;[\s\S]*?AGENT_DONE_NOTIFICATION_RETRY_GRACE_MS/, "agent-done notifications should wait briefly so imminent auto-retry events can cancel them");
732
793
  assert.match(app, /case "extension_error":[\s\S]*?addTransientMessage\(\{ role: "error", title: "extension error"/, "extension errors should be transcript-visible error cards");
733
794
  assert.match(app, /setRunIndicatorActivity\("Requesting context compaction…"\);\n\s+scrollChatToBottom\(\{ force: true \}\);/, "manual compaction should force-follow the transcript to the bottom status card");
734
795
  assert.match(app, /function markContextUsageUnknownAfterCompaction\(/, "compaction should have a dedicated context-usage invalidation helper");
@@ -754,7 +815,7 @@ assert.match(app, /const target = runActive \? elements\.composerRow : elements\
754
815
  assert.match(app, /const before = runActive \? elements\.abortButton : null/, "active Steer and Follow-up controls should sit before Abort and Send");
755
816
  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");
756
817
  assert.match(app, /renderBusyPromptBehaviorTag\(\);\n\s+document\.body\.classList\.toggle\("pi-run-active", runActive \|\| abortAvailable\)/, "composer mode refresh should keep the busy prompt behavior tag current");
757
- 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");
818
+ assert.match(app, /const abortHoldActive = isAbortLongPressActive\(\);\n\s+if \(!abortAvailable && !abortHoldActive\) resetAbortLongPressAffordance\(\);\n\s+elements\.abortButton\.hidden = !abortAvailable && !abortHoldActive;\n\s+elements\.abortButton\.disabled = \(!abortAvailable && !abortHoldActive\) \|\| abortRequestInFlight;/, "Abort should stay visible during an active hold even if run state briefly refreshes unavailable");
758
819
  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");
759
820
  assert.match(app, /function showComposerButtonTooltip\(button\)/, "empty mode-button taps should show the usage tooltip");
760
821
  assert.match(app, /function renderBusyPromptBehaviorTag\(\)[\s\S]*?tag\.textContent = label/, "busy prompt behavior tag should render only the current follow-up\/steer setting");
@@ -778,6 +839,11 @@ assert.match(app, /busyPromptBehaviorMenu\?\.addEventListener\("click"[\s\S]*cho
778
839
  assert.match(app, /setBusyPromptBehavior\(controls\.busyBehavior\.select\.value\)/, "native settings should update the busy prompt behavior tag immediately");
779
840
  assert.match(app, /sendPromptFromModeButton\("steer", elements\.steerButton\)/, "Steer should show tooltip instead of silently doing nothing when input is empty");
780
841
  assert.match(app, /sendPromptFromModeButton\("follow-up", elements\.followUpButton\)/, "Follow-up should show tooltip instead of silently doing nothing when input is empty");
842
+ assert.match(app, /async function sendBtwQuestion\(question,[\s\S]*?`\/btw \$\{cleanQuestion\}`[\s\S]*?await sendPrompt\("prompt", message, \{ targetTabId, throwOnError: true \}\)/, "/btw helper should send text as an ephemeral slash command");
843
+ assert.match(app, /async function sendBtwPromptFromButton\(\)[\s\S]*?if \(!question\) \{\n\s+openBtwComposerWidget\(\);/, "empty /btw button should open the side-question widget input");
844
+ assert.match(app, /function renderBtwComposerForm\(\)[\s\S]*?form\.requestSubmit\(\)[\s\S]*?sendBtwQuestion\(question\)/, "/btw widget input should submit each message as a /btw trigger");
845
+ assert.match(app, /function makeBtwTransferIcon\(\)[\s\S]*?class", "btw-transfer-icon"[\s\S]*?function transferBtwContextToMain\(button\)[\s\S]*?`\/btw-transfer \$\{encoded\}`[\s\S]*?streamingBehavior: liveSteer \? "steer" : undefined/, "/btw widget should expose an iconified transfer-context action that steers during active runs");
846
+ assert.match(app, /async function sendPrompt\(kind = "prompt", explicitMessage, \{ targetTabId = activeTabId, throwOnError = false, streamingBehavior \} = \{\}\)[\s\S]*?if \(targetWasStreaming\) body\.streamingBehavior = streamingBehavior \|\| busyBehavior/, "prompt sending should support a per-call streaming behavior override");
781
847
  assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*?sendPrompt\("prompt", `\/\$\{resolvedCommandName\}\$\{commandRest\}`\)/, "Publish workflows should send resolved slash commands directly without replacing the draft");
782
848
  assert.match(app, /async function runNativeCommandMenu\(command\)[\s\S]*?await handleNativeSlashSelectorCommand\(command\)/, "skills/tools command menu should open native selector dialogs directly");
783
849
  assert.match(app, /async function runNativeCommandMenu\(command\)[\s\S]*?sendPrompt\("prompt", command\)/, "generic native command menu should fall back to slash-command prompt execution");
@@ -795,11 +861,11 @@ assert.match(app, /releaseNpmButton\.addEventListener\("click", \(\) => runPubli
795
861
  assert.match(app, /releaseAurButton\.addEventListener\("click", \(\) => runPublishWorkflow\("\/release-aur"\)\)/, "Publish menu should launch /release-aur");
796
862
  assert.match(app, /nativeSkillsButton\.addEventListener\("click", \(\) => runNativeCommandMenu\("\/skills"\)\)/, "skills/tools command menu should launch /skills");
797
863
  assert.match(app, /nativeToolsButton\.addEventListener\("click", \(\) => runNativeCommandMenu\("\/tools"\)\)/, "skills/tools command menu should launch /tools");
798
- for (const command of ["resume", "reload", "name", "clone", "settings", "export", "fork", "tree"]) {
864
+ for (const command of ["resume", "reload", "remote", "name", "clone", "settings", "export", "fork", "tree"]) {
799
865
  const id = command.replace(/^./, (letter) => letter.toUpperCase());
800
866
  assert.match(app, new RegExp(`options${id}Button\\.addEventListener\\("click", \\(\\) => runNativeCommandMenu\\("\\/${command}"\\)\\)`), `Options menu should launch /${command}`);
801
867
  }
802
- assert.match(app, /async function sendPrompt\(kind = "prompt", explicitMessage, \{ targetTabId = activeTabId, throwOnError = false \} = \{\}\)/, "prompt sending should accept direct messages that bypass the input field and optional target tab");
868
+ assert.match(app, /async function sendPrompt\(kind = "prompt", explicitMessage, \{ targetTabId = activeTabId, throwOnError = false, streamingBehavior \} = \{\}\)/, "prompt sending should accept direct messages that bypass the input field and optional target tab");
803
869
  assert.match(app, /const rawMessage = usesPromptInput \? elements\.promptInput\.value : explicitMessage/, "direct prompt sends should not read the input textarea");
804
870
  assert.match(app, /if \(usesPromptInput\) \{[\s\S]*?if \(targetStillActive\) \{[\s\S]*?elements\.promptInput\.value = "";/, "direct prompt sends should preserve the input textarea draft");
805
871
  assert.match(app, /make\("button", "command-item"\)[\s\S]*?sendPrompt\("prompt", `\/\$\{command\.name\}`\)/, "side-panel command clicks should send the slash command directly");
@@ -817,6 +883,7 @@ assert.match(app, /function openNativeTreeSelector\(\)[\s\S]*?\/api\/session-tre
817
883
  assert.match(app, /async function openNativeAuthSelector\(mode\)[\s\S]*?\/api\/auth-providers[\s\S]*?Browser login is not implemented yet/, "native /login should list provider status without browser credential entry");
818
884
  assert.match(app, /\/api\/auth-logout[\s\S]*?confirmed: true/, "native /logout should remove stored credentials through a confirmed localhost-only endpoint");
819
885
  assert.match(app, /const HIDDEN_COMMAND_NAMES = new Set\(\["webui-tree-navigate", "webui-helper"\]\)/, "internal Web UI helper commands should stay out of command pickers");
886
+ assert.match(app, /HIDDEN_COMMAND_NAMES\.add\("btw-transfer"\)/, "/btw transfer helper command should stay out of command pickers");
820
887
  assert.match(app, /function shouldSendPromptFromEnter\(event\)/, "prompt keyboard handling should be centralized");
821
888
  assert.match(app, /const PROMPT_HISTORY_STORAGE_KEY = "pi-webui-prompt-history"/, "prompt history should be persisted per browser for keyboard recall");
822
889
  assert.match(app, /function recallPreviousPromptFromHistory\(\)/, "prompt history should support recalling older prompts from the textarea");
@@ -860,10 +927,10 @@ assert.match(css, /\.composer-actions-panel \{[\s\S]*?z-index:\s*55;[\s\S]*?over
860
927
  assert.match(css, /\.composer-actions-panel > \.composer-publish-menu\.open \{\n\s+z-index:\s*120;\n\s+\}/, "opened mobile Actions dropdowns should overlay neighboring controls without taking grid space");
861
928
  assert.match(css, /\.composer-actions-panel > \.composer-publish-menu::after \{[\s\S]*?bottom:\s*100%;[\s\S]*?height:\s*0\.8rem;[\s\S]*?pointer-events:\s*auto;/, "mobile Actions dropdowns should keep a hover bridge above the trigger and below the floating submenu");
862
929
  assert.match(css, /\.composer-actions-panel > \.composer-publish-menu:hover::after,[\s\S]*?\.composer-actions-panel > \.composer-publish-menu:focus-within::after,[\s\S]*?\.composer-actions-panel > \.composer-publish-menu\.open::after \{\n\s+display:\s*block;/, "mobile Actions dropdown hover bridge should activate while hovered, focused, or opened");
863
- assert.match(css, /\.composer-actions-panel > \.composer-publish-menu \.composer-publish-menu-panel \{[\s\S]*?position:\s*absolute;[\s\S]*?inset:\s*auto auto calc\(100% \+ 0\.38rem\) 0;[\s\S]*?max-height:\s*min\(34dvh, 18rem\);[\s\S]*?overflow:\s*auto;/, "opened mobile Actions dropdown panels should float upward over the Actions controls with their own scrollbar");
930
+ assert.match(css, /\.composer-actions-panel > \.composer-publish-menu \.composer-publish-menu-panel \{[\s\S]*?position:\s*absolute;[\s\S]*?inset:\s*auto auto calc\(100% \+ 0\.38rem\) 0;[\s\S]*?max-height:\s*min\(var\(--mobile-dropdown-max-height, 34dvh\), calc\(var\(--visual-viewport-height, 100dvh\) - 2rem\)\);[\s\S]*?overflow:\s*auto;[\s\S]*?overscroll-behavior:\s*contain;/, "opened mobile Actions dropdown panels should float upward over the Actions controls with a viewport-bounded scrollbar");
864
931
  assert.match(css, /\.composer-actions-panel > \.composer-publish-menu \.composer-publish-menu-panel \{[\s\S]*?width:\s*100%;[\s\S]*?min-width:\s*0;[\s\S]*?max-width:\s*100%;/, "mobile Actions dropdown panels should align to the width of their trigger buttons");
865
932
  assert.match(css, /\.composer-actions-panel > \.composer-publish-menu \.composer-publish-menu-item \{[\s\S]*?width:\s*100%;[\s\S]*?min-width:\s*0;[\s\S]*?white-space:\s*normal;/, "mobile Actions dropdown option buttons should not keep desktop min-widths that misalign with triggers");
866
- assert.match(css, /\.composer-actions-panel > \.composer-options-menu \.composer-publish-menu-panel \{[\s\S]*?inset-inline:\s*auto 0;[\s\S]*?max-height:\s*min\(calc\(var\(--visual-viewport-height, 100dvh\) - 2rem\), 44rem\);/, "mobile Options dropdown should be tall enough to avoid scrolling for the standard option list");
933
+ assert.match(css, /\.composer-actions-panel > \.composer-options-menu \.composer-publish-menu-panel,\n\s+\.composer-actions-panel > \.composer-app-runner-menu \.composer-publish-menu-panel \{\n\s+inset-inline:\s*auto 0;/, "mobile Options and app-runner dropdowns should use the shared viewport-bounded scrollbar instead of forcing full viewport height");
867
934
  assert.match(app, /function setMobileTabsExpanded\(/, "mobile tab strip should be JS-toggleable");
868
935
  assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\) \{[\s\S]*?\.terminal-tab-group \{\n\s+display:\s*grid;\n\s+grid-template-columns:\s*minmax\(0, 1fr\) auto;/, "mobile terminal tab groups should use a stable grid row for the tab and close button when expanded");
869
936
  assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\) \{[\s\S]*?\.terminal-tab-group-menu \{[\s\S]*?grid-column:\s*1 \/ -1;[\s\S]*?margin:\s*0\.34rem 0 0;/, "mobile terminal tab group menus should not add horizontal margins that overflow and distort the tab card");
@@ -970,6 +1037,7 @@ assert.match(server, /WEBUI_TUI_NATIVE_PARITY\.json/, "native command descriptio
970
1037
  assert.match(server, /function parseSlashCommand\(message\)/, "server should parse native slash commands before prompt forwarding");
971
1038
  assert.match(server, /function generatedTabTitleFromPrompt\(message\)/, "server should derive concise automatic tab titles from first prompts");
972
1039
  assert.match(server, /function maybeNameTabForConversation\(tab, command\)/, "server should auto-name default tabs when a conversation starts");
1040
+ assert.match(server, /function maybeNameTabForConversation\(tab, command\) \{[\s\S]*const shouldRename = !tab\.conversationStarted && tab\.titleSource !== "explicit";[\s\S]*tab\.conversationStarted = true;[\s\S]*if \(!shouldRename\) return false;/, "server should mark conversations as started even when an explicit title prevents auto-renaming");
973
1041
  assert.match(server, /function createTabActivity\(/, "server should track per-tab activity for idle, working, and completed work");
974
1042
  assert.match(server, /function reconcileTabActivityFromState\(tab, state/, "server should recover stale working tab activity from get_state snapshots");
975
1043
  assert.match(server, /pendingExtensionUiRequests\(tab\)\.length > 0[\s\S]*?markTabWorking\(tab, timestamp\)/, "server should keep tabs with pending blockers in working activity until the blocker resolves");
@@ -1074,6 +1142,7 @@ assert.match(server, /type: "set_follow_up_mode"/, "server should expose follow-
1074
1142
  assert.match(server, /type: "set_auto_compaction"/, "server should expose auto-compaction changes for native /settings");
1075
1143
  assert.match(server, /@firstpick\/pi-themes-bundle/, "server should discover themes from the optional theme package");
1076
1144
  assert.match(server, /const OPTIONAL_FEATURE_PACKAGES = new Map/, "server should whitelist optional feature packages for install actions");
1145
+ assert.match(server, /\["btwCommand", "@firstpick\/pi-extension-btw"\]/, "server should allow installing the /btw optional feature");
1077
1146
  assert.match(server, /\["safetyGuard", "@firstpick\/pi-extension-safety-guard"\]/, "server should allow installing the safety guard optional feature");
1078
1147
  assert.match(server, /\["tuiSkillsCommand", "@firstpick\/pi-extension-setup-skills"\]/, "server should allow installing the TUI skills optional feature");
1079
1148
  assert.match(server, /\["tuiToolsCommand", "@firstpick\/pi-extension-tools"\]/, "server should allow installing the TUI tools optional feature");
@@ -1114,7 +1183,7 @@ assert.match(readme, /GET \/api\/path-suggestions\?tab=<tabId>&query=<path>/, "R
1114
1183
  assert.match(readme, /GET \/api\/optional-features/, "README should document optional feature status endpoint");
1115
1184
  assert.match(readme, /POST \/api\/optional-feature-install/, "README should document optional feature install endpoint");
1116
1185
  assert.match(readme, /server-persisted fast picks/, "README should describe server-persisted fast picks");
1117
- 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");
1186
+ assert.match(readme, /`\/btw` side-question output widgets with optional context transfer\/live steering, browser notifications when a tab needs an extension UI response, and an optional side-panel toggle for agent-done notifications/, "README should describe /btw, blocked-tab, and agent-done notifications");
1118
1187
  assert.match(readme, /blocked-tab browser notifications, and optional agent-done notifications require browser service-worker\/notification support/, "README should document notification requirements");
1119
1188
  assert.match(readme, /Side-panel theme picker backed by optional `@firstpick\/pi-themes-bundle` themes when loaded/, "README should describe optional theme selection");
1120
1189
  assert.match(readme, /## Optional companion packages/, "README should document optional Web UI companion packages");
@@ -1124,17 +1193,19 @@ assert.match(readme, /avoiding duplicate loads while keeping global `pi-webui` l
1124
1193
  assert.match(readme, /checks loaded Pi capabilities directly through RPC-visible commands and live widget events/, "README should document capability-based startup checks");
1125
1194
  assert.match(readme, /side panel shows each optional feature as enabled, disabled, installed-but-not-loaded, update-available, or install-needed/, "README should document optional feature side-panel controls");
1126
1195
  assert.match(readme, /Installing or updating a feature is an explicit, warned action with running\/failure feedback/, "README should document optional feature install and update warning behavior");
1127
- assert.match(readme, /\.\/start-webui\.sh --dev --cwd \/path\/to\/project/, "README should document the dev helper launcher");
1196
+ assert.match(readme, /\.\/dev\/scripts\/start-webui\.sh --dev --cwd \/path\/to\/project/, "README should document the dev helper launcher");
1128
1197
  assert.match(readme, /sync-pi-package-symlinks\.sh[\s\S]*only one copy is loaded/, "README should document dev companion symlink setup");
1129
1198
  assert.match(startScript, /--dev\)/, "start-webui.sh should accept a --dev flag");
1130
1199
  assert.match(startScript, /local_pi_webui_bin\(\)/, "start-webui.sh should resolve this checkout's local server entrypoint");
1200
+ assert.match(startScript, /candidate="\$\(package_root\)\/bin\/pi-webui\.mjs"/, "start-webui.sh should resolve the package-root bin from dev/scripts");
1131
1201
  assert.match(startScript, /webui_cmd=\(node "\$local_webui_bin"\)/, "start-webui.sh --dev should run the local bin with node");
1132
1202
  assert.match(startScript, /export PI_WEBUI_DEV=1/, "start-webui.sh --dev should mark the Web UI server as dev mode");
1133
1203
  assert.match(startScript, /"\$\{webui_cmd\[@\]\}" --cwd "\$cwd" --host "\$host" --port "\$port" "\$\{pass_args\[@\]\}"/, "start-webui.sh should launch through the selected server command without forwarding --dev");
1134
1204
 
1135
1205
  assert.match(pkg.scripts?.test || "", /node tests\/run-all\.mjs/, "package test script should run every tests/*.test.mjs through the shared runner");
1136
- assert.ok(pkg.files?.includes("start-webui.sh"), "npm package should include the Bash helper launcher");
1137
- assert.ok(pkg.files?.includes("start-webui.ps1"), "npm package should include the PowerShell helper launcher");
1206
+ assert.ok(!pkg.files?.includes("start-webui.sh"), "npm package should not list the moved Bash dev helper at the package root");
1207
+ assert.ok(!pkg.files?.includes("start-webui.ps1"), "npm package should not list the moved PowerShell dev helper at the package root");
1208
+ assert.ok(!pkg.files?.some((entry) => entry === "dev/scripts" || entry.startsWith("dev/scripts/")), "npm package should not publish development helper scripts");
1138
1209
  for (const [name, range] of Object.entries(companionDependencies)) {
1139
1210
  assert.equal(pkg.optionalDependencies?.[name], range, `webui package should optionally depend on ${name}`);
1140
1211
  assert.equal(pkg.dependencies?.[name], undefined, `webui package should not require optional companion ${name}`);
@@ -28,7 +28,7 @@ import {
28
28
  } from "../lib/native-command-adapter.mjs";
29
29
 
30
30
  const root = join(dirname(fileURLToPath(import.meta.url)), "..");
31
- const parity = JSON.parse(await readFile(join(root, "WEBUI_TUI_NATIVE_PARITY.json"), "utf8"));
31
+ const parity = JSON.parse(await readFile(join(root, "dev", "docs", "WEBUI_TUI_NATIVE_PARITY.json"), "utf8"));
32
32
 
33
33
  const localReq = { socket: { remoteAddress: "127.0.0.1" } };
34
34
  const remoteReq = { socket: { remoteAddress: "192.168.1.50" } };
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
 
6
6
  const root = join(dirname(fileURLToPath(import.meta.url)), "..");
7
7
  const [parityRaw, server, app, pkgRaw] = await Promise.all([
8
- readFile(join(root, "WEBUI_TUI_NATIVE_PARITY.json"), "utf8"),
8
+ readFile(join(root, "dev", "docs", "WEBUI_TUI_NATIVE_PARITY.json"), "utf8"),
9
9
  readFile(join(root, "bin", "pi-webui.mjs"), "utf8"),
10
10
  readFile(join(root, "public", "app.js"), "utf8"),
11
11
  readFile(join(root, "package.json"), "utf8"),
@@ -181,7 +181,7 @@ assert.match(app, /let userBashQueuesByTab = new Map\(\)/, "frontend should trac
181
181
  assert.match(app, /enqueueUserBashCommand\(parsed, \{ usesPromptInput, targetTabId \}\)/, "user bash should enqueue while an active or queued bash command exists");
182
182
  assert.match(server, /function sendQueuedBashCommand\(tab, command\)/, "server should serialize user bash commands per tab");
183
183
  assert.match(server, /command\.type === "bash"[\s\S]*?await sendQueuedBashCommand\(tab, command\)[\s\S]*?: await tab\.rpc\.send\(command\)/, "generic POST handling should route bash through the FIFO queue");
184
- assert.ok(pkg.files.includes("WEBUI_TUI_NATIVE_PARITY.json"), "published package should include the native parity matrix");
184
+ assert.ok(pkg.files.includes("dev/docs/WEBUI_TUI_NATIVE_PARITY.json"), "published package should include the native parity matrix");
185
185
  assert.ok(pkg.files.includes("lib"), "published package should include shared Web UI foundation modules");
186
186
  assert.ok(pkg.files.includes("webui-rpc-helper.mjs"), "published package should include the Web UI RPC helper extension");
187
187
 
@@ -0,0 +1,81 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import { setTimeout as delay } from "node:timers/promises";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
11
+ const serverScript = join(root, "bin", "pi-webui.mjs");
12
+ const fakePi = join(root, "tests", "fixtures", "fake-pi.mjs");
13
+ const port = 30000 + Math.floor(Math.random() * 20000);
14
+ const cwd = await mkdtemp(path.join(tmpdir(), "pi-webui-remote-auth-settings-"));
15
+ const settingsFile = path.join(cwd, "webui-settings.json");
16
+
17
+ async function request(pathname, { method = "GET", body, timeoutMs = 5_000 } = {}) {
18
+ const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
19
+ method,
20
+ headers: body === undefined ? undefined : { "Content-Type": "application/json" },
21
+ body: body === undefined ? undefined : JSON.stringify(body),
22
+ signal: AbortSignal.timeout(timeoutMs),
23
+ });
24
+ const payload = await response.json().catch(() => undefined);
25
+ return { status: response.status, body: payload };
26
+ }
27
+
28
+ await chmod(fakePi, 0o755);
29
+ await writeFile(settingsFile, `${JSON.stringify({ version: 1, remoteAuthEnabled: true }, null, 2)}\n`, "utf8");
30
+
31
+ const child = spawn(process.execPath, [serverScript, "--cwd", cwd, "--host", "127.0.0.1", "--port", String(port), "--pi", fakePi], {
32
+ stdio: ["ignore", "pipe", "pipe"],
33
+ env: {
34
+ ...process.env,
35
+ PI_WEBUI_SETTINGS_FILE: settingsFile,
36
+ },
37
+ });
38
+ let serverOutput = "";
39
+ child.stdout.on("data", (chunk) => {
40
+ serverOutput += String(chunk);
41
+ });
42
+ child.stderr.on("data", (chunk) => {
43
+ serverOutput += String(chunk);
44
+ });
45
+
46
+ try {
47
+ let health;
48
+ for (let attempt = 0; attempt < 100; attempt++) {
49
+ if (child.exitCode !== null) break;
50
+ try {
51
+ health = await request("/api/health", { timeoutMs: 1_000 });
52
+ if (health.status === 200) break;
53
+ } catch {
54
+ // Server not listening yet.
55
+ }
56
+ await delay(200);
57
+ }
58
+ assert.equal(health?.status, 200, `server should become healthy, output:\n${serverOutput}`);
59
+
60
+ const startupAuth = await request("/api/remote-auth");
61
+ assert.equal(startupAuth.status, 200);
62
+ assert.equal(startupAuth.body?.data?.auth?.enabled, true, "saved Remote PIN auth setting should enable auth at next startup");
63
+ assert.match(startupAuth.body?.data?.auth?.pin, /^\d{4}$/, "startup auth should generate a fresh PIN, not persist one");
64
+
65
+ const disableAuth = await request("/api/remote-auth/settings", { method: "POST", body: { enabled: false } });
66
+ assert.equal(disableAuth.status, 200);
67
+ const savedAfterDisable = JSON.parse(await readFile(settingsFile, "utf8"));
68
+ assert.equal(savedAfterDisable.remoteAuthEnabled, false, "disabling Remote PIN auth should persist the off preference");
69
+
70
+ const enableAuth = await request("/api/remote-auth/settings", { method: "POST", body: { enabled: true } });
71
+ assert.equal(enableAuth.status, 200);
72
+ const savedAfterEnable = JSON.parse(await readFile(settingsFile, "utf8"));
73
+ assert.equal(savedAfterEnable.remoteAuthEnabled, true, "enabling Remote PIN auth should persist the on preference");
74
+
75
+ console.log("remote-auth-settings-harness.test.mjs passed");
76
+ } finally {
77
+ child.kill("SIGTERM");
78
+ await delay(150);
79
+ if (child.exitCode === null) child.kill("SIGKILL");
80
+ await rm(cwd, { recursive: true, force: true });
81
+ }