@firstpick/pi-package-webui 0.5.2 → 0.5.4

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
@@ -119,34 +119,68 @@ 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.
150
184
 
151
185
  ### Main window
152
186
 
@@ -269,12 +303,25 @@ These screenshots show the v0.4.8 Web UI surfaces. Unless noted otherwise, actio
269
303
 
270
304
  Useful browser endpoints exposed by the local server include:
271
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.
272
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.
273
320
  - `POST /api/action-feedback?tab=<tabId>` for feedback on final assistant output and action cards.
274
321
  - `GET /api/optional-features` for optional companion package install/update status.
275
322
  - `POST /api/optional-feature-install` for installing or updating known optional companion packages from the side panel.
276
- - `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.
277
- - `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.
278
325
 
279
326
  For local development, run the checkout helper directly, for example:
280
327
 
@@ -308,19 +355,32 @@ Optional companions:
308
355
 
309
356
  ## Guided Git workflow
310
357
 
311
- 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:
312
371
 
313
- 1. `git add .`
314
- 2. Send `/git-staged-msg` to Pi
315
- 3. Read the generated commit message files from `dev/COMMIT/`
316
- 4. Commit with the selected generated message, or type a manual message in the Message stage and use **Commit input**
317
- 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`.
318
378
 
319
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.
320
380
 
321
- 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.
322
382
 
323
- 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.
324
384
 
325
385
  ## Mobile and PWA notes
326
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.2",
3
+ "version": "0.5.4",
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",
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;
@@ -506,6 +516,9 @@ const STREAM_OUTPUT_TOOLCALL_GUARD_MS = 220;
506
516
  const STREAM_OUTPUT_MIN_VISIBLE_MS = 900;
507
517
  const TOOL_LIVE_UPDATE_THROTTLE_MS = 80;
508
518
  const UNEXPOSED_THINKING_TEXT = "No thinking content was exposed by the provider.";
519
+ const THINKING_FORMAT_OPEN_TAG_REGEX = /^<think\b[^>]*>/i;
520
+ const THINKING_FORMAT_CLOSE_TAG_REGEX = /<\/think\s*>/i;
521
+ const CHANNEL_THINKING_FORMAT_OPEN_TAG_REGEX = /^<\|([a-z][\w-]*)>/i;
509
522
  const TODO_PROGRESS_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)\]\s+.+$/;
510
523
  const TODO_PROGRESS_PARTIAL_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)?\]?\s*.*$/;
511
524
  const CHAT_SCROLL_KEYS = new Set(["ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " "]);
@@ -4660,7 +4673,7 @@ function restoreActiveDraft() {
4660
4673
 
4661
4674
  function focusPromptInput({ defer = false } = {}) {
4662
4675
  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;
4676
+ 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
4677
  try {
4665
4678
  elements.promptInput.focus({ preventScroll: true });
4666
4679
  } catch {
@@ -10440,6 +10453,289 @@ function remoteWebuiWidgetLines(lines = []) {
10440
10453
  .filter((line, index, array) => line.trim() || (index > 0 && index < array.length - 1));
10441
10454
  }
10442
10455
 
10456
+ function remoteWebuiLineUrl(line) {
10457
+ const text = String(line || "").trim();
10458
+ const match = text.match(/https?:\/\/[^\s<>"']+/i);
10459
+ if (!match) return "";
10460
+ const candidate = match[0].replace(/[),.;]+$/, "");
10461
+ return safeHttpUrl(candidate);
10462
+ }
10463
+
10464
+ function remoteWebuiQrPayload(lines = []) {
10465
+ const cleanLines = remoteWebuiWidgetLines(lines);
10466
+ const url = remoteWebuiLineUrl(cleanLines.find((line) => remoteWebuiLineUrl(line)) || "");
10467
+ const scanIndex = cleanLines.findIndex((line) => /scan with your phone/i.test(line));
10468
+ const urlIndex = cleanLines.findIndex((line, index) => index > scanIndex && remoteWebuiLineUrl(line));
10469
+ const qrStart = scanIndex >= 0 ? scanIndex + 1 : -1;
10470
+ const qrEnd = urlIndex >= 0 ? urlIndex : cleanLines.length;
10471
+ const qrLines = qrStart >= 0
10472
+ ? cleanLines.slice(qrStart, qrEnd).filter((line, index, array) => line.trim() || (index > 0 && index < array.length - 1))
10473
+ : [];
10474
+ const detailLines = cleanLines.filter((line, index) => {
10475
+ if (!line.trim()) return false;
10476
+ if (/^Pi Remote WebUI$/i.test(line.trim())) return false;
10477
+ if (/scan with your phone/i.test(line)) return false;
10478
+ if (qrStart >= 0 && index >= qrStart && index < qrEnd) return false;
10479
+ return true;
10480
+ });
10481
+ return { cleanLines, detailLines, qrLines, url };
10482
+ }
10483
+
10484
+ function remoteWebuiQrMatrix(qrLines = []) {
10485
+ const lines = qrLines.map((line) => String(line ?? "")).filter((line) => /[█▀▄]/u.test(line));
10486
+ if (!lines.length) return null;
10487
+ const width = Math.max(...lines.map((line) => Array.from(line).length));
10488
+ if (!Number.isFinite(width) || width <= 0) return null;
10489
+
10490
+ const matrix = [];
10491
+ for (const line of lines) {
10492
+ const chars = Array.from(line);
10493
+ while (chars.length < width) chars.push(" ");
10494
+ const top = [];
10495
+ const bottom = [];
10496
+ for (const char of chars) {
10497
+ if (char === "█") {
10498
+ top.push(false);
10499
+ bottom.push(false);
10500
+ } else if (char === "▀") {
10501
+ top.push(false);
10502
+ bottom.push(true);
10503
+ } else if (char === "▄") {
10504
+ top.push(true);
10505
+ bottom.push(false);
10506
+ } else {
10507
+ top.push(true);
10508
+ bottom.push(true);
10509
+ }
10510
+ }
10511
+ matrix.push(top, bottom);
10512
+ }
10513
+
10514
+ while (matrix.length > width && matrix[0]?.every(Boolean)) matrix.shift();
10515
+ while (matrix.length > width && matrix.at(-1)?.every(Boolean)) matrix.pop();
10516
+ if (matrix.length !== width || matrix.some((row) => row.length !== width)) return null;
10517
+ if (!matrix.some((row) => row.some(Boolean))) return null;
10518
+ return matrix;
10519
+ }
10520
+
10521
+ function remoteWebuiQrSvg(qrLines = []) {
10522
+ const matrix = remoteWebuiQrMatrix(qrLines);
10523
+ if (!matrix) return null;
10524
+ const size = matrix.length;
10525
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
10526
+ svg.setAttribute("class", "remote-qr-svg");
10527
+ svg.setAttribute("viewBox", `0 0 ${size} ${size}`);
10528
+ svg.setAttribute("role", "img");
10529
+ svg.setAttribute("aria-label", "/remote QR code");
10530
+ svg.setAttribute("shape-rendering", "crispEdges");
10531
+ svg.setAttribute("focusable", "false");
10532
+
10533
+ const background = document.createElementNS(svg.namespaceURI, "rect");
10534
+ background.setAttribute("class", "remote-qr-svg-bg");
10535
+ background.setAttribute("width", String(size));
10536
+ background.setAttribute("height", String(size));
10537
+ svg.append(background);
10538
+
10539
+ for (let y = 0; y < size; y++) {
10540
+ for (let x = 0; x < size; x++) {
10541
+ if (!matrix[y][x]) continue;
10542
+ const rect = document.createElementNS(svg.namespaceURI, "rect");
10543
+ rect.setAttribute("class", "remote-qr-svg-dark");
10544
+ rect.setAttribute("x", String(x));
10545
+ rect.setAttribute("y", String(y));
10546
+ rect.setAttribute("width", "1");
10547
+ rect.setAttribute("height", "1");
10548
+ svg.append(rect);
10549
+ }
10550
+ }
10551
+ return svg;
10552
+ }
10553
+
10554
+ function closeRemoteWebuiQrPopup() {
10555
+ latestRemoteWebuiQrUrl = "";
10556
+ elements.remoteQrDialog?.classList.remove("is-loading");
10557
+ if (elements.remoteQrDialog?.open) elements.remoteQrDialog.close();
10558
+ }
10559
+
10560
+ function openRemoteWebuiQrUrl() {
10561
+ const url = latestRemoteWebuiQrUrl;
10562
+ if (!url) return;
10563
+ const anchor = document.createElement("a");
10564
+ anchor.href = url;
10565
+ anchor.target = "_blank";
10566
+ anchor.rel = "noopener noreferrer";
10567
+ anchor.hidden = true;
10568
+ document.body.append(anchor);
10569
+ anchor.click();
10570
+ anchor.remove();
10571
+ addEvent("opened /remote URL", "info");
10572
+ }
10573
+
10574
+ async function copyRemoteWebuiQrUrl() {
10575
+ if (!latestRemoteWebuiQrUrl) return;
10576
+ try {
10577
+ await copyText(latestRemoteWebuiQrUrl);
10578
+ addEvent("copied /remote URL", "info");
10579
+ } catch (error) {
10580
+ addEvent(`copy /remote URL failed: ${error.message || String(error)}`, "error");
10581
+ }
10582
+ }
10583
+
10584
+ function isLocalWebuiBrowserOrigin() {
10585
+ const host = window.location.hostname.toLowerCase();
10586
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
10587
+ }
10588
+
10589
+ function remoteWebuiQrLinesFromData(data = {}) {
10590
+ const network = data.network || latestNetwork || {};
10591
+ const auth = network.auth || {};
10592
+ const networkUrls = Array.isArray(network.networkUrls) ? network.networkUrls : [];
10593
+ const displayUrl = data.url || networkUrls.find((candidate) => /^https?:\/\//i.test(String(candidate || ""))) || network.localUrl || "";
10594
+ const qrLines = Array.isArray(data.qrLines) ? data.qrLines.map((line) => String(line ?? "")) : [];
10595
+ const hasAutoAuthQr = !!(auth.enabled && auth.pin && data.qrUrl && displayUrl && data.qrUrl !== displayUrl);
10596
+ const authLine = auth.enabled ? `Remote PIN auth: on${auth.pin ? ` · PIN ${auth.pin}` : ""}` : "Remote PIN auth: off";
10597
+ const warningLine = hasAutoAuthQr
10598
+ ? "Trusted LAN only. The QR signs in with the embedded PIN; keep it private."
10599
+ : auth.enabled
10600
+ ? "Trusted LAN only. Anyone with this URL and PIN can control Pi/WebUI."
10601
+ : "Trusted LAN only. Remote PIN auth is off; anyone with this URL can control Pi/WebUI.";
10602
+ return [
10603
+ "Pi Remote WebUI",
10604
+ "",
10605
+ hasAutoAuthQr ? "Scan with your phone (auto-auth QR):" : "Scan with your phone:",
10606
+ "",
10607
+ ...qrLines,
10608
+ "",
10609
+ displayUrl,
10610
+ authLine,
10611
+ "",
10612
+ warningLine,
10613
+ "Close LAN access with: /remote close",
10614
+ ];
10615
+ }
10616
+
10617
+ function showRemoteWebuiQrLoadingPopup(message = "Opening Remote WebUI QR…") {
10618
+ if (!elements.remoteQrDialog || !elements.remoteQrBody) return;
10619
+ latestRemoteWebuiQrUrl = "";
10620
+ elements.remoteQrDialog.classList.add("is-loading");
10621
+
10622
+ const loading = make("div", "remote-qr-loading");
10623
+ loading.setAttribute("role", "status");
10624
+ loading.setAttribute("aria-live", "polite");
10625
+ const spinner = make("div", "remote-qr-spinner");
10626
+ spinner.setAttribute("aria-hidden", "true");
10627
+ const copy = make("div", "remote-qr-loading-copy");
10628
+ copy.append(
10629
+ make("strong", "", message),
10630
+ make("span", "muted", "Starting /remote and generating a QR code. This can take a moment."),
10631
+ );
10632
+ loading.append(spinner, copy);
10633
+
10634
+ elements.remoteQrBody.replaceChildren(loading);
10635
+ if (elements.remoteQrMessage) elements.remoteQrMessage.textContent = "Preparing trusted-LAN browser access…";
10636
+ if (elements.remoteQrCopyButton) elements.remoteQrCopyButton.disabled = true;
10637
+ if (elements.remoteQrOpenButton) elements.remoteQrOpenButton.disabled = true;
10638
+
10639
+ try {
10640
+ if (!elements.remoteQrDialog.open) elements.remoteQrDialog.showModal();
10641
+ } catch (error) {
10642
+ addEvent(`remote QR loading popup unavailable: ${error.message || String(error)}`, "warn");
10643
+ }
10644
+ }
10645
+
10646
+ function isRemoteWebuiQrPopupLoading() {
10647
+ return !!elements.remoteQrDialog?.classList.contains("is-loading");
10648
+ }
10649
+
10650
+ function remoteWebuiStatusLoadingMessage(statusText) {
10651
+ const text = String(statusText || "").toLowerCase();
10652
+ if (text.includes("refresh")) return "Refreshing Remote WebUI QR…";
10653
+ if (text.includes("pin auth")) return "Preparing Remote PIN auth and QR…";
10654
+ return "Opening Remote WebUI QR…";
10655
+ }
10656
+
10657
+ function handleRemoteWebuiStatus(statusText) {
10658
+ const text = String(statusText || "").toLowerCase();
10659
+ if (text.includes("opening remote webui") || text.includes("refreshing remote qr") || text.includes("enabling remote pin auth")) {
10660
+ showRemoteWebuiQrLoadingPopup(remoteWebuiStatusLoadingMessage(statusText));
10661
+ return;
10662
+ }
10663
+ if (text.includes("closing remote webui")) {
10664
+ closeRemoteWebuiQrPopup();
10665
+ return;
10666
+ }
10667
+ if (!statusText && isRemoteWebuiQrPopupLoading()) closeRemoteWebuiQrPopup();
10668
+ }
10669
+
10670
+ async function showRemoteWebuiQrPopupFromNetwork({ auto = false } = {}) {
10671
+ if (auto) remoteQrAutoPopupShown = true;
10672
+ showRemoteWebuiQrLoadingPopup("Preparing Remote WebUI QR…");
10673
+ try {
10674
+ const response = await api("/api/network/qr", { scoped: false });
10675
+ openRemoteWebuiQrPopup(remoteWebuiQrLinesFromData(response.data || {}));
10676
+ return true;
10677
+ } catch (error) {
10678
+ if (isRemoteWebuiQrPopupLoading()) closeRemoteWebuiQrPopup();
10679
+ addEvent(`remote QR popup failed: ${error.message || String(error)}`, auto ? "warn" : "error");
10680
+ return false;
10681
+ }
10682
+ }
10683
+
10684
+ function openRemoteWebuiQrPopup(lines = []) {
10685
+ if (!elements.remoteQrDialog || !elements.remoteQrBody) return;
10686
+ const { cleanLines, detailLines, qrLines, url } = remoteWebuiQrPayload(lines);
10687
+ latestRemoteWebuiQrUrl = url;
10688
+ elements.remoteQrDialog.classList.remove("is-loading");
10689
+
10690
+ const qrText = (qrLines.length ? qrLines : cleanLines).join("\n").trimEnd();
10691
+ const card = make("div", "remote-qr-card");
10692
+ const svgQr = remoteWebuiQrSvg(qrLines);
10693
+ if (svgQr) {
10694
+ card.append(svgQr);
10695
+ } else {
10696
+ const code = make("pre", "remote-qr-code", qrText || "QR code unavailable.");
10697
+ code.setAttribute("aria-label", "/remote QR code");
10698
+ card.append(code);
10699
+ }
10700
+
10701
+ const details = make("div", "remote-qr-details");
10702
+ if (url) {
10703
+ const urlRow = make("div", "remote-qr-url-row");
10704
+ const urlLabel = make("span", "remote-qr-url-label", "URL");
10705
+ const link = make("a", "remote-qr-url", url);
10706
+ link.href = url;
10707
+ link.target = "_blank";
10708
+ link.rel = "noopener noreferrer";
10709
+ urlRow.append(urlLabel, link);
10710
+ details.append(urlRow);
10711
+ }
10712
+ for (const line of detailLines.filter((line) => !remoteWebuiLineUrl(line))) {
10713
+ details.append(make("p", "remote-qr-note", line));
10714
+ }
10715
+ if (!details.childElementCount) details.append(make("p", "remote-qr-note muted", "Scan from a trusted device on the same local network."));
10716
+
10717
+ elements.remoteQrBody.replaceChildren(card, details);
10718
+ if (elements.remoteQrMessage) {
10719
+ elements.remoteQrMessage.textContent = url
10720
+ ? "Scan this /remote QR code from a trusted local-network device."
10721
+ : "The /remote QR output is ready. Use the transcript if this terminal QR cannot be scanned.";
10722
+ }
10723
+ if (elements.remoteQrCopyButton) elements.remoteQrCopyButton.disabled = !url;
10724
+ if (elements.remoteQrOpenButton) elements.remoteQrOpenButton.disabled = !url;
10725
+
10726
+ try {
10727
+ if (!elements.remoteQrDialog.open) elements.remoteQrDialog.showModal();
10728
+ } catch (error) {
10729
+ addEvent(`remote QR popup unavailable; see /remote output in transcript: ${error.message || String(error)}`, "warn");
10730
+ }
10731
+ }
10732
+
10733
+ function showRemoteWebuiQrPopup(widgetKey, lines = [], request = {}) {
10734
+ if (widgetKey !== "pi-remote-webui" || !Array.isArray(lines)) return;
10735
+ remoteQrAutoPopupShown = true;
10736
+ openRemoteWebuiQrPopup(lines);
10737
+ }
10738
+
10443
10739
  function mirrorRemoteWebuiWidgetToTranscript(widgetKey, lines = [], request = {}) {
10444
10740
  if (widgetKey !== "pi-remote-webui" || request.replayed) return;
10445
10741
  const content = remoteWebuiWidgetLines(lines).join("\n").trimEnd();
@@ -13153,6 +13449,89 @@ function visibleThinkingText(text) {
13153
13449
  return value;
13154
13450
  }
13155
13451
 
13452
+ function escapeRegExp(value) {
13453
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13454
+ }
13455
+
13456
+ function isPartialThinkingFormatOpenTag(text) {
13457
+ const value = String(text || "").trimStart().toLowerCase();
13458
+ if (!value) return false;
13459
+ if ("<think>".startsWith(value)) return true;
13460
+ if (value === "<|" || /^<\|[a-z][\w-]*$/i.test(value)) return true;
13461
+ return /^<think\b[^>]*$/i.test(value);
13462
+ }
13463
+
13464
+ function stripPartialThinkingFormatClose(text, closeTag = "</think>") {
13465
+ const value = String(text || "");
13466
+ const lower = value.toLowerCase();
13467
+ const expected = String(closeTag || "").toLowerCase();
13468
+ const start = lower.lastIndexOf("<");
13469
+ if (start < 0) return value;
13470
+ const partial = lower.slice(start).trimEnd();
13471
+ return expected.startsWith(partial) ? value.slice(0, start) : value;
13472
+ }
13473
+
13474
+ function stripThinkingFormatOutputSeparator(text) {
13475
+ return String(text || "").replace(/^(?:[ \t]*\r?\n)+/, "").replace(/^[ \t]+/, "");
13476
+ }
13477
+
13478
+ function joinedThinkingFormatParts(parts) {
13479
+ return parts.map((part) => String(part || "")).filter((part) => part.length > 0).join("\n\n");
13480
+ }
13481
+
13482
+ function thinkingFormatOpenMatch(text) {
13483
+ const value = String(text || "");
13484
+ const think = THINKING_FORMAT_OPEN_TAG_REGEX.exec(value);
13485
+ if (think) return { raw: think[0], closeRegex: THINKING_FORMAT_CLOSE_TAG_REGEX, closeTag: "</think>" };
13486
+ const channel = CHANNEL_THINKING_FORMAT_OPEN_TAG_REGEX.exec(value);
13487
+ if (!channel) return null;
13488
+ const name = channel[1];
13489
+ return { raw: channel[0], closeRegex: new RegExp(`<${escapeRegExp(name)}\\|>`, "i"), closeTag: `<${name}|>` };
13490
+ }
13491
+
13492
+ function splitThinkingFormatText(text, { streaming = false } = {}) {
13493
+ let rest = String(text ?? "").trimStart();
13494
+ if (!rest) return null;
13495
+ if (!thinkingFormatOpenMatch(rest)) {
13496
+ return streaming && isPartialThinkingFormatOpenTag(rest)
13497
+ ? { hasThinkingFormat: true, thinkingText: "", finalText: "", complete: false }
13498
+ : null;
13499
+ }
13500
+
13501
+ const thinkingParts = [];
13502
+ let open = thinkingFormatOpenMatch(rest);
13503
+ while (open) {
13504
+ const afterOpen = rest.slice(open.raw.length);
13505
+ const close = open.closeRegex.exec(afterOpen);
13506
+ if (!close) {
13507
+ thinkingParts.push(streaming ? stripPartialThinkingFormatClose(afterOpen, open.closeTag) : afterOpen);
13508
+ return { hasThinkingFormat: true, thinkingText: joinedThinkingFormatParts(thinkingParts), finalText: "", complete: false };
13509
+ }
13510
+
13511
+ thinkingParts.push(afterOpen.slice(0, close.index));
13512
+ rest = afterOpen.slice(close.index + close[0].length);
13513
+ const next = rest.trimStart();
13514
+ open = thinkingFormatOpenMatch(next);
13515
+ if (open) {
13516
+ rest = next;
13517
+ continue;
13518
+ }
13519
+ break;
13520
+ }
13521
+
13522
+ return {
13523
+ hasThinkingFormat: true,
13524
+ thinkingText: joinedThinkingFormatParts(thinkingParts),
13525
+ finalText: stripThinkingFormatOutputSeparator(rest),
13526
+ complete: true,
13527
+ };
13528
+ }
13529
+
13530
+ function appendThinkingFormatDisplayMessages(displayMessages, base, parsed) {
13531
+ const thinking = visibleThinkingText(parsed?.thinkingText || "");
13532
+ if (thinking) displayMessages.push({ ...base, role: "thinking", title: "thinking", content: thinking, thinking });
13533
+ }
13534
+
13156
13535
  function isAssistantToolCallPart(part) {
13157
13536
  return !!(part && typeof part === "object" && (part.type === "toolCall" || part.toolCall));
13158
13537
  }
@@ -13202,6 +13581,13 @@ function assistantDisplayMessages(message) {
13202
13581
  const base = { timestamp: message.timestamp };
13203
13582
  const content = message.content;
13204
13583
  if (typeof content === "string") {
13584
+ const parsed = splitThinkingFormatText(content);
13585
+ if (parsed?.hasThinkingFormat) {
13586
+ const displayMessages = [];
13587
+ appendThinkingFormatDisplayMessages(displayMessages, base, parsed);
13588
+ if (parsed.finalText.trim()) displayMessages.push({ ...message, title: "final output", content: parsed.finalText });
13589
+ return displayMessages;
13590
+ }
13205
13591
  return content.trim() ? [{ ...message, title: "final output" }] : [];
13206
13592
  }
13207
13593
  if (!Array.isArray(content)) {
@@ -13225,6 +13611,16 @@ function assistantDisplayMessages(message) {
13225
13611
  displayMessages.push({ ...base, role: "toolCall", title: `tool call: ${toolName}`, toolName, toolCallId, arguments: args, content: args });
13226
13612
  continue;
13227
13613
  }
13614
+ const primitiveText = part !== undefined && part !== null && typeof part !== "object" ? String(part) : "";
13615
+ const textForThinkingFormat = primitiveText || (part && typeof part === "object" && (part.type === "text" || typeof part.text === "string") ? assistantTextPartText(part) || part.text : "");
13616
+ if (textForThinkingFormat) {
13617
+ const parsed = splitThinkingFormatText(textForThinkingFormat);
13618
+ if (parsed?.hasThinkingFormat) {
13619
+ appendThinkingFormatDisplayMessages(displayMessages, base, parsed);
13620
+ if (parsed.finalText.trim() && !assistantHasToolCallAfter(content, index)) finalParts.push(part && typeof part === "object" ? { ...part, type: "text", text: parsed.finalText } : { type: "text", text: parsed.finalText });
13621
+ continue;
13622
+ }
13623
+ }
13228
13624
  const finalPart = assistantFinalOutputPart(part);
13229
13625
  if (finalPart) {
13230
13626
  if (!assistantHasToolCallAfter(content, index)) finalParts.push(finalPart);
@@ -16390,6 +16786,12 @@ function removeStreamBubble() {
16390
16786
  renderRunIndicator({ scroll: false });
16391
16787
  }
16392
16788
 
16789
+ function streamRenderableAssistantText() {
16790
+ const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
16791
+ const parsed = splitThinkingFormatText(assistantText, { streaming: true });
16792
+ return parsed?.hasThinkingFormat ? stripTodoProgressLines(parsed.finalText, { streaming: true }) : assistantText;
16793
+ }
16794
+
16393
16795
  function scheduleStreamBubbleHide() {
16394
16796
  if (!streamBubble) return;
16395
16797
  const visibleForMs = streamBubbleVisibleSince ? performance.now() - streamBubbleVisibleSince : STREAM_OUTPUT_MIN_VISIBLE_MS;
@@ -16397,16 +16799,27 @@ function scheduleStreamBubbleHide() {
16397
16799
  clearTimeout(streamBubbleHideTimer);
16398
16800
  streamBubbleHideTimer = setTimeout(() => {
16399
16801
  streamBubbleHideTimer = null;
16400
- if (stripTodoProgressLines(streamRawText, { streaming: true }) || !streamBubble) return;
16802
+ if (streamRenderableAssistantText() || !streamBubble) return;
16401
16803
  removeStreamBubble();
16402
16804
  }, delayMs);
16403
16805
  }
16404
16806
 
16807
+ function syncStreamingThinkingFormat(assistantText) {
16808
+ const parsed = splitThinkingFormatText(assistantText, { streaming: true });
16809
+ if (!parsed?.hasThinkingFormat) return null;
16810
+ const thinking = visibleThinkingText(parsed.thinkingText);
16811
+ if (thinking) setStreamingThinkingText(thinking);
16812
+ if (parsed.complete && streamThinkingBubble) streamThinkingBubble.classList.add("complete");
16813
+ return parsed;
16814
+ }
16815
+
16405
16816
  function renderStreamingAssistantText() {
16406
16817
  const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
16407
- if (assistantText) {
16818
+ const thinkingFormat = syncStreamingThinkingFormat(assistantText);
16819
+ const finalText = thinkingFormat?.hasThinkingFormat ? stripTodoProgressLines(thinkingFormat.finalText, { streaming: true }) : assistantText;
16820
+ if (finalText) {
16408
16821
  ensureStreamBubble();
16409
- renderStreamingMarkdown(streamText, assistantText);
16822
+ renderStreamingMarkdown(streamText, finalText);
16410
16823
  } else {
16411
16824
  scheduleStreamBubbleHide();
16412
16825
  }
@@ -16500,26 +16913,39 @@ function assistantStreamingMessage(event) {
16500
16913
  return partial?.role === "assistant" ? partial : null;
16501
16914
  }
16502
16915
 
16503
- function assistantTextFromMessage(message) {
16916
+ function assistantTextFromMessage(message, { streaming = false } = {}) {
16917
+ void streaming;
16504
16918
  const content = message?.content;
16505
16919
  if (typeof content === "string") return content;
16506
16920
  if (!Array.isArray(content)) return null;
16507
16921
  const parts = [];
16508
16922
  for (let index = 0; index < content.length; index += 1) {
16509
16923
  const part = content[index];
16510
- const text = assistantTextPartText(part);
16924
+ const text = assistantTextPartText(part) || (part && typeof part === "object" && typeof part.text === "string" ? part.text : part !== undefined && part !== null && typeof part !== "object" ? String(part) : "");
16511
16925
  if (text && !assistantHasToolCallAfter(content, index)) parts.push(text);
16512
16926
  }
16513
16927
  return parts.length ? parts.join("\n\n") : "";
16514
16928
  }
16515
16929
 
16516
- function assistantThinkingTextFromMessage(message) {
16930
+ function assistantThinkingTextFromMessage(message, { streaming = false } = {}) {
16517
16931
  const content = message?.content;
16932
+ if (typeof content === "string") {
16933
+ const parsed = splitThinkingFormatText(content, { streaming });
16934
+ return parsed?.hasThinkingFormat ? visibleThinkingText(parsed.thinkingText) : null;
16935
+ }
16518
16936
  if (!Array.isArray(content)) return null;
16519
- const parts = content
16520
- .filter((part) => part && typeof part === "object" && (part.type === "thinking" || typeof part.thinking === "string"))
16521
- .map((part) => visibleThinkingText(assistantThinkingText(part)))
16522
- .filter((text) => text.trim());
16937
+ const parts = [];
16938
+ for (const part of content) {
16939
+ if (part && typeof part === "object" && (part.type === "thinking" || typeof part.thinking === "string")) {
16940
+ const thinking = visibleThinkingText(assistantThinkingText(part));
16941
+ if (thinking.trim()) parts.push(thinking);
16942
+ continue;
16943
+ }
16944
+ const text = assistantTextPartText(part) || (part && typeof part === "object" && typeof part.text === "string" ? part.text : part !== undefined && part !== null && typeof part !== "object" ? String(part) : "");
16945
+ const parsed = splitThinkingFormatText(text, { streaming });
16946
+ const thinking = parsed?.hasThinkingFormat ? visibleThinkingText(parsed.thinkingText) : "";
16947
+ if (thinking.trim()) parts.push(thinking);
16948
+ }
16523
16949
  return parts.length ? parts.join("\n\n") : "";
16524
16950
  }
16525
16951
 
@@ -16533,7 +16959,7 @@ function setStreamingThinkingText(text) {
16533
16959
 
16534
16960
  function syncStreamingThinkingFromMessage(event, { placeholder = "" } = {}) {
16535
16961
  if (!thinkingOutputVisible) return true;
16536
- const text = assistantThinkingTextFromMessage(assistantStreamingMessage(event));
16962
+ const text = assistantThinkingTextFromMessage(assistantStreamingMessage(event), { streaming: true });
16537
16963
  if (text === null) return false;
16538
16964
  return setStreamingThinkingText(text || placeholder);
16539
16965
  }
@@ -16555,13 +16981,13 @@ function handleMessageUpdate(event) {
16555
16981
  }
16556
16982
  scrollChatToBottom();
16557
16983
  } else if (update.type === "thinking_end") {
16558
- const finalThinking = assistantThinkingTextFromMessage(assistantStreamingMessage(event)) || thinkingDeltaText(update);
16984
+ const finalThinking = assistantThinkingTextFromMessage(assistantStreamingMessage(event), { streaming: true }) || thinkingDeltaText(update);
16559
16985
  if (finalThinking) setStreamingThinkingText(finalThinking);
16560
16986
  streamThinkingBubble?.classList.add("complete");
16561
16987
  setRunIndicatorActivity("Finished thinking; waiting for the next output or action…", { scroll: false });
16562
16988
  } else if (update.type === "text_delta" || update.type === "text_end") {
16563
16989
  const delta = update.type === "text_delta" ? update.delta || "" : "";
16564
- const partialText = assistantTextFromMessage(assistantStreamingMessage(event));
16990
+ const partialText = assistantTextFromMessage(assistantStreamingMessage(event), { streaming: true });
16565
16991
  if (typeof partialText === "string") streamRawText = partialText;
16566
16992
  else if (update.type === "text_end" && typeof update.content === "string") streamRawText = update.content;
16567
16993
  else streamRawText += delta;
@@ -16719,6 +17145,8 @@ function renderNetworkStatus() {
16719
17145
  }
16720
17146
 
16721
17147
  async function refreshNetworkStatus() {
17148
+ const hadNetworkStatus = networkStatusLoaded;
17149
+ const wasOpen = !!latestNetwork?.open;
16722
17150
  try {
16723
17151
  const response = await api("/api/network", { scoped: false });
16724
17152
  latestNetwork = response.data || null;
@@ -16727,6 +17155,22 @@ async function refreshNetworkStatus() {
16727
17155
  latestNetwork = health.network || { open: false, opening: false, localUrl: window.location.origin };
16728
17156
  }
16729
17157
  renderNetworkStatus();
17158
+ networkStatusLoaded = true;
17159
+
17160
+ const open = !!latestNetwork?.open;
17161
+ if (!open) {
17162
+ remoteQrAutoPopupShown = false;
17163
+ return;
17164
+ }
17165
+
17166
+ if (!hadNetworkStatus) {
17167
+ remoteQrAutoPopupShown = true;
17168
+ return;
17169
+ }
17170
+
17171
+ if (!wasOpen && !remoteQrAutoPopupShown && isLocalWebuiBrowserOrigin()) {
17172
+ showRemoteWebuiQrPopupFromNetwork({ auto: true }).catch((error) => addEvent(error.message || String(error), "warn"));
17173
+ }
16730
17174
  }
16731
17175
 
16732
17176
  async function runRemoteWebuiCommand(command) {
@@ -18145,6 +18589,7 @@ function handleExtensionUiRequest(request) {
18145
18589
  }
18146
18590
  if (statusKey === STATS_WEBUI_STATUS_KEY) handleStatsWebuiStatus(request.statusText);
18147
18591
  if (statusKey === BTW_WEBUI_STATUS_KEY) handleBtwWebuiStatus(request.statusText);
18592
+ if (statusKey === "pi-remote-webui") handleRemoteWebuiStatus(request.statusText);
18148
18593
  updateOptionalFeatureAvailability();
18149
18594
  if (statusKey === GIT_FOOTER_WEBUI_STATUS_KEY) {
18150
18595
  if (currentState?.isStreaming || runIndicatorLocallyActive) return;
@@ -18162,7 +18607,12 @@ function handleExtensionUiRequest(request) {
18162
18607
  const widgetKey = request.widgetKey || request.id;
18163
18608
  if (widgetKey === "pi-remote-webui") {
18164
18609
  widgets.delete(widgetKey);
18165
- if (Array.isArray(request.widgetLines)) mirrorRemoteWebuiWidgetToTranscript(widgetKey, request.widgetLines, request);
18610
+ if (Array.isArray(request.widgetLines)) {
18611
+ mirrorRemoteWebuiWidgetToTranscript(widgetKey, request.widgetLines, request);
18612
+ showRemoteWebuiQrPopup(widgetKey, request.widgetLines, request);
18613
+ } else {
18614
+ closeRemoteWebuiQrPopup();
18615
+ }
18166
18616
  } else if (Array.isArray(request.widgetLines)) {
18167
18617
  widgets.set(widgetKey, request);
18168
18618
  } else {
@@ -18185,6 +18635,7 @@ function handleExtensionUiRequest(request) {
18185
18635
  case "confirm":
18186
18636
  case "input":
18187
18637
  case "editor":
18638
+ if (isRemoteWebuiQrPopupLoading()) closeRemoteWebuiQrPopup();
18188
18639
  if (hasQueuedDialogRequest(request.id)) return;
18189
18640
  if (request.pendingExtensionUiRequestCount === undefined) {
18190
18641
  const tab = tabs.find((item) => item.id === request.tabId);
@@ -19290,6 +19741,14 @@ if (elements.backgroundClearButton) {
19290
19741
  }
19291
19742
  elements.remoteAuthToggle.addEventListener("change", () => toggleRemoteAuth().catch((error) => addEvent(error.message || String(error), "error")));
19292
19743
  elements.openNetworkButton.addEventListener("click", openToNetwork);
19744
+ elements.remoteQrCopyButton?.addEventListener("click", () => copyRemoteWebuiQrUrl().catch((error) => addEvent(error.message || String(error), "error")));
19745
+ elements.remoteQrOpenButton?.addEventListener("click", openRemoteWebuiQrUrl);
19746
+ elements.remoteQrCloseButton?.addEventListener("click", closeRemoteWebuiQrPopup);
19747
+ elements.remoteQrCloseMenuButton?.addEventListener("click", closeRemoteWebuiQrPopup);
19748
+ elements.remoteQrDialog?.addEventListener("close", () => {
19749
+ latestRemoteWebuiQrUrl = "";
19750
+ elements.remoteQrDialog?.classList.remove("is-loading");
19751
+ });
19293
19752
  elements.serverActionSelect.addEventListener("change", updateServerActionButton);
19294
19753
  elements.runServerActionButton.addEventListener("click", () => runSelectedServerAction().catch((error) => addEvent(error.message || String(error), "error")));
19295
19754
  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");
@@ -734,6 +747,8 @@ assert.match(app, /appendText\(preview, toolResultPreviewText\(message, 10\), "c
734
747
  assert.match(app, /function assistantDisplayMessages\(message\)/, "assistant history should split thinking and tool-call parts out of the final Assistant output card");
735
748
  assert.match(app, /function assistantHasToolCallAfter\(content, index\)/, "assistant text that precedes a tool call should be detectable and suppressible");
736
749
  assert.match(app, /if \(!assistantHasToolCallAfter\(content, index\)\) finalParts\.push\(finalPart\);/, "assistant history should not render pre-tool-call assistant text as final output");
750
+ assert.match(app, /typeof content === "string"[\s\S]*?splitThinkingFormatText\(content\)[\s\S]*?content: parsed\.finalText/, "assistant string messages with tagged <think> output should render final text separately");
751
+ assert.match(app, /const textForThinkingFormat[\s\S]*?splitThinkingFormatText\(textForThinkingFormat\)[\s\S]*?appendThinkingFormatDisplayMessages\(displayMessages, base, parsed\)[\s\S]*?finalParts\.push/, "assistant text parts with tagged <think> output should split into thinking and final-output cards");
737
752
  assert.match(app, /return content\.trim\(\) \? \[\{ \.\.\.message, title: "final output" \}\] : \[\]/, "assistant messages with stripped empty text should not render empty final-output cards");
738
753
  assert.match(app, /function isEmptyAssistantTextPart\(part\)[\s\S]*?part\.type === "text"[\s\S]*?!assistantTextPartText\(part\)\.trim\(\)/, "empty assistant text parts should be recognized as skippable provider metadata");
739
754
  assert.match(app, /if \(isEmptyAssistantTextPart\(part\)\) continue;/, "empty assistant text parts should not render as assistant-event cards");
@@ -741,6 +756,10 @@ assert.match(app, /function assistantFinalOutputPart\(part\)[\s\S]*?if \(part\.t
741
756
  assert.match(app, /\["assistant", "toolExecution"\]\.includes\(transcriptMessage\.role\) \? messageIndex : -1/, "final Assistant output and paired tool action cards should keep the source message index for feedback");
742
757
  assert.match(app, /function ensureStreamingThinkingBubble\(\)[\s\S]*if \(!thinkingOutputVisible\) return false/, "live thinking should respect the show/hide thinking-output toggle");
743
758
  assert.match(app, /const UNEXPOSED_THINKING_TEXT = "No thinking content was exposed by the provider\."/, "frontend should name the provider no-thinking placeholder for suppression");
759
+ assert.match(app, /THINKING_FORMAT_OPEN_TAG_REGEX/, "frontend should recognize tagged <think> provider output");
760
+ assert.match(app, /CHANNEL_THINKING_FORMAT_OPEN_TAG_REGEX = \/\^<\\\|\(\[a-z\]\[\\w-\]\*\)>\/i/, "frontend should recognize tagged <|channel> provider output");
761
+ assert.match(app, /function thinkingFormatOpenMatch\(text\)[\s\S]*?CHANNEL_THINKING_FORMAT_OPEN_TAG_REGEX[\s\S]*?closeRegex: new RegExp\(`<\$\{escapeRegExp\(name\)\}\\\\\|>`/, "channel-style tagged output should create a matching <channel|> close delimiter");
762
+ assert.match(app, /function splitThinkingFormatText\(text, \{ streaming = false \} = \{\}\)[\s\S]*?thinkingFormatOpenMatch\(rest\)[\s\S]*?finalText: stripThinkingFormatOutputSeparator\(rest\)/, "tagged thinking output should split thinking text from final response text");
744
763
  assert.match(app, /function visibleThinkingText\(text\)[\s\S]*?trimmed === UNEXPOSED_THINKING_TEXT[\s\S]*?return "";/, "provider no-thinking placeholders should normalize to empty thinking output");
745
764
  assert.match(app, /if \(isThinkingPart\) \{[\s\S]*?visibleThinkingText\(assistantThinkingText\(part\)\)[\s\S]*?if \(thinking\) displayMessages\.push/, "assistant transcript splitting should skip empty or unexposed thinking parts");
746
765
  assert.match(app, /message\.role === "thinking"[\s\S]*?visibleThinkingText\(message\.thinking \|\| textFromContent\(message\.content\)\)[\s\S]*?if \(thinkingOutputVisible && thinkingText\) appendText\(body, thinkingText, "thinking-text"\);/, "thinking cards should suppress empty and provider no-thinking placeholder output");
@@ -753,15 +772,17 @@ assert.match(app, /function thinkingDeltaText\(update\) \{[\s\S]*?return visible
753
772
  assert.match(app, /const THINKING_VISIBILITY_STORAGE_KEY = "pi-webui-thinking-visible"/, "thinking visibility should persist in browser storage");
754
773
  assert.match(app, /function setThinkingOutputVisible\(visible[\s\S]*renderAllMessages\(\{ preserveScroll: true \}\)/, "thinking visibility changes should immediately re-render the transcript");
755
774
  assert.match(app, /function assistantStreamingMessage\(event\)/, "live streaming should read the authoritative partial assistant message from RPC events like the TUI");
756
- assert.match(app, /assistantThinkingTextFromMessage\(assistantStreamingMessage\(event\)\) \|\| thinkingDeltaText\(update\)/, "live thinking end should replace deltas with the final partial-message thinking content");
775
+ assert.match(app, /assistantThinkingTextFromMessage\(assistantStreamingMessage\(event\), \{ streaming: true \}\) \|\| thinkingDeltaText\(update\)/, "live thinking end should replace deltas with the final partial-message thinking content");
757
776
  assert.match(app, /if \(typeof partialText === "string"\) streamRawText = partialText;/, "live assistant text should synchronize from partial messages instead of relying only on deltas");
758
777
  assert.match(app, /const TODO_PROGRESS_LINE_REGEX = /, "frontend should recognize live todo progress lines that will be moved into the todo widget");
759
778
  assert.match(app, /function stripTodoProgressLines\(text, \{ streaming = false \} = \{\}\)/, "live Assistant output should strip todo-progress lines before rendering final-output text");
760
779
  assert.match(app, /function renderStreamingAssistantText\(\)[\s\S]*?const assistantText = stripTodoProgressLines\(streamRawText, \{ streaming: true \}\)/, "streamed Assistant text should classify from accumulated output without flashing partial todo-progress lines");
780
+ assert.match(app, /function syncStreamingThinkingFormat\(assistantText\)[\s\S]*?splitThinkingFormatText\(assistantText, \{ streaming: true \}\)[\s\S]*?setStreamingThinkingText\(thinking\)/, "tagged <think> streaming output should update the live thinking card instead of flashing raw tags");
781
+ assert.match(app, /const finalText = thinkingFormat\?\.hasThinkingFormat \? stripTodoProgressLines\(thinkingFormat\.finalText, \{ streaming: true \}\) : assistantText;/, "tagged <think> streaming output should render only final response text in the Assistant card");
761
782
  assert.match(app, /const STREAM_OUTPUT_HIDE_DELAY_MS = 300/, "stream output hiding should be debounced to prevent rapid flicker");
762
783
  assert.match(app, /const STREAM_OUTPUT_TOOLCALL_GUARD_MS = 220/, "live assistant text should be briefly guarded so pre-tool-call text can be suppressed");
763
784
  assert.match(app, /function scheduleStreamBubbleHide\([\s\S]*?STREAM_OUTPUT_MIN_VISIBLE_MS/, "stream output cards should observe a minimum visible duration before hiding");
764
- assert.match(app, /if \(assistantText\) \{[\s\S]*?renderStreamingMarkdown\(streamText, assistantText\);[\s\S]*?\} else \{\n\s+scheduleStreamBubbleHide\(\);/, "empty filtered stream output should schedule hide while visible stream output renders as Markdown");
785
+ assert.match(app, /if \(finalText\) \{[\s\S]*?renderStreamingMarkdown\(streamText, finalText\);[\s\S]*?\} else \{\n\s+scheduleStreamBubbleHide\(\);/, "empty filtered stream output should schedule hide while visible stream output renders as Markdown");
765
786
  assert.match(app, /if \(streamToolCallSeen \|\| streamBubble\) renderStreamingAssistantText\(\);\n\s+else scheduleStreamingAssistantTextRender\(\);/, "live assistant text should wait briefly before showing unless it is already visible or follows a tool call");
766
787
  assert.match(app, /streamToolCallSeen = true;\n\s+suppressStreamingAssistantTextBeforeToolCall\(\);/, "tool-call starts should remove pending assistant text from the live transcript");
767
788
  assert.match(app, /const created = appendMessage\(\{ role: "assistant", title: "final output"/, "live Assistant cards should be created only for final output text without a noisy Assistant label");