@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.
- package/README.md +1 -1
- package/bin/pi-webui.mjs +94 -8
- package/package.json +2 -2
- package/public/app.js +880 -71
- package/public/index.html +6 -2
- package/public/styles.css +458 -46
- package/tests/http-endpoints-harness.test.mjs +55 -0
- package/tests/mobile-static.test.mjs +27 -10
- package/tests/native-parity.test.mjs +5 -1
|
@@ -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"
|
|
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,
|
|
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-
|
|
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.
|
|
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]*
|
|
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\(\)
|
|
664
|
-
assert.match(app, /renderGitWorkflowManualCommitInput\(\);[\s\S]*addGitWorkflowAction\("
|
|
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
|
|
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 &&
|
|
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");
|