@firstpick/pi-package-webui 0.1.9 → 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.
- package/README.md +15 -0
- package/WEBUI_TUI_NATIVE_PARITY.json +666 -0
- package/bin/pi-webui.mjs +375 -28
- package/package.json +6 -3
- package/public/app.js +802 -94
- package/public/index.html +25 -21
- package/public/styles.css +209 -82
- package/start-webui.ps1 +323 -0
- package/start-webui.sh +461 -0
- package/tests/mobile-static.test.mjs +118 -12
- package/tests/native-parity.test.mjs +148 -0
|
@@ -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");
|
|
@@ -60,6 +62,22 @@ assert.match(html, /data-side-panel-section="controls"/, "side panel controls sh
|
|
|
60
62
|
assert.match(html, /data-side-panel-section="commands"/, "side panel commands should live in a collapsible section");
|
|
61
63
|
assert.match(html, /class="side-panel-section-toggle"[^>]*aria-controls="sidePanelSectionControls"/, "side panel section toggles should target their content panels");
|
|
62
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
|
+
);
|
|
63
81
|
assert.match(html, /id="jumpToLatestButton"/, "chat should expose a jump-to-latest control for non-forced streaming");
|
|
64
82
|
assert.match(html, /id="stickyUserPromptButton"/, "chat should expose a fixed last-user-prompt jump control");
|
|
65
83
|
assert.match(html, /id="feedbackTray"/, "chat should expose a queued action-feedback tray");
|
|
@@ -102,9 +120,11 @@ assert.match(css, /#promptInput \{[\s\S]*?overflow-y:\s*hidden/, "prompt input s
|
|
|
102
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");
|
|
103
121
|
assert.match(css, /\.message\.extension,[\s\S]*?\.message\.native/, "extension and native command output should have visible transcript styling");
|
|
104
122
|
assert.match(css, /\.message\.run-indicator-message \{[\s\S]*?border-color/, "active agent runs should render a visible transcript indicator card");
|
|
105
|
-
assert.match(css, /\.message\.action-enter \{[\s\S]*?action-card-slide-in/, "new action cards should
|
|
106
|
-
assert.match(css, /@keyframes action-card-slide-in \{[\s\S]*?
|
|
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");
|
|
107
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");
|
|
108
128
|
assert.match(css, /\.message\.toolResult, \.message\.bashExecution, \.message\.compactionSummary/, "compaction summaries should render as compact collapsible transcript cards");
|
|
109
129
|
assert.match(css, /\.message\.toolExecution \{[\s\S]*?border-color/, "paired tool executions should render as distinct TUI-like action cards");
|
|
110
130
|
assert.match(css, /\.tool-diff \{[\s\S]*?font-family:/, "edit tool diffs should have a dedicated monospace renderer");
|
|
@@ -119,9 +139,13 @@ assert.match(css, /\.side-panel-section\.collapsed \.side-panel-section-content,
|
|
|
119
139
|
assert.match(css, /\.side-panel-section:not\(\.collapsed\) \.side-panel-section-chevron/, "expanded side panel sections should rotate the chevron");
|
|
120
140
|
assert.match(css, /\.optional-feature-pill\.enabled/, "optional features should visually distinguish enabled state");
|
|
121
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");
|
|
122
144
|
assert.match(css, /\.todo-widget-item\.partial \.todo-widget-marker/, "todo-progress partial items should have distinct styling");
|
|
123
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");
|
|
124
|
-
assert.match(css, /\.release-npm-widget \{[\s\S]*?
|
|
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");
|
|
125
149
|
assert.match(css, /\.release-aur-widget \{[\s\S]*?border-color/, "release-aur output should render as a specialized Web UI widget variant");
|
|
126
150
|
assert.match(css, /\.widget-area \.widget:not\(\.todo-widget\):not\(\.release-npm-widget\)/, "mobile widget filtering should keep release workflow output visible");
|
|
127
151
|
assert.match(css, /\.message\.warn \.message-role \{ color: var\(--ctp-yellow\); \}/, "warning-level command output should be visually distinct");
|
|
@@ -215,6 +239,8 @@ assert.match(app, /Restart Web UI to load themes/, "frontend should explain when
|
|
|
215
239
|
assert.match(app, /themeSelect\.addEventListener\("change"/, "side-panel theme selector should switch themes immediately");
|
|
216
240
|
assert.match(app, /open \? "Close for network" : "Open to network"/, "network button should toggle from open to close action");
|
|
217
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");
|
|
218
244
|
assert.match(server, /async function closeNetworkAccess\(\)/, "server should expose a local-only rebind helper for closing network access");
|
|
219
245
|
assert.match(server, /url\.pathname === "\/api\/network\/close" && req\.method === "POST"/, "server should route network close requests");
|
|
220
246
|
assert.match(server, /server\.closeAllConnections\?\.\(\)/, "network rebind should force-close long-lived clients so close-to-localhost can complete");
|
|
@@ -256,10 +282,27 @@ assert.match(app, /function hasQueuedDialogRequest\(id\)/, "frontend should dedu
|
|
|
256
282
|
assert.match(app, /if \(request\.replayed\) addEvent\(`recovered pending \$\{request\.method\} request`, "warn"\)/, "frontend should surface replayed extension UI blockers");
|
|
257
283
|
assert.match(app, /case "webui_extension_ui_cancelled":/, "frontend should close dialogs cancelled by backend abort handling");
|
|
258
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");
|
|
259
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");
|
|
260
288
|
assert.match(app, /function resetOptionalFeatureAvailability\(\)/, "optional feature state should reset across active-tab and reload boundaries");
|
|
261
289
|
assert.match(app, /function renderOptionalFeaturePanel\(\)/, "side panel should render optional feature installed/enabled state");
|
|
262
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
|
+
);
|
|
263
306
|
assert.match(app, /function renderCodexUsage\(\)/, "frontend should render Codex usage buckets in the side panel");
|
|
264
307
|
assert.match(app, /api\(`\/api\/codex-usage\$\{suffix\}`, \{ scoped: false \}\)/, "Codex usage should load through a server endpoint without browser credentials");
|
|
265
308
|
assert.match(app, /restoreSidePanelSectionState\(\);\nbindSidePanelSectionToggles\(\);/, "side panel section state should restore before toggles are bound");
|
|
@@ -276,7 +319,12 @@ assert.match(app, /const releasePrompt = detectedReleasePrompt && isOptionalFeat
|
|
|
276
319
|
assert.match(app, /case "webui_tab_reloaded":[\s\S]*resetOptionalFeatureAvailability\(\)/, "optional feature state should reset when the RPC tab reloads resources");
|
|
277
320
|
assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*hasAvailableCommand\(commandName\)/, "publish workflow launch should guard on loaded slash commands");
|
|
278
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");
|
|
279
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");
|
|
280
328
|
assert.match(app, /function renderReleaseAurOutputWidget\(\)/, "release-aur live output should use a specialized Web UI renderer");
|
|
281
329
|
assert.match(app, /releaseNpmActionButton\("Abort", "\/release-abort", "danger"\)/, "release-npm Web UI output should expose an abort action");
|
|
282
330
|
assert.match(app, /releaseNpmActionButton\("Abort", "\/release-aur abort", "danger"\)/, "release-aur Web UI output should expose an abort action");
|
|
@@ -293,6 +341,10 @@ assert.match(app, /appendToolRawDetails\(parent, tool\)/, "paired tool cards sho
|
|
|
293
341
|
assert.match(app, /function toolStateMeta\(tool\)/, "tool cards should expose consistent status and elapsed metadata across built-in renderers");
|
|
294
342
|
assert.match(app, /const TOOL_LIVE_UPDATE_THROTTLE_MS = 80/, "live tool cards should coalesce rapid partial updates before re-rendering");
|
|
295
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");
|
|
296
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");
|
|
297
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");
|
|
298
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");
|
|
@@ -309,7 +361,16 @@ assert.match(app, /if \(isEmptyAssistantTextPart\(part\)\) continue;/, "empty as
|
|
|
309
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");
|
|
310
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");
|
|
311
363
|
assert.match(app, /function ensureStreamingThinkingBubble\(\)[\s\S]*if \(!thinkingOutputVisible\) return false/, "live thinking should respect the show/hide thinking-output toggle");
|
|
312
|
-
assert.match(app, /
|
|
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");
|
|
313
374
|
assert.match(app, /const THINKING_VISIBILITY_STORAGE_KEY = "pi-webui-thinking-visible"/, "thinking visibility should persist in browser storage");
|
|
314
375
|
assert.match(app, /function setThinkingOutputVisible\(visible[\s\S]*renderAllMessages\(\{ preserveScroll: true \}\)/, "thinking visibility changes should immediately re-render the transcript");
|
|
315
376
|
assert.match(app, /function assistantStreamingMessage\(event\)/, "live streaming should read the authoritative partial assistant message from RPC events like the TUI");
|
|
@@ -340,6 +401,11 @@ assert.match(app, /return "Agent is running: ";/, "active agent indicator should
|
|
|
340
401
|
assert.doesNotMatch(app, /"agent running"/, "active agent indicator should not render a separate title/header label");
|
|
341
402
|
assert.doesNotMatch(app, /runIndicatorTimestamp/, "active agent indicator should not render a separate live timestamp header");
|
|
342
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");
|
|
343
409
|
assert.match(app, /runIndicatorShowsElapsed\(\) \? `\$\{detail\} · run time/, "active agent indicator should label elapsed run time instead of showing a bare counter");
|
|
344
410
|
assert.match(app, /Abort requested/, "abort feedback should clarify that Web UI is checking stop status");
|
|
345
411
|
assert.match(app, /const ABORT_LONG_PRESS_MS = 700/, "Abort long-press timing should be explicit");
|
|
@@ -375,7 +441,7 @@ assert.match(app, /item\.addEventListener\("pointermove", \(event\) => setActive
|
|
|
375
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");
|
|
376
442
|
assert.doesNotMatch(app, /addEventListener\("mouseenter", \(\) => setActiveCommandSuggestion\(index\)\)/, "autocomplete should not change active selection on stationary mouseenter");
|
|
377
443
|
assert.match(app, /function resizePromptInput\(\)/, "prompt textarea should auto-resize from a one-line default");
|
|
378
|
-
assert.match(app, /elements\.promptInput\.addEventListener\("input", \(\) => \{\
|
|
444
|
+
assert.match(app, /elements\.promptInput\.addEventListener\("input", \(\) => \{[\s\S]*?resizePromptInput\(\);/, "prompt textarea should resize whenever the user edits it");
|
|
379
445
|
assert.match(app, /function updateComposerModeButtons\(\)/, "composer should relocate Steer and Follow-up based on run state");
|
|
380
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");
|
|
381
447
|
assert.match(app, /const before = runActive \? elements\.abortButton : null/, "active Steer and Follow-up controls should sit before Abort and Send");
|
|
@@ -405,6 +471,20 @@ assert.match(app, /function openNativeTreeSelector\(\)[\s\S]*?\/api\/session-tre
|
|
|
405
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");
|
|
406
472
|
assert.match(app, /const HIDDEN_COMMAND_NAMES = new Set\(\["webui-tree-navigate"\]\)/, "internal Web UI helper commands should stay out of command pickers");
|
|
407
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");
|
|
408
488
|
assert.match(app, /return !isMobileView\(\);/, "plain Enter should send only outside mobile view so mobile Return can insert newlines");
|
|
409
489
|
assert.match(app, /mobile-keyboard-open/, "JS should toggle mobile keyboard mode from viewport/focus state");
|
|
410
490
|
assert.match(app, /maxVisualViewportHeight - viewportHeight > 120/, "keyboard mode should detect viewport shrink even when keyboard inset is unavailable");
|
|
@@ -433,6 +513,11 @@ assert.match(app, /let tabSeenCompletionSerials = new Map\(\)/, "frontend should
|
|
|
433
513
|
assert.match(app, /let activeTabGeneration = 0/, "frontend should version active-tab UI state to reject stale async work");
|
|
434
514
|
assert.match(app, /function isCurrentTabContext\(context\)/, "frontend should identify stale active-tab refresh contexts");
|
|
435
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");
|
|
436
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");
|
|
437
522
|
assert.match(app, /function tabIndicator\(tab\)/, "frontend should derive idle, working, blocked, and work-done tab indicator states");
|
|
438
523
|
assert.match(app, /pendingBlockerCount > 0[\s\S]*?state: "blocked"/, "frontend should show blocked tabs when extension UI blockers are pending");
|
|
@@ -470,7 +555,7 @@ assert.match(app, /bindMobileViewChanges\(/, "side panel state should react to m
|
|
|
470
555
|
assert.match(app, /function restoreSidePanelState\(\) \{\n\s+if \(isMobileView\(\)\)/, "mobile should start with side panel collapsed even if desktop state was expanded");
|
|
471
556
|
assert.match(app, /case "webui_tab_reloaded":/, "frontend should handle native /reload tab restart events");
|
|
472
557
|
assert.match(app, /addTransientMessage\(\{ role: "native", title: "\/reload"/, "native /reload should produce visible transcript output");
|
|
473
|
-
assert.match(app, /
|
|
558
|
+
assert.match(app, /await copyText\(response\.data\.copyText\)/, "native /copy should use the shared browser clipboard helper when available");
|
|
474
559
|
assert.match(app, /Clipboard access failed:[\s\S]*?response\.data\.copyText/, "native /copy should show text in transcript when clipboard access fails");
|
|
475
560
|
assert.match(app, /setTimeout\(\(\) => \{[\s\S]*?refreshAll\(tabContext\)\.catch/, "frontend should refresh state after native /reload restarts the RPC process");
|
|
476
561
|
assert.match(app, /api\("\/api\/path-fast-picks"/, "frontend should load/save fast picks through the server API");
|
|
@@ -498,8 +583,8 @@ assert.match(server, /AuthStorage, SessionManager/, "server should import AuthSt
|
|
|
498
583
|
assert.match(server, /const CODEX_TOKEN_REFRESH_SKEW_MS = 5 \* 60 \* 1000/, "server should refresh Codex OAuth tokens before they expire");
|
|
499
584
|
assert.match(server, /url\.pathname === "\/api\/codex-usage" && req\.method === "GET"/, "server should expose a sanitized Codex usage endpoint");
|
|
500
585
|
assert.match(server, /OPENAI_CODEX_USAGE_ENDPOINT/, "server should query Codex usage from the backend, not the browser");
|
|
501
|
-
assert.match(server, /const NATIVE_SLASH_COMMANDS = \
|
|
502
|
-
assert.match(server,
|
|
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");
|
|
503
588
|
assert.match(server, /function parseSlashCommand\(message\)/, "server should parse native slash commands before prompt forwarding");
|
|
504
589
|
assert.match(server, /function generatedTabTitleFromPrompt\(message\)/, "server should derive concise automatic tab titles from first prompts");
|
|
505
590
|
assert.match(server, /function maybeNameTabForConversation\(tab, command\)/, "server should auto-name default tabs when a conversation starts");
|
|
@@ -522,7 +607,7 @@ assert.match(server, /resolvePendingExtensionUiRequest\(tab, payload\.id\)/, "ex
|
|
|
522
607
|
assert.match(server, /type: "webui_extension_ui_resolved"[\s\S]*?pendingExtensionUiRequestCount/, "extension UI responses should notify clients that a blocker resolved");
|
|
523
608
|
assert.match(server, /command\.type === "abort"[\s\S]*?cancelPendingExtensionUiRequests\(tab\)/, "abort should cancel hidden pending extension UI requests");
|
|
524
609
|
assert.match(server, /type: "webui_extension_ui_cancelled"/, "server should notify browsers when pending extension UI requests are cancelled");
|
|
525
|
-
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");
|
|
526
611
|
assert.match(server, /const restoreTabs = readRestoreTabsFromEnv\(\)/, "server should accept restart tab restore descriptors from the launcher environment");
|
|
527
612
|
assert.match(server, /delete process\.env\.PI_WEBUI_RESTORE_TABS/, "server should avoid leaking restore descriptors into spawned Pi RPC processes");
|
|
528
613
|
assert.match(server, /if \(sessionFile && !options\.noSession\) piArgs\.push\("--session", sessionFile\)/, "restored tabs should resume previous session files");
|
|
@@ -557,15 +642,28 @@ assert.match(server, /maybeNameTabForConversation\(tab, command\);[\s\S]*?markTa
|
|
|
557
642
|
assert.match(server, /function formatSessionOutput\(tab, state, stats\)/, "native /session should have visible Web UI output");
|
|
558
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");
|
|
559
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");
|
|
560
656
|
assert.match(server, /case "hotkeys": \{[\s\S]*?webuiHotkeysOutput\(\)/, "native /hotkeys should return Web UI hotkey output");
|
|
561
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");
|
|
562
660
|
assert.match(server, /function safeRpcResponse\(tab, command/, "server should provide stopped-RPC fallbacks for refresh endpoints");
|
|
563
661
|
assert.match(server, /function primeTabRpc\(tab\)/, "server should prime new terminal RPC state before returning created tabs");
|
|
564
662
|
assert.match(server, /specific Web UI action or final-output cards/, "server feedback-learning prompt should cover final outputs as well as actions");
|
|
565
663
|
assert.match(server, /function formatActionFeedbackLearningPrompt\(items\)/, "server should convert feedback into a LEARNING prompt");
|
|
566
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");
|
|
567
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");
|
|
568
|
-
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");
|
|
569
667
|
assert.match(server, /function fastPicksStorageFile\(/, "server should define a persistent fast-picks storage file");
|
|
570
668
|
assert.match(server, /PI_WEBUI_FAST_PICKS_FILE/, "server should allow overriding the fast-picks storage path");
|
|
571
669
|
assert.match(server, /async function getPathSuggestionData\(tab, rawQuery\)/, "server should compute @ file\/path reference suggestions for the active tab cwd");
|
|
@@ -613,8 +711,16 @@ assert.match(readme, /## Optional companion packages/, "README should document o
|
|
|
613
711
|
assert.match(readme, /checks loaded Pi capabilities directly through RPC-visible commands and live widget events/, "README should document capability-based startup checks");
|
|
614
712
|
assert.match(readme, /side panel shows each optional feature as enabled, disabled, or install-needed/, "README should document optional feature side-panel controls");
|
|
615
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");
|
|
616
719
|
|
|
617
|
-
assert.
|
|
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");
|
|
618
724
|
for (const [name, range] of Object.entries(companionDependencies)) {
|
|
619
725
|
assert.equal(pkg.optionalDependencies?.[name], range, `webui package should optionally depend on ${name}`);
|
|
620
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");
|