@rexvane/piweb 0.3.3 → 0.3.5

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
@@ -14,109 +14,84 @@
14
14
 
15
15
  ---
16
16
 
17
- **PiWeb** brings a sleek, feature-rich browser interface to the [pi coding agent](https://github.com/earendil-works/pi) with zero modifications to the core Pi engine. Built with Next.js and Tailwind CSS.
17
+ **PiWeb** brings a sleek, feature-rich browser interface to the [pi coding agent](https://github.com/earendil-works/pi) with zero modifications to the core Pi engine. Install with one command, and all your existing pi configuration, sessions, models, skills, and plugins work out of the box.
18
18
 
19
- ## Key Features
19
+ ## Features
20
20
 
21
- - **Real-Time Streaming**: Full SSE snapshots with ordered deltas, automatic replay via `Last-Event-ID`, real-time thinking process and inline tool execution cards.
22
- - **Composer & Input**: Image attachments (paste, pick, drag-and-drop), 2-level model selector, model cycling forward/backward, queue management (steering / follow-up), and `/` slash commands.
23
- - **Decoupled Workspaces & Sessions**: Group sessions by workspace folders, native system folder picker on Windows, rename/delete workspaces with centered confirmation modals, preserve sessions under "Ungrouped", and maintain empty workspaces independently.
24
- - **Session Exploration**: Session tree branch visualization & navigation, user message draft recovery, export session logs to JSONL / HTML, and trajectory views.
25
- - **Comprehensive Settings**:
26
- - **General**: Tool presets (Read Only / Workspace Write / Full Access), language (zh/en), appearance, enter key behaviors, and auto-compact.
27
- - **Models**: Manage 30+ built-in providers, custom providers in `models.json`, and OAuth logins (Claude, Codex, Copilot, etc.).
28
- - **Plugins & Skills**: View and manage extensions and skills directly via Pi's built-in package manager.
29
- - **Security & Sandboxing**: Optional HTTP Basic Auth via `PI_WEB_PASSWORD`, origin validation on write actions, and strict path boundary checks.
21
+ ### Real-Time Streaming Chat
30
22
 
31
- ## Quick Start
23
+ Full SSE snapshots with ordered deltas and automatic replay via `Last-Event-ID`. The thinking process and inline tool execution cards stream live.
32
24
 
33
- **Prerequisites**: Node.js ≥ 22.19.0 (Node.js 24 recommended) and Git for repository and growth-history features.
25
+ <p align="center"><img src="assets/showcase.png" alt="Streaming chat with tool cards" width="840" /></p>
34
26
 
35
- ### Install from npm (recommended)
27
+ ### Workspaces & Sessions
36
28
 
37
- ```bash
38
- npm install -g @rexvane/piweb
39
- piweb
40
- ```
29
+ Sessions grouped by project folder, with a native folder picker (Windows / macOS / Linux), rename and archive. Session tree branches, draft recovery, and JSONL / HTML export.
41
30
 
42
- A global install prepares the production bundle once (during install, or on the first run if the script was skipped) and then starts in production mode. If that build fails, `piweb` reports it instead of falling back to a development server that cannot work without dev dependencies; retry with `npm rebuild -g @rexvane/piweb`. The package registers only the `piweb` command, so it never conflicts with the official `pi` CLI (`npm i -g @earendil-works/pi-coding-agent`) if you have both.
31
+ ### Project Growth Tree
43
32
 
44
- ### Inside this repository
33
+ Every step the agent takes on your codebase is snapshotted: navigate the timeline round by round, see added/modified/deleted files, and open line-level diffs for any file at any step — even if the workspace has no git history of its own.
45
34
 
46
- ```bash
47
- npm install
48
- npm run dev # development server with hot reload
49
- ```
35
+ ### Context Meter
50
36
 
51
- Or run a production build locally:
37
+ Click the ring next to the composer to see the context window broken down into 13 segments — System Prompt, System/Custom Tools, Memory (AGENTS.md), Skills, messages by type, Compacted Data, Auto-Compact Buffer, and Free Space.
52
38
 
53
- ```bash
54
- npm install
55
- npm run build # or: npm run build:release (staged, does not touch a running build)
56
- npm start # serves on http://127.0.0.1:30141 (auto-increments if busy)
57
- ```
39
+ ### Model & Provider Management
58
40
 
59
- ### CLI Options (`bin/piweb.js`)
41
+ 30+ built-in providers, OAuth logins (Claude, Codex, Copilot, etc.), custom providers written to `models.json`, one-click model discovery, and a placeholder key auto-filled for local gateways like Ollama.
60
42
 
61
- ```
62
- -p, --port <port> Listen port (default 30141, env PORT; auto-increments if in use)
63
- -H, --hostname <host> Bind address (default 127.0.0.1, env PI_WEB_HOSTNAME)
64
- --dev Start in development mode (`next dev`; env PI_WEB_DEV=1; also used when no production build exists)
65
- --no-open Do not automatically open browser (env PI_WEB_NO_OPEN=1)
66
- -h, --help Show help
67
- ```
43
+ ### Plugins & Skills
44
+
45
+ Install, update, and uninstall pi packages directly from the UI. Enable/disable or delete skills; every change applies to running sessions immediately.
68
46
 
69
- Environment variables: `PI_WEB_PASSWORD` enables a browser login session and HTTP Basic Auth for API clients (user `pi`). It is **required** for any non-loopback production bind address. Development mode is loopback-only, even with a password; if no production build exists, an external bind fails rather than falling back to an exposed development server. Use HTTPS or a trusted VPN for remote access. `PI_WEB_EDITOR` overrides the editor used by "open in editor" (default `code`). Without a password, PiWeb only accepts requests whose `Host` is loopback.
47
+ ### Security
70
48
 
71
- `GET /api/health` exposes only a fixed service identifier for credential-free startup probes. Runtime versions are available from the authenticated `/api/version` endpoint. Tool presets limit the tools offered to the agent; they are not an operating-system sandbox, and installed extensions run with the server process's permissions.
49
+ Optional login via `PI_WEB_PASSWORD` (required for non-loopback binds), origin validation on write actions, strict path boundary checks, and tool presets (Read Only / Workspace Write / Full Access).
72
50
 
73
- Model catalog discovery blocks loopback, private, and reserved network addresses by default. If you intentionally use a local model gateway, set `PI_WEB_ALLOW_PRIVATE_MODEL_DISCOVERY=1` before starting PiWeb. This relaxes the discovery endpoint only; use it only on a trusted PiWeb instance.
51
+ ## Usage
74
52
 
75
- ### Development
53
+ ### Install
76
54
 
77
55
  ```bash
78
- npm run dev # Run Next.js in development mode
79
- npm run typecheck # TypeScript check
80
- npm test # Service, protocol and component regressions
81
- npm run check # Full check (types + tests + build)
82
- npm run build:release # Validate and stage a production build without replacing the running build
56
+ npm install -g @rexvane/piweb
57
+ piweb
83
58
  ```
84
59
 
85
- ## Status and known limitations (2026-09-14)
86
-
87
- The audit follow-up is merged into this branch and has been through an isolated release plus a local production deployment. It covers the production login build, upload integrity through the Next.js proxy, per-session extension isolation and tool-policy reloads, lossless model/settings writes, composer and pending-extension-UI lifecycle, Git/Growth/diff correctness, and isolated release preparation.
60
+ The package registers only the `piweb` command, so it never conflicts with the official `pi` CLI. Requires Node.js ≥ 22.19.0 (24 recommended); Git is needed for growth-history features.
88
61
 
89
- The latest local validation passed TypeScript checking, **355 tests across 52 files** (one additional test skipped), `npm run build`, and `git diff --check`. The skipped test requires Windows symlink privileges or Developer Mode.
62
+ ### Update
90
63
 
91
- Known limitations: saving the model configuration normalizes it to plain JSON (comments are not preserved, data is); updates replace dependencies in place during a maintenance window rather than being zero-downtime; a custom tool allowlist lives only for the session lifetime; third-party extension module globals are not isolated.
64
+ ```bash
65
+ npm update -g @rexvane/piweb
66
+ ```
92
67
 
93
- ## Production builds and updates
68
+ Or install a specific version: `npm install -g @rexvane/piweb@0.3.4`.
94
69
 
95
- Use `npm run build:release` when preparing a build while PiWeb is running. It validates types and tests, builds into a fresh `.next-releases/<id>` directory, and only then publishes the build selection for the next start. It does not restart the server or replace the output used by an existing process. `npm start` uses the last successfully prepared release; after a failed or interrupted release attempt it reports the problem instead of silently serving a stale build. Correct the error and run `npm run build:release` again to recover.
70
+ ### Uninstall
96
71
 
97
- The in-app updater uses the same validation path. Source files and npm dependencies are still updated in the installation directory, so perform updates during a maintenance window with no running agent turns. This is not a fully isolated zero-downtime deployment system. Restart PiWeb manually after a successful update; running processes do not automatically switch to the new build.
72
+ ```bash
73
+ npm uninstall -g @rexvane/piweb
74
+ ```
98
75
 
99
- CI runs the full checks on Linux and Windows with Node.js 22.19.0 and 24. Ordinary `npm run build` still uses `.next`, so do not run it against an installation whose active production process is using that directory.
76
+ This removes PiWeb only — your pi data lives in `~/.pi/agent/` and is left untouched.
100
77
 
101
- ## Architecture
78
+ ### Run
102
79
 
80
+ ```bash
81
+ piweb # http://127.0.0.1:30141 (auto-increments if busy)
82
+ piweb -p 30143 --no-open # custom port, don't open the browser
83
+ piweb -H 0.0.0.0 # LAN access (requires PI_WEB_PASSWORD)
103
84
  ```
104
- Browser (React 19 + Tailwind, src/components)
105
- │ REST commands (POST /api/agent/[id]) + SSE streams (GET /api/agent/[id]/events)
106
- ▼
107
- Next.js Server (src/app/api, src/lib)
108
- ├─ agent-manager AgentSession pool: lazy startup, event translation, idle eviction
109
- ├─ session-reader Direct read ~/.pi/agent/sessions/**.jsonl (cold render without running agent)
110
- ├─ trajectory Trajectory ledger + server-side timing (TTFT / decode)
111
- ├─ skills/prompts Skills & Prompt template discovery + frontmatter toggles
112
- ├─ models-service Model catalogue, auth, OAuth bridge & models.json
113
- ├─ security-service Tool presets & project trust
114
- ├─ plugins-service Pi package management (DefaultPackageManager) & extension registry
115
- ├─ workspace-store Persistent workspaces registry & native folder picker
116
- ▼
117
- @earendil-works/pi-coding-agent (Official Pi SDK, zero engine modifications)
118
- ```
85
+
86
+ | Option | Description |
87
+ | --- | --- |
88
+ | `-p, --port <port>` | Listen port (default 30141, env `PORT`) |
89
+ | `-H, --hostname <host>` | Bind address (default 127.0.0.1, env `PI_WEB_HOSTNAME`) |
90
+ | `--dev` | Development mode |
91
+ | `--no-open` | Do not auto-open the browser |
92
+
93
+ Set `PI_WEB_PASSWORD` to enable the login page and HTTP Basic Auth (user `pi`). Remote access over plain HTTP is not recommended; use HTTPS or a trusted VPN.
119
94
 
120
95
  ## License
121
96
 
122
- [MIT License](LICENSE)
97
+ [MIT](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rexvane/piweb",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Modern web UI for the pi coding agent, driven by the official pi SDK without modifying pi",
@@ -54,8 +54,9 @@ export function assertInstallation(root) {
54
54
  const realRoot = fs.realpathSync(root);
55
55
  const manifestPath = assertLocalPath(realRoot, "package.json");
56
56
  const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
57
- // Accept the historical name so installations made before the npm rename keep working.
58
- if (manifest?.name !== "piweb" && manifest?.name !== "pi-web") throw new Error(`refusing to update outside the PiWeb installation: ${realRoot}`);
57
+ // Accept the historical names so installations made before the npm rename keep working.
58
+ const validNames = ["piweb", "pi-web", "@rexvane/piweb"];
59
+ if (!validNames.includes(manifest?.name)) throw new Error(`refusing to update outside the PiWeb installation: ${realRoot}`);
59
60
  return realRoot;
60
61
  }
61
62
 
@@ -9,6 +9,7 @@
9
9
  * Repository checkouts always have dev dependencies and keep `npm run dev`.
10
10
  */
11
11
  import fs from "node:fs";
12
+ import os from "node:os";
12
13
  import path from "node:path";
13
14
  import { createRequire } from "node:module";
14
15
  import { spawnSync } from "node:child_process";
@@ -65,15 +66,64 @@ export function resolveNextBin(root) {
65
66
  * neither compiled nor mapped through the `@/*` path alias. Build in a staging
66
67
  * directory outside `node_modules` (same drive, so the dependency links resolve)
67
68
  * and copy the finished output back into the package.
69
+ *
70
+ * The staging directory must NOT sit inside any `node_modules`: for a global
71
+ * install like ~/.local/lib/node_modules/@rexvane/piweb, the grandparent IS
72
+ * node_modules, and Next would exclude the staged sources exactly the same way.
73
+ * Walk up until we are outside every node_modules segment; give up and use the
74
+ * system temp dir (cross-device copy is still correct, just slower).
68
75
  */
76
+ function stagingParent(root) {
77
+ // Split on BOTH separators: a win32 Node can see POSIX paths (and vice versa)
78
+ // when the package is installed through a compatibility layer or the test
79
+ // suite simulates foreign layouts.
80
+ const segments = path.dirname(root).split(/[\\/]/).filter(Boolean);
81
+ // Drop trailing path components while the path still contains a node_modules segment.
82
+ while (segments.length > 1 && segments.includes("node_modules")) {
83
+ segments.pop();
84
+ }
85
+ const candidate = segments.length ? path.resolve(`/${segments.join(path.sep)}`) : path.sep;
86
+ try {
87
+ fs.mkdirSync(candidate, { recursive: true });
88
+ fs.accessSync(candidate, fs.constants.W_OK);
89
+ return candidate;
90
+ } catch {
91
+ return os.tmpdir();
92
+ }
93
+ }
94
+
95
+ /** Staging directory must live outside EVERY node_modules on the way up (Next excludes those paths). */
96
+ export function assertStagingOutsideNodeModules(root) {
97
+ const parent = stagingParent(root);
98
+ if (parent.split(/[\\/]/).includes("node_modules")) {
99
+ throw new Error(`staging parent is inside node_modules: ${parent}`);
100
+ }
101
+ return parent;
102
+ }
103
+
104
+ function tryLinkDependencies(root, staging) {
105
+ try {
106
+ fs.symlinkSync(path.join(root, "node_modules"), path.join(staging, "node_modules"), "junction");
107
+ return true;
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
69
113
  function buildInStaging(root, nextBin, env, log, warn) {
70
- const parent = path.dirname(path.dirname(root));
71
114
  let staging;
72
115
  try {
73
- staging = fs.mkdtempSync(path.join(parent, ".piweb-build-"));
116
+ staging = fs.mkdtempSync(path.join(stagingParent(root), ".piweb-build-"));
74
117
  } catch {
75
118
  staging = fs.mkdtempSync(path.join(os.tmpdir(), "piweb-build-"));
76
119
  }
120
+ // junction 不能跨卷:staging 与包不同盘时链接会失败,退回系统临时目录再试一次
121
+ if (!tryLinkDependencies(root, staging) && path.parse(staging).root !== path.parse(os.tmpdir()).root) {
122
+ try {
123
+ fs.rmSync(staging, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
124
+ } catch { /* proceed to the tmpdir attempt */ }
125
+ staging = fs.mkdtempSync(path.join(os.tmpdir(), "piweb-build-"));
126
+ }
77
127
  try {
78
128
  for (const entry of ["src", "public", "assets", "scripts", "bin"]) {
79
129
  const from = path.join(root, entry);
@@ -83,10 +133,8 @@ function buildInStaging(root, nextBin, env, log, warn) {
83
133
  const from = path.join(root, file);
84
134
  if (fs.existsSync(from)) fs.copyFileSync(from, path.join(staging, file));
85
135
  }
86
- try {
87
- fs.symlinkSync(path.join(root, "node_modules"), path.join(staging, "node_modules"), "junction");
88
- } catch (error) {
89
- warn(`[piweb] Could not link dependencies into the staging build: ${error.message}`);
136
+ if (!tryLinkDependencies(root, staging)) {
137
+ warn("[piweb] Could not link dependencies into the staging build (junction unavailable).");
90
138
  return false;
91
139
  }
92
140
  log(`[piweb] Building in ${staging} ...`);
@@ -72,6 +72,7 @@ export async function POST(req: Request) {
72
72
  revision?: string;
73
73
  text?: string;
74
74
  force?: boolean;
75
+ allowPrivate?: boolean;
75
76
  };
76
77
  if (body.action === "setKey") {
77
78
  if (!body.providerId || !body.apiKey) return NextResponse.json({ success: false, error: "missing fields" }, { status: 400 });
@@ -93,7 +94,7 @@ export async function POST(req: Request) {
93
94
  }
94
95
  if (body.action === "discoverModels") {
95
96
  if (!body.baseUrl) return NextResponse.json({ success: false, error: "missing baseUrl" }, { status: 400 });
96
- const models = await discoverModels({ baseUrl: body.baseUrl, api: body.api, apiKey: body.apiKey, providerId: body.providerId });
97
+ const models = await discoverModels({ baseUrl: body.baseUrl, api: body.api, apiKey: body.apiKey, providerId: body.providerId, allowPrivate: body.allowPrivate === true });
97
98
  return NextResponse.json({ success: true, data: { models } });
98
99
  }
99
100
  if (body.action === "providerUsage") {
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from "next/server";
2
- import { listSkills, readSkillFile, setSkillDisabled } from "@/lib/skills-service";
2
+ import { deleteSkill, listSkills, readSkillFile, setSkillDisabled } from "@/lib/skills-service";
3
3
  import { BoundaryError, resolveWorkspacePath } from "@/lib/path-security";
4
4
 
5
5
  export const dynamic = "force-dynamic";
@@ -17,7 +17,7 @@ export async function GET(req: Request) {
17
17
 
18
18
  export async function POST(req: Request) {
19
19
  try {
20
- const body = (await req.json()) as { action: "toggle" | "read"; filePath?: string; disabled?: boolean; cwd?: string };
20
+ const body = (await req.json()) as { action: "toggle" | "read" | "delete"; filePath?: string; disabled?: boolean; cwd?: string };
21
21
  const cwd = await resolveWorkspacePath(body.cwd || process.cwd());
22
22
  if (body.action === "toggle") {
23
23
  if (!body.filePath || typeof body.disabled !== "boolean")
@@ -29,6 +29,11 @@ export async function POST(req: Request) {
29
29
  if (!body.filePath) return NextResponse.json({ success: false, error: "missing filePath" }, { status: 400 });
30
30
  return NextResponse.json({ success: true, data: { content: await readSkillFile(body.filePath, cwd) } });
31
31
  }
32
+ if (body.action === "delete") {
33
+ if (!body.filePath) return NextResponse.json({ success: false, error: "missing filePath" }, { status: 400 });
34
+ const r = await deleteSkill(body.filePath, cwd);
35
+ return NextResponse.json({ success: true, data: r });
36
+ }
32
37
  return NextResponse.json({ success: false, error: "unknown action" }, { status: 400 });
33
38
  } catch (error) {
34
39
  return NextResponse.json(
@@ -201,11 +201,17 @@ export function AppShell() {
201
201
  }, [currentPath]);
202
202
 
203
203
  // 已知工作区列表(给 Hero 建议);启动不预选任何工作区,
204
- // 未选择时输入框仍可用,发送会提示先选择工作区
205
- const knownCwds = useMemo(
206
- () => Array.from(new Set([...addedWorkspaces, ...sessions.map((s) => s.cwd)])).filter(Boolean),
207
- [addedWorkspaces, sessions],
208
- );
204
+ // 未选择时输入框仍可用,发送会提示先选择工作区。
205
+ // 已删除工作区要过滤(与会话栏分组同口径):否则它下面残留的会话 cwd
206
+ // 会把刚删掉的工作区重新喂回顶部下拉。
207
+ const knownCwds = useMemo(() => {
208
+ const removed = new Set(removedWorkspaces.map((w) => w.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()));
209
+ const keep = (cwd: string) => {
210
+ const norm = cwd.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
211
+ return Boolean(norm) && !removed.has(norm);
212
+ };
213
+ return Array.from(new Set([...addedWorkspaces, ...sessions.map((s) => s.cwd)].filter(keep)));
214
+ }, [addedWorkspaces, sessions, removedWorkspaces]);
209
215
 
210
216
  // 模型目录派生值:数百个模型对象的映射只在目录变化时重算,
211
217
  // 不能跟着每次 token 增量 / 3 秒轮询重跑(usePiWeb 的 setState 都会触发本组件渲染)
@@ -825,7 +831,7 @@ export function AppShell() {
825
831
  contextPercent={snapshot?.contextUsage?.percent ?? null}
826
832
  contextTokens={snapshot?.contextUsage?.tokens ?? null}
827
833
  contextWindow={snapshot?.contextUsage?.contextWindow ?? null}
828
- contextSource={{ systemChars: contextSystemChars, messages: state.messages }}
834
+ contextSource={{ systemChars: contextSystemChars, messages: state.messages, breakdown: snapshot?.contextBreakdown }}
829
835
  contextVisible={(snapshot?.messages?.length ?? 0) > 0}
830
836
  model={snapshot?.model}
831
837
  thinkingLevel={snapshot?.thinkingLevel}
@@ -84,7 +84,11 @@ export function ChatInput({
84
84
  contextTokens: number | null;
85
85
  contextWindow: number | null;
86
86
  /** 上下文分段数据源:ContextMeter 弹窗打开时才做字符统计 */
87
- contextSource?: { systemChars: number; messages: { content: Array<{ type: string; text?: string; thinking?: string }> }[] };
87
+ contextSource?: {
88
+ systemChars: number;
89
+ messages: { role?: string; content: Array<{ type: string; text?: string; thinking?: string }> }[];
90
+ breakdown?: import("@/lib/types").ContextBreakdown;
91
+ };
88
92
  /** 还没有对话时隐藏上下文计量 */
89
93
  contextVisible?: boolean;
90
94
  model?: { provider: string; id: string; name: string };
@@ -35,8 +35,10 @@ import type { ContextResource, TrajEntry, TrajTokens, WebContent, WebMessage, We
35
35
  import {
36
36
  classifyModelError,
37
37
  cleanCommand,
38
+ deleteTargetOf,
38
39
  displayToolName,
39
40
  firstSentence,
41
+ isDeleteCommand,
40
42
  langOfPath,
41
43
  previewSide,
42
44
  relativizeInText,
@@ -457,10 +459,10 @@ const ToolStep = memo(function ToolStep({
457
459
  if (kind !== "write" || !state || state.state !== "done" || state.isError) return null;
458
460
  const parsed = parseUnifiedDiff(state.patch || output);
459
461
  if (parsed) return parsed;
460
- // pi 的 write 工具不带 patch:新文件按带行号的正文展示(中性色,不标 +)
462
+ // pi 的 write 工具不带 patch:新文件按新增行展示(全绿 +),与 edit 的 diff 口径一致
461
463
  if (isWriteTool) {
462
464
  const content = str((args as { content?: unknown } | undefined)?.content);
463
- if (content) return content.replace(/\r/g, "").replace(/\n$/, "").split("\n").map((text, i): DiffLine => ({ kind: "ctx", old: i + 1, new: i + 1, text }));
465
+ if (content) return content.replace(/\r/g, "").replace(/\n$/, "").split("\n").map((text, i): DiffLine => ({ kind: "add", new: i + 1, text }));
464
466
  }
465
467
  return null;
466
468
  }, [args, isWriteTool, kind, output, state]);
@@ -478,6 +480,8 @@ const ToolStep = memo(function ToolStep({
478
480
  return str(a?.path ?? a?.file_path) ?? "";
479
481
  })();
480
482
  const fullCommand = kind === "cmd" ? (str((args as Record<string, unknown>)?.command) ?? str((args as Record<string, unknown>)?.cmd) ?? str((args as Record<string, unknown>)?.script) ?? "") : "";
483
+ // 删除文件的命令:完成后给红色标记(内容级 diff 不强求)
484
+ const deletedTarget = kind === "cmd" && !running && !failed && isDeleteCommand(fullCommand) ? deleteTargetOf(fullCommand) : "";
481
485
  const preview = state ? toolPreview(kind, state, tt, cwd) : { lines: [], hidden: 0 };
482
486
  const hasDiff = Boolean(diff && diff.length) && !failed;
483
487
  const hasOutput = Boolean(output || state?.encodingLoss);
@@ -488,7 +492,7 @@ const ToolStep = memo(function ToolStep({
488
492
  const diffHidden = diff && diffLines && !open ? diff.length - diffLines.length : 0;
489
493
  const diffStat = hasDiff && diff
490
494
  ? isWriteTool && !state?.patch
491
- ? t.resultWrote.replace("{n}", String(diff.length))
495
+ ? t.diffSummary.replace("{add}", String(diff.length)).replace("{del}", "0")
492
496
  : t.diffSummary.replace("{add}", String(diff.filter((l) => l.kind === "add").length)).replace("{del}", String(diff.filter((l) => l.kind === "del").length))
493
497
  : "";
494
498
  const toggle = () => setOpen((o) => !o);
@@ -508,6 +512,11 @@ const ToolStep = memo(function ToolStep({
508
512
  <span className="pw-arg" style={{ color: "var(--dsw-label-caption)" }}>{displayToolName(name)}</span>
509
513
  )}
510
514
  {diffStat && <span className="pw-meta">{diffStat}</span>}
515
+ {deletedTarget && (
516
+ <span className="pw-meta" style={{ color: "var(--dsw-danger)" }} title={deletedTarget}>
517
+ {t.deletedFile.replace("{path}", relativizeInText(deletedTarget, cwd || ""))}
518
+ </span>
519
+ )}
511
520
  {summary.scriptLines > 1 && <span className="pw-meta">{t.commandLines.replace("{n}", String(summary.scriptLines))}</span>}
512
521
  {running && <span className="pw-meta">{fmtSpan(elapsed)}</span>}
513
522
  {!running && duration >= 1000 && <span className="pw-meta">{fmtSpan(duration)}</span>}
@@ -638,6 +647,7 @@ const ERROR_HINT_KEY: Record<ModelErrorKind, string> = {
638
647
  timeout: "modelErrorTimeout",
639
648
  server: "modelErrorServer",
640
649
  network: "modelErrorNetwork",
650
+ moderation: "modelErrorModeration",
641
651
  };
642
652
 
643
653
  function ErrorStep({ message, count, model }: { message: string; count: number; model?: string }) {
@@ -1,18 +1,31 @@
1
1
  "use client";
2
2
 
3
3
  /**
4
- * 上下文占用(对齐 dsh ContextMeter):
5
- * 圆环常驻按钮 + 弹窗显示「百分比 / ~已用 / 窗口」与分段占用条
6
- * (系统提示词 / 对话消息 / 工具,mac 磁盘存储条式的多色分段)。
4
+ * 上下文占用:
5
+ * 圆环常驻按钮 + 弹窗显示「百分比 / ~已用 / 窗口」与 13 类分段占用条
6
+ * (mac 磁盘存储条式的多色分段,含 Free Space 段)。
7
+ * 分段口径:全部为字符/4 启发式估算(与 SDK estimateTokens 一致),
8
+ * 服务端分项(System Prompt / Tools / Memory / Skills / Compacted / Buffer)来自快照,
9
+ * 消息分项(User / Agent Text / Thinking / Tool Call / Tool Output)弹窗打开时前端统计。
7
10
  * 无对话时不渲染(由父组件经 contextVisible 控制)。
8
11
  */
9
12
  import { useEffect, useMemo, useRef, useState } from "react";
10
13
  import { useI18n } from "@/i18n";
14
+ import { classifyMessageChars } from "@/lib/process-format";
15
+ import type { ContextBreakdown } from "@/lib/types";
11
16
 
12
17
  function fmtTok(n: number): string {
13
18
  return n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(Math.round(n));
14
19
  }
15
20
 
21
+ /** 13 类分段的定义(顺序即渲染顺序) */
22
+ interface Segment {
23
+ key: string;
24
+ label: string;
25
+ tokens: number;
26
+ color: string;
27
+ }
28
+
16
29
  export function ContextMeter({
17
30
  percent,
18
31
  tokens,
@@ -22,8 +35,12 @@ export function ContextMeter({
22
35
  percent: number | null;
23
36
  tokens: number | null;
24
37
  contextWindow: number | null;
25
- /** 分段数据源:字符统计只在弹窗打开时执行(流式期间消息每 token 都变,不能常驻扫描) */
26
- source?: { systemChars: number; messages: { content: Array<{ type: string; text?: string; thinking?: string }> }[] };
38
+ /** 分段数据源:消息字符统计只在弹窗打开时执行(流式期间消息每 token 都变,不能常驻扫描) */
39
+ source?: {
40
+ systemChars: number;
41
+ messages: { role?: string; content: Array<{ type: string; text?: string; thinking?: string; arguments?: unknown }> }[];
42
+ breakdown?: ContextBreakdown;
43
+ };
27
44
  }) {
28
45
  const [open, setOpen] = useState(false);
29
46
  const ref = useRef<HTMLDivElement>(null);
@@ -38,16 +55,21 @@ export function ContextMeter({
38
55
  return () => document.removeEventListener("mousedown", h);
39
56
  }, [open]);
40
57
 
41
- // 分段估算只在弹窗打开时计算(≈3.5 字符/token)
42
- const segments = useMemo(() => {
43
- if (!open) return { system: 0, messages: 0 };
44
- let chars = 0;
45
- for (const message of source?.messages ?? []) {
46
- for (const content of message.content) {
47
- chars += content.type === "text" ? (content.text?.length ?? 0) : content.type === "thinking" ? (content.thinking?.length ?? 0) : 0;
48
- }
49
- }
50
- return { system: Math.round((source?.systemChars ?? 0) / 3.5), messages: Math.round(chars / 3.5) };
58
+ const used = tokens ?? 0;
59
+ const window_ = contextWindow ?? 0;
60
+ const b = source?.breakdown;
61
+
62
+ // 消息分项只在弹窗打开时统计(≈4 字符/token,与 SDK 一致)
63
+ const messageSegments = useMemo(() => {
64
+ if (!open) return { user: 0, agentText: 0, agentThinking: 0, agentToolCall: 0, toolOutput: 0 };
65
+ const chars = classifyMessageChars(source?.messages ?? []);
66
+ return {
67
+ user: Math.max(0, Math.round(chars.user / 4)),
68
+ agentText: Math.max(0, Math.round(chars.agentText / 4)),
69
+ agentThinking: Math.max(0, Math.round(chars.agentThinking / 4)),
70
+ agentToolCall: Math.max(0, Math.round(chars.agentToolCall / 4)),
71
+ toolOutput: Math.max(0, Math.round(chars.toolOutput / 4)),
72
+ };
51
73
  }, [open, source]);
52
74
 
53
75
  const p = percent ?? 0;
@@ -55,15 +77,34 @@ export function ContextMeter({
55
77
  const r = 5.5;
56
78
  const c = 2 * Math.PI * r;
57
79
  const shown = Math.max(0, Math.min(100, p));
58
- const used = tokens ?? 0;
59
- const window_ = contextWindow ?? 0;
60
80
 
61
- // 分段估算:系统提示词 / 对话消息;工具与基底取余量,保证各段之和等于总量
62
- const systemTokens = Math.max(0, Math.round(segments?.system ?? 0));
63
- const messageTokens = Math.max(0, Math.round(segments?.messages ?? 0));
64
- const toolTokens = Math.max(0, used - systemTokens - messageTokens);
65
- const segTotal = systemTokens + messageTokens + toolTokens || 1;
66
- const bar = (n: number) => `${(n / segTotal) * 100}%`;
81
+ const segments: Segment[] = b
82
+ ? [
83
+ { key: "systemPrompt", label: t.contextSystemPrompt, tokens: b.systemPrompt, color: "var(--dsw-accent)" },
84
+ { key: "systemTools", label: t.contextSystemTools, tokens: b.systemTools, color: "#8B7EC8" },
85
+ { key: "customTools", label: t.contextCustomTools, tokens: b.customTools, color: "#B39DDB" },
86
+ { key: "memory", label: t.contextMemory, tokens: b.memory, color: "#5FA8D3" },
87
+ { key: "skills", label: t.contextSkills, tokens: b.skills, color: "#4FB286" },
88
+ { key: "user", label: t.contextUserMessages, tokens: messageSegments.user, color: "var(--dsw-success)" },
89
+ { key: "agentText", label: t.contextAgentText, tokens: messageSegments.agentText, color: "#7BC96F" },
90
+ { key: "agentThinking", label: t.contextAgentThinking, tokens: messageSegments.agentThinking, color: "#C9A66B" },
91
+ { key: "agentToolCall", label: t.contextAgentToolCall, tokens: messageSegments.agentToolCall, color: "#D3A15F" },
92
+ { key: "toolOutput", label: t.contextToolOutput, tokens: messageSegments.toolOutput, color: "var(--dsw-warn)" },
93
+ { key: "compacted", label: t.contextCompacted, tokens: b.compacted, color: "#A05A78" },
94
+ { key: "buffer", label: t.contextAutoCompactBuffer, tokens: b.autoCompactBuffer, color: "var(--dsw-border-l3)" },
95
+ ]
96
+ : // 服务端分项缺失(旧快照):退回旧三段口径
97
+ [
98
+ { key: "system", label: t.contextSystem, tokens: Math.max(0, Math.round((source?.systemChars ?? 0) / 4)), color: "var(--dsw-accent)" },
99
+ { key: "messages", label: t.contextMessages, tokens: messageSegments.user + messageSegments.agentText + messageSegments.agentThinking, color: "var(--dsw-success)" },
100
+ { key: "tools", label: t.contextTools, tokens: Math.max(0, used - Math.max(0, Math.round((source?.systemChars ?? 0) / 4)) - messageSegments.user - messageSegments.agentText - messageSegments.agentThinking), color: "var(--dsw-warn)" },
101
+ ];
102
+
103
+ // Free Space:窗口余量(窗口未知则不显示);分段条不归一化到已用之和
104
+ const usedSum = segments.reduce((sum, s) => sum + s.tokens, 0);
105
+ const free = window_ > 0 ? Math.max(0, window_ - usedSum) : 0;
106
+ const barTotal = window_ > 0 ? window_ : usedSum || 1;
107
+ const bar = (n: number) => `${(n / barTotal) * 100}%`;
67
108
 
68
109
  return (
69
110
  <div ref={ref} className="relative">
@@ -98,18 +139,20 @@ export function ContextMeter({
98
139
  {tokens === null ? "—" : `~${fmtTok(used)}`} / {window_ > 0 ? fmtTok(window_) : "—"}
99
140
  </div>
100
141
 
101
- {/* 分段占用条(mac 磁盘存储条式多色分段) */}
102
- {(systemTokens > 0 || messageTokens > 0 || toolTokens > 0) && (
142
+ {/* 分段占用条(mac 磁盘存储条式多色分段,含 Free Space) */}
143
+ {usedSum > 0 && (
103
144
  <div className="mt-3 flex h-2 w-full overflow-hidden rounded-full" style={{ background: "var(--dsw-selector)" }} aria-hidden>
104
- {systemTokens > 0 && <span style={{ width: bar(systemTokens), background: "var(--dsw-accent)" }} />}
105
- {messageTokens > 0 && <span style={{ width: bar(messageTokens), background: "var(--dsw-success)" }} />}
106
- {toolTokens > 0 && <span style={{ width: bar(toolTokens), background: "var(--dsw-warn)" }} />}
145
+ {segments.filter((s) => s.tokens > 0).map((s) => (
146
+ <span key={s.key} style={{ width: bar(s.tokens), background: s.color }} />
147
+ ))}
148
+ {free > 0 && <span style={{ width: bar(free), background: "transparent" }} />}
107
149
  </div>
108
150
  )}
109
151
  <div className="mt-2 flex flex-col gap-1" style={{ color: "var(--dsw-label-tertiary)" }}>
110
- <SegmentRow label={t.contextSystem} tokens={systemTokens} color="var(--dsw-accent)" />
111
- <SegmentRow label={t.contextMessages} tokens={messageTokens} color="var(--dsw-success)" />
112
- <SegmentRow label={t.contextTools} tokens={toolTokens} color="var(--dsw-warn)" />
152
+ {segments.filter((s) => s.tokens > 0).map((s) => (
153
+ <SegmentRow key={s.key} label={s.label} tokens={s.tokens} color={s.color} />
154
+ ))}
155
+ {window_ > 0 && <SegmentRow label={t.contextFreeSpace} tokens={free} color="var(--dsw-selector)" />}
113
156
  </div>
114
157
  </div>
115
158
  )}
@@ -161,6 +161,12 @@ export function ProjectPanel({
161
161
  <IconCloseOutline14 size={13} />
162
162
  </button>
163
163
  </div>
164
+ {/* 生长快照不可用(找不到 git 等):明确提示 + 已退化为当前目录结构 */}
165
+ {cwd && hasSession && !growth.available && !growth.loading && (
166
+ <div className="mx-3 mb-2 rounded-lg px-2 py-1" style={{ fontSize: 11.5, color: "var(--dsw-warn)", background: "var(--dsw-hover)" }} role="status">
167
+ {t.growthUnavailableFallback}
168
+ </div>
169
+ )}
164
170
  {gitError && (
165
171
  <div className="mx-3 mb-2 rounded-lg px-2 py-1" style={{ fontSize: 11.5, color: "var(--dsw-danger)" }} title={gitError} role="alert" data-testid="growth-git-error">
166
172
  {t.gitStatusUnknown}
@@ -271,6 +271,24 @@
271
271
  opacity: 0.48;
272
272
  }
273
273
 
274
+ .discoverControls {
275
+ display: flex;
276
+ flex: none;
277
+ align-items: center;
278
+ gap: 12px;
279
+ }
280
+
281
+ .allowPrivate {
282
+ display: flex;
283
+ align-items: center;
284
+ gap: 4px;
285
+ font-size: 11.5px;
286
+ color: var(--dsw-label-caption);
287
+ cursor: pointer;
288
+ user-select: none;
289
+ white-space: nowrap;
290
+ }
291
+
274
292
  .modelEmpty {
275
293
  margin-top: 13px;
276
294
  padding: 17px;