@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 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
- ![Pi Web UI workspace dashboard showing the active project, model, session state, and quick actions](https://raw.githubusercontent.com/Firstp1ck/npm-packages/main/pi-package-webui/images/Webui_Workspace_v0.4.8.png)
5
+ ![Pi Web UI main window showing multi-tab chat, streaming output, footer status, composer, and side controls](https://raw.githubusercontent.com/Firstp1ck/npm-packages/main/pi-package-webui/images/Webui_MainWindow_v0.4.8.png)
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
- - `PI_WEBUI_PORT`
124
- - `PI_WEBUI_PI_BIN`
125
- - `PI_WEBUI_REMOTE_AUTH=1` to start with remote PIN authentication enabled
126
- - `PI_WEBUI_SETTINGS_FILE=/path/to/settings.json` to override where Web UI stores persisted settings such as the Remote PIN auth preference
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
- - Browser dialogs for common Pi selectors such as `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, `/tree`, `/scoped-models`, `/tools`, and `/skills`.
137
- - Model, thinking, session, workspace, theme, optional-feature, Codex usage, optional Remote WebUI, update/restart, event, and notification controls in the side panel.
138
- - Persistent context-window meter with manual compact and auto-compaction controls near the composer.
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 and PWA install support where the browser allows it.
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
- ## v0.4.8 feature gallery
153
+ ## Native Pi command coverage
148
154
 
149
- These screenshots show the v0.4.8 Web UI surfaces. Unless noted otherwise, actions apply to the active tab and its current working directory.
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
+ ![Pi Web UI main window showing multi-tab chat, streaming output, footer status, composer, and side controls](https://raw.githubusercontent.com/Firstp1ck/npm-packages/main/pi-package-webui/images/Webui_MainWindow_v0.4.8.png)
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 running `pi update` followed by a Web UI server restart. Use `POST /api/update?all=1` to run `pi update --all` for Pi plus configured packages.
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. `git add .`
307
- 2. Send `/git-staged-msg` to Pi
308
- 3. Read the generated commit message files from `dev/COMMIT/`
309
- 4. Commit with the selected generated message, or type a manual message in the Message stage and use **Commit input**
310
- 5. Run `git push`
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**, or **Push** 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.
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 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.5.1",
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://unpkg.com/@firstpick/pi-package-webui/images/WebUI_v0.3.7.png",
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)) mirrorRemoteWebuiWidgetToTranscript(widgetKey, request.widgetLines, request);
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=54" />
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=52"></script>
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 mirrorRemoteWebuiWidgetToTranscript\(widgetKey, lines = \[\], request = \{\}\)[\s\S]*?widgetKey !== "pi-remote-webui"[\s\S]*?addTransientMessage\(\{ role: "extension", title: "\/remote"/, "remote WebUI QR widget events should mirror into the active tab transcript");
211
- assert.match(app, /if \(widgetKey === "pi-remote-webui"\) \{[\s\S]*?widgets\.delete\(widgetKey\);[\s\S]*?mirrorRemoteWebuiWidgetToTranscript/, "remote WebUI QR widget events should not render a Web UI overlay widget");
212
- assert.doesNotMatch(app, /function renderRemoteWebuiWidget/, "remote WebUI QR should only render in the transcript");
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");