@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.
- package/README.md +27 -24
- package/WEBUI_TUI_NATIVE_PARITY.json +666 -0
- package/bin/pi-webui.mjs +686 -29
- package/package.json +6 -3
- package/public/app.js +1007 -94
- package/public/index.html +36 -18
- package/public/styles.css +286 -82
- package/start-webui.ps1 +323 -0
- package/start-webui.sh +461 -0
- package/tests/mobile-static.test.mjs +126 -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");
|
|
@@ -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
|
|
104
|
-
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");
|
|
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]*?
|
|
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, /
|
|
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", \(\) => \{\
|
|
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, /
|
|
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, /
|
|
494
|
-
assert.match(server,
|
|
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.
|
|
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");
|