pi-ui-extend 1.0.18 → 1.0.20

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.
@@ -14,7 +14,7 @@ This package keeps shared Pi tools as ordinary source folders under `src/` and r
14
14
  - `src/repo-discovery` — `/idx-init`, `/idx-update`, and indexed-only `repo_architecture` / `repo_structure` / `repo_ast` / `repo_search` / `repo_explain` / `repo_deps`; tools register only when the launch project has `.indexer-cli`
15
15
  - `src/antigravity-auth` — `antigravity` custom provider with Google Antigravity OAuth login, startup account list, auth.json-only runtime account loading, `/antigravity-add-account` OAuth append into rotation, `/antigravity-account` status display, account rotation/failover, Antigravity plus Gemini CLI model registration, and streaming through the Cloud Code Assist unified gateway
16
16
  - `src/opencode-import` — `/opencode-import` for bounded migration of supported OpenCode OpenAI/Codex, GitHub Copilot, Z.ai, and Antigravity credentials into Pi; existing entries are preserved unless `--force` is passed
17
- - `src/todo` — `todo` tool, `/todos`, `/todos-persist`, and `/todos-scope`; supports parent/subtask hierarchy, blockers, ready-task filtering, deferred out-of-scope items, batch operations, JSON/Markdown import/export, automatic clearing when all visible todos are completed, and optional project persistence via `/todos persist on` or `/todos-persist on`; localization/i18n has been removed
17
+ - `src/todo` — `todo` tool, `/todos`, `/todos-persist`, `/todos-scope`, and `/todos-clear` (also `/todos clear`); supports parent/subtask hierarchy, blockers, ready-task filtering, deferred out-of-scope items, batch operations, JSON/Markdown import/export, automatic clearing when all visible todos are completed, and optional project persistence via `/todos persist on` or `/todos-persist on`; localization/i18n has been removed
18
18
  - `src/model-tools` — model-specific tool aliases such as Claude/GLM-style `Read` / `Edit` / `Write` / `Bash` / `Grep` / `Glob` / `LS`, GPT/Codex-style `shell`, and model-gated `apply_patch`
19
19
  - `src/usage` — `/usage` command and startup hint for read-only AI quota checks across OpenAI, Zhipu AI, Z.ai, and Google Antigravity, including Antigravity quota by model
20
20
  - `src/web-search` — `web_search` and `web_fetch` tools migrated from `@ollama/pi-web-search`; uses local Ollama by default or the official Ollama cloud API when an API key is configured, supports Tavily Search/Extract fallback, provides `/web-credentials` for secure user-level key storage, honors `OLLAMA_HOST`, supports request timeouts via `timeout_ms` / `PI_WEB_SEARCH_TIMEOUT_MS`, and reports provider-specific errors
@@ -251,8 +251,9 @@ The built-in `browser-qa` role runs on `antigravity/gemini-3-flash-preview`, wit
251
251
  `openai-codex/gpt-5.4-mini` as its fallback. Its browser workflow is an explicit
252
252
  private skill under `src/async-subagents/private-skills/`, outside normal Pi skill
253
253
  discovery. The role's first-class `isolatedSkills` setting launches the child with
254
- `--no-skills` plus only that skill, so parent and ordinary sub-agent sessions do
255
- not discover it.
254
+ `--no-skills` plus the private workflow and the packaged `skills/playwright-cli`
255
+ skill. Both are mandatory even when configuration adds other isolated skills;
256
+ parent and ordinary sub-agent sessions do not discover the private workflow.
256
257
 
257
258
  Keep named dev/staging auth profiles in project `.pi/qa_auth.jsonc` (there is no
258
259
  `/qa-auth` command). The private runner supports `form`, `cookie`, `localStorage`,
@@ -17,6 +17,9 @@ evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
17
17
  Antigravity-backed role unavailable.
18
18
  - A type profile may declare `isolatedSkills`. Spawning that profile adds
19
19
  `--no-skills` followed by one explicit `--skill` per configured path.
20
+ - The `browser-qa` profile always loads both its private workflow and the
21
+ packaged `skills/playwright-cli` skill. Configuration may append isolated
22
+ skills but cannot remove either mandatory browser skill.
20
23
  - Other sub-agent profiles and the parent session must not discover the private
21
24
  skill automatically.
22
25
 
@@ -66,9 +69,10 @@ evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
66
69
 
67
70
  ## Acceptance criteria
68
71
 
