@firstpick/pi-package-webui 0.4.8 → 0.4.9

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.
@@ -313,6 +313,61 @@ try {
313
313
  assert.match((customRunState.body?.data?.activeRun?.lines || []).join("\n"), /custom runner ok/, "custom runner output should be captured");
314
314
  await request("127.0.0.1", "/api/app-runner/clear", { method: "POST", body: { tab: tabId } });
315
315
 
316
+ await writeFile(path.join(cwd, "interactive-runner.mjs"), [
317
+ "import readline from 'node:readline';",
318
+ "const rl = readline.createInterface({ input: process.stdin, output: process.stdout });",
319
+ "console.log('interactive ready');",
320
+ "rl.question('name? ', (answer) => {",
321
+ " console.log(`hello ${answer}`);",
322
+ " rl.close();",
323
+ "});",
324
+ "",
325
+ ].join("\n"));
326
+ const savedInteractiveRunner = await request("127.0.0.1", "/api/app-runner-config", {
327
+ method: "POST",
328
+ body: { tab: tabId, runner: { label: "Interactive node", command: process.execPath, path: "interactive-runner.mjs" } },
329
+ timeoutMs: 10_000,
330
+ });
331
+ assert.equal(savedInteractiveRunner.status, 200, `saving an interactive custom runner should succeed: ${savedInteractiveRunner.body?.error || ""}`);
332
+ const interactiveRunner = savedInteractiveRunner.body?.data?.runners?.find((runner) => runner.custom === true && runner.label === "Interactive node");
333
+ assert.ok(interactiveRunner?.id, "interactive custom runner should appear in detected app runners");
334
+ const interactiveRunStart = await request("127.0.0.1", "/api/app-runner", {
335
+ method: "POST",
336
+ body: { tab: tabId, runnerId: interactiveRunner.id },
337
+ timeoutMs: 10_000,
338
+ });
339
+ assert.equal(interactiveRunStart.status, 200, `interactive runner start should return ok: ${interactiveRunStart.body?.error || ""}`);
340
+ let interactiveRunState = interactiveRunStart;
341
+ for (let attempt = 0; attempt < 50; attempt++) {
342
+ interactiveRunState = await request("127.0.0.1", `/api/app-runners?tab=${encodeURIComponent(tabId)}`, { timeoutMs: 5_000 });
343
+ const output = [
344
+ ...(interactiveRunState.body?.data?.activeRun?.lines || []),
345
+ interactiveRunState.body?.data?.activeRun?.pendingLine || "",
346
+ ].join("\n");
347
+ if (/name\?/.test(output)) break;
348
+ await delay(100);
349
+ }
350
+ assert.match([
351
+ ...(interactiveRunState.body?.data?.activeRun?.lines || []),
352
+ interactiveRunState.body?.data?.activeRun?.pendingLine || "",
353
+ ].join("\n"), /name\?/, "interactive app runner should expose a prompt without waiting for a newline");
354
+ const interactiveInput = await request("127.0.0.1", "/api/app-runner/input", {
355
+ method: "POST",
356
+ body: { tab: tabId, text: "webui", closeStdin: true },
357
+ timeoutMs: 10_000,
358
+ });
359
+ assert.equal(interactiveInput.status, 200, `interactive app runner input should be accepted: ${interactiveInput.body?.error || ""}`);
360
+ for (let attempt = 0; attempt < 50; attempt++) {
361
+ if (interactiveRunState.body?.data?.activeRun?.status && interactiveRunState.body.data.activeRun.status !== "running") break;
362
+ await delay(100);
363
+ interactiveRunState = await request("127.0.0.1", `/api/app-runners?tab=${encodeURIComponent(tabId)}`, { timeoutMs: 5_000 });
364
+ }
365
+ assert.equal(interactiveRunState.body?.data?.activeRun?.status, "done", "interactive custom runner should finish after stdin");
366
+ const interactiveOutput = (interactiveRunState.body?.data?.activeRun?.lines || []).join("\n");
367
+ assert.match(interactiveOutput, /hello webui/, "interactive custom runner should receive stdin from the app-runner input endpoint");
368
+ assert.match(interactiveOutput, /# stdin sent \(5 chars\) and closed/, "app runner output should show that stdin was sent without echoing the input text itself");
369
+ await request("127.0.0.1", "/api/app-runner/clear", { method: "POST", body: { tab: tabId } });
370
+
316
371
  await writeFile(path.join(cwd, ".pi-webui-runners.json"), `${JSON.stringify({
317
372
  version: 1,
318
373
  runners: [{ id: "broken-custom", label: "Broken custom", command: "definitely-missing-pi-webui-runner", path: "custom-runner.mjs" }],
@@ -53,7 +53,8 @@ assert.match(html, /id="sidePanelBackdrop"/, "mobile side panel needs an overlay
53
53
  assert.match(html, /<strong class="side-panel-title">[\s\S]*Control Deck[\s\S]*id="webuiVersionBadge"[\s\S]*id="webuiDevBadge"/, "Control Deck title should expose Web UI version and dev badges");
54
54
  assert.doesNotMatch(html, /id="sessionLine"/, "Control Deck title should not show verbose session status metadata");
55
55
  assert.match(html, /id="themeSelect"/, "side panel should expose a theme selector");
56
- assert.match(html, /<label for="themeSelect">Theme<\/label>/, "theme selector should be labeled in side-panel controls");
56
+ assert.match(html, /<label for="themeSelect"[^>]*id="themeControlLabel"[^>]*>Theme<\/label>/, "theme selector should be labeled in side-panel controls");
57
+ assert.match(html, /id="themeSearchInput"[\s\S]*id="themeSelect"[\s\S]*id="themeSearchResults"/, "side-panel theme selector should expose searchable theme results");
57
58
  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");
58
59
  assert.match(html, /id="backgroundClearButton"[\s\S]*?>×<\/button>/, "side-panel background control should expose an X remove button");
59
60
  assert.match(html, /id="serverActionSelect"[\s\S]*<option value="restart">Restart Server<\/option>[\s\S]*<option value="stop">Stop Server<\/option>/, "side panel should expose restart and stop server actions in a dropdown");
@@ -328,7 +329,7 @@ assert.match(css, /body\.mobile-keyboard-open \.terminal-tabs-shell,[\s\S]*?body
328
329
  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");
329
330
  assert.match(css, /\.server-offline-panel/, "PWA/offline shell should style a backend-offline recovery panel");
330
331
  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");
331
- assert.match(css, /button\[hidden\] \{ display: none !important; \}/, "hidden bottom-row controls should not occupy layout space");
332
+ assert.match(css, /\[hidden\] \{ display: none !important; \}/, "hidden controls should not occupy layout space or be overridden by component display styles");
332
333
  assert.match(css, /\.statusbar-tui-footer \{[\s\S]*?gap:\s*0/, "default TUI-like footer should reduce statusbar chrome around the compact line");
333
334
  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");
334
335
  assert.match(css, /\.footer-line-main \.footer-metric \{[\s\S]*?flex:\s*1 1 var\(--footer-chip-min-width\);[\s\S]*?width:\s*auto;[\s\S]*?min-width:\s*0/, "git-footer metrics should use a shared preferred minimum and distribute spare row space equally");
@@ -343,8 +344,10 @@ assert.match(css, /\.footer-changed-file\.modified \.footer-changed-file-status
343
344
  assert.match(css, /\.footer-git-extra \.footer-meta-value \{[\s\S]*?color:\s*var\(--ctp-sky\)[\s\S]*?font-weight:\s*900/, "git-footer extras value should be bright enough to read at footer size");
344
345
  assert.match(css, /\.footer-metric-action,\n\.footer-meta-action \{[\s\S]*?position:\s*relative;[\s\S]*?border-color:\s*rgba\(148, 226, 213, 0\.26\)/, "clickable footer boxes should have a subtle always-visible highlight");
345
346
  assert.doesNotMatch(css, /\.footer-(?:metric|meta)-action::after/, "clickable footer boxes should not show a corner indicator dot");
346
- assert.match(css, /\.extension-dialog\.git-changes-dialog \{[\s\S]*?--git-changes-dialog-size:[\s\S]*?width:\s*var\(--git-changes-dialog-size\)[\s\S]*?height:\s*var\(--git-changes-dialog-size\)[\s\S]*?aspect-ratio:\s*1 \/ 1/, "git changes modal should override the base dialog with a square wide diff layout");
347
+ assert.match(css, /\.extension-dialog\.git-changes-dialog \{[\s\S]*?--git-changes-dialog-width:[\s\S]*?--git-changes-dialog-height:[\s\S]*?width:\s*var\(--git-changes-dialog-width\)[\s\S]*?height:\s*var\(--git-changes-dialog-height\)/, "git changes modal should override the base dialog with a wide bounded diff layout");
348
+ assert.match(css, /\.git-changes-body \{[\s\S]*?align-content:\s*start/, "git changes modal should keep summary and file content packed at the top of the scroller");
347
349
  assert.match(css, /\.git-current-file-header \{[\s\S]*?position:\s*sticky[\s\S]*?top:\s*-0\.72rem/, "git changes modal should keep a sticky current-file header inside the diff scroller");
350
+ assert.match(css, /\.git-changes-file-list \{[\s\S]*?grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/, "git changes modal should show changed-file jump buttons in two columns without horizontal scrolling");
348
351
  assert.match(css, /\.git-diff-grid \{[\s\S]*?grid-template-columns:\s*3\.8rem minmax\(22rem, 1fr\) 3\.8rem minmax\(22rem, 1fr\)/, "git changes modal should render a side-by-side diff grid");
349
352
  assert.match(html, /id="gitChangesDialog"[\s\S]*id="gitChangesRefreshButton"[\s\S]*id="gitChangesPullButton"[\s\S]*id="gitChangesBody"/, "git changes modal should expose refresh, pull controls, and a diff body");
350
353
  assert.match(app, /chip\.key === "changes"[\s\S]*?options\.onClick = openGitChangesDialog/, "footer CHANGES chip should open the git changes modal");
@@ -354,6 +357,8 @@ assert.match(app, /function gitDiffDisplayLine\(row, side\)[\s\S]*`-\$\{text\}`[
354
357
  assert.match(app, /function gitUntrackedEntryToDiffFile\(entry\)[\s\S]*?renderRowLimit:\s*Number\.POSITIVE_INFINITY[\s\S]*?type: "added"/, "untracked files should render as complete added-file diffs without the row preview cap");
355
358
  assert.match(app, /async function loadMissingGitUntrackedContent\(entry[\s\S]*?\/api\/git-changes\/untracked-file\?path=/, "untracked path-only payloads should fetch complete file contents instead of rendering as empty files");
356
359
  assert.match(app, /function updateGitChangesCurrentFileHeader\(\)[\s\S]*?querySelectorAll\("\.git-diff-file\[data-git-diff-file\]"\)/, "git changes modal should derive the sticky current-file header from visible file cards");
360
+ assert.match(app, /function renderGitChangesFileList\(parsedSections, untracked\)[\s\S]*dataset\.gitChangesJumpFile = item\.path[\s\S]*git-changes-file-jump-meta/, "git changes modal should render jump buttons for each changed file");
361
+ assert.match(app, /gitChangesBody\?\.addEventListener\("click"[\s\S]*data-git-changes-jump-file[\s\S]*scrollIntoView\(\{ block: "start", behavior: "smooth" \}\)/, "git changes file jump buttons should scroll to their diff cards");
357
362
  assert.match(server, /async function readGitUntrackedEntry\(root, file\)[\s\S]*?content: binary \? "" : buffer\.toString\("utf8"\)/, "server should include complete text contents for untracked files");
358
363
  assert.match(server, /url\.pathname === "\/api\/git-changes\/untracked-file" && req\.method === "GET"/, "server should expose a focused untracked-file content endpoint for stale path-only payload fallbacks");
359
364
  assert.match(server, /async function readGitChanges\(cwd\)[\s\S]*?const diffArgs = \["diff", "--no-ext-diff"[\s\S]*?"--unified=0"[\s\S]*?\["diff", "--cached"/, "server should collect compact staged and unstaged git diffs for the changes modal");
@@ -385,7 +390,8 @@ assert.match(css, /\.extension-dialog[\s\S]*?max-height:\s*calc\(var\(--visual-v
385
390
  assert.match(css, /\.extension-dialog[\s\S]*?inset:\s*auto 0 0 0/, "mobile dialogs should behave like bottom sheets");
386
391
  assert.match(css, /#dialogMessage \{[\s\S]*?white-space:\s*pre-wrap/, "extension dialog messages should preserve multiline prompts");
387
392
  assert.match(css, /\.native-command-dialog \{[\s\S]*?width:\s*min\(56rem/, "native slash selector dialog should have roomy desktop layout");
388
- assert.match(css, /\.native-selector-item \{[\s\S]*?--tree-depth/, "native slash selector choices should support tree indentation");
393
+ assert.doesNotMatch(css, /--tree-depth/, "native slash selector choices should not indent tree entries by depth");
394
+ assert.match(css, /\.native-selector-index \{[\s\S]*?font-variant-numeric:\s*tabular-nums/, "native tree selector choices should use numeric prefixes");
389
395
  assert.match(css, /\.native-selector-badge\.native-selector-badge-pi-native[\s\S]*?color:\s*var\(--ctp-blue\)/, "Tools Setup should distinguish Pi native tools with a Pi Native tag");
390
396
  assert.match(css, /\.native-selector-badge\.native-selector-badge-external[\s\S]*?color:\s*var\(--ctp-mauve\)/, "Tools Setup should distinguish external tools with an External tag");
391
397
  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");
@@ -630,7 +636,10 @@ assert.match(app, /id: "tuiSkillsCommand"[\s\S]*?@firstpick\/pi-extension-setup-
630
636
  assert.match(app, /id: "tuiToolsCommand"[\s\S]*?@firstpick\/pi-extension-tools/, "optional features should include the TUI tools command companion");
631
637
  assert.match(app, /id: "remoteWebui"[\s\S]*?@firstpick\/pi-package-remote-webui/, "optional features should include the Remote WebUI companion");
632
638
  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");
633
- assert.match(app, /hasRemoteWebuiCommand = isOptionalFeatureEnabled\("remoteWebui"\) && hasAvailableCommand\("remote"\)[\s\S]*optionsRemoteButton\.hidden = !hasRemoteWebuiCommand[\s\S]*networkControlField\.hidden = !hasRemoteWebuiCommand/, "Options menu and browser controls should show Remote WebUI only when /remote is loaded and enabled");
639
+ assert.match(app, /hasRemoteWebuiCommand = isOptionalFeatureEnabled\("remoteWebui"\) && hasAvailableCommand\("remote"\)[\s\S]*optionsRemoteButton\.hidden = !hasRemoteWebuiCommand[\s\S]*syncRemoteWebuiControlVisibility\(hasRemoteWebuiCommand\)/, "Options menu should track /remote availability and delegate network card visibility");
640
+ assert.match(app, /function syncRemoteWebuiControlVisibility[\s\S]*networkControlField\.hidden = !hasRemoteWebuiCommand/, "Remote WebUI network card should render whenever the optional feature and /remote command are enabled");
641
+ assert.match(app, /if \(featureId === "remoteWebui"\) syncRemoteWebuiControlVisibility\(false\)/, "Disabling Remote WebUI should immediately hide browser network controls before broader rerendering");
642
+ assert.match(app, /window\.addEventListener\("storage"[\s\S]*OPTIONAL_FEATURES_STORAGE_KEY[\s\S]*reconcileDisabledOptionalFeaturesFromStorage/, "Optional feature disables should live-sync across open Web UI pages");
634
643
  assert.match(app, /if \(key === "pi-remote-webui"\) return "remoteWebui"/, "optional feature handling should recognize Remote WebUI widget events without rendering them as overlays");
635
644
  assert.match(app, /REMOTE_WEBUI_CONTROLS_PAYLOAD_TYPE = "firstpick\.pi-package-remote-webui\.controls"/, "Remote WebUI package should announce browser controls through a package-owned status payload");
636
645
  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");
@@ -659,9 +668,9 @@ assert.match(app, /addGitWorkflowAction\("Create PR", \(\) => createGitPrBranch\
659
668
  assert.match(app, /const GIT_WORKFLOW_CREATE_PR_TOOLTIP = \[[\s\S]*"Create PR branch:"[\s\S]*"1\. Ask Pi to generate a type\/feature-name branch from staged changes\."[\s\S]*"6\. Push and Create PR will push upstream, run \/pr, let you review, then run gh pr create\."/, "Create PR should have an up-to-date step-by-step tooltip");
660
669
  assert.match(app, /const GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP = \[[\s\S]*"Manual PR branch:"[\s\S]*"1\. Skip agent branch-name generation\."[\s\S]*"6\. Push and Create PR will push upstream, run \/pr, let you review, then run gh pr create\."/, "Manual branch should have an up-to-date step-by-step tooltip");
661
670
  assert.match(app, /addGitWorkflowAction\("Manual branch", \(\) => createGitPrBranchManually\(\), "", false, GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP\)/, "Manual branch should render with its tooltip");
662
- assert.match(app, /function renderGitWorkflowManualCommitInput\(\)[\s\S]*git-workflow-message-input[\s\S]*Commit input[\s\S]*commitGitWorkflow\("input", tabId\)/, "Message stage should render a manual commit message input with a Commit input action");
663
- assert.match(app, /gitWorkflow\.step === "generate"\) \{\n\s+renderGitWorkflowManualCommitInput\(\);\n\s+addGitWorkflowAction\("Run \/git-staged-msg"/, "Message process stage should show manual input before generated-message actions");
664
- assert.match(app, /renderGitWorkflowManualCommitInput\(\);[\s\S]*addGitWorkflowAction\("Commit short"/, "Commit choice stage should keep manual commit input before generated commit choices");
671
+ assert.match(app, /function renderGitWorkflowManualCommitInput\(\{ appendCommitButton = true \} = \{\}\)[\s\S]*git-workflow-message-input[\s\S]*Commit input[\s\S]*commitGitWorkflow\("input", tabId\)/, "Message stage should render a manual commit message input with a Commit input action");
672
+ assert.match(app, /gitWorkflow\.step === "generate"\) \{\n\s+const commitInputButton = renderGitWorkflowManualCommitInput\(\{ appendCommitButton: false \}\);[\s\S]*addGitWorkflowAction\("Preview current message files"[\s\S]*gitWorkflowActions\.append\(commitInputButton\)/, "Message process stage should place Commit input immediately after Preview current message files");
673
+ assert.match(app, /gitWorkflow\.step === "message"\) \{[\s\S]*const commitInputButton = renderGitWorkflowManualCommitInput\(\{ appendCommitButton: false \}\);[\s\S]*addGitWorkflowAction\("Regenerate"[\s\S]*gitWorkflowActions\.append\(commitInputButton\)/, "Commit choice stage should place Commit input immediately after Regenerate");
665
674
  assert.match(app, /async function commitGitWorkflow\(variant[\s\S]*variant === "input"[\s\S]*message: inputMessage/, "Commit input should send the typed message to the git workflow commit API");
666
675
  assert.match(app, /const donePatch = variant === "input"[\s\S]*message: true, commit: true/, "Commit input should mark both message and commit workflow processes done");
667
676
  assert.match(server, /\["short", "long", "input"\][\s\S]*cleanGitCommitMessageInput\(body\.message\)[\s\S]*git commit -m <input message>/, "server should accept typed git workflow commit messages");
@@ -679,11 +688,13 @@ assert.doesNotMatch(app, /gitWorkflowVisibleTabId|Workflow belongs to/, "guided
679
688
  assert.match(app, /function renderReleaseNpmOutputWidget\(\)/, "release-npm live output should use a specialized Web UI renderer");
680
689
  assert.match(app, /async function refreshAppRunners\(tabContext = activeTabContext\(\)\)/, "frontend should load detected app runners for the active tab cwd");
681
690
  assert.match(app, /function renderAppRunnerWidget\(\)/, "frontend should render app runner output in the shared top widget area");
691
+ assert.match(app, /function renderAppRunnerInputForm\(run\)[\s\S]*app-runner-stdin-input[\s\S]*Send stdin/, "frontend should let running app runners receive line-oriented stdin");
682
692
  assert.match(app, /function appRunnerFailureState\(runnerId, error[\s\S]*failed to start app runner/, "frontend should render visible app-runner start failures instead of only logging them");
683
693
  assert.match(app, /appRunnerCustomFeedback[\s\S]*Custom app runner was not saved/, "custom app-runner save failures should be shown inline in the dialog");
684
694
  assert.match(server, /function customAppRunnerUnavailableReason\(projectRoot, runner\)[\s\S]*Command is not available/, "server should explain why saved custom app runners are unavailable");
685
695
  assert.match(server, /url\.pathname === "\/api\/app-runners" && req\.method === "GET"/, "server should expose detected app runners for the active tab cwd");
686
696
  assert.match(server, /url\.pathname === "\/api\/app-runner" && req\.method === "POST"/, "server should start selected app runners directly");
697
+ assert.match(server, /url\.pathname === "\/api\/app-runner\/input" && req\.method === "POST"[\s\S]*sendAppRunnerInput/, "server should accept stdin for running app runners");
687
698
  assert.match(server, /function addGoRunner\(runners, cwd\)[\s\S]*Go\/Golang app entry/, "server should detect Go\/Golang app runners");
688
699
  assert.match(server, /function addZigRunner\(runners, cwd\)[\s\S]*zig build run[\s\S]*zig run/, "server should detect Zig build and entry-file runners");
689
700
  assert.match(server, /function addCppRunners\(runners, cwd\)[\s\S]*C\/C\+\+ CMake executable target[\s\S]*language: "C\+\+"/, "server should detect C\/C++ CMake and entry-file runners");
@@ -779,6 +790,8 @@ assert.match(app, /Abort requested/, "abort feedback should clarify that Web UI
779
790
  assert.match(app, /const ABORT_LONG_PRESS_MS = 3000/, "Abort long-press timing should be explicit");
780
791
  assert.match(app, /const ABORT_LONG_PRESS_TICK_MS = 100/, "Abort hold countdown should update visibly while held");
781
792
  assert.match(app, /const ABORT_LONG_PRESS_RELEASE_GRACE_MS = 350/, "Escape release cancellation should be debounced to ignore spurious keyup during key repeat");
793
+ assert.match(app, /let escapeAbortHoldSuppressesDoubleEscape = false/, "Escape abort hold should track suppression separately from abort button UI state");
794
+ assert.match(app, /function shouldSuppressEmptyPromptEscapeAction\(\)[\s\S]*escapeAbortHoldSuppressesDoubleEscape[\s\S]*suppressEmptyPromptEscapeUntil/, "Escape abort hold should suppress the empty-prompt double-Escape action until keyup or grace expiry");
782
795
  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");
783
796
  assert.match(app, /async function abortActiveRun\(\{ source = "button" \} = \{\}\)/, "Abort should be centralized for button, Esc, and long-press triggers");
784
797
  assert.match(app, /elements\.abortButton\.addEventListener\("pointerdown", startAbortLongPress\)/, "Abort should support pointer long-press");
@@ -786,8 +799,9 @@ assert.match(app, /else if \(!event\.repeat\) startAbortLongPress\(event, \{ sou
786
799
  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");
787
800
  assert.match(app, /abortLongPressDeadlineAt = abortLongPressStartedAt \+ ABORT_LONG_PRESS_MS/, "Abort hold countdown should use an immutable deadline for display and completion");
788
801
  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");
802
+ assert.match(app, /if \(shouldSuppressEmptyPromptEscapeAction\(\)\) \{\n\s+event\.preventDefault\(\);\n\s+return;\n\s+\}\n\s+if \(event\.repeat\)/, "completed Escape abort holds should suppress trailing Escape events before double-Escape handling");
789
803
  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");
790
- assert.match(app, /window\.addEventListener\("keyup"[\s\S]*abortLongPressSource === "escape"[\s\S]*scheduleAbortLongPressReleaseReset/, "releasing Escape should debounce-cancel a pending guarded abort hold");
804
+ assert.match(app, /window\.addEventListener\("keyup"[\s\S]*abortLongPressSource === "escape"[\s\S]*scheduleAbortLongPressReleaseReset[\s\S]*finishEscapeAbortHoldSuppression\(\)/, "releasing Escape should debounce-cancel a pending guarded abort hold and re-enable double-Escape after a grace window");
791
805
  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");
792
806
  assert.match(app, /function addAbortTranscriptNotice\(/, "abort button should render a transcript-visible aborted notice");
793
807
  assert.match(app, /this transcript marks the run as aborted/, "abort notice should clearly mark the agent output as aborted");
@@ -895,6 +909,7 @@ assert.match(app, /function openNativeResumeSelector\(scope = "current"\)[\s\S]*
895
909
  assert.match(app, /\/api\/session-rename/, "native /resume selector should rename session metadata");
896
910
  assert.match(app, /\/api\/session-delete/, "native /resume selector should delete sessions with confirmation");
897
911
  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");
912
+ assert.match(app, /renderNativeSelectorItems\(toItems\(\), \{ emptyText: "No session tree entries match this filter\.", onSelect: navigate, numbered: true \}\)/, "native /tree selector should number entries instead of indenting by depth");
898
913
  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");
899
914
  assert.match(app, /\/api\/auth-logout[\s\S]*?confirmed: true/, "native /logout should remove stored credentials through a confirmed localhost-only endpoint");
900
915
  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");
@@ -1119,7 +1134,9 @@ assert.match(server, /case "session": \{[\s\S]*?formatSessionOutput\(tab, state\
1119
1134
  assert.match(server, /case "copy": \{[\s\S]*?get_last_assistant_text[\s\S]*?copyText: text/, "native /copy should return text for browser clipboard handling");
1120
1135
  assert.match(server, /case "export": \{[\s\S]*?handleNativeExportCommand\(tab, parsed\.args, req\)/, "native /export should run through the Web UI export helper");
1121
1136
  assert.match(server, /url\.pathname\.startsWith\("\/api\/native-download\/"\) && req\.method === "GET"/, "native /export should expose short-lived opaque download URLs");
1122
- assert.match(app, /function triggerNativeDownload\(download\)/, "frontend should auto-start native command downloads");
1137
+ assert.match(app, /function triggerNativeDownload\(download\)/, "frontend should be able to start native command downloads");
1138
+ assert.match(app, /function openNativeExportDownloadPrompt\(download\)/, "frontend should prompt before opening /export HTML in the browser");
1139
+ assert.match(app, /function alternateLoopbackBrowserUrl\(value\)/, "frontend should avoid reopening exports inside the installed PWA when possible");
1123
1140
  assert.match(app, /function safeHttpUrl\(value/, "frontend should validate server-provided URLs through a shared helper");
1124
1141
  assert.match(app, /const url = safeHttpUrl\(download\?\.url\)/, "native downloads must reject non-http(s) URL schemes");
1125
1142
  assert.match(app, /const href = safeHttpUrl\(url\);/, "network status links must reject non-http(s) URL schemes");
@@ -148,10 +148,14 @@ assert.match(server, /url\.pathname\.startsWith\("\/api\/native-download\/"\) &&
148
148
  assert.match(server, /case "export": \{\n\s+return handleNativeExportCommand\(tab, parsed\.args, req\);\n\s+\}/, "native /export should route through the native command adapter");
149
149
  assert.match(server, /tab\.rpc\.send\(\{ type: "export_html", outputPath \}\)/, "no-path /export should use RPC export_html into a controlled temp path");
150
150
  assert.match(server, /registerNativeDownload\(exportedPath/, "no-path /export should return a short-lived browser download token");
151
+ assert.match(server, /openUrl: record\.contentType === MIME_TYPES\.get\("\.html"\)/, "HTML native downloads should expose a browser-open URL");
152
+ assert.match(server, /url\.searchParams\.get\("disposition"\) === "inline"/, "native download endpoint should support inline HTML rendering");
151
153
  assert.match(server, /copyFile\(sessionFile, targetPath\)/, "explicit .jsonl /export should copy the active session file");
152
154
  assert.match(app, /function triggerNativeDownload\(download\)/, "frontend should know how to trigger native command downloads");
153
155
  assert.match(app, /function applyNativeSlashCommandEffects\(response, message, tabContext/, "frontend should apply centralized native slash-command adapter effects");
154
- assert.match(app, /data\.download && triggerNativeDownload\(data\.download\)/, "frontend should handle download responses from native commands");
156
+ assert.match(app, /data\.download && handleNativeDownloadResponse\(data\.download, data\.command\)/, "frontend should route native downloads through command-specific handling");
157
+ assert.match(app, /function openNativeExportDownloadPrompt\(download\)[\s\S]*Open in browser/, "frontend should ask Web UI users to open /export results in the browser");
158
+ assert.match(app, /function alternateLoopbackBrowserUrl\(value\)[\s\S]*hostname === "localhost"[\s\S]*127\.0\.0\.1/, "PWA export opens should escape same-origin app scope via alternate loopback host");
155
159
  assert.match(app, /for \(const warning of response\.warnings/, "frontend should surface remote bash trust warnings");
156
160
  assert.match(server, /case "\/api\/bash": \{[\s\S]*?return \{ type: "bash", command, excludeFromContext: body\.excludeFromContext === true \}/, "server should expose RPC bash with include/exclude context semantics");
157
161
  assert.match(server, /case "\/api\/abort-bash":[\s\S]*?return \{ type: "abort_bash" \}/, "server should expose abort_bash for user bash cancellation");