@firstpick/pi-package-webui 0.5.1 → 0.5.3
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 +94 -27
- package/bin/pi-webui.mjs +47 -0
- package/images/Webui_MainWindow_v0.4.8.png +0 -0
- package/package.json +2 -2
- package/public/app.js +328 -2
- package/public/index.html +21 -2
- package/public/styles.css +161 -0
- package/tests/http-endpoints-harness.test.mjs +10 -0
- package/tests/mobile-static.test.mjs +16 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Local browser UI for [Pi coding agent](https://www.npmjs.com/package/@earendil-works/pi-coding-agent).
|
|
4
4
|
|
|
5
|
-

|
|
6
6
|
|
|
7
7
|
Pi Web UI gives you a local browser companion for Pi: multi-tab chat, streaming output, model controls, uploads, slash-command helpers, workspace navigation, and optional extension widgets.
|
|
8
8
|
|
|
@@ -119,34 +119,75 @@ PI_WEBUI_PI_BIN=/path/to/pi pi-webui --no-session
|
|
|
119
119
|
|
|
120
120
|
Environment variables:
|
|
121
121
|
|
|
122
|
-
- `PI_WEBUI_HOST`
|
|
123
|
-
- `
|
|
124
|
-
- `
|
|
125
|
-
- `
|
|
126
|
-
- `
|
|
122
|
+
- `PI_WEBUI_HOST` and `PI_WEBUI_PORT` set the default bind address.
|
|
123
|
+
- `PI_WEBUI_PI_BIN=/path/to/pi` selects the Pi executable when `--pi` is not passed.
|
|
124
|
+
- `PI_WEBUI_REMOTE_AUTH=1` starts with Remote PIN authentication enabled.
|
|
125
|
+
- `PI_WEBUI_SETTINGS_FILE=/path/to/settings.json` overrides persisted Web UI settings such as the Remote PIN auth preference.
|
|
126
|
+
- `PI_WEBUI_OPTIONAL_FEATURE_INSTALL_ROOT=/path/to/package-root` overrides the npm prefix used for optional companion installs.
|
|
127
|
+
- `PI_WEBUI_FAST_PICKS_FILE=/path/to/paths.json` overrides saved cwd fast-pick storage.
|
|
128
|
+
- `PI_WEBUI_NPM_BIN=/path/to/npm` selects the npm executable used by optional feature install/update actions.
|
|
127
129
|
|
|
128
130
|
## Main features
|
|
129
131
|
|
|
130
132
|
- Pathless `pi-webui` startup: the server opens first, then the browser prompts for the first terminal CWD.
|
|
131
|
-
- Multi-tab Pi sessions with isolated processes, working directories, prompt drafts, activity state, and a workspace dashboard for common actions.
|
|
132
|
-
- Unified command palette (`Ctrl/Cmd+K`) for commands, tabs, models, sessions, settings, and frequent Web UI actions.
|
|
133
|
+
- Multi-tab Pi sessions with isolated processes, working directories, prompt drafts, activity state, per-tab settings, and a workspace dashboard for common actions.
|
|
134
|
+
- Unified command palette (`Ctrl/Cmd+K`) for commands, tabs, models, sessions, settings, app controls, and frequent Web UI actions.
|
|
133
135
|
- Automatic tab naming from the first prompt, with `--name <name>` still available for an explicit initial tab name.
|
|
134
|
-
- Streaming chat transcript with Markdown, thinking output, tool/bash cards, queue and compaction events, edit-and-retry from user prompts, and guarded abort controls that require holding Esc or the Abort button for 3 seconds.
|
|
135
|
-
- Prompt composer with uploads, drag/drop/paste, inline image support, slash-command autocomplete, and `@` file/path references with live suggestions.
|
|
136
|
-
-
|
|
137
|
-
-
|
|
138
|
-
-
|
|
136
|
+
- Streaming chat transcript with Markdown, thinking output, tool/bash cards, queue and compaction events, edit-and-retry from user prompts, transcript search, copy buttons, and guarded abort controls that require holding Esc or the Abort button for 3 seconds.
|
|
137
|
+
- Prompt composer with uploads, drag/drop/paste, inline image support, generated text attachments for long input or clipboard text, editable text attachments, slash-command autocomplete, and `@` file/path references with live suggestions.
|
|
138
|
+
- Leading `!` and `!!` user-bash commands from the composer, serialized per tab; `!` keeps output in the next model context and `!!` excludes it.
|
|
139
|
+
- Browser-native Pi dialogs for `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/name`, `/resume`, `/tree`, `/login`, `/logout`, `/scoped-models`, `/tools`, and `/skills`, plus native-command adapter output for `/copy`, `/session`, `/new`, `/compact`, `/reload`, and `/export`.
|
|
140
|
+
- Runtime `/tools` and `/skills` selectors backed by the hidden Web UI RPC helper; skill toggles persist on the session branch, disabled skills are removed from the system prompt, and tracked `SKILL.md` files can be opened/edited from skill tags.
|
|
141
|
+
- Session resume/switch, metadata rename, and localhost-only safe delete with active/open-tab/session-directory guards.
|
|
142
|
+
- Model, thinking, session, workspace, theme, optional-feature, Codex usage, optional Remote WebUI, update/restart/stop, event, notification, thinking-visibility, terminal-tab-layout, and custom-background controls in collapsible side-panel sections.
|
|
143
|
+
- Persistent context-window meter with manual compact and auto-compaction controls near the composer; side-panel thinking changes made while a tab is busy are queued for the next prompt.
|
|
139
144
|
- Side-panel theme picker backed by optional `@firstpick/pi-themes-bundle` themes when loaded.
|
|
140
|
-
- Per-tab cwd changes, a clickable footer cwd picker, saved path fast picks, server-persisted fast picks, and restart-safe restoration of open tabs.
|
|
145
|
+
- Per-tab cwd changes, a clickable footer cwd picker, directory creation/search in the picker, saved path fast picks, server-persisted fast picks, and restart-safe restoration of open tabs.
|
|
141
146
|
- Detected app runner dropdown for the active tab cwd, including Cargo, Bun, npm/npx/pnpm, Python/uv, Go/Golang, Zig, C/C++, Docker Compose, root/dev/scripts shell scripts, and other common project runners with live output pinned at the top of the terminal. Running app runners expose line-oriented stdin in the widget for interactive scripts. Projects can add browseable custom runners in `.pi-webui-runners.json` with a command (default `./`) plus a relative path to the file to run.
|
|
147
|
+
- Guided Git workflow for existing repos and new repos: initialize, create README/.gitignore, initial commit, rename to `main`, add a GitHub remote, pull fetched incoming changes, stage, generate or type commit messages, push, and optionally create a PR.
|
|
142
148
|
- Browser support for Pi extension UI prompts, widgets, status updates, `/btw` side-question output widgets with optional context transfer/live steering, browser notifications when a tab needs an extension UI response, and an optional side-panel toggle for agent-done notifications.
|
|
143
149
|
- Localhost-only Pi/Web UI update checks with a top-right update notification and confirmed restart actions: **Update Pi & restart** runs `pi update` for Pi-only updates, while **Update Pi + Packages & Restart** runs `pi update --all` for Pi plus configured packages.
|
|
144
150
|
- Feedback reactions (`👍`, `👎`, `?`) on final assistant output plus tool/bash action cards, which can ask Pi to create or update a LEARNING.
|
|
145
|
-
- Mobile-friendly layout
|
|
151
|
+
- Mobile-friendly layout, PWA install support where the browser allows it, backend-offline recovery, and a dedicated server-restart overlay while confirmed restart/update actions run.
|
|
146
152
|
|
|
147
|
-
##
|
|
153
|
+
## Native Pi command coverage
|
|
148
154
|
|
|
149
|
-
|
|
155
|
+
Web UI keeps a packaged parity matrix at `dev/docs/WEBUI_TUI_NATIVE_PARITY.json` and exposes it at `GET /api/native-parity`.
|
|
156
|
+
|
|
157
|
+
| Status | Commands and behavior |
|
|
158
|
+
| --- | --- |
|
|
159
|
+
| Implemented | `/model`, `/settings`, `/tools`, `/skills`, `/copy`, `/name`, `/session`, `/clone`, `/logout`, `/new`, `/compact`, and `/reload` use browser-native dialogs or structured native-command cards. |
|
|
160
|
+
| Degraded / browser-specific | `/theme` changes the browser Web UI theme only; `/scoped-models` points to the footer scoped-model picker; `/export` supports no-path HTML downloads plus explicit new `.html`/`.jsonl` server paths; `/hotkeys` lists Web UI shortcuts; `/fork`, `/tree`, `/login`, and `/resume` have browser flows with documented gaps. |
|
|
161
|
+
| Unsupported in Web UI | `/import`, `/share`, `/changelog`, and `/quit` return structured unavailable output instead of raw HTTP errors. |
|
|
162
|
+
|
|
163
|
+
Sensitive native flows use shared trust-boundary guards: localhost-only APIs, trusted-context checks for LAN clients, confirmation-oriented dialogs, and session-directory confinement for session file operations.
|
|
164
|
+
|
|
165
|
+
## Keyboard shortcuts
|
|
166
|
+
|
|
167
|
+
| Shortcut | Action |
|
|
168
|
+
| --- | --- |
|
|
169
|
+
| `Ctrl/Cmd+K` | Open the command palette. |
|
|
170
|
+
| `Ctrl/Cmd+L` | Open the model selector. |
|
|
171
|
+
| `Ctrl/Cmd+P` / `Shift+Ctrl/Cmd+P` | Cycle scoped or available models forward/backward. |
|
|
172
|
+
| `Shift+Tab` | Cycle thinking effort. |
|
|
173
|
+
| `Ctrl/Cmd+T` | Toggle thinking-output visibility. |
|
|
174
|
+
| `Ctrl/Cmd+O` | Toggle global expansion for tool and bash output cards. |
|
|
175
|
+
| `Alt+Enter` | Queue the composer as a follow-up. |
|
|
176
|
+
| `Alt+Up` | Restore the latest observed steering/follow-up queue snapshot into the composer. |
|
|
177
|
+
| hold `Esc` | Abort active user bash first, then active agent work. |
|
|
178
|
+
| `Ctrl/Cmd+C` in an empty, focused composer | Clear the prompt. |
|
|
179
|
+
| `Ctrl/Cmd+F` | Search the transcript. |
|
|
180
|
+
|
|
181
|
+
## Feature gallery (screenshots from v0.4.8)
|
|
182
|
+
|
|
183
|
+
These screenshots show the v0.4.8 Web UI surfaces. Current implementations include the additional native-command, shortcut, attachment, Git, app-runner, server-control, and safety features documented above. Unless noted otherwise, actions apply to the active tab and its current working directory.
|
|
184
|
+
|
|
185
|
+
### Main window
|
|
186
|
+
|
|
187
|
+

|
|
188
|
+
|
|
189
|
+
- **What it is:** The primary Web UI workspace for Pi, with terminal tabs, chat transcript, live assistant output, footer metrics, prompt composer, attachments, and side-panel controls in one browser view.
|
|
190
|
+
- **What you can do:** Run multiple Pi sessions, send prompts or follow-ups, monitor tokens/cache/cost/context/git/model state, attach files, launch quick actions, and control the active session without returning to the terminal.
|
|
150
191
|
|
|
151
192
|
### Workspace dashboard
|
|
152
193
|
|
|
@@ -262,12 +303,25 @@ These screenshots show the v0.4.8 Web UI surfaces. Unless noted otherwise, actio
|
|
|
262
303
|
|
|
263
304
|
Useful browser endpoints exposed by the local server include:
|
|
264
305
|
|
|
306
|
+
- `GET /api/health` and `GET /api/webui-status?detailed=1` for server health, network exposure, tabs, sessions, models/providers, update state, and recent events.
|
|
307
|
+
- `GET /api/tabs`, `POST /api/tabs`, `PATCH /api/tabs/<tabId>`, and tab close/delete routes for multi-tab lifecycle management.
|
|
308
|
+
- `GET /api/messages?tab=<tabId>&since=<index>` for transcript snapshots or delta refreshes.
|
|
309
|
+
- `POST /api/prompt`, `POST /api/follow-up`, `POST /api/steer`, `POST /api/bash`, `POST /api/abort`, and `POST /api/abort-bash` for tab-scoped Pi interaction.
|
|
310
|
+
- `POST /api/attachments` for uploaded/generated prompt attachments and inline images.
|
|
265
311
|
- `GET /api/path-suggestions?tab=<tabId>&query=<path>` for `@` file/path references with live suggestions.
|
|
312
|
+
- `GET /api/path-fast-picks` and `POST /api/path-fast-picks` for server-persisted cwd fast picks.
|
|
313
|
+
- `GET /api/native-parity` for the packaged native TUI/Web UI parity matrix.
|
|
314
|
+
- `GET /api/settings`, `POST /api/settings`, `GET /api/tools`, `POST /api/tools`, `GET /api/skills`, and `POST /api/skills` for browser-native Pi settings/tool/skill selectors.
|
|
315
|
+
- `GET /api/skill-file` and localhost-only `POST /api/skill-file` for guarded `SKILL.md` editing from tracked skill tags.
|
|
316
|
+
- `GET /api/sessions`, `GET /api/session-tree`, `POST /api/switch-session`, `POST /api/session-rename`, and localhost-only `POST /api/session-delete` for resume/tree/session metadata flows.
|
|
317
|
+
- `GET /api/auth-providers` and localhost-only `POST /api/auth-logout` for provider-auth status and stored-credential removal.
|
|
318
|
+
- `GET /api/app-runners`, `POST /api/app-runner`, `POST /api/app-runner/input`, `POST /api/app-runner/stop`, `GET/POST/DELETE /api/app-runner-config`, and `GET /api/app-runner-files` for detected and custom project runners.
|
|
319
|
+
- `GET /api/git-changes`, `POST /api/git-changes/pull`, `GET /api/git-branches`, `POST /api/git-branch`, and `/api/git-workflow/*` for browser Git status, diff, branch, init, commit, push, and PR helpers.
|
|
266
320
|
- `POST /api/action-feedback?tab=<tabId>` for feedback on final assistant output and action cards.
|
|
267
321
|
- `GET /api/optional-features` for optional companion package install/update status.
|
|
268
322
|
- `POST /api/optional-feature-install` for installing or updating known optional companion packages from the side panel.
|
|
269
|
-
- `GET /api/update-status` and localhost-only `POST /api/update` for checking Pi/Web UI updates and
|
|
270
|
-
- `GET /api/remote-auth`, `POST /api/remote-auth`, and localhost-only `POST /api/remote-auth/settings` for optional 4-digit PIN authentication when serving non-local browser clients.
|
|
323
|
+
- `GET /api/update-status`, localhost-only `POST /api/restart`, and localhost-only `POST /api/update` for checking Pi/Web UI updates and restarting the Web UI. Use `POST /api/update?all=1` to run `pi update --all` for Pi plus configured packages.
|
|
324
|
+
- `GET /api/network`, localhost-only `POST /api/network/open`, localhost-only `POST /api/network/close`, `GET /api/remote-auth`, `POST /api/remote-auth`, and localhost-only `POST /api/remote-auth/settings` for trusted-LAN exposure and optional 4-digit PIN authentication when serving non-local browser clients.
|
|
271
325
|
|
|
272
326
|
For local development, run the checkout helper directly, for example:
|
|
273
327
|
|
|
@@ -301,19 +355,32 @@ Optional companions:
|
|
|
301
355
|
|
|
302
356
|
## Guided Git workflow
|
|
303
357
|
|
|
304
|
-
The Git workflow button runs local git commands in the active Pi working directory
|
|
358
|
+
The Git workflow button runs local git commands in the active Pi working directory. It now covers both empty/new projects and existing repositories.
|
|
359
|
+
|
|
360
|
+
For a new project, the browser flow can:
|
|
361
|
+
|
|
362
|
+
1. Run `git init` when the active cwd is not yet a repository.
|
|
363
|
+
2. Check for `README.md` and `.gitignore`.
|
|
364
|
+
3. Create and stage starter `README.md`/`.gitignore` files without overwriting existing files.
|
|
365
|
+
4. Create an initial commit.
|
|
366
|
+
5. Rename the branch to `main`.
|
|
367
|
+
6. Add a GitHub remote from a confirmed `owner/repo`.
|
|
368
|
+
7. Push the initialized branch when you confirm the remote target.
|
|
369
|
+
|
|
370
|
+
For an existing repository, the workflow can:
|
|
305
371
|
|
|
306
|
-
1.
|
|
307
|
-
2.
|
|
308
|
-
3.
|
|
309
|
-
4.
|
|
310
|
-
5.
|
|
372
|
+
1. Show staged, unstaged, untracked, and fetched incoming changes.
|
|
373
|
+
2. Fast-forward pull fetched incoming commits when the repository is safely behind.
|
|
374
|
+
3. Run `git add .`.
|
|
375
|
+
4. Send `/git-staged-msg` to Pi and read generated commit message files from `dev/COMMIT/`.
|
|
376
|
+
5. Use a generated short/long message, a generated single-file default such as `updated file.txt`, or a manual **Commit input** message.
|
|
377
|
+
6. Run `git push`.
|
|
311
378
|
|
|
312
379
|
After the message is generated, **Create PR** asks Pi to generate `dev/COMMIT/staged-branch-name.txt`, lets you confirm or edit the `type/feature-name` branch, then switches with `git switch -c` before committing. In PR mode, choose **Commit short**, **Commit long**, or type a message and use **Commit input**, then **Push and Create PR** pushes the branch, sends `/pr`, shows the generated `dev/PR/<branch>.md` description for editing/confirmation, and creates the pull request with `gh pr create`. Use **Manual branch** to skip agent branch-name generation and type the branch directly.
|
|
313
380
|
|
|
314
|
-
Use the workflow process buttons to jump directly to **Stage**, **Message**, **Commit**,
|
|
381
|
+
Use the workflow process buttons to jump directly to **Initialize**, **Stage**, **Message**, **Commit**, **Push**, or PR steps when earlier work was already completed manually. Selecting **Message** lets you either run `/git-staged-msg` or type a commit message and use **Commit input** directly. Selecting **Commit** loads the current generated files from `dev/COMMIT/` before enabling the commit choices. A yellow dot means that process was selected or is available but its action has not completed in this workflow; green means the process action completed.
|
|
315
382
|
|
|
316
|
-
This requires `/git-staged-msg` and `/pr` from `@firstpick/pi-prompts-git-pr`; branch-name generation uses `/git-branch-name` when available and otherwise sends an equivalent inline prompt. Creating the PR also requires an authenticated GitHub CLI (`gh`). Review the generated commit message, branch name, and PR description before committing, pushing, or creating a PR.
|
|
383
|
+
This requires `/git-staged-msg` and `/pr` from `@firstpick/pi-prompts-git-pr`; branch-name generation uses `/git-branch-name` when available and otherwise sends an equivalent inline prompt. Creating the PR also requires an authenticated GitHub CLI (`gh`). Review the generated commit message, branch name, remote URL, and PR description before committing, pushing, or creating a PR.
|
|
317
384
|
|
|
318
385
|
## Mobile and PWA notes
|
|
319
386
|
|
package/bin/pi-webui.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
guardsForNativeCommand,
|
|
27
27
|
isLocalRequest,
|
|
28
28
|
remoteShellTrustWarning,
|
|
29
|
+
requireLocalhost,
|
|
29
30
|
requireLocalhostRoute,
|
|
30
31
|
} from "../lib/trust-boundaries.mjs";
|
|
31
32
|
import {
|
|
@@ -54,6 +55,7 @@ try {
|
|
|
54
55
|
}
|
|
55
56
|
const nativeParityMatrix = JSON.parse(await readFile(path.join(packageRoot, "dev", "docs", "WEBUI_TUI_NATIVE_PARITY.json"), "utf8"));
|
|
56
57
|
const webuiDevServer = isTruthyEnv(process.env.PI_WEBUI_DEV) || isSourceCheckout(packageRoot);
|
|
58
|
+
let remoteQrCorePromise = null;
|
|
57
59
|
|
|
58
60
|
const DEFAULT_HOST = "127.0.0.1";
|
|
59
61
|
const DEFAULT_PORT = 31415;
|
|
@@ -6977,6 +6979,45 @@ function networkStatus({ includeAuthPin = false } = {}) {
|
|
|
6977
6979
|
};
|
|
6978
6980
|
}
|
|
6979
6981
|
|
|
6982
|
+
async function loadRemoteQrCore() {
|
|
6983
|
+
if (!remoteQrCorePromise) {
|
|
6984
|
+
remoteQrCorePromise = (async () => {
|
|
6985
|
+
const candidates = [];
|
|
6986
|
+
try {
|
|
6987
|
+
candidates.push(require.resolve("@firstpick/pi-package-remote-webui/lib/remote-core.mjs", { paths: [packageRoot] }));
|
|
6988
|
+
} catch {
|
|
6989
|
+
// Optional companion package is not installed; try the monorepo sibling below.
|
|
6990
|
+
}
|
|
6991
|
+
candidates.push(path.resolve(packageRoot, "..", "pi-package-remote-webui", "lib", "remote-core.mjs"));
|
|
6992
|
+
let lastError;
|
|
6993
|
+
for (const candidate of candidates) {
|
|
6994
|
+
try {
|
|
6995
|
+
await access(candidate);
|
|
6996
|
+
return await import(pathToFileURL(candidate).href);
|
|
6997
|
+
} catch (error) {
|
|
6998
|
+
lastError = error;
|
|
6999
|
+
}
|
|
7000
|
+
}
|
|
7001
|
+
throw lastError || new Error("Remote WebUI QR support is unavailable");
|
|
7002
|
+
})();
|
|
7003
|
+
}
|
|
7004
|
+
return remoteQrCorePromise;
|
|
7005
|
+
}
|
|
7006
|
+
|
|
7007
|
+
function networkQrDisplayUrl(network) {
|
|
7008
|
+
const urls = Array.isArray(network?.networkUrls) ? network.networkUrls : [];
|
|
7009
|
+
return urls.find((candidate) => typeof candidate === "string" && /^https?:\/\//i.test(candidate)) || network?.localUrl || `http://127.0.0.1:${options.port}/`;
|
|
7010
|
+
}
|
|
7011
|
+
|
|
7012
|
+
async function remoteNetworkQrPayload() {
|
|
7013
|
+
const network = networkStatus({ includeAuthPin: true });
|
|
7014
|
+
const { generateQrLines, remoteAuthQrUrl } = await loadRemoteQrCore();
|
|
7015
|
+
const displayUrl = networkQrDisplayUrl(network);
|
|
7016
|
+
const qrUrl = remoteAuthQrUrl(displayUrl, network);
|
|
7017
|
+
const qrLines = await generateQrLines(qrUrl);
|
|
7018
|
+
return { url: displayUrl, qrUrl, qrLines, network };
|
|
7019
|
+
}
|
|
7020
|
+
|
|
6980
7021
|
function closeSseClientsForRebind(nextHost) {
|
|
6981
7022
|
for (const tab of tabs.values()) {
|
|
6982
7023
|
const rebindEvent = {
|
|
@@ -7406,6 +7447,12 @@ const server = createServer(async (req, res) => {
|
|
|
7406
7447
|
return;
|
|
7407
7448
|
}
|
|
7408
7449
|
|
|
7450
|
+
if (url.pathname === "/api/network/qr" && req.method === "GET") {
|
|
7451
|
+
requireLocalhost(req, "Remote QR generation is only allowed from localhost");
|
|
7452
|
+
sendJson(res, 200, { ok: true, data: await remoteNetworkQrPayload() });
|
|
7453
|
+
return;
|
|
7454
|
+
}
|
|
7455
|
+
|
|
7409
7456
|
if (url.pathname === "/api/network/open" && req.method === "POST") {
|
|
7410
7457
|
requireLocalhostRoute(req, url.pathname);
|
|
7411
7458
|
const before = networkStatus({ includeAuthPin: true });
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firstpick/pi-package-webui",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/Firstp1ck/npm-packages/tree/main/pi-package-webui#readme",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"extension"
|
|
21
21
|
],
|
|
22
22
|
"pi": {
|
|
23
|
-
"image": "https://
|
|
23
|
+
"image": "https://raw.githubusercontent.com/Firstp1ck/npm-packages/main/pi-package-webui/images/Webui_MainWindow_v0.4.8.png",
|
|
24
24
|
"extensions": [
|
|
25
25
|
"./index.ts",
|
|
26
26
|
"node_modules/@firstpick/pi-extension-btw/index.ts",
|
package/public/app.js
CHANGED
|
@@ -141,6 +141,13 @@ const elements = {
|
|
|
141
141
|
remoteAuthToggle: $("#remoteAuthToggle"),
|
|
142
142
|
remoteAuthStatus: $("#remoteAuthStatus"),
|
|
143
143
|
openNetworkButton: $("#openNetworkButton"),
|
|
144
|
+
remoteQrDialog: $("#remoteQrDialog"),
|
|
145
|
+
remoteQrMessage: $("#remoteQrMessage"),
|
|
146
|
+
remoteQrBody: $("#remoteQrBody"),
|
|
147
|
+
remoteQrCopyButton: $("#remoteQrCopyButton"),
|
|
148
|
+
remoteQrOpenButton: $("#remoteQrOpenButton"),
|
|
149
|
+
remoteQrCloseButton: $("#remoteQrCloseButton"),
|
|
150
|
+
remoteQrCloseMenuButton: $("#remoteQrCloseMenuButton"),
|
|
144
151
|
serverActionSelect: $("#serverActionSelect"),
|
|
145
152
|
runServerActionButton: $("#runServerActionButton"),
|
|
146
153
|
serverActionStatus: $("#serverActionStatus"),
|
|
@@ -322,6 +329,9 @@ let btwWidgetInputDraft = "";
|
|
|
322
329
|
let btwWidgetFocusAfterRender = false;
|
|
323
330
|
let latestWorkspace = null;
|
|
324
331
|
let latestNetwork = null;
|
|
332
|
+
let latestRemoteWebuiQrUrl = "";
|
|
333
|
+
let remoteQrAutoPopupShown = false;
|
|
334
|
+
let networkStatusLoaded = false;
|
|
325
335
|
let webuiVersion = "";
|
|
326
336
|
let webuiDevServer = false;
|
|
327
337
|
let latestCodexUsage = null;
|
|
@@ -4660,7 +4670,7 @@ function restoreActiveDraft() {
|
|
|
4660
4670
|
|
|
4661
4671
|
function focusPromptInput({ defer = false } = {}) {
|
|
4662
4672
|
const focus = () => {
|
|
4663
|
-
if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.gitChangesDialog?.open || elements.commandPaletteDialog?.open || elements.editRetryDialog?.open || elements.nativeCommandDialog.open || elements.appRunnerInfoDialog?.open || elements.promptListDialog?.open || elements.attachmentTextDialog?.open || elements.skillEditorDialog?.open || document.visibilityState === "hidden") return;
|
|
4673
|
+
if (!elements.promptInput || elements.dialog.open || elements.pathPickerDialog.open || elements.gitChangesDialog?.open || elements.commandPaletteDialog?.open || elements.editRetryDialog?.open || elements.nativeCommandDialog.open || elements.remoteQrDialog?.open || elements.appRunnerInfoDialog?.open || elements.promptListDialog?.open || elements.attachmentTextDialog?.open || elements.skillEditorDialog?.open || document.visibilityState === "hidden") return;
|
|
4664
4674
|
try {
|
|
4665
4675
|
elements.promptInput.focus({ preventScroll: true });
|
|
4666
4676
|
} catch {
|
|
@@ -10440,6 +10450,289 @@ function remoteWebuiWidgetLines(lines = []) {
|
|
|
10440
10450
|
.filter((line, index, array) => line.trim() || (index > 0 && index < array.length - 1));
|
|
10441
10451
|
}
|
|
10442
10452
|
|
|
10453
|
+
function remoteWebuiLineUrl(line) {
|
|
10454
|
+
const text = String(line || "").trim();
|
|
10455
|
+
const match = text.match(/https?:\/\/[^\s<>"']+/i);
|
|
10456
|
+
if (!match) return "";
|
|
10457
|
+
const candidate = match[0].replace(/[),.;]+$/, "");
|
|
10458
|
+
return safeHttpUrl(candidate);
|
|
10459
|
+
}
|
|
10460
|
+
|
|
10461
|
+
function remoteWebuiQrPayload(lines = []) {
|
|
10462
|
+
const cleanLines = remoteWebuiWidgetLines(lines);
|
|
10463
|
+
const url = remoteWebuiLineUrl(cleanLines.find((line) => remoteWebuiLineUrl(line)) || "");
|
|
10464
|
+
const scanIndex = cleanLines.findIndex((line) => /scan with your phone/i.test(line));
|
|
10465
|
+
const urlIndex = cleanLines.findIndex((line, index) => index > scanIndex && remoteWebuiLineUrl(line));
|
|
10466
|
+
const qrStart = scanIndex >= 0 ? scanIndex + 1 : -1;
|
|
10467
|
+
const qrEnd = urlIndex >= 0 ? urlIndex : cleanLines.length;
|
|
10468
|
+
const qrLines = qrStart >= 0
|
|
10469
|
+
? cleanLines.slice(qrStart, qrEnd).filter((line, index, array) => line.trim() || (index > 0 && index < array.length - 1))
|
|
10470
|
+
: [];
|
|
10471
|
+
const detailLines = cleanLines.filter((line, index) => {
|
|
10472
|
+
if (!line.trim()) return false;
|
|
10473
|
+
if (/^Pi Remote WebUI$/i.test(line.trim())) return false;
|
|
10474
|
+
if (/scan with your phone/i.test(line)) return false;
|
|
10475
|
+
if (qrStart >= 0 && index >= qrStart && index < qrEnd) return false;
|
|
10476
|
+
return true;
|
|
10477
|
+
});
|
|
10478
|
+
return { cleanLines, detailLines, qrLines, url };
|
|
10479
|
+
}
|
|
10480
|
+
|
|
10481
|
+
function remoteWebuiQrMatrix(qrLines = []) {
|
|
10482
|
+
const lines = qrLines.map((line) => String(line ?? "")).filter((line) => /[█▀▄]/u.test(line));
|
|
10483
|
+
if (!lines.length) return null;
|
|
10484
|
+
const width = Math.max(...lines.map((line) => Array.from(line).length));
|
|
10485
|
+
if (!Number.isFinite(width) || width <= 0) return null;
|
|
10486
|
+
|
|
10487
|
+
const matrix = [];
|
|
10488
|
+
for (const line of lines) {
|
|
10489
|
+
const chars = Array.from(line);
|
|
10490
|
+
while (chars.length < width) chars.push(" ");
|
|
10491
|
+
const top = [];
|
|
10492
|
+
const bottom = [];
|
|
10493
|
+
for (const char of chars) {
|
|
10494
|
+
if (char === "█") {
|
|
10495
|
+
top.push(false);
|
|
10496
|
+
bottom.push(false);
|
|
10497
|
+
} else if (char === "▀") {
|
|
10498
|
+
top.push(false);
|
|
10499
|
+
bottom.push(true);
|
|
10500
|
+
} else if (char === "▄") {
|
|
10501
|
+
top.push(true);
|
|
10502
|
+
bottom.push(false);
|
|
10503
|
+
} else {
|
|
10504
|
+
top.push(true);
|
|
10505
|
+
bottom.push(true);
|
|
10506
|
+
}
|
|
10507
|
+
}
|
|
10508
|
+
matrix.push(top, bottom);
|
|
10509
|
+
}
|
|
10510
|
+
|
|
10511
|
+
while (matrix.length > width && matrix[0]?.every(Boolean)) matrix.shift();
|
|
10512
|
+
while (matrix.length > width && matrix.at(-1)?.every(Boolean)) matrix.pop();
|
|
10513
|
+
if (matrix.length !== width || matrix.some((row) => row.length !== width)) return null;
|
|
10514
|
+
if (!matrix.some((row) => row.some(Boolean))) return null;
|
|
10515
|
+
return matrix;
|
|
10516
|
+
}
|
|
10517
|
+
|
|
10518
|
+
function remoteWebuiQrSvg(qrLines = []) {
|
|
10519
|
+
const matrix = remoteWebuiQrMatrix(qrLines);
|
|
10520
|
+
if (!matrix) return null;
|
|
10521
|
+
const size = matrix.length;
|
|
10522
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
10523
|
+
svg.setAttribute("class", "remote-qr-svg");
|
|
10524
|
+
svg.setAttribute("viewBox", `0 0 ${size} ${size}`);
|
|
10525
|
+
svg.setAttribute("role", "img");
|
|
10526
|
+
svg.setAttribute("aria-label", "/remote QR code");
|
|
10527
|
+
svg.setAttribute("shape-rendering", "crispEdges");
|
|
10528
|
+
svg.setAttribute("focusable", "false");
|
|
10529
|
+
|
|
10530
|
+
const background = document.createElementNS(svg.namespaceURI, "rect");
|
|
10531
|
+
background.setAttribute("class", "remote-qr-svg-bg");
|
|
10532
|
+
background.setAttribute("width", String(size));
|
|
10533
|
+
background.setAttribute("height", String(size));
|
|
10534
|
+
svg.append(background);
|
|
10535
|
+
|
|
10536
|
+
for (let y = 0; y < size; y++) {
|
|
10537
|
+
for (let x = 0; x < size; x++) {
|
|
10538
|
+
if (!matrix[y][x]) continue;
|
|
10539
|
+
const rect = document.createElementNS(svg.namespaceURI, "rect");
|
|
10540
|
+
rect.setAttribute("class", "remote-qr-svg-dark");
|
|
10541
|
+
rect.setAttribute("x", String(x));
|
|
10542
|
+
rect.setAttribute("y", String(y));
|
|
10543
|
+
rect.setAttribute("width", "1");
|
|
10544
|
+
rect.setAttribute("height", "1");
|
|
10545
|
+
svg.append(rect);
|
|
10546
|
+
}
|
|
10547
|
+
}
|
|
10548
|
+
return svg;
|
|
10549
|
+
}
|
|
10550
|
+
|
|
10551
|
+
function closeRemoteWebuiQrPopup() {
|
|
10552
|
+
latestRemoteWebuiQrUrl = "";
|
|
10553
|
+
elements.remoteQrDialog?.classList.remove("is-loading");
|
|
10554
|
+
if (elements.remoteQrDialog?.open) elements.remoteQrDialog.close();
|
|
10555
|
+
}
|
|
10556
|
+
|
|
10557
|
+
function openRemoteWebuiQrUrl() {
|
|
10558
|
+
const url = latestRemoteWebuiQrUrl;
|
|
10559
|
+
if (!url) return;
|
|
10560
|
+
const anchor = document.createElement("a");
|
|
10561
|
+
anchor.href = url;
|
|
10562
|
+
anchor.target = "_blank";
|
|
10563
|
+
anchor.rel = "noopener noreferrer";
|
|
10564
|
+
anchor.hidden = true;
|
|
10565
|
+
document.body.append(anchor);
|
|
10566
|
+
anchor.click();
|
|
10567
|
+
anchor.remove();
|
|
10568
|
+
addEvent("opened /remote URL", "info");
|
|
10569
|
+
}
|
|
10570
|
+
|
|
10571
|
+
async function copyRemoteWebuiQrUrl() {
|
|
10572
|
+
if (!latestRemoteWebuiQrUrl) return;
|
|
10573
|
+
try {
|
|
10574
|
+
await copyText(latestRemoteWebuiQrUrl);
|
|
10575
|
+
addEvent("copied /remote URL", "info");
|
|
10576
|
+
} catch (error) {
|
|
10577
|
+
addEvent(`copy /remote URL failed: ${error.message || String(error)}`, "error");
|
|
10578
|
+
}
|
|
10579
|
+
}
|
|
10580
|
+
|
|
10581
|
+
function isLocalWebuiBrowserOrigin() {
|
|
10582
|
+
const host = window.location.hostname.toLowerCase();
|
|
10583
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
10584
|
+
}
|
|
10585
|
+
|
|
10586
|
+
function remoteWebuiQrLinesFromData(data = {}) {
|
|
10587
|
+
const network = data.network || latestNetwork || {};
|
|
10588
|
+
const auth = network.auth || {};
|
|
10589
|
+
const networkUrls = Array.isArray(network.networkUrls) ? network.networkUrls : [];
|
|
10590
|
+
const displayUrl = data.url || networkUrls.find((candidate) => /^https?:\/\//i.test(String(candidate || ""))) || network.localUrl || "";
|
|
10591
|
+
const qrLines = Array.isArray(data.qrLines) ? data.qrLines.map((line) => String(line ?? "")) : [];
|
|
10592
|
+
const hasAutoAuthQr = !!(auth.enabled && auth.pin && data.qrUrl && displayUrl && data.qrUrl !== displayUrl);
|
|
10593
|
+
const authLine = auth.enabled ? `Remote PIN auth: on${auth.pin ? ` · PIN ${auth.pin}` : ""}` : "Remote PIN auth: off";
|
|
10594
|
+
const warningLine = hasAutoAuthQr
|
|
10595
|
+
? "Trusted LAN only. The QR signs in with the embedded PIN; keep it private."
|
|
10596
|
+
: auth.enabled
|
|
10597
|
+
? "Trusted LAN only. Anyone with this URL and PIN can control Pi/WebUI."
|
|
10598
|
+
: "Trusted LAN only. Remote PIN auth is off; anyone with this URL can control Pi/WebUI.";
|
|
10599
|
+
return [
|
|
10600
|
+
"Pi Remote WebUI",
|
|
10601
|
+
"",
|
|
10602
|
+
hasAutoAuthQr ? "Scan with your phone (auto-auth QR):" : "Scan with your phone:",
|
|
10603
|
+
"",
|
|
10604
|
+
...qrLines,
|
|
10605
|
+
"",
|
|
10606
|
+
displayUrl,
|
|
10607
|
+
authLine,
|
|
10608
|
+
"",
|
|
10609
|
+
warningLine,
|
|
10610
|
+
"Close LAN access with: /remote close",
|
|
10611
|
+
];
|
|
10612
|
+
}
|
|
10613
|
+
|
|
10614
|
+
function showRemoteWebuiQrLoadingPopup(message = "Opening Remote WebUI QR…") {
|
|
10615
|
+
if (!elements.remoteQrDialog || !elements.remoteQrBody) return;
|
|
10616
|
+
latestRemoteWebuiQrUrl = "";
|
|
10617
|
+
elements.remoteQrDialog.classList.add("is-loading");
|
|
10618
|
+
|
|
10619
|
+
const loading = make("div", "remote-qr-loading");
|
|
10620
|
+
loading.setAttribute("role", "status");
|
|
10621
|
+
loading.setAttribute("aria-live", "polite");
|
|
10622
|
+
const spinner = make("div", "remote-qr-spinner");
|
|
10623
|
+
spinner.setAttribute("aria-hidden", "true");
|
|
10624
|
+
const copy = make("div", "remote-qr-loading-copy");
|
|
10625
|
+
copy.append(
|
|
10626
|
+
make("strong", "", message),
|
|
10627
|
+
make("span", "muted", "Starting /remote and generating a QR code. This can take a moment."),
|
|
10628
|
+
);
|
|
10629
|
+
loading.append(spinner, copy);
|
|
10630
|
+
|
|
10631
|
+
elements.remoteQrBody.replaceChildren(loading);
|
|
10632
|
+
if (elements.remoteQrMessage) elements.remoteQrMessage.textContent = "Preparing trusted-LAN browser access…";
|
|
10633
|
+
if (elements.remoteQrCopyButton) elements.remoteQrCopyButton.disabled = true;
|
|
10634
|
+
if (elements.remoteQrOpenButton) elements.remoteQrOpenButton.disabled = true;
|
|
10635
|
+
|
|
10636
|
+
try {
|
|
10637
|
+
if (!elements.remoteQrDialog.open) elements.remoteQrDialog.showModal();
|
|
10638
|
+
} catch (error) {
|
|
10639
|
+
addEvent(`remote QR loading popup unavailable: ${error.message || String(error)}`, "warn");
|
|
10640
|
+
}
|
|
10641
|
+
}
|
|
10642
|
+
|
|
10643
|
+
function isRemoteWebuiQrPopupLoading() {
|
|
10644
|
+
return !!elements.remoteQrDialog?.classList.contains("is-loading");
|
|
10645
|
+
}
|
|
10646
|
+
|
|
10647
|
+
function remoteWebuiStatusLoadingMessage(statusText) {
|
|
10648
|
+
const text = String(statusText || "").toLowerCase();
|
|
10649
|
+
if (text.includes("refresh")) return "Refreshing Remote WebUI QR…";
|
|
10650
|
+
if (text.includes("pin auth")) return "Preparing Remote PIN auth and QR…";
|
|
10651
|
+
return "Opening Remote WebUI QR…";
|
|
10652
|
+
}
|
|
10653
|
+
|
|
10654
|
+
function handleRemoteWebuiStatus(statusText) {
|
|
10655
|
+
const text = String(statusText || "").toLowerCase();
|
|
10656
|
+
if (text.includes("opening remote webui") || text.includes("refreshing remote qr") || text.includes("enabling remote pin auth")) {
|
|
10657
|
+
showRemoteWebuiQrLoadingPopup(remoteWebuiStatusLoadingMessage(statusText));
|
|
10658
|
+
return;
|
|
10659
|
+
}
|
|
10660
|
+
if (text.includes("closing remote webui")) {
|
|
10661
|
+
closeRemoteWebuiQrPopup();
|
|
10662
|
+
return;
|
|
10663
|
+
}
|
|
10664
|
+
if (!statusText && isRemoteWebuiQrPopupLoading()) closeRemoteWebuiQrPopup();
|
|
10665
|
+
}
|
|
10666
|
+
|
|
10667
|
+
async function showRemoteWebuiQrPopupFromNetwork({ auto = false } = {}) {
|
|
10668
|
+
if (auto) remoteQrAutoPopupShown = true;
|
|
10669
|
+
showRemoteWebuiQrLoadingPopup("Preparing Remote WebUI QR…");
|
|
10670
|
+
try {
|
|
10671
|
+
const response = await api("/api/network/qr", { scoped: false });
|
|
10672
|
+
openRemoteWebuiQrPopup(remoteWebuiQrLinesFromData(response.data || {}));
|
|
10673
|
+
return true;
|
|
10674
|
+
} catch (error) {
|
|
10675
|
+
if (isRemoteWebuiQrPopupLoading()) closeRemoteWebuiQrPopup();
|
|
10676
|
+
addEvent(`remote QR popup failed: ${error.message || String(error)}`, auto ? "warn" : "error");
|
|
10677
|
+
return false;
|
|
10678
|
+
}
|
|
10679
|
+
}
|
|
10680
|
+
|
|
10681
|
+
function openRemoteWebuiQrPopup(lines = []) {
|
|
10682
|
+
if (!elements.remoteQrDialog || !elements.remoteQrBody) return;
|
|
10683
|
+
const { cleanLines, detailLines, qrLines, url } = remoteWebuiQrPayload(lines);
|
|
10684
|
+
latestRemoteWebuiQrUrl = url;
|
|
10685
|
+
elements.remoteQrDialog.classList.remove("is-loading");
|
|
10686
|
+
|
|
10687
|
+
const qrText = (qrLines.length ? qrLines : cleanLines).join("\n").trimEnd();
|
|
10688
|
+
const card = make("div", "remote-qr-card");
|
|
10689
|
+
const svgQr = remoteWebuiQrSvg(qrLines);
|
|
10690
|
+
if (svgQr) {
|
|
10691
|
+
card.append(svgQr);
|
|
10692
|
+
} else {
|
|
10693
|
+
const code = make("pre", "remote-qr-code", qrText || "QR code unavailable.");
|
|
10694
|
+
code.setAttribute("aria-label", "/remote QR code");
|
|
10695
|
+
card.append(code);
|
|
10696
|
+
}
|
|
10697
|
+
|
|
10698
|
+
const details = make("div", "remote-qr-details");
|
|
10699
|
+
if (url) {
|
|
10700
|
+
const urlRow = make("div", "remote-qr-url-row");
|
|
10701
|
+
const urlLabel = make("span", "remote-qr-url-label", "URL");
|
|
10702
|
+
const link = make("a", "remote-qr-url", url);
|
|
10703
|
+
link.href = url;
|
|
10704
|
+
link.target = "_blank";
|
|
10705
|
+
link.rel = "noopener noreferrer";
|
|
10706
|
+
urlRow.append(urlLabel, link);
|
|
10707
|
+
details.append(urlRow);
|
|
10708
|
+
}
|
|
10709
|
+
for (const line of detailLines.filter((line) => !remoteWebuiLineUrl(line))) {
|
|
10710
|
+
details.append(make("p", "remote-qr-note", line));
|
|
10711
|
+
}
|
|
10712
|
+
if (!details.childElementCount) details.append(make("p", "remote-qr-note muted", "Scan from a trusted device on the same local network."));
|
|
10713
|
+
|
|
10714
|
+
elements.remoteQrBody.replaceChildren(card, details);
|
|
10715
|
+
if (elements.remoteQrMessage) {
|
|
10716
|
+
elements.remoteQrMessage.textContent = url
|
|
10717
|
+
? "Scan this /remote QR code from a trusted local-network device."
|
|
10718
|
+
: "The /remote QR output is ready. Use the transcript if this terminal QR cannot be scanned.";
|
|
10719
|
+
}
|
|
10720
|
+
if (elements.remoteQrCopyButton) elements.remoteQrCopyButton.disabled = !url;
|
|
10721
|
+
if (elements.remoteQrOpenButton) elements.remoteQrOpenButton.disabled = !url;
|
|
10722
|
+
|
|
10723
|
+
try {
|
|
10724
|
+
if (!elements.remoteQrDialog.open) elements.remoteQrDialog.showModal();
|
|
10725
|
+
} catch (error) {
|
|
10726
|
+
addEvent(`remote QR popup unavailable; see /remote output in transcript: ${error.message || String(error)}`, "warn");
|
|
10727
|
+
}
|
|
10728
|
+
}
|
|
10729
|
+
|
|
10730
|
+
function showRemoteWebuiQrPopup(widgetKey, lines = [], request = {}) {
|
|
10731
|
+
if (widgetKey !== "pi-remote-webui" || !Array.isArray(lines)) return;
|
|
10732
|
+
remoteQrAutoPopupShown = true;
|
|
10733
|
+
openRemoteWebuiQrPopup(lines);
|
|
10734
|
+
}
|
|
10735
|
+
|
|
10443
10736
|
function mirrorRemoteWebuiWidgetToTranscript(widgetKey, lines = [], request = {}) {
|
|
10444
10737
|
if (widgetKey !== "pi-remote-webui" || request.replayed) return;
|
|
10445
10738
|
const content = remoteWebuiWidgetLines(lines).join("\n").trimEnd();
|
|
@@ -16719,6 +17012,8 @@ function renderNetworkStatus() {
|
|
|
16719
17012
|
}
|
|
16720
17013
|
|
|
16721
17014
|
async function refreshNetworkStatus() {
|
|
17015
|
+
const hadNetworkStatus = networkStatusLoaded;
|
|
17016
|
+
const wasOpen = !!latestNetwork?.open;
|
|
16722
17017
|
try {
|
|
16723
17018
|
const response = await api("/api/network", { scoped: false });
|
|
16724
17019
|
latestNetwork = response.data || null;
|
|
@@ -16727,6 +17022,22 @@ async function refreshNetworkStatus() {
|
|
|
16727
17022
|
latestNetwork = health.network || { open: false, opening: false, localUrl: window.location.origin };
|
|
16728
17023
|
}
|
|
16729
17024
|
renderNetworkStatus();
|
|
17025
|
+
networkStatusLoaded = true;
|
|
17026
|
+
|
|
17027
|
+
const open = !!latestNetwork?.open;
|
|
17028
|
+
if (!open) {
|
|
17029
|
+
remoteQrAutoPopupShown = false;
|
|
17030
|
+
return;
|
|
17031
|
+
}
|
|
17032
|
+
|
|
17033
|
+
if (!hadNetworkStatus) {
|
|
17034
|
+
remoteQrAutoPopupShown = true;
|
|
17035
|
+
return;
|
|
17036
|
+
}
|
|
17037
|
+
|
|
17038
|
+
if (!wasOpen && !remoteQrAutoPopupShown && isLocalWebuiBrowserOrigin()) {
|
|
17039
|
+
showRemoteWebuiQrPopupFromNetwork({ auto: true }).catch((error) => addEvent(error.message || String(error), "warn"));
|
|
17040
|
+
}
|
|
16730
17041
|
}
|
|
16731
17042
|
|
|
16732
17043
|
async function runRemoteWebuiCommand(command) {
|
|
@@ -18145,6 +18456,7 @@ function handleExtensionUiRequest(request) {
|
|
|
18145
18456
|
}
|
|
18146
18457
|
if (statusKey === STATS_WEBUI_STATUS_KEY) handleStatsWebuiStatus(request.statusText);
|
|
18147
18458
|
if (statusKey === BTW_WEBUI_STATUS_KEY) handleBtwWebuiStatus(request.statusText);
|
|
18459
|
+
if (statusKey === "pi-remote-webui") handleRemoteWebuiStatus(request.statusText);
|
|
18148
18460
|
updateOptionalFeatureAvailability();
|
|
18149
18461
|
if (statusKey === GIT_FOOTER_WEBUI_STATUS_KEY) {
|
|
18150
18462
|
if (currentState?.isStreaming || runIndicatorLocallyActive) return;
|
|
@@ -18162,7 +18474,12 @@ function handleExtensionUiRequest(request) {
|
|
|
18162
18474
|
const widgetKey = request.widgetKey || request.id;
|
|
18163
18475
|
if (widgetKey === "pi-remote-webui") {
|
|
18164
18476
|
widgets.delete(widgetKey);
|
|
18165
|
-
if (Array.isArray(request.widgetLines))
|
|
18477
|
+
if (Array.isArray(request.widgetLines)) {
|
|
18478
|
+
mirrorRemoteWebuiWidgetToTranscript(widgetKey, request.widgetLines, request);
|
|
18479
|
+
showRemoteWebuiQrPopup(widgetKey, request.widgetLines, request);
|
|
18480
|
+
} else {
|
|
18481
|
+
closeRemoteWebuiQrPopup();
|
|
18482
|
+
}
|
|
18166
18483
|
} else if (Array.isArray(request.widgetLines)) {
|
|
18167
18484
|
widgets.set(widgetKey, request);
|
|
18168
18485
|
} else {
|
|
@@ -18185,6 +18502,7 @@ function handleExtensionUiRequest(request) {
|
|
|
18185
18502
|
case "confirm":
|
|
18186
18503
|
case "input":
|
|
18187
18504
|
case "editor":
|
|
18505
|
+
if (isRemoteWebuiQrPopupLoading()) closeRemoteWebuiQrPopup();
|
|
18188
18506
|
if (hasQueuedDialogRequest(request.id)) return;
|
|
18189
18507
|
if (request.pendingExtensionUiRequestCount === undefined) {
|
|
18190
18508
|
const tab = tabs.find((item) => item.id === request.tabId);
|
|
@@ -19290,6 +19608,14 @@ if (elements.backgroundClearButton) {
|
|
|
19290
19608
|
}
|
|
19291
19609
|
elements.remoteAuthToggle.addEventListener("change", () => toggleRemoteAuth().catch((error) => addEvent(error.message || String(error), "error")));
|
|
19292
19610
|
elements.openNetworkButton.addEventListener("click", openToNetwork);
|
|
19611
|
+
elements.remoteQrCopyButton?.addEventListener("click", () => copyRemoteWebuiQrUrl().catch((error) => addEvent(error.message || String(error), "error")));
|
|
19612
|
+
elements.remoteQrOpenButton?.addEventListener("click", openRemoteWebuiQrUrl);
|
|
19613
|
+
elements.remoteQrCloseButton?.addEventListener("click", closeRemoteWebuiQrPopup);
|
|
19614
|
+
elements.remoteQrCloseMenuButton?.addEventListener("click", closeRemoteWebuiQrPopup);
|
|
19615
|
+
elements.remoteQrDialog?.addEventListener("close", () => {
|
|
19616
|
+
latestRemoteWebuiQrUrl = "";
|
|
19617
|
+
elements.remoteQrDialog?.classList.remove("is-loading");
|
|
19618
|
+
});
|
|
19293
19619
|
elements.serverActionSelect.addEventListener("change", updateServerActionButton);
|
|
19294
19620
|
elements.runServerActionButton.addEventListener("click", () => runSelectedServerAction().catch((error) => addEvent(error.message || String(error), "error")));
|
|
19295
19621
|
elements.updateNotificationUpdateButton?.addEventListener("click", () => runPiUpdateAndRestart().catch((error) => addEvent(error.message || String(error), "error")));
|
package/public/index.html
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
<link rel="manifest" href="/manifest.webmanifest" />
|
|
13
13
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
14
14
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
15
|
-
<link rel="stylesheet" href="/styles.css?v=
|
|
15
|
+
<link rel="stylesheet" href="/styles.css?v=56" />
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<button id="sidePanelExpandButton" class="side-panel-expand-button" type="button" aria-controls="sidePanel" aria-expanded="false" aria-label="Expand side panel" title="Expand side panel">
|
|
@@ -645,6 +645,25 @@
|
|
|
645
645
|
</form>
|
|
646
646
|
</dialog>
|
|
647
647
|
|
|
648
|
+
<dialog id="remoteQrDialog" class="extension-dialog remote-qr-dialog">
|
|
649
|
+
<form method="dialog">
|
|
650
|
+
<div class="remote-qr-header">
|
|
651
|
+
<div>
|
|
652
|
+
<span class="remote-qr-kicker">/remote</span>
|
|
653
|
+
<h2>Remote WebUI QR</h2>
|
|
654
|
+
<p id="remoteQrMessage" class="muted">Scan this QR code from a trusted local-network device.</p>
|
|
655
|
+
</div>
|
|
656
|
+
<button id="remoteQrCloseButton" class="remote-qr-close-button" type="button" aria-label="Close Remote WebUI QR popup">Close</button>
|
|
657
|
+
</div>
|
|
658
|
+
<div id="remoteQrBody" class="remote-qr-body"></div>
|
|
659
|
+
<menu>
|
|
660
|
+
<button id="remoteQrCopyButton" type="button" disabled>Copy URL</button>
|
|
661
|
+
<button id="remoteQrOpenButton" type="button" disabled>Open URL</button>
|
|
662
|
+
<button id="remoteQrCloseMenuButton" class="primary" type="button">Done</button>
|
|
663
|
+
</menu>
|
|
664
|
+
</form>
|
|
665
|
+
</dialog>
|
|
666
|
+
|
|
648
667
|
<dialog id="nativeCommandDialog" class="extension-dialog native-command-dialog">
|
|
649
668
|
<form method="dialog">
|
|
650
669
|
<h2 id="nativeCommandTitle">Pi command</h2>
|
|
@@ -721,6 +740,6 @@
|
|
|
721
740
|
</form>
|
|
722
741
|
</dialog>
|
|
723
742
|
|
|
724
|
-
<script type="module" src="/app.js?v=
|
|
743
|
+
<script type="module" src="/app.js?v=55"></script>
|
|
725
744
|
</body>
|
|
726
745
|
</html>
|
package/public/styles.css
CHANGED
|
@@ -5626,6 +5626,161 @@ button.composer-skill-tag:focus-visible {
|
|
|
5626
5626
|
padding: 0;
|
|
5627
5627
|
margin: 1rem 0 0;
|
|
5628
5628
|
}
|
|
5629
|
+
.extension-dialog.remote-qr-dialog {
|
|
5630
|
+
width: min(34rem, calc(100vw - 2rem));
|
|
5631
|
+
border-color: rgba(148, 226, 213, 0.44);
|
|
5632
|
+
box-shadow: 0 2rem 5rem var(--shadow), 0 0 2rem rgba(148, 226, 213, 0.18);
|
|
5633
|
+
}
|
|
5634
|
+
.remote-qr-dialog form {
|
|
5635
|
+
display: grid;
|
|
5636
|
+
gap: 0.85rem;
|
|
5637
|
+
}
|
|
5638
|
+
.remote-qr-header {
|
|
5639
|
+
display: flex;
|
|
5640
|
+
align-items: flex-start;
|
|
5641
|
+
justify-content: space-between;
|
|
5642
|
+
gap: 1rem;
|
|
5643
|
+
}
|
|
5644
|
+
.remote-qr-kicker {
|
|
5645
|
+
display: block;
|
|
5646
|
+
color: var(--ctp-teal);
|
|
5647
|
+
font-size: 0.72rem;
|
|
5648
|
+
font-weight: 900;
|
|
5649
|
+
letter-spacing: 0.12em;
|
|
5650
|
+
text-transform: uppercase;
|
|
5651
|
+
}
|
|
5652
|
+
.remote-qr-header h2 {
|
|
5653
|
+
margin: 0.12rem 0 0;
|
|
5654
|
+
color: var(--ctp-text);
|
|
5655
|
+
}
|
|
5656
|
+
.remote-qr-header p {
|
|
5657
|
+
margin: 0.28rem 0 0;
|
|
5658
|
+
line-height: 1.45;
|
|
5659
|
+
}
|
|
5660
|
+
.remote-qr-close-button {
|
|
5661
|
+
min-height: 34px;
|
|
5662
|
+
padding: 0.32rem 0.62rem;
|
|
5663
|
+
color: var(--ctp-teal);
|
|
5664
|
+
border-color: rgba(148, 226, 213, 0.34);
|
|
5665
|
+
}
|
|
5666
|
+
.remote-qr-body {
|
|
5667
|
+
display: grid;
|
|
5668
|
+
gap: 0.75rem;
|
|
5669
|
+
min-width: 0;
|
|
5670
|
+
}
|
|
5671
|
+
.remote-qr-loading {
|
|
5672
|
+
display: flex;
|
|
5673
|
+
align-items: center;
|
|
5674
|
+
gap: 0.9rem;
|
|
5675
|
+
padding: 1rem;
|
|
5676
|
+
border: 1px solid rgba(148, 226, 213, 0.26);
|
|
5677
|
+
border-radius: 0.9rem;
|
|
5678
|
+
background: rgba(var(--ctp-crust-rgb), 0.74);
|
|
5679
|
+
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 0 1rem rgba(148, 226, 213, 0.08);
|
|
5680
|
+
}
|
|
5681
|
+
.remote-qr-spinner {
|
|
5682
|
+
flex: 0 0 auto;
|
|
5683
|
+
width: 2.25rem;
|
|
5684
|
+
height: 2.25rem;
|
|
5685
|
+
border: 0.22rem solid rgba(148, 226, 213, 0.18);
|
|
5686
|
+
border-top-color: var(--ctp-teal);
|
|
5687
|
+
border-radius: 999px;
|
|
5688
|
+
animation: remote-qr-spinner-spin 900ms linear infinite;
|
|
5689
|
+
}
|
|
5690
|
+
.remote-qr-loading-copy {
|
|
5691
|
+
display: grid;
|
|
5692
|
+
gap: 0.26rem;
|
|
5693
|
+
min-width: 0;
|
|
5694
|
+
line-height: 1.45;
|
|
5695
|
+
}
|
|
5696
|
+
.remote-qr-loading-copy strong {
|
|
5697
|
+
color: var(--ctp-text);
|
|
5698
|
+
font-size: 0.95rem;
|
|
5699
|
+
}
|
|
5700
|
+
.remote-qr-loading-copy span {
|
|
5701
|
+
font-size: 0.78rem;
|
|
5702
|
+
font-weight: 700;
|
|
5703
|
+
}
|
|
5704
|
+
@keyframes remote-qr-spinner-spin {
|
|
5705
|
+
to { transform: rotate(360deg); }
|
|
5706
|
+
}
|
|
5707
|
+
.remote-qr-card {
|
|
5708
|
+
display: flex;
|
|
5709
|
+
justify-content: center;
|
|
5710
|
+
align-items: center;
|
|
5711
|
+
max-width: 100%;
|
|
5712
|
+
padding: 0.8rem;
|
|
5713
|
+
overflow: auto;
|
|
5714
|
+
border: 1px solid rgba(148, 226, 213, 0.22);
|
|
5715
|
+
border-radius: 0.9rem;
|
|
5716
|
+
background: rgba(var(--ctp-crust-rgb), 0.74);
|
|
5717
|
+
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 0 1rem rgba(148, 226, 213, 0.08);
|
|
5718
|
+
}
|
|
5719
|
+
.remote-qr-svg {
|
|
5720
|
+
display: block;
|
|
5721
|
+
width: min(100%, 15.5rem);
|
|
5722
|
+
height: auto;
|
|
5723
|
+
aspect-ratio: 1 / 1;
|
|
5724
|
+
overflow: visible;
|
|
5725
|
+
border-radius: 0.12rem;
|
|
5726
|
+
background: #cdd6f4;
|
|
5727
|
+
}
|
|
5728
|
+
.remote-qr-svg-bg { fill: #cdd6f4; }
|
|
5729
|
+
.remote-qr-svg-dark { fill: #1e2030; }
|
|
5730
|
+
.remote-qr-code {
|
|
5731
|
+
min-width: max-content;
|
|
5732
|
+
margin: 0;
|
|
5733
|
+
color: var(--ctp-text);
|
|
5734
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
5735
|
+
font-size: clamp(0.38rem, 1.25vw, 0.72rem);
|
|
5736
|
+
font-weight: 900;
|
|
5737
|
+
line-height: 1;
|
|
5738
|
+
letter-spacing: 0;
|
|
5739
|
+
text-align: center;
|
|
5740
|
+
white-space: pre;
|
|
5741
|
+
}
|
|
5742
|
+
.remote-qr-details {
|
|
5743
|
+
display: grid;
|
|
5744
|
+
gap: 0.42rem;
|
|
5745
|
+
}
|
|
5746
|
+
.remote-qr-url-row {
|
|
5747
|
+
display: grid;
|
|
5748
|
+
grid-template-columns: 3rem minmax(0, 1fr);
|
|
5749
|
+
gap: 0.5rem;
|
|
5750
|
+
align-items: start;
|
|
5751
|
+
padding: 0.58rem 0.64rem;
|
|
5752
|
+
border: 1px solid rgba(180, 190, 254, 0.16);
|
|
5753
|
+
border-radius: 0.72rem;
|
|
5754
|
+
background: rgba(var(--ctp-surface-rgb), 0.34);
|
|
5755
|
+
}
|
|
5756
|
+
.remote-qr-url-label {
|
|
5757
|
+
color: rgba(var(--ctp-subtext-rgb), 0.72);
|
|
5758
|
+
font-size: 0.68rem;
|
|
5759
|
+
font-weight: 900;
|
|
5760
|
+
letter-spacing: 0.08em;
|
|
5761
|
+
text-transform: uppercase;
|
|
5762
|
+
}
|
|
5763
|
+
.remote-qr-url {
|
|
5764
|
+
min-width: 0;
|
|
5765
|
+
color: var(--ctp-teal);
|
|
5766
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
5767
|
+
font-size: 0.8rem;
|
|
5768
|
+
font-weight: 800;
|
|
5769
|
+
overflow-wrap: anywhere;
|
|
5770
|
+
text-decoration: none;
|
|
5771
|
+
}
|
|
5772
|
+
.remote-qr-url:hover,
|
|
5773
|
+
.remote-qr-url:focus-visible {
|
|
5774
|
+
color: var(--ctp-sky);
|
|
5775
|
+
text-decoration: underline;
|
|
5776
|
+
}
|
|
5777
|
+
.remote-qr-note {
|
|
5778
|
+
margin: 0;
|
|
5779
|
+
color: rgba(var(--ctp-subtext-rgb), 0.74);
|
|
5780
|
+
font-size: 0.78rem;
|
|
5781
|
+
font-weight: 700;
|
|
5782
|
+
line-height: 1.45;
|
|
5783
|
+
}
|
|
5629
5784
|
.extension-dialog.stats-overlay-dialog {
|
|
5630
5785
|
width: min(92rem, calc(100vw - 1.5rem));
|
|
5631
5786
|
height: min(54rem, calc(var(--visual-viewport-height, 100dvh) - 1.5rem));
|
|
@@ -7416,6 +7571,12 @@ button.composer-skill-tag:focus-visible {
|
|
|
7416
7571
|
background: linear-gradient(180deg, transparent, rgba(var(--ctp-crust-rgb), 0.96) 35%);
|
|
7417
7572
|
}
|
|
7418
7573
|
.extension-dialog menu button { flex: 1 1 9rem; }
|
|
7574
|
+
.remote-qr-header {
|
|
7575
|
+
flex-direction: column;
|
|
7576
|
+
gap: 0.6rem;
|
|
7577
|
+
}
|
|
7578
|
+
.remote-qr-close-button { width: 100%; }
|
|
7579
|
+
.remote-qr-code { font-size: clamp(0.34rem, 1.55vw, 0.62rem); }
|
|
7419
7580
|
.extension-dialog.git-changes-dialog {
|
|
7420
7581
|
inset: 0;
|
|
7421
7582
|
margin: auto;
|
|
@@ -407,6 +407,13 @@ try {
|
|
|
407
407
|
assert.equal(traversalDelete.status, 403, "session delete outside the session dir must return 403");
|
|
408
408
|
assert.match(String(traversalDelete.body?.error || ""), /session directory/i);
|
|
409
409
|
|
|
410
|
+
const networkQr = await request("127.0.0.1", "/api/network/qr");
|
|
411
|
+
assert.equal(networkQr.status, 200, "localhost can generate a /remote QR payload");
|
|
412
|
+
assert.equal(networkQr.body?.ok, true);
|
|
413
|
+
assert.match(String(networkQr.body?.data?.url || ""), /^http:\/\//, "remote QR payload should include a display URL");
|
|
414
|
+
assert.ok(Array.isArray(networkQr.body?.data?.qrLines), "remote QR payload should include terminal QR lines");
|
|
415
|
+
assert.equal(networkQr.body?.data?.network?.open, true, "remote QR payload should describe current network state");
|
|
416
|
+
|
|
410
417
|
const initialAuth = await request("127.0.0.1", "/api/remote-auth");
|
|
411
418
|
assert.equal(initialAuth.status, 200);
|
|
412
419
|
assert.equal(initialAuth.body?.data?.auth?.enabled, false, "remote PIN auth should be off by default");
|
|
@@ -432,6 +439,9 @@ try {
|
|
|
432
439
|
const remoteClose = await request(lan, "/api/network/close", { method: "POST" });
|
|
433
440
|
assert.equal(remoteClose.status, 403, "network close must be localhost-only");
|
|
434
441
|
|
|
442
|
+
const remoteQr = await request(lan, "/api/network/qr");
|
|
443
|
+
assert.equal(remoteQr.status, 403, "remote QR generation must be localhost-only because it can embed the PIN");
|
|
444
|
+
|
|
435
445
|
const enableAuth = await request("127.0.0.1", "/api/remote-auth/settings", { method: "POST", body: { enabled: true } });
|
|
436
446
|
assert.equal(enableAuth.status, 200, "localhost can enable remote PIN auth");
|
|
437
447
|
const pin = enableAuth.body?.data?.auth?.pin;
|
|
@@ -68,6 +68,7 @@ assert.match(html, /id="terminalTabsLayoutSelect"[\s\S]*<option value="left">Lef
|
|
|
68
68
|
assert.match(html, /id="terminalTabsLayoutStatus"/, "terminal-tabs layout selector should expose status text");
|
|
69
69
|
assert.match(html, /id="nativeCommandDialog"/, "native slash selector UI should have a dedicated dialog");
|
|
70
70
|
assert.match(html, /id="nativeCommandSearch"[^>]*type="search"/, "native slash selector dialog should expose a filter box");
|
|
71
|
+
assert.match(html, /id="remoteQrDialog"[\s\S]*id="remoteQrBody"[\s\S]*id="remoteQrCopyButton"/, "remote WebUI should expose a dedicated QR popup dialog");
|
|
71
72
|
assert.match(html, /id="commandPaletteCloseButton"[^>]*aria-label="Close command palette"[^>]*>Close<\/button>/, "command palette should expose a visible accessible close button");
|
|
72
73
|
assert.match(html, /id="pathPickerCreateNameInput"[^>]*placeholder="New directory name"/, "cwd picker should expose a new-directory name input");
|
|
73
74
|
assert.match(html, /id="pathPickerCreateButton"[^>]*>Create directory<\/button>/, "cwd picker should expose a create-directory action");
|
|
@@ -207,9 +208,14 @@ assert.match(css, /\.composer-busy-mode-menu \{[\s\S]*?bottom:\s*calc\(100% \+ 0
|
|
|
207
208
|
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");
|
|
208
209
|
assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\)[\s\S]*?\.sticky-user-prompt-button \{\n\s+grid-template-columns:\s*minmax\(0, 1fr\) auto;\n\s+min-height:\s*36px;[\s\S]*?\.sticky-user-prompt-text \{[\s\S]*?font-size:\s*0\.72rem/, "mobile last-user-prompt card should use compact height and text");
|
|
209
210
|
assert.match(css, /\.message\.extension,[\s\S]*?\.message\.native/, "extension and native command output should have visible transcript styling");
|
|
210
|
-
assert.match(app, /function
|
|
211
|
-
assert.match(app, /
|
|
212
|
-
assert.
|
|
211
|
+
assert.match(app, /function remoteWebuiQrSvg\(qrLines = \[\]\)[\s\S]*?viewBox[\s\S]*?shape-rendering[\s\S]*?crispEdges/, "remote WebUI QR popup should render terminal QR output as square SVG modules");
|
|
212
|
+
assert.match(app, /function showRemoteWebuiQrLoadingPopup\(message = "Opening Remote WebUI QR…"\)[\s\S]*?remote-qr-loading[\s\S]*?showModal\(\)/, "remote WebUI QR popup should show a loading state while QR generation is pending");
|
|
213
|
+
assert.match(app, /function handleRemoteWebuiStatus\(statusText\)[\s\S]*?opening remote webui[\s\S]*?refreshing remote qr[\s\S]*?enabling remote pin auth[\s\S]*?showRemoteWebuiQrLoadingPopup/, "remote WebUI status updates should open the QR loading popup before widget lines arrive");
|
|
214
|
+
assert.match(app, /case "confirm":[\s\S]*?if \(isRemoteWebuiQrPopupLoading\(\)\) closeRemoteWebuiQrPopup\(\)/, "blocking extension dialogs should close the QR loading popup before opening");
|
|
215
|
+
assert.match(app, /function showRemoteWebuiQrPopup\(widgetKey, lines = \[\], request = \{\}\)[\s\S]*?widgetKey !== "pi-remote-webui"[\s\S]*?openRemoteWebuiQrPopup\(lines\)/, "remote WebUI QR widget events should open the QR popup");
|
|
216
|
+
assert.match(app, /function mirrorRemoteWebuiWidgetToTranscript\(widgetKey, lines = \[\], request = \{\}\)[\s\S]*?widgetKey !== "pi-remote-webui"[\s\S]*?addTransientMessage\(\{ role: "extension", title: "\/remote"/, "remote WebUI QR widget events should still mirror into the active tab transcript");
|
|
217
|
+
assert.match(app, /if \(widgetKey === "pi-remote-webui"\) \{[\s\S]*?widgets\.delete\(widgetKey\);[\s\S]*?showRemoteWebuiQrPopup\(widgetKey, request\.widgetLines, request\)/, "remote WebUI QR widget events should not render in the generic widget area");
|
|
218
|
+
assert.doesNotMatch(app, /function renderRemoteWebuiWidget/, "remote WebUI QR should not render through the generic widget renderer");
|
|
213
219
|
assert.match(css, /\.message\.run-indicator-message \{[\s\S]*?border-color/, "active agent runs should render a visible transcript indicator card");
|
|
214
220
|
assert.match(css, /\.message-copy-button \{[\s\S]*?position:\s*absolute/, "transcript messages should expose a top-right copy button");
|
|
215
221
|
assert.match(css, /\.message\.has-copy-action[\s\S]*?padding-right:\s*3\.1rem/, "copy buttons should reserve space in message cards");
|
|
@@ -390,6 +396,11 @@ assert.match(css, /\.extension-dialog[\s\S]*?max-height:\s*calc\(var\(--visual-v
|
|
|
390
396
|
assert.match(css, /\.extension-dialog[\s\S]*?inset:\s*auto 0 0 0/, "mobile dialogs should behave like bottom sheets");
|
|
391
397
|
assert.match(css, /#dialogMessage \{[\s\S]*?white-space:\s*pre-wrap/, "extension dialog messages should preserve multiline prompts");
|
|
392
398
|
assert.match(css, /\.native-command-dialog \{[\s\S]*?width:\s*min\(56rem/, "native slash selector dialog should have roomy desktop layout");
|
|
399
|
+
assert.match(css, /\.extension-dialog\.remote-qr-dialog \{[\s\S]*?width:\s*min\(34rem/, "remote QR popup should have a bounded modal layout");
|
|
400
|
+
assert.match(css, /\.remote-qr-loading \{[\s\S]*?display:\s*flex/, "remote QR popup should style its loading placeholder");
|
|
401
|
+
assert.match(css, /\.remote-qr-spinner \{[\s\S]*?animation:\s*remote-qr-spinner-spin 900ms linear infinite/, "remote QR popup loading state should include a spinner animation");
|
|
402
|
+
assert.match(css, /\.remote-qr-svg \{[\s\S]*?aspect-ratio:\s*1 \/ 1/, "remote QR popup should render QR modules as a square image");
|
|
403
|
+
assert.match(css, /\.remote-qr-code \{[\s\S]*?white-space:\s*pre/, "remote QR popup should preserve terminal QR whitespace as fallback");
|
|
393
404
|
assert.doesNotMatch(css, /--tree-depth/, "native slash selector choices should not indent tree entries by depth");
|
|
394
405
|
assert.match(css, /\.native-selector-index \{[\s\S]*?font-variant-numeric:\s*tabular-nums/, "native tree selector choices should use numeric prefixes");
|
|
395
406
|
assert.match(css, /\.native-selector-badge\.native-selector-badge-pi-native[\s\S]*?color:\s*var\(--ctp-blue\)/, "Tools Setup should distinguish Pi native tools with a Pi Native tag");
|
|
@@ -439,6 +450,8 @@ assert.match(app, /initializeCustomBackground\(\)\.catch/, "startup should resto
|
|
|
439
450
|
assert.match(app, /Restart Web UI to load themes/, "frontend should explain when a stale server cannot serve the themes endpoint");
|
|
440
451
|
assert.match(app, /themeSelect\.addEventListener\("change"/, "side-panel theme selector should switch themes immediately");
|
|
441
452
|
assert.match(app, /open \? "Close for network" : "Open to network"/, "network button should toggle from open to close action");
|
|
453
|
+
assert.match(app, /let networkStatusLoaded = false;/, "Remote WebUI QR auto-popup state should track the first loaded network status");
|
|
454
|
+
assert.match(app, /const hadNetworkStatus = networkStatusLoaded;[\s\S]*if \(!hadNetworkStatus\) \{\n\s+remoteQrAutoPopupShown = true;\n\s+return;\n\s+\}[\s\S]*if \(!wasOpen && !remoteQrAutoPopupShown && isLocalWebuiBrowserOrigin\(\)\)/, "Remote WebUI QR should auto-open only after network access transitions open, not on initial refresh");
|
|
442
455
|
assert.match(app, /remoteAuthToggle: \$\("#remoteAuthToggle"\)/, "Remote WebUI controls should bind the remote PIN auth toggle");
|
|
443
456
|
assert.match(html, /id="networkControlField"[^>]*hidden/, "Remote WebUI browser controls should be hidden until the optional package is loaded and enabled");
|
|
444
457
|
assert.match(app, /remoteWebuiCommand\(enable \? "authOn" : "authOff"/, "remote PIN auth toggle should dispatch through the Remote WebUI package command");
|