69
- 1. `browser-qa` resolves to the intended model/fallback and private skill, and
70
- its isolated child process can register the configured Antigravity model.
71
- 2. Spawn args contain `--no-skills` and only the explicit private skill for this
72
+ 1. `browser-qa` resolves to the intended model/fallback, private workflow, and
73
+ packaged `playwright-cli` skill, and its isolated child process can register
74
+ the configured Antigravity model.
75
+ 2. Spawn args contain `--no-skills` and both mandatory explicit skills for this
72
76
  profile; ordinary profiles retain existing skill discovery behavior.
73
77
  3. Auth profile listing and all error output are redacted; model-authored input
74
78
  cannot execute code in the credential-bearing process.
@@ -79,3 +83,19 @@ evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
79
83
  5. Completed test runs report clickable screenshot, video, and trace links
80
84
  whenever those artifacts exist.
81
85
  6. Suite tests/typecheck, host checks, and suite sync pass.
86
+
87
+ ## Real-browser regression test
88
+
89
+ The repository includes a local mock-page E2E that launches real Chromium and
90
+ asserts PNG screenshots, WebM video, sanitized trace output, and absolute
91
+ path/`file:` URI metadata:
92
+
93
+ ```bash
94
+ npx playwright install chromium
95
+ npm run test:browser-qa-e2e
96
+ ```
97
+
98
+ Normal suite tests keep this case skipped; the Publish workflow runs it on
99
+ Linux after installing Chromium. Explicit runs retain the latest artifacts in
100
+ `.pi/qa-runs/browser-qa-e2e/latest/` and print a clickable link for every file.
101
+ Set `BROWSER_QA_KEEP_EVIDENCE=0` to clean up without publishing evidence.
@@ -22,6 +22,7 @@
22
22
  "smoke:tools": "PI_OFFLINE=1 pi --no-session -p \"ping\"",
23
23
  "smoke": "npm run smoke:explicit && npm run smoke:auto && npm run smoke:tools",
24
24
  "test": "bun test test",
25
+ "test:browser-qa-e2e": "BROWSER_QA_RUNNER_E2E=1 bun test test/async-subagents/browser-qa-runner.e2e.test.ts",
25
26
  "test:async-subagents-e2e": "ASYNC_SUBAGENTS_E2E=1 ASYNC_SUBAGENTS_DEBUG_LOGS=1 ASYNC_SUBAGENTS_MODEL=zai/glm-5-turbo bun test --concurrent --max-concurrency=30 test/async-subagents",
26
27
  "test:async-subagents-selection-e2e": "ASYNC_SUBAGENTS_SELECTION_E2E=1 ASYNC_SUBAGENTS_MODEL=zai/glm-5-turbo bun test --concurrent --max-concurrency=30 test/async-subagents/selection-e2e.test.ts",
27
28
  "test:prompt-evals:tool-selection": "PROMPT_EVAL_E2E=1 bun test --concurrent --max-concurrency=10 test/tool-selection-e2e.test.ts",
@@ -288,6 +288,21 @@ export function getBrowserQaSkillPath(): string {
288
288
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "private-skills", "browser-qa", "SKILL.md");
289
289
  }
290
290
 
291
+ export function getPlaywrightCliSkillPath(): string {
292
+ const bundledPath = path.resolve(
293
+ path.dirname(fileURLToPath(import.meta.url)),
294
+ "../../../../..",
295
+ "skills",
296
+ "playwright-cli",
297
+ "SKILL.md",
298
+ );
299
+ const installedPaths = [
300
+ path.join(os.homedir(), ".agents", "skills", "playwright-cli", "SKILL.md"),
301
+ path.join(os.homedir(), ".pi", "agent", "skills", "playwright-cli", "SKILL.md"),
302
+ ];
303
+ return [bundledPath, ...installedPaths].find((candidate) => fs.existsSync(candidate)) ?? bundledPath;
304
+ }
305
+
291
306
  export function getSubagentConfigInitTargetPath(cwd: string, env: NodeJS.ProcessEnv = process.env): string {
292
307
  return explicitSubagentConfigPath(cwd, env) ?? getDefaultSubagentConfigPath();
293
308
  }
