@firstpick/pi-package-webui 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
6
  const root = join(dirname(fileURLToPath(import.meta.url)), "..");
7
- const [pkgRaw, html, css, app, server, extension, readme, manifestRaw, serviceWorker, appleIcon, icon192, icon512, matrixBackground, mochaBackground] = await Promise.all([
7
+ const [pkgRaw, html, css, app, server, extension, readme, startScript, manifestRaw, serviceWorker, appleIcon, icon192, icon512, matrixBackground, mochaBackground] = await Promise.all([
8
8
  readFile(join(root, "package.json"), "utf8"),
9
9
  readFile(join(root, "public", "index.html"), "utf8"),
10
10
  readFile(join(root, "public", "styles.css"), "utf8"),
@@ -12,6 +12,7 @@ const [pkgRaw, html, css, app, server, extension, readme, manifestRaw, serviceWo
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
16
  readFile(join(root, "public", "manifest.webmanifest"), "utf8"),
16
17
  readFile(join(root, "public", "service-worker.js"), "utf8"),
17
18
  readFile(join(root, "public", "apple-touch-icon.png")),
@@ -44,6 +45,7 @@ assert.match(html, /id="themeSelect"/, "side panel should expose a theme selecto
44
45
  assert.match(html, /<label for="themeSelect">Theme<\/label>/, "theme selector should be labeled in side-panel controls");
45
46
  assert.match(html, /id="backgroundInput"[^>]*type="file"[^>]*accept="image\/png,image\/jpeg,image\/webp,image\/gif"/, "side panel should expose an image picker for custom backgrounds");
46
47
  assert.match(html, /id="backgroundClearButton"[\s\S]*?>×<\/button>/, "side-panel background control should expose an X remove button");
48
+ assert.match(html, /id="stopServerButton"[^>]*?>Stop Server<\/button>/, "side panel should expose a Stop Server button");
47
49
  assert.match(html, /id="agentDoneNotificationsToggle"/, "side panel should expose an agent-done notifications toggle");
48
50
  assert.match(html, /id="agentDoneNotificationsStatus"/, "agent-done notifications toggle should expose status text");
49
51
  assert.match(html, /id="thinkingVisibilityToggle"/, "side panel should expose a thinking-output visibility toggle");
@@ -51,6 +53,8 @@ assert.match(html, /id="thinkingVisibilityStatus"/, "thinking-output visibility
51
53
  assert.match(html, /id="nativeCommandDialog"/, "native slash selector UI should have a dedicated dialog");
52
54
  assert.match(html, /id="nativeCommandSearch"[^>]*type="search"/, "native slash selector dialog should expose a filter box");
53
55
  assert.match(html, /id="optionalFeaturesBox"/, "side panel should expose optional feature status and controls");
56
+ assert.match(html, /id="codexUsageBox"/, "side panel should expose Codex subscription usage status");
57
+ assert.match(html, /data-side-panel-section="codex-usage"/, "Codex usage should live in a collapsible side-panel section");
54
58
  assert.match(html, /id="serverOfflinePanel"/, "PWA/offline shell should expose a backend-offline recovery panel");
55
59
  assert.match(html, /id="copyServerCommandButton"/, "backend-offline recovery panel should expose a start-command copy button");
56
60
  assert.match(html, /id="retryServerConnectionButton"/, "backend-offline recovery panel should expose a retry button");
@@ -58,6 +62,22 @@ assert.match(html, /data-side-panel-section="controls"/, "side panel controls sh
58
62
  assert.match(html, /data-side-panel-section="commands"/, "side panel commands should live in a collapsible section");
59
63
  assert.match(html, /class="side-panel-section-toggle"[^>]*aria-controls="sidePanelSectionControls"/, "side panel section toggles should target their content panels");
60
64
  assert.match(html, /class="side-panel-section-label">Events<\/span>/, "side panel events should expose a section toggle label");
65
+ const sidePanelToggleStates = Array.from(
66
+ html.matchAll(/class="side-panel-section-toggle"[^>]*aria-expanded="([^"]+)"/g),
67
+ (match) => match[1],
68
+ );
69
+ assert.ok(sidePanelToggleStates.length >= 7, "side panel should expose section toggle states");
70
+ assert.deepEqual([...new Set(sidePanelToggleStates)], ["false"], "side-panel sections should start collapsed by default");
71
+ assert.equal(
72
+ (html.match(/<section class="side-panel-section collapsed" data-side-panel-section=/g) || []).length,
73
+ sidePanelToggleStates.length,
74
+ "side-panel sections should render with the collapsed class by default",
75
+ );
76
+ assert.equal(
77
+ (html.match(/class="side-panel-section-content" hidden/g) || []).length,
78
+ sidePanelToggleStates.length,
79
+ "side-panel section panels should be hidden by default",
80
+ );
61
81
  assert.match(html, /id="jumpToLatestButton"/, "chat should expose a jump-to-latest control for non-forced streaming");
62
82
  assert.match(html, /id="stickyUserPromptButton"/, "chat should expose a fixed last-user-prompt jump control");
63
83
  assert.match(html, /id="feedbackTray"/, "chat should expose a queued action-feedback tray");
@@ -100,9 +120,11 @@ assert.match(css, /#promptInput \{[\s\S]*?overflow-y:\s*hidden/, "prompt input s
100
120
  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");
101
121
  assert.match(css, /\.message\.extension,[\s\S]*?\.message\.native/, "extension and native command output should have visible transcript styling");
102
122
  assert.match(css, /\.message\.run-indicator-message \{[\s\S]*?border-color/, "active agent runs should render a visible transcript indicator card");
103
- assert.match(css, /\.message\.action-enter \{[\s\S]*?action-card-slide-in/, "new action cards should subtly slide in from the bottom");
104
- assert.match(css, /@keyframes action-card-slide-in \{[\s\S]*?translateY\(0\.42rem\)/, "action-card entry animation should start below the final position");
123
+ assert.match(css, /\.message\.action-enter \{[\s\S]*?action-card-slide-in 340ms/, "new action cards should visibly slide in from the bottom");
124
+ assert.match(css, /@keyframes action-card-slide-in \{[\s\S]*?translate3d\(0, 1\.45rem, 0\)/, "action-card entry animation should start well below the final position");
105
125
  assert.match(css, /\.message\.thinking,\n\.message\.toolCall,\n\.message\.assistantEvent/, "thinking and assistant events should have non-assistant transcript card styling");
126
+ assert.doesNotMatch(css, /\.message\.thinking\.streaming\.complete[\s\S]*?content:\s*" done"/, "completed live thinking cards should not append a green DONE label");
127
+ assert.doesNotMatch(css, /\.thinking-block\.streaming-thinking\.complete[\s\S]*?content:\s*" done"/, "completed thinking details should not append a green DONE label");
106
128
  assert.match(css, /\.message\.toolResult, \.message\.bashExecution, \.message\.compactionSummary/, "compaction summaries should render as compact collapsible transcript cards");
107
129
  assert.match(css, /\.message\.toolExecution \{[\s\S]*?border-color/, "paired tool executions should render as distinct TUI-like action cards");
108
130
  assert.match(css, /\.tool-diff \{[\s\S]*?font-family:/, "edit tool diffs should have a dedicated monospace renderer");
@@ -117,9 +139,13 @@ assert.match(css, /\.side-panel-section\.collapsed \.side-panel-section-content,
117
139
  assert.match(css, /\.side-panel-section:not\(\.collapsed\) \.side-panel-section-chevron/, "expanded side panel sections should rotate the chevron");
118
140
  assert.match(css, /\.optional-feature-pill\.enabled/, "optional features should visually distinguish enabled state");
119
141
  assert.match(css, /\.todo-widget \{[\s\S]*?display:\s*grid/, "todo-progress widget should render as a styled checklist card");
142
+ assert.match(css, /\.todo-widget-summary \{[\s\S]*?cursor:\s*pointer/, "todo-progress widget should expose a compact expandable summary");
143
+ assert.match(css, /\.todo-widget-body \{[\s\S]*?max-height:/, "expanded todo-progress details should be height-limited");
120
144
  assert.match(css, /\.todo-widget-item\.partial \.todo-widget-marker/, "todo-progress partial items should have distinct styling");
121
145
  assert.match(css, /\.todo-widget-item\.done \.todo-widget-text[\s\S]*?text-decoration:\s*line-through/, "todo-progress completed items should be visually crossed out");
122
- assert.match(css, /\.release-npm-widget \{[\s\S]*?display:\s*grid/, "release-npm output should render as a specialized Web UI widget");
146
+ assert.match(css, /\.release-npm-widget \{[\s\S]*?border-left:\s*0\.28rem solid/, "release-npm output should stand apart from the page background");
147
+ assert.match(css, /\.release-npm-stream-header \{[\s\S]*?text-transform:\s*uppercase/, "release-npm output should label the output stream clearly");
148
+ assert.match(css, /\.release-npm-terminal \{[\s\S]*?rgba\(3, 4, 10, 0\.98\)/, "release-npm terminal should use a high-contrast stream panel");
123
149
  assert.match(css, /\.release-aur-widget \{[\s\S]*?border-color/, "release-aur output should render as a specialized Web UI widget variant");
124
150
  assert.match(css, /\.widget-area \.widget:not\(\.todo-widget\):not\(\.release-npm-widget\)/, "mobile widget filtering should keep release workflow output visible");
125
151
  assert.match(css, /\.message\.warn \.message-role \{ color: var\(--ctp-yellow\); \}/, "warning-level command output should be visually distinct");
@@ -213,6 +239,8 @@ assert.match(app, /Restart Web UI to load themes/, "frontend should explain when
213
239
  assert.match(app, /themeSelect\.addEventListener\("change"/, "side-panel theme selector should switch themes immediately");
214
240
  assert.match(app, /open \? "Close for network" : "Open to network"/, "network button should toggle from open to close action");
215
241
  assert.match(app, /api\("\/api\/network\/close", \{ method: "POST"/, "network close action should call the close endpoint");
242
+ assert.match(app, /stopServerButton\.addEventListener\("click", stopServer\)/, "Stop Server button should be wired to the shutdown handler");
243
+ assert.match(app, /api\("\/api\/shutdown", \{ method: "POST", scoped: false \}\)/, "Stop Server action should call the unscoped shutdown endpoint");
216
244
  assert.match(server, /async function closeNetworkAccess\(\)/, "server should expose a local-only rebind helper for closing network access");
217
245
  assert.match(server, /url\.pathname === "\/api\/network\/close" && req\.method === "POST"/, "server should route network close requests");
218
246
  assert.match(server, /server\.closeAllConnections\?\.\(\)/, "network rebind should force-close long-lived clients so close-to-localhost can complete");
@@ -254,10 +282,29 @@ assert.match(app, /function hasQueuedDialogRequest\(id\)/, "frontend should dedu
254
282
  assert.match(app, /if \(request\.replayed\) addEvent\(`recovered pending \$\{request\.method\} request`, "warn"\)/, "frontend should surface replayed extension UI blockers");
255
283
  assert.match(app, /case "webui_extension_ui_cancelled":/, "frontend should close dialogs cancelled by backend abort handling");
256
284
  assert.match(app, /function parseTodoProgressWidget\(lines\)/, "todo-progress widgets should be parsed from extension widget lines");
285
+ assert.match(app, /const todoProgressWidgetExpandedByTab = new Map\(\)/, "todo-progress expansion state should survive widget re-renders per tab");
286
+ assert.match(app, /const node = make\("details", "widget todo-widget"\)/, "todo-progress widget should render collapsed by default as expandable details");
257
287
  assert.match(app, /Optional feature detection intentionally checks loaded Pi capabilities/, "optional Web UI features should be detected through loaded capabilities, not package folders");
258
288
  assert.match(app, /function resetOptionalFeatureAvailability\(\)/, "optional feature state should reset across active-tab and reload boundaries");
259
289
  assert.match(app, /function renderOptionalFeaturePanel\(\)/, "side panel should render optional feature installed/enabled state");
260
290
  assert.match(app, /function setSidePanelSectionCollapsed\(record, collapsed/, "side panel sections should have explicit collapse/expand behavior");
291
+ assert.match(
292
+ app,
293
+ /function setOnlySidePanelSectionExpanded\(targetRecord,[\s\S]*?setSidePanelSectionCollapsed\(record, record\.id !== targetId, \{ persist: false \}\);[\s\S]*?persistSidePanelSectionState\(\);/,
294
+ "opening a side-panel section should collapse every other section before persisting",
295
+ );
296
+ assert.match(
297
+ app,
298
+ /const expandedRecords = collapsedIds \? records\.filter\(\(\{ id \}\) => !collapsedIds\.has\(id\)\) : \[\];\n\s+const expandedId = expandedRecords\.length === 1 \? expandedRecords\[0\]\.id : null;/,
299
+ "side-panel section restore should preserve at most one expanded section and otherwise default collapsed",
300
+ );
301
+ assert.match(
302
+ app,
303
+ /if \(record\.section\.classList\.contains\("collapsed"\)\) \{\n\s+setOnlySidePanelSectionExpanded\(record\);\n\s+\} else \{\n\s+setSidePanelSectionCollapsed\(record, true\);\n\s+\}/,
304
+ "side-panel section toggles should expand at most one section at a time",
305
+ );
306
+ assert.match(app, /function renderCodexUsage\(\)/, "frontend should render Codex usage buckets in the side panel");
307
+ assert.match(app, /api\(`\/api\/codex-usage\$\{suffix\}`, \{ scoped: false \}\)/, "Codex usage should load through a server endpoint without browser credentials");
261
308
  assert.match(app, /restoreSidePanelSectionState\(\);\nbindSidePanelSectionToggles\(\);/, "side panel section state should restore before toggles are bound");
262
309
  assert.match(app, /OPTIONAL_FEATURES_STORAGE_KEY/, "optional feature disable toggles should persist in browser storage");
263
310
  assert.match(app, /function renderOptionalFeatureDependentDisplays\(\)[\s\S]*renderOptionalFeatureControls\(\);[\s\S]*renderThemeSelect\(\);[\s\S]*renderWidgets\(\);[\s\S]*renderStatus\(\);[\s\S]*renderCommands\(\);[\s\S]*renderAllMessages\(\{ preserveScroll: true \}\);[\s\S]*if \(streamRawText\) renderStreamingAssistantText\(\);/, "optional feature toggles should immediately refresh visible controls, commands, transcript, and live stream displays");
@@ -272,7 +319,12 @@ assert.match(app, /const releasePrompt = detectedReleasePrompt && isOptionalFeat
272
319
  assert.match(app, /case "webui_tab_reloaded":[\s\S]*resetOptionalFeatureAvailability\(\)/, "optional feature state should reset when the RPC tab reloads resources");
273
320
  assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*hasAvailableCommand\(commandName\)/, "publish workflow launch should guard on loaded slash commands");
274
321
  assert.match(app, /if \(!isOptionalFeatureEnabled\("gitWorkflow"\)\)/, "guided git workflow should guard on enabled /git-staged-msg feature");
322
+ assert.match(app, /const gitWorkflowsByTab = new Map\(\)/, "guided git workflow state should be stored per terminal tab");
323
+ assert.match(app, /function bindGitWorkflowToActiveTab\(\) \{\n\s+gitWorkflow = gitWorkflowForTab\(activeTabId\) \|\| createGitWorkflowState\(\);/, "guided git workflow should render only the active terminal tab's workflow state");
324
+ assert.match(app, /function setGitWorkflow\(patch, \{ tabId = activeTabId \} = \{\}\)[\s\S]*if \(tabId === activeTabId\) \{[\s\S]*renderGitWorkflow\(\);/, "guided git workflow should not render inactive terminal workflows globally");
325
+ assert.doesNotMatch(app, /gitWorkflowVisibleTabId|Workflow belongs to/, "guided git workflow should not pin or show workflows outside their owning terminal tab");
275
326
  assert.match(app, /function renderReleaseNpmOutputWidget\(\)/, "release-npm live output should use a specialized Web UI renderer");
327
+ assert.match(app, /releaseNpmStreamHeader\("Live output stream", outputLines\.length, \{ live: true \}\)/, "release-npm live output should expose a clear stream heading");
276
328
  assert.match(app, /function renderReleaseAurOutputWidget\(\)/, "release-aur live output should use a specialized Web UI renderer");
277
329
  assert.match(app, /releaseNpmActionButton\("Abort", "\/release-abort", "danger"\)/, "release-npm Web UI output should expose an abort action");
278
330
  assert.match(app, /releaseNpmActionButton\("Abort", "\/release-aur abort", "danger"\)/, "release-aur Web UI output should expose an abort action");
@@ -289,6 +341,10 @@ assert.match(app, /appendToolRawDetails\(parent, tool\)/, "paired tool cards sho
289
341
  assert.match(app, /function toolStateMeta\(tool\)/, "tool cards should expose consistent status and elapsed metadata across built-in renderers");
290
342
  assert.match(app, /const TOOL_LIVE_UPDATE_THROTTLE_MS = 80/, "live tool cards should coalesce rapid partial updates before re-rendering");
291
343
  assert.match(app, /function updateLiveToolCard\(bubble, message\)[\s\S]*?body\.replaceChildren\(\);[\s\S]*?renderToolExecution\(body, message\);/, "live tool card updates should re-render the existing card body in place");
344
+ assert.match(app, /function applyToolExecutionBubbleState\(bubble, message\)[\s\S]*?bubble\.dataset\.toolStatus !== status[\s\S]*?bubble\.classList\.add\(nextClass\)[\s\S]*?bubble\.classList\.toggle\("error"/, "tool status classes should not be removed and re-added on every live update");
345
+ assert.match(app, /function toolExecutionRenderSignature\(message\)[\s\S]*?normalizeToolExecution\(message\)[\s\S]*?toolRenderSignatureReplacer\(\)/, "tool cards should derive stable render signatures from normalized tool payloads");
346
+ assert.match(app, /const nextRenderSignature = toolExecutionRenderSignature\(message\)[\s\S]*?bubble\._toolRenderSignature === nextRenderSignature[\s\S]*?return true;[\s\S]*?bubble\._toolRenderSignature = nextRenderSignature/, "live tool card updates should skip identical body re-renders");
347
+ assert.match(app, /message\.role === "toolExecution"[\s\S]*?renderToolExecution\(body, message\);[\s\S]*?bubble\._toolRenderSignature = toolExecutionRenderSignature\(message\);/, "new tool cards should cache their initial render signature");
292
348
  assert.match(app, /function scheduleLiveToolRunRender\(run[\s\S]*?liveToolRenderQueue\.set[\s\S]*?TOOL_LIVE_UPDATE_THROTTLE_MS/, "live tool update events should be queued and throttled for smoother browser output");
293
349
  assert.match(app, /function handleToolExecutionUpdate\(event\)[\s\S]*?event\.partialResult[\s\S]*?scheduleLiveToolRunRender\(run, \{ scroll: false \}\)/, "live tool_execution_update events should update transcript-visible tool cards without replacing them per event");
294
350
  assert.match(app, /function captureReusableToolCards\(\)[\s\S]*?\.message\.toolExecution\[data-tool-call-id\]/, "full transcript re-renders should capture existing tool cards before clearing the chat");
@@ -305,7 +361,16 @@ assert.match(app, /if \(isEmptyAssistantTextPart\(part\)\) continue;/, "empty as
305
361
  assert.match(app, /function assistantFinalOutputPart\(part\)[\s\S]*?if \(part\.type === "text"\) \{[\s\S]*?const text = assistantTextPartText\(part\);[\s\S]*?return text\.trim\(\) \? \{ \.\.\.part, type: "text", text \} : null;/, "assistant text parts should normalize supported text payload shapes");
306
362
  assert.match(app, /\["assistant", "toolExecution"\]\.includes\(transcriptMessage\.role\) \? messageIndex : -1/, "final Assistant output and paired tool action cards should keep the source message index for feedback");
307
363
  assert.match(app, /function ensureStreamingThinkingBubble\(\)[\s\S]*if \(!thinkingOutputVisible\) return false/, "live thinking should respect the show/hide thinking-output toggle");
308
- assert.match(app, /if \(thinkingOutputVisible && \(thinkingText \|\| !streaming\)\) appendText\(body, thinkingText \|\| "No thinking content was exposed by the provider\.", "thinking-text"\);/, "empty live thinking cards should not render the no-thinking fallback before deltas arrive");
364
+ assert.match(app, /const UNEXPOSED_THINKING_TEXT = "No thinking content was exposed by the provider\."/, "frontend should name the provider no-thinking placeholder for suppression");
365
+ assert.match(app, /function visibleThinkingText\(text\)[\s\S]*?trimmed === UNEXPOSED_THINKING_TEXT[\s\S]*?return "";/, "provider no-thinking placeholders should normalize to empty thinking output");
366
+ assert.match(app, /if \(isThinkingPart\) \{[\s\S]*?visibleThinkingText\(assistantThinkingText\(part\)\)[\s\S]*?if \(thinking\) displayMessages\.push/, "assistant transcript splitting should skip empty or unexposed thinking parts");
367
+ assert.match(app, /message\.role === "thinking"[\s\S]*?visibleThinkingText\(message\.thinking \|\| textFromContent\(message\.content\)\)[\s\S]*?if \(thinkingOutputVisible && thinkingText\) appendText\(body, thinkingText, "thinking-text"\);/, "thinking cards should suppress empty and provider no-thinking placeholder output");
368
+ assert.match(app, /function showStreamingThinking\(initialText = ""\)[\s\S]*?if \(initialText && !streamThinking\.textContent\) streamThinking\.textContent = initialText;/, "live thinking should not create a visible placeholder card before content arrives");
369
+ assert.match(app, /function setStreamingThinkingText\(text\)[\s\S]*?const thinking = visibleThinkingText\(text\);[\s\S]*?if \(!thinkingOutputVisible \|\| !thinking\) return false;[\s\S]*?return true;/, "live thinking text setters should ignore empty text instead of clearing or flashing the card");
370
+ assert.match(app, /function syncStreamingThinkingFromMessage\(event[\s\S]*?return setStreamingThinkingText\(text \|\| placeholder\);/, "partial-message thinking sync should only report success after setting visible thinking text");
371
+ assert.doesNotMatch(app, /text \|\| placeholder \|\| streamThinkingBubble/, "partial-message thinking sync should not clear an existing thinking card when a partial carries no visible thinking text");
372
+ assert.match(app, /if \(thinkingOutputVisible && delta && \(!synced \|\| !streamThinking\?\.textContent\)\) \{/, "live thinking delta fallback should require visible delta text before creating a card");
373
+ assert.match(app, /function thinkingDeltaText\(update\) \{[\s\S]*?return visibleThinkingText\(update\.delta \|\| update\.thinking \|\| update\.content \|\| ""\);/, "live thinking deltas should suppress provider no-thinking placeholders too");
309
374
  assert.match(app, /const THINKING_VISIBILITY_STORAGE_KEY = "pi-webui-thinking-visible"/, "thinking visibility should persist in browser storage");
310
375
  assert.match(app, /function setThinkingOutputVisible\(visible[\s\S]*renderAllMessages\(\{ preserveScroll: true \}\)/, "thinking visibility changes should immediately re-render the transcript");
311
376
  assert.match(app, /function assistantStreamingMessage\(event\)/, "live streaming should read the authoritative partial assistant message from RPC events like the TUI");
@@ -336,6 +401,11 @@ assert.match(app, /return "Agent is running: ";/, "active agent indicator should
336
401
  assert.doesNotMatch(app, /"agent running"/, "active agent indicator should not render a separate title/header label");
337
402
  assert.doesNotMatch(app, /runIndicatorTimestamp/, "active agent indicator should not render a separate live timestamp header");
338
403
  assert.match(app, /runIndicatorBubble = make\("article", "message runIndicator run-indicator-message streaming"\)/, "active agent indicator should use a dedicated streaming transcript card");
404
+ assert.match(app, /function resetChatOutput\(\)[\s\S]*?preservedNodes[\s\S]*?runIndicatorBubble\?\.parentElement === elements\.chat[\s\S]*?elements\.chat\.replaceChildren\(\.\.\.preservedNodes\)/, "transcript re-renders should preserve the live run indicator node instead of tearing it down");
405
+ assert.match(app, /function appendChatMessageBubble\(bubble\)[\s\S]*?insertBefore\(bubble, runIndicatorBubble\)/, "new transcript cards should insert before the active run indicator so it stays stable at the bottom");
406
+ assert.match(app, /function ensureRunIndicatorBubble\(\)[\s\S]*?!runIndicatorBubble \|\| !runIndicatorText \|\| !runIndicatorMeta[\s\S]*?elements\.chat\.lastElementChild !== runIndicatorBubble/, "active agent indicator should reuse the existing bubble across transcript re-renders instead of recreating it");
407
+ assert.match(app, /const headline = runIndicatorHeadline\(\);\n\s+if \(runIndicatorText\.textContent !== headline\) runIndicatorText\.textContent = headline;/, "active agent indicator should avoid redundant headline DOM writes that can flicker");
408
+ 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");
339
409
  assert.match(app, /runIndicatorShowsElapsed\(\) \? `\$\{detail\} · run time/, "active agent indicator should label elapsed run time instead of showing a bare counter");
340
410
  assert.match(app, /Abort requested/, "abort feedback should clarify that Web UI is checking stop status");
341
411
  assert.match(app, /const ABORT_LONG_PRESS_MS = 700/, "Abort long-press timing should be explicit");
@@ -371,7 +441,7 @@ assert.match(app, /item\.addEventListener\("pointermove", \(event\) => setActive
371
441
  assert.match(app, /item\.addEventListener\("pointermove", \(event\) => setActiveCommandSuggestionFromPointerMove\(index, event\)\);[\s\S]*?item\.addEventListener\("click", \(\) => insertPathSuggestion\(index\)\);/, "path autocomplete should only follow pointer movement before click insertion");
372
442
  assert.doesNotMatch(app, /addEventListener\("mouseenter", \(\) => setActiveCommandSuggestion\(index\)\)/, "autocomplete should not change active selection on stationary mouseenter");
373
443
  assert.match(app, /function resizePromptInput\(\)/, "prompt textarea should auto-resize from a one-line default");
374
- assert.match(app, /elements\.promptInput\.addEventListener\("input", \(\) => \{\n\s+resizePromptInput\(\);/, "prompt textarea should resize whenever the user edits it");
444
+ assert.match(app, /elements\.promptInput\.addEventListener\("input", \(\) => \{[\s\S]*?resizePromptInput\(\);/, "prompt textarea should resize whenever the user edits it");
375
445
  assert.match(app, /function updateComposerModeButtons\(\)/, "composer should relocate Steer and Follow-up based on run state");
376
446
  assert.match(app, /const target = runActive \? elements\.composerRow : elements\.composerActionsPanel/, "Steer and Follow-up should move into the bottom row only while an agent run is active");
377
447
  assert.match(app, /const before = runActive \? elements\.abortButton : null/, "active Steer and Follow-up controls should sit before Abort and Send");
@@ -401,6 +471,20 @@ assert.match(app, /function openNativeTreeSelector\(\)[\s\S]*?\/api\/session-tre
401
471
  assert.match(app, /Provider credential entry is intentionally not implemented in the browser yet/, "native /login should remain a safe non-secret guidance dialog");
402
472
  assert.match(app, /const HIDDEN_COMMAND_NAMES = new Set\(\["webui-tree-navigate"\]\)/, "internal Web UI helper commands should stay out of command pickers");
403
473
  assert.match(app, /function shouldSendPromptFromEnter\(event\)/, "prompt keyboard handling should be centralized");
474
+ assert.match(app, /const PROMPT_HISTORY_STORAGE_KEY = "pi-webui-prompt-history"/, "prompt history should be persisted per browser for keyboard recall");
475
+ assert.match(app, /function recallPreviousPromptFromHistory\(\)/, "prompt history should support recalling older prompts from the textarea");
476
+ assert.match(app, /event\.key === "ArrowUp" && recallPreviousPromptFromHistory\(\)/, "plain Up should recall prompt history after command suggestions decline it");
477
+ assert.match(app, /function recallNextPromptFromHistory\(\)/, "prompt history should support returning toward the current draft");
478
+ assert.match(app, /syncPromptHistoryFromMessages\(latestMessages\)/, "message refresh should seed prompt history from existing user prompts");
479
+ assert.match(app, /function handleNativeAppShortcut\(event\)/, "native app shortcut handling should be centralized");
480
+ assert.match(app, /window\.addEventListener\("keydown", handleNativeAppShortcut, \{ capture: true \}\)/, "native shortcuts should run before textarea-specific key handling");
481
+ assert.match(app, /cycleModelFromShortcut\(event\.shiftKey \? "backward" : "forward"\)/, "Ctrl+P and Shift+Ctrl+P should cycle models");
482
+ assert.match(app, /cycleThinkingFromShortcut\(\)/, "Shift+Tab should cycle thinking level");
483
+ assert.match(app, /setToolOutputGloballyExpanded\(!toolOutputGloballyExpanded, \{ announce: true \}\)/, "Ctrl+O should toggle global tool expansion");
484
+ assert.match(app, /function restoreQueuedMessagesToComposerFromShortcut\(\)/, "Alt+Up should restore queued steering\/follow-up text into the composer");
485
+ assert.match(app, /event\.altKey && key === "ArrowUp"[\s\S]*?restoreQueuedMessagesToComposerFromShortcut\(\)/, "Alt+Up should be handled by native shortcut routing");
486
+ assert.match(app, /clearPromptFromShortcut\(\)/, "Ctrl+C should clear only through a guarded prompt helper");
487
+ assert.match(app, /if \(event\.defaultPrevented\) return;/, "textarea keydown handling should respect app-level shortcut interception");
404
488
  assert.match(app, /return !isMobileView\(\);/, "plain Enter should send only outside mobile view so mobile Return can insert newlines");
405
489
  assert.match(app, /mobile-keyboard-open/, "JS should toggle mobile keyboard mode from viewport/focus state");
406
490
  assert.match(app, /maxVisualViewportHeight - viewportHeight > 120/, "keyboard mode should detect viewport shrink even when keyboard inset is unavailable");
@@ -429,6 +513,11 @@ assert.match(app, /let tabSeenCompletionSerials = new Map\(\)/, "frontend should
429
513
  assert.match(app, /let activeTabGeneration = 0/, "frontend should version active-tab UI state to reject stale async work");
430
514
  assert.match(app, /function isCurrentTabContext\(context\)/, "frontend should identify stale active-tab refresh contexts");
431
515
  assert.match(app, /function connectEvents\(tabContext = activeTabContext\(\)\)[\s\S]*?eventSource !== source/, "frontend should ignore stale SSE messages from old active tabs");
516
+ assert.match(app, /let foregroundReconcileTimer = null/, "frontend should debounce foreground resume reconciliation");
517
+ assert.match(app, /case "webui_connected":[\s\S]*?scheduleForegroundReconcile\("event stream reconnect", 0\)/, "SSE reconnect should reconcile authoritative state and messages instead of only tabs");
518
+ assert.match(app, /async function reconcileForegroundState\(reason = "resume"\)[\s\S]*?refreshTabs\(\)[\s\S]*?ensureActiveEventStream\(tabContext\)[\s\S]*?refreshAll\(tabContext\)/, "foreground reconciliation should refresh tabs plus active transcript after mobile backgrounding");
519
+ assert.match(app, /document\.addEventListener\("visibilitychange"[\s\S]*?scheduleForegroundReconcile\("visibility resume", 0\)/, "returning to a hidden mobile tab should force a server snapshot refresh");
520
+ assert.match(app, /window\.addEventListener\("pageshow", \(\) => scheduleForegroundReconcile\("page show", 0\)\)/, "BFCache or PWA page resume should force a server snapshot refresh");
432
521
  assert.match(app, /async function refreshMessages\(tabContext = activeTabContext\(\)\)[\s\S]*?if \(!isCurrentTabContext\(tabContext\)\) return;/, "message refreshes should not render after the user switches tabs");
433
522
  assert.match(app, /function tabIndicator\(tab\)/, "frontend should derive idle, working, blocked, and work-done tab indicator states");
434
523
  assert.match(app, /pendingBlockerCount > 0[\s\S]*?state: "blocked"/, "frontend should show blocked tabs when extension UI blockers are pending");
@@ -466,7 +555,7 @@ assert.match(app, /bindMobileViewChanges\(/, "side panel state should react to m
466
555
  assert.match(app, /function restoreSidePanelState\(\) \{\n\s+if \(isMobileView\(\)\)/, "mobile should start with side panel collapsed even if desktop state was expanded");
467
556
  assert.match(app, /case "webui_tab_reloaded":/, "frontend should handle native /reload tab restart events");
468
557
  assert.match(app, /addTransientMessage\(\{ role: "native", title: "\/reload"/, "native /reload should produce visible transcript output");
469
- assert.match(app, /navigator\.clipboard\.writeText\(response\.data\.copyText\)/, "native /copy should use the browser clipboard when available");
558
+ assert.match(app, /await copyText\(response\.data\.copyText\)/, "native /copy should use the shared browser clipboard helper when available");
470
559
  assert.match(app, /Clipboard access failed:[\s\S]*?response\.data\.copyText/, "native /copy should show text in transcript when clipboard access fails");
471
560
  assert.match(app, /setTimeout\(\(\) => \{[\s\S]*?refreshAll\(tabContext\)\.catch/, "frontend should refresh state after native /reload restarts the RPC process");
472
561
  assert.match(app, /api\("\/api\/path-fast-picks"/, "frontend should load/save fast picks through the server API");
@@ -490,8 +579,12 @@ assert.ok(icon512.length > icon192.length, "PWA 512px icon should be present and
490
579
  assert.ok(matrixBackground.length > 100000, "Matrix background image should be present as an optimized WebP asset");
491
580
  assert.ok(mochaBackground.length > 8000, "Catppuccin Mocha background image should be present as a compact PNG asset");
492
581
 
493
- assert.match(server, /const NATIVE_SLASH_COMMANDS = \[/, "server should define Pi native slash commands for autocomplete");
494
- assert.match(server, /\{ name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes" \}/, "native /reload should be advertised for autocomplete");
582
+ assert.match(server, /AuthStorage, SessionManager/, "server should import AuthStorage for safe OAuth token refresh");
583
+ assert.match(server, /const CODEX_TOKEN_REFRESH_SKEW_MS = 5 \* 60 \* 1000/, "server should refresh Codex OAuth tokens before they expire");
584
+ assert.match(server, /url\.pathname === "\/api\/codex-usage" && req\.method === "GET"/, "server should expose a sanitized Codex usage endpoint");
585
+ assert.match(server, /OPENAI_CODEX_USAGE_ENDPOINT/, "server should query Codex usage from the backend, not the browser");
586
+ assert.match(server, /const NATIVE_SLASH_COMMANDS = nativeSlashCommandEntries\(\)/, "server should define Pi native slash commands for autocomplete from the parity matrix");
587
+ assert.match(server, /WEBUI_TUI_NATIVE_PARITY\.json/, "native command descriptions should come from the parity matrix source of truth");
495
588
  assert.match(server, /function parseSlashCommand\(message\)/, "server should parse native slash commands before prompt forwarding");
496
589
  assert.match(server, /function generatedTabTitleFromPrompt\(message\)/, "server should derive concise automatic tab titles from first prompts");
497
590
  assert.match(server, /function maybeNameTabForConversation\(tab, command\)/, "server should auto-name default tabs when a conversation starts");
@@ -514,7 +607,7 @@ assert.match(server, /resolvePendingExtensionUiRequest\(tab, payload\.id\)/, "ex
514
607
  assert.match(server, /type: "webui_extension_ui_resolved"[\s\S]*?pendingExtensionUiRequestCount/, "extension UI responses should notify clients that a blocker resolved");
515
608
  assert.match(server, /command\.type === "abort"[\s\S]*?cancelPendingExtensionUiRequests\(tab\)/, "abort should cancel hidden pending extension UI requests");
516
609
  assert.match(server, /type: "webui_extension_ui_cancelled"/, "server should notify browsers when pending extension UI requests are cancelled");
517
- assert.match(server, /async function handleNativeSlashCommand\(tab, body\)/, "server should intercept supported native slash commands");
610
+ assert.match(server, /async function handleNativeSlashCommand\(tab, body, req\)/, "server should intercept supported native slash commands with request context for security guards");
518
611
  assert.match(server, /const restoreTabs = readRestoreTabsFromEnv\(\)/, "server should accept restart tab restore descriptors from the launcher environment");
519
612
  assert.match(server, /delete process\.env\.PI_WEBUI_RESTORE_TABS/, "server should avoid leaking restore descriptors into spawned Pi RPC processes");
520
613
  assert.match(server, /if \(sessionFile && !options\.noSession\) piArgs\.push\("--session", sessionFile\)/, "restored tabs should resume previous session files");
@@ -549,15 +642,28 @@ assert.match(server, /maybeNameTabForConversation\(tab, command\);[\s\S]*?markTa
549
642
  assert.match(server, /function formatSessionOutput\(tab, state, stats\)/, "native /session should have visible Web UI output");
550
643
  assert.match(server, /case "session": \{[\s\S]*?formatSessionOutput\(tab, state\.data \|\| \{\}, stats\.success === false \? null : stats\.data\)/, "native /session should render state and stats through Web UI");
551
644
  assert.match(server, /case "copy": \{[\s\S]*?get_last_assistant_text[\s\S]*?copyText: text/, "native /copy should return text for browser clipboard handling");
645
+ assert.match(server, /case "export": \{[\s\S]*?handleNativeExportCommand\(tab, parsed\.args, req\)/, "native /export should run through the Web UI export helper");
646
+ assert.match(server, /url\.pathname\.startsWith\("\/api\/native-download\/"\) && req\.method === "GET"/, "native /export should expose short-lived opaque download URLs");
647
+ assert.match(app, /function triggerNativeDownload\(download\)/, "frontend should auto-start native command downloads");
648
+ assert.match(server, /case "\/api\/bash": \{[\s\S]*?type: "bash", command, excludeFromContext: body\.excludeFromContext === true/, "server should expose user bash execution with exclude-from-context support");
649
+ assert.match(server, /case "\/api\/abort-bash":[\s\S]*?type: "abort_bash"/, "server should expose user bash abort");
650
+ assert.match(server, /function sendQueuedBashCommand\(tab, command\)/, "server should serialize user bash through a per-tab FIFO queue");
651
+ assert.match(server, /command\.type === "bash" \? await sendQueuedBashCommand\(tab, command\) : await tab\.rpc\.send\(command\)/, "POST routing should use the bash FIFO queue before RPC send");
652
+ assert.match(app, /function parseUserBashInput\(message\)/, "frontend should parse leading ! and !! bash commands");
653
+ assert.match(app, /let userBashQueuesByTab = new Map\(\)/, "frontend should keep a per-tab user bash queue");
654
+ assert.match(app, /enqueueUserBashCommand\(parsed, \{ usesPromptInput, targetTabId \}\)/, "frontend should queue additional bash commands while one is active");
655
+ assert.match(app, /await sendUserBashCommand\(userBash, \{ usesPromptInput, targetTabId \}\)/, "prompt sending should run user bash before normal prompt forwarding");
552
656
  assert.match(server, /case "hotkeys": \{[\s\S]*?webuiHotkeysOutput\(\)/, "native /hotkeys should return Web UI hotkey output");
553
657
  assert.match(server, /url\.pathname === "\/api\/commands" && req\.method === "GET"[\s\S]*?getCommandData\(tab\)/, "GET /api/commands should merge native and RPC-visible commands");
658
+ assert.match(server, /WEBUI_TUI_NATIVE_PARITY\.json/, "server should load the native parity matrix");
659
+ assert.match(server, /url\.pathname === "\/api\/native-parity" && req\.method === "GET"/, "server should expose the native parity matrix endpoint");
554
660
  assert.match(server, /function safeRpcResponse\(tab, command/, "server should provide stopped-RPC fallbacks for refresh endpoints");
555
661
  assert.match(server, /function primeTabRpc\(tab\)/, "server should prime new terminal RPC state before returning created tabs");
556
662
  assert.match(server, /specific Web UI action or final-output cards/, "server feedback-learning prompt should cover final outputs as well as actions");
557
663
  assert.match(server, /function formatActionFeedbackLearningPrompt\(items\)/, "server should convert feedback into a LEARNING prompt");
558
664
  assert.match(server, /url\.pathname === "\/api\/action-feedback" && req\.method === "POST"[\s\S]*?handleActionFeedback\(tab, body\)/, "POST /api/action-feedback should trigger the feedback-learning prompt");
559
665
  assert.match(server, /Wait for the current agent run or compaction to finish before sending feedback\./, "server should only accept post-run feedback submissions");
560
- assert.match(server, /url\.pathname === "\/api\/prompt" && req\.method === "POST"[\s\S]*?handleNativeSlashCommand\(tab, body\)/, "POST /api/prompt should intercept native slash commands before normal prompt forwarding");
666
+ assert.match(server, /url\.pathname === "\/api\/prompt" && req\.method === "POST"[\s\S]*?handleNativeSlashCommand\(tab, body, req\)/, "POST /api/prompt should intercept native slash commands before normal prompt forwarding with request context");
561
667
  assert.match(server, /function fastPicksStorageFile\(/, "server should define a persistent fast-picks storage file");
562
668
  assert.match(server, /PI_WEBUI_FAST_PICKS_FILE/, "server should allow overriding the fast-picks storage path");
563
669
  assert.match(server, /async function getPathSuggestionData\(tab, rawQuery\)/, "server should compute @ file\/path reference suggestions for the active tab cwd");
@@ -605,8 +711,16 @@ assert.match(readme, /## Optional companion packages/, "README should document o
605
711
  assert.match(readme, /checks loaded Pi capabilities directly through RPC-visible commands and live widget events/, "README should document capability-based startup checks");
606
712
  assert.match(readme, /side panel shows each optional feature as enabled, disabled, or install-needed/, "README should document optional feature side-panel controls");
607
713
  assert.match(readme, /Installing a missing feature is an explicit, warned action/, "README should document optional feature install warning behavior");
714
+ assert.match(readme, /\.\/start-webui\.sh --dev --cwd \/path\/to\/project/, "README should document the dev helper launcher");
715
+ assert.match(startScript, /--dev\)/, "start-webui.sh should accept a --dev flag");
716
+ assert.match(startScript, /local_pi_webui_bin\(\)/, "start-webui.sh should resolve this checkout's local server entrypoint");
717
+ assert.match(startScript, /webui_cmd=\(node "\$local_webui_bin"\)/, "start-webui.sh --dev should run the local bin with node");
718
+ 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");
608
719
 
609
- assert.equal(pkg.scripts?.test, "node tests/mobile-static.test.mjs", "package test script should run the mobile static harness");
720
+ assert.match(pkg.scripts?.test || "", /node tests\/mobile-static\.test\.mjs/, "package test script should run the mobile static harness");
721
+ assert.match(pkg.scripts?.test || "", /node tests\/native-parity\.test\.mjs/, "package test script should run the native parity harness");
722
+ assert.ok(pkg.files?.includes("start-webui.sh"), "npm package should include the Bash helper launcher");
723
+ assert.ok(pkg.files?.includes("start-webui.ps1"), "npm package should include the PowerShell helper launcher");
610
724
  for (const [name, range] of Object.entries(companionDependencies)) {
611
725
  assert.equal(pkg.optionalDependencies?.[name], range, `webui package should optionally depend on ${name}`);
612
726
  assert.equal(pkg.dependencies?.[name], undefined, `webui package should not require optional companion ${name}`);
@@ -0,0 +1,148 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
7
+ const [parityRaw, server, app, pkgRaw] = await Promise.all([
8
+ readFile(join(root, "WEBUI_TUI_NATIVE_PARITY.json"), "utf8"),
9
+ readFile(join(root, "bin", "pi-webui.mjs"), "utf8"),
10
+ readFile(join(root, "public", "app.js"), "utf8"),
11
+ readFile(join(root, "package.json"), "utf8"),
12
+ ]);
13
+
14
+ const parity = JSON.parse(parityRaw);
15
+ const pkg = JSON.parse(pkgRaw);
16
+ const statusValues = new Set(parity.statusTaxonomy);
17
+ const guardValues = new Set(parity.guardTaxonomy);
18
+ const kindValues = new Set([
19
+ "slash-command",
20
+ "keyboard-shortcut",
21
+ "editor-feature",
22
+ "session-action",
23
+ "extension-ui-method",
24
+ "native-command-adapter",
25
+ "security-guard",
26
+ "test-harness",
27
+ ]);
28
+ const priorityValues = new Set(["P0", "P1", "P2"]);
29
+
30
+ assert.equal(parity.schemaVersion, 1, "native parity matrix should have a schema version");
31
+ assert.deepEqual(parity.statusTaxonomy, ["implemented", "degraded", "unsupported"], "native parity status taxonomy should stay strict");
32
+ assert.ok(Array.isArray(parity.surfaces) && parity.surfaces.length > 0, "native parity matrix should contain surfaces");
33
+
34
+ const ids = new Set();
35
+ for (const surface of parity.surfaces) {
36
+ assert.equal(typeof surface.id, "string", "every parity surface should have an id");
37
+ assert.ok(surface.id.trim(), "parity surface ids should be non-empty");
38
+ assert.ok(!ids.has(surface.id), `duplicate parity surface id: ${surface.id}`);
39
+ ids.add(surface.id);
40
+ assert.ok(kindValues.has(surface.kind), `${surface.id} should use a known kind`);
41
+ assert.ok(statusValues.has(surface.webStatus), `${surface.id} should use the strict webStatus taxonomy`);
42
+ assert.ok(priorityValues.has(surface.priority), `${surface.id} should declare a P0/P1/P2 priority`);
43
+ assert.equal(typeof surface.sensitive, "boolean", `${surface.id} should declare whether it is sensitive`);
44
+ assert.ok(Array.isArray(surface.guards) && surface.guards.length > 0, `${surface.id} should declare at least one guard`);
45
+ for (const guard of surface.guards) {
46
+ assert.ok(guardValues.has(guard), `${surface.id} guard ${guard} should be in guardTaxonomy`);
47
+ }
48
+ assert.equal(typeof surface.currentBehavior, "string", `${surface.id} should document current behavior`);
49
+ assert.equal(typeof surface.targetBehavior, "string", `${surface.id} should document target behavior`);
50
+ }
51
+
52
+ const slashSurfaces = parity.surfaces.filter((surface) => surface.kind === "slash-command");
53
+ const slashCommandNames = slashSurfaces.map((surface) => surface.command?.name).filter(Boolean);
54
+ const requiredNativeCommands = [
55
+ "settings",
56
+ "model",
57
+ "theme",
58
+ "scoped-models",
59
+ "export",
60
+ "import",
61
+ "share",
62
+ "copy",
63
+ "name",
64
+ "session",
65
+ "changelog",
66
+ "hotkeys",
67
+ "fork",
68
+ "clone",
69
+ "tree",
70
+ "login",
71
+ "logout",
72
+ "new",
73
+ "compact",
74
+ "resume",
75
+ "reload",
76
+ "quit",
77
+ ];
78
+ assert.deepEqual(slashCommandNames, requiredNativeCommands, "matrix slash-command order should define the native command picker order");
79
+
80
+ for (const surface of slashSurfaces) {
81
+ assert.equal(surface.id, `/${surface.command.name}`, `${surface.command.name} slash-command id should match command.name`);
82
+ assert.equal(typeof surface.command.description, "string", `${surface.id} should provide a command description`);
83
+ assert.ok(surface.command.description.trim(), `${surface.id} command description should not be empty`);
84
+ }
85
+
86
+ const selectorMatch = app.match(/const NATIVE_SELECTOR_COMMANDS = new Set\(\[(.*?)\]\)/s);
87
+ assert.ok(selectorMatch, "frontend native selector command set should be discoverable");
88
+ const frontendSelectorCommands = [...selectorMatch[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
89
+ for (const name of frontendSelectorCommands) {
90
+ assert.ok(slashCommandNames.includes(name), `frontend native selector /${name} should be represented in the parity matrix`);
91
+ }
92
+
93
+ const sensitiveCommands = new Set(["export", "import", "share", "login", "logout", "resume", "quit"]);
94
+ for (const name of sensitiveCommands) {
95
+ const surface = slashSurfaces.find((item) => item.command.name === name);
96
+ assert.ok(surface, `sensitive command /${name} should be in the matrix`);
97
+ assert.equal(surface.sensitive, true, `/${name} should be marked sensitive`);
98
+ assert.ok(!surface.guards.includes("none"), `/${name} should not have a no-op guard`);
99
+ }
100
+
101
+ for (const id of [
102
+ "adapter.native-command-response",
103
+ "security.trust-boundaries",
104
+ "tests.native-parity-harness",
105
+ ]) {
106
+ const surface = parity.surfaces.find((item) => item.id === id);
107
+ assert.ok(surface, `P0 foundation surface ${id} should be tracked`);
108
+ assert.equal(surface.priority, "P0", `${id} should remain P0`);
109
+ }
110
+
111
+ assert.match(server, /WEBUI_TUI_NATIVE_PARITY\.json/, "server should load the native parity matrix file");
112
+ assert.match(server, /function nativeSlashCommandEntries\(matrix = nativeParityMatrix\)/, "server should derive native slash commands from the parity matrix");
113
+ assert.match(server, /const NATIVE_SLASH_COMMANDS = nativeSlashCommandEntries\(\)/, "native slash commands should use the matrix-derived source of truth");
114
+ assert.match(server, /function nativeCommandResponse\(command, data = \{\}\)/, "server should define a centralized native command adapter response helper");
115
+ assert.match(server, /function nativeCommandUnavailable\(command, details = \{\}\)/, "server should define structured unavailable native command output");
116
+ assert.match(server, /default:\n\s+return nativeCommandUnavailable\(parsed\.name\)/, "unsupported native commands should return structured unavailable cards instead of raw HTTP errors");
117
+ assert.match(server, /url\.pathname === "\/api\/native-parity" && req\.method === "GET"/, "server should expose the native parity matrix for clients/tests");
118
+ assert.match(server, /const NATIVE_DOWNLOAD_TOKEN_TTL_MS = 10 \* 60 \* 1000/, "native downloads should use short-lived tokens");
119
+ assert.match(server, /function registerNativeDownload\(filePath, \{ fileName, contentType, command = "native" \} = \{\}\)/, "server should register opaque native download tokens");
120
+ assert.match(server, /url\.pathname\.startsWith\("\/api\/native-download\/"\) && req\.method === "GET"/, "server should expose opaque native download endpoint");
121
+ assert.match(server, /case "export": \{\n\s+return handleNativeExportCommand\(tab, parsed\.args, req\);\n\s+\}/, "native /export should route through the native command adapter");
122
+ assert.match(server, /tab\.rpc\.send\(\{ type: "export_html", outputPath \}\)/, "no-path /export should use RPC export_html into a controlled temp path");
123
+ assert.match(server, /registerNativeDownload\(exportedPath/, "no-path /export should return a short-lived browser download token");
124
+ assert.match(server, /copyFile\(sessionFile, targetPath\)/, "explicit .jsonl /export should copy the active session file");
125
+ assert.match(app, /function triggerNativeDownload\(download\)/, "frontend should know how to trigger native command downloads");
126
+ assert.match(app, /response\?\.command === "native_slash_command" && response\.data\?\.download/, "frontend should handle download responses from native commands");
127
+ 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");
128
+ assert.match(server, /case "\/api\/abort-bash":[\s\S]*?return \{ type: "abort_bash" \}/, "server should expose abort_bash for user bash cancellation");
129
+ assert.match(app, /function parseUserBashInput\(message\)[\s\S]*?text\.startsWith\("!!"\)/, "frontend should detect !! bash commands before prompt forwarding");
130
+ assert.match(app, /const userBash = kind === "prompt" && attachments\.length === 0 \? parseUserBashInput\(originalMessage\) : null/, "prompt sending should intercept user bash only for plain prompt input");
131
+ assert.match(app, /api\("\/api\/bash", \{ method: "POST", body: \{ command, excludeFromContext \}/, "frontend should send user bash commands to the bash endpoint");
132
+ assert.match(app, /api\("\/api\/abort-bash", \{ method: "POST", body: \{\}, tabId: tabContext\.tabId \}\)/, "abort should target active user bash before agent abort");
133
+ assert.match(server, /async function cycleTabModel\(tab, direction = "forward"\)/, "server should provide scoped\/all model cycling helper");
134
+ assert.match(server, /url\.pathname === "\/api\/model-cycle" && req\.method === "POST"/, "server should expose model-cycle endpoint for shortcuts");
135
+ assert.match(server, /case "\/api\/thinking-cycle":[\s\S]*?type: "cycle_thinking_level"/, "server should expose thinking-cycle endpoint for shortcuts");
136
+ assert.match(app, /function handleNativeAppShortcut\(event\)/, "frontend should centralize native app shortcut handling");
137
+ assert.match(app, /openNativeModelSelector\(\)/, "Ctrl+L shortcut should open the native model selector");
138
+ assert.match(app, /cycleModelFromShortcut\(event\.shiftKey \? "backward" : "forward"\)/, "Ctrl+P shortcuts should cycle models forward and backward");
139
+ assert.match(app, /cycleThinkingFromShortcut\(\)/, "Shift+Tab shortcut should cycle thinking level");
140
+ assert.match(app, /setToolOutputGloballyExpanded\(!toolOutputGloballyExpanded, \{ announce: true \}\)/, "Ctrl+O shortcut should toggle global tool output expansion");
141
+ assert.match(app, /clearPromptFromShortcut\(\)/, "Ctrl+C shortcut should clear the prompt only through the guarded helper");
142
+ assert.match(app, /function restoreQueuedMessagesToComposerFromShortcut\(\)/, "Alt+Up should restore the observed queue snapshot into the composer");
143
+ assert.match(app, /event\.altKey && key === "ArrowUp"[\s\S]*?restoreQueuedMessagesToComposerFromShortcut\(\)/, "Alt+Up should be routed through native shortcut handling");
144
+ assert.match(app, /let userBashQueuesByTab = new Map\(\)/, "frontend should track per-tab user bash FIFO queues");
145
+ assert.match(app, /enqueueUserBashCommand\(parsed, \{ usesPromptInput, targetTabId \}\)/, "user bash should enqueue while an active or queued bash command exists");
146
+ assert.match(server, /function sendQueuedBashCommand\(tab, command\)/, "server should serialize user bash commands per tab");
147
+ assert.match(server, /command\.type === "bash" \? await sendQueuedBashCommand\(tab, command\) : await tab\.rpc\.send\(command\)/, "generic POST handling should route bash through the FIFO queue");
148
+ assert.ok(pkg.files.includes("WEBUI_TUI_NATIVE_PARITY.json"), "published package should include the native parity matrix");