@@ -381,7 +396,7 @@ export function resolveAgentTaskConfig(
381
396
  return {
382
397
  profile,
383
398
  extraArgs,
384
- isolatedSkills: arrayOfStrings(profile?.isolatedSkills) ?? [],
399
+ isolatedSkills: resolveIsolatedSkills(selectedType, profile),
385
400
  fallbackModels,
386
401
  retry: resolveRetryConfig(config.retry, profile?.retry),
387
402
  maxResultBytes: profile?.maxResultBytes ?? config.maxResultBytes,
@@ -399,6 +414,12 @@ export function resolveAgentTaskConfig(
399
414
  };
400
415
  }
401
416
 
417
+ function resolveIsolatedSkills(selectedType: string | undefined, profile: SubagentTypeConfig | undefined): string[] {
418
+ const configured = arrayOfStrings(profile?.isolatedSkills) ?? [];
419
+ if (selectedType !== "browser-qa") return configured;
420
+ return [...new Set([getBrowserQaSkillPath(), getPlaywrightCliSkillPath(), ...configured])];
421
+ }
422
+
402
423
  export function resolveSubagentRoutingConfig(config: SubagentConfig): ResolvedSubagentRoutingConfig {
403
424
  return { ...DEFAULT_ROUTING_CONFIG, ...(config.routing ?? {}) };
404
425
  }
@@ -24,6 +24,7 @@ export {
24
24
  existingSubagentConfigFiles,
25
25
  getDefaultSubagentConfigPath,
26
26
  getBrowserQaSkillPath,
27
+ getPlaywrightCliSkillPath,
27
28
  getSubagentConfigInitTargetPath,
28
29
  getSubagentConfigSamplePath,
29
30
  isBlindModelRef,
@@ -1,6 +1,8 @@
1
1
  import type { TaskDetails } from "../tool/types.js";
2
2
  import { EMPTY_STATE, type TaskState } from "./state.js";
3
3
 
4
+ export const TODO_STATE_ENTRY_TYPE = "pi-tools-suite:todo-state";
5
+
4
6
  /**
5
7
  * Discriminator for `details` envelopes that match the persisted `TaskDetails`
6
8
  * shape. Defensive — branch entries from older or corrupt sessions are
@@ -12,10 +14,29 @@ export function isTaskDetails(value: unknown): value is TaskDetails {
12
14
  return Array.isArray(v.tasks) && typeof v.nextId === "number";
13
15
  }
14
16
 
17
+ function taskDetailsFromEntry(entry: unknown): TaskDetails | undefined {
18
+ const candidate = entry as {
19
+ type?: string;
20
+ customType?: string;
21
+ data?: unknown;
22
+ message?: { role?: string; toolName?: string; details?: unknown };
23
+ };
24
+
25
+ if (candidate.type === "custom" && candidate.customType === TODO_STATE_ENTRY_TYPE) {
26
+ return isTaskDetails(candidate.data) ? candidate.data : undefined;
27
+ }
28
+
29
+ const message = candidate.message;
30
+ if (candidate.type !== "message" || message?.role !== "toolResult" || message.toolName !== "todo") return undefined;
31
+ return isTaskDetails(message.details) ? message.details : undefined;
32
+ }
33
+
15
34
  /**
16
- * Walk the current branch in chronological order; the LAST `toolResult` whose
17
- * `toolName === "todo"` and whose `details` shape matches `TaskDetails` wins
18
- * (last-write-wins). When no matching entry exists, returns `EMPTY_STATE`.
35
+ * Walk the current branch in chronological order; the LAST valid snapshot
36
+ * from either a `todo` tool result or a todo custom state entry wins
37
+ * (last-write-wins). Custom entries persist slash-command mutations, which do
38
+ * not otherwise produce tool results. When no matching entry exists, returns
39
+ * `EMPTY_STATE`.
19
40
  *
20
41
  * Pure of module state — `index.ts` writes the returned snapshot into the
21
42
  * store after this returns. The function explicitly does NOT touch the store
@@ -24,14 +45,11 @@ export function isTaskDetails(value: unknown): value is TaskDetails {
24
45
  export function replayFromBranch(ctx: { sessionManager: { getBranch(): Iterable<unknown> } }): TaskState {
25
46
  let result: TaskState = { tasks: [...EMPTY_STATE.tasks], nextId: EMPTY_STATE.nextId };
26
47
  for (const entry of ctx.sessionManager.getBranch()) {
27
- const e = entry as { type?: string; message?: { role?: string; toolName?: string; details?: unknown } };
28
- if (e.type !== "message") continue;
29
- const msg = e.message;
30
- if (!msg || msg.role !== "toolResult" || msg.toolName !== "todo") continue;
31
- if (!isTaskDetails(msg.details)) continue;
48
+ const details = taskDetailsFromEntry(entry);
49
+ if (!details) continue;
32
50
  result = {
33
- tasks: msg.details.tasks.map((t) => ({ ...t })),
34
- nextId: msg.details.nextId,
51
+ tasks: details.tasks.map((t) => ({ ...t })),
52
+ nextId: details.nextId,
35
53
  };
36
54
  }
37
55
  return result;
@@ -23,7 +23,7 @@ import {
23
23
  syncPersistedPlan,
24
24
  } from "./state/persistence.js";
25
25
  import { AUTO_CLEAR_COMPLETED_MESSAGE, autoClearCompletedTodos } from "./state/auto-clear.js";
26
- import { replayFromBranch } from "./state/replay.js";
26
+ import { replayFromBranch, TODO_STATE_ENTRY_TYPE } from "./state/replay.js";
27
27
  import { isTaskBlocked, selectTasksByStatus, selectTodoCounts } from "./state/selectors.js";
28
28
  import { applyTaskMutation } from "./state/state-reducer.js";
29
29
  import { activateStateScope, commitState, getState, replaceState } from "./state/store.js";
@@ -49,6 +49,7 @@ const SECTION_DEFERRED = "── Deferred ──";
49
49
  const SECTION_COMPLETED = "── Completed ──";
50
50
  const PERSIST_COMMAND_NAME = "todos-persist";
51
51
  const SCOPE_COMMAND_NAME = "todos-scope";
52
+ const CLEAR_COMMAND_NAME = "todos-clear";
52
53
  export const TODO_STATE_EVENT = "pi-tools-suite:todo:state";
53
54
 
54
55
  type CommandCompletion = { value: string; label: string; description?: string };
@@ -59,6 +60,7 @@ const TODOS_ARGUMENT_COMPLETIONS: CommandCompletion[] = [
59
60
  { value: "persist off", label: "persist off", description: "Disable persistence and remove the project plan file" },
60
61
  { value: "persist clear", label: "persist clear", description: "Alias for persist off" },
61
62
  { value: "scope ", label: "scope <id...>", description: "Keep selected items active and defer out-of-scope items" },
63
+ { value: "clear", label: "clear", description: "Clear all todos" },
62
64
  { value: "--active", label: "--active", description: "Show pending/in_progress tasks" },
63
65
  { value: "--ready", label: "--ready", description: "Show pending tasks whose blockers are completed" },
64
66
  { value: "--blocked", label: "--blocked", description: "Show tasks with blockers" },
@@ -96,6 +98,7 @@ interface TodoToolRegistrationOptions extends TodoToolHooks {
96
98
 
97
99
  type TodoStateEventContext = { sessionManager?: { getSessionFile?: () => unknown; getSessionId?: () => unknown } };
98
100
  type TodoStateEventEmitter = { events?: { emit?: (channel: string, data: unknown) => void } };
101
+ type TodoStateEntryWriter = { appendEntry<T = unknown>(customType: string, data?: T): void };
99
102
 
100
103
  interface TodosCommandOptions {
101
104
  status?: TaskStatus;
@@ -236,6 +239,20 @@ export function publishTodoState(
236
239
  });
237
240
  }
238
241
 
242
+ function appendTodoStateSnapshot(
243
+ pi: TodoStateEntryWriter,
244
+ action: TaskAction,
245
+ params: Record<string, unknown>,
246
+ ): void {
247
+ const state = getState();
248
+ pi.appendEntry(TODO_STATE_ENTRY_TYPE, {
249
+ action,
250
+ params,
251
+ tasks: state.tasks,
252
+ nextId: state.nextId,
253
+ });
254
+ }
255
+
239
256
  function handlePersistCommand(args: unknown, ctx: { cwd?: string; hasUI?: boolean; ui?: { notify?: (message: string, level?: NotifyLevel) => void } }): boolean {
240
257
  const [command, subcommand = "status"] = getCommandTokens(args);
241
258
  if (command !== "persist") return false;
@@ -307,6 +324,20 @@ function handleScopeCommand(
307
324
  return true;
308
325
  }
309
326
 
327
+ function clearTodos(
328
+ pi: TodoStateEventEmitter & TodoStateEntryWriter,
329
+ ctx: { cwd?: string; hasUI?: boolean; ui?: { notify?: (message: string, level?: NotifyLevel) => void } },
330
+ ): void {
331
+ const result = applyTaskMutation(getState(), "clear", { action: "clear" });
332
+ if (result.op.kind !== "clear") return;
333
+ commitState(result.state);
334
+ appendTodoStateSnapshot(pi, "clear", { action: "clear" });
335
+ publishTodoState(pi, ctx, "clear", { action: "clear" });
336
+ const sync = syncPersistedPlan(ctx.cwd, result.state);
337
+ const persistedText = sync?.completed ? `\nProject todo plan removed: ${sync.path}` : "";
338
+ notifyCommand(ctx, `Cleared ${result.op.count} todos.${persistedText}`);
339
+ }
340
+
310
341
  function filterCommandTasks(tasks: readonly Task[], options: TodosCommandOptions): Task[] {
311
342
  const byId = new Map(tasks.map((task) => [task.id, task]));
312
343
  let view = [...tasks];
@@ -429,12 +460,19 @@ export function registerTodoTool(pi: ExtensionAPI, hooks: TodoToolRegistrationOp
429
460
 
430
461
  export function registerTodosCommand(pi: ExtensionAPI): void {
431
462
  pi.registerCommand(COMMAND_NAME, {
432
- description: "Show todos on the current branch. Flags: --active, --ready, --blocked, --tree, --status <status>, --export [json|markdown]. Commands: persist on|off|clear|status, scope <id...>",
463
+ description: "Show todos on the current branch. Flags: --active, --ready, --blocked, --tree, --status <status>, --export [json|markdown]. Commands: persist on|off|clear|status, scope <id...>, clear",
433
464
  getArgumentCompletions: (prefix) => completeCommandArguments(String(prefix ?? ""), TODOS_ARGUMENT_COMPLETIONS),
434
465
  handler: async (args, ctx) => {
435
466
  activateTodoStateScope(ctx);
436
467
  if (handlePersistCommand(args, ctx)) return;
437
- if (handleScopeCommand(args, ctx, () => publishTodoState(pi as TodoStateEventEmitter, ctx))) return;
468
+ if (handleScopeCommand(args, ctx, () => {
469
+ appendTodoStateSnapshot(pi, "update", { command: "scope" });
470
+ publishTodoState(pi as TodoStateEventEmitter, ctx);
471
+ })) return;
472
+ if (getCommandTokens(args)[0] === "clear") {
473
+ clearTodos(pi, ctx);
474
+ return;
475
+ }
438
476
  if (!ctx.hasUI) {
439
477
  console.error(ERR_REQUIRES_INTERACTIVE);
440
478
  return;
@@ -510,7 +548,18 @@ export function registerTodosCommand(pi: ExtensionAPI): void {
510
548
  description: "Select todo ids to continue from a persisted plan; pending/in_progress items outside the scope become deferred.",
511
549
  handler: async (args, ctx) => {
512
550
  activateTodoStateScope(ctx);
513
- handleScopeCommand(`scope ${getCommandText(args)}`, ctx, () => publishTodoState(pi as TodoStateEventEmitter, ctx));
551
+ handleScopeCommand(`scope ${getCommandText(args)}`, ctx, () => {
552
+ appendTodoStateSnapshot(pi, "update", { command: "scope" });
553
+ publishTodoState(pi as TodoStateEventEmitter, ctx);
554
+ });
555
+ },
556
+ });
557
+
558
+ pi.registerCommand(CLEAR_COMMAND_NAME, {
559
+ description: "Clear all todos on the current branch and remove the persisted project plan if enabled.",
560
+ handler: async (_args, ctx) => {
561
+ activateTodoStateScope(ctx);
562
+ clearTodos(pi, ctx);
514
563
  },
515
564
  });
516
565
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
4
4
  "description": "Pix: a workspace-first terminal UI for Pi with tabs, readable tool activity, voice input, and bundled agent tools.",
5
5
  "private": false,
6
6
  "repository": {
@@ -58,6 +58,7 @@
58
58
  "test": "mise exec node@24.16.0 -- npm run test:inner",
59
59
  "test:inner": "node --import tsx --test \"tests/**/*.test.ts\"",
60
60
  "test:tools-suite": "npm --prefix external/pi-tools-suite test",
61
+ "test:browser-qa-e2e": "npm --prefix external/pi-tools-suite run test:browser-qa-e2e",
61
62
  "sync:pi-tools-suite": "node scripts/sync-pi-tools-suite.mjs",
62
63
  "sync:pi-tools-suite:check": "node scripts/sync-pi-tools-suite.mjs --check",
63
64
  "sync:sdk-pin": "node scripts/sync-sdk-pin.mjs",
@@ -86,6 +87,7 @@
86
87
  },
87
88
  "devDependencies": {
88
89
  "@types/node": "24.12.4",
90
+ "playwright": "1.62.1",
89
91
  "tsx": "4.22.1",
90
92
  "typescript": "5.9.3"
91
93
  },