dsh-any-agent 0.6.1 → 0.6.2
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/CHANGELOG.md +36 -0
- package/docs/en/tests.md +4 -4
- package/docs/en/usage.md +1 -0
- package/docs/zh/live-view-design.md +2 -1
- package/docs/zh/tests.md +4 -4
- package/docs/zh/usage.md +1 -0
- package/package.json +1 -1
- package/src/conversation.mjs +69 -1
- package/src/live-cli.mjs +38 -11
- package/src/live-dashboard.html +320 -8
- package/src/live-view.mjs +177 -7
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.2] — 2026-11-08
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Token usage in the live view.** A new `usage` event carries the run's token
|
|
15
|
+
delta (uncached input, output, cache read, cache write), the session total and
|
|
16
|
+
the context-window use, read from DSH's own projection cache. It updates during
|
|
17
|
+
a run (throttled to every 2 s, only when the numbers change; verified against
|
|
18
|
+
real DSH, which updates its totals after every model step) and once more with
|
|
19
|
+
the final numbers after the run ends. `watch` shows per-run and session totals;
|
|
20
|
+
the dashboard adds a token stat, per-run tokens on each turn, and a "Token 消耗"
|
|
21
|
+
panel (input / output / cache-hit rate, composition, context window, per-turn
|
|
22
|
+
chart). Counts are metadata only and are written at every level except `off`.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- **`watch` redraws in place.** It now runs on the terminal's alternate screen
|
|
27
|
+
(like `top`) and rewrites only the lines that changed, instead of clearing the
|
|
28
|
+
screen every tick — which in Windows Terminal piled every frame into the
|
|
29
|
+
scrollback. Lines are clipped to the terminal width (CJK counted as two
|
|
30
|
+
columns) and the frame to its height; leaving restores the original screen.
|
|
31
|
+
- **Dashboard: inspector column and scrollable turns.** Wide screens get a right
|
|
32
|
+
column for the selected conversation: totals, per-tool breakdown, files read /
|
|
33
|
+
written, approvals. A long turn scrolls inside its card (hidden scrollbar, edge
|
|
34
|
+
fades, a "↓ N 条" hint), follows the newest tool while you are at the bottom,
|
|
35
|
+
and keeps your position across live updates when you scroll up. The page no
|
|
36
|
+
longer caps its width, so ultra-wide screens are filled.
|
|
37
|
+
- Viewers prefer the caller's `title_hint` over DSH's native title, which is only
|
|
38
|
+
a truncated prefix of the first prompt.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- Dashboard: on narrow screens the token stat's long label widened the header
|
|
43
|
+
and caused horizontal scrolling; stat labels now truncate and the full token
|
|
44
|
+
breakdown is in a tooltip.
|
|
45
|
+
|
|
10
46
|
## [0.6.1] — 2026-11-08
|
|
11
47
|
|
|
12
48
|
### Added
|
package/docs/en/tests.md
CHANGED
|
@@ -46,8 +46,8 @@ Why model-backed suites do not point `DSH_HOME` at a throwaway sandbox: DSH keep
|
|
|
46
46
|
| `line-decoder` | 4 | **UTF-8 line framing**: multi-byte characters split across MCP/ACP stream chunks are preserved (no U+FFFD), including CRLF and EOF-without-newline (no tokens) |
|
|
47
47
|
| `read-cursor` | 26 | **`dsh_read` cursor semantics**: text appended to an already-read entry is delivered again (revision cursor, match by `seq`); **each tool call gets its own entry** and a status update replaces that entry in place (they used to be concatenated into one entry under the first call's id); an empty turn's start/end stay two entries; **message segments separated by tool calls get a blank line between them in `answer`** (they used to run together); and `limit` is clamped to 1..500 so `0`/negative cannot spin or drop the tail (no tokens) |
|
|
48
48
|
| `sentinel-redaction` | 19 | **sentinel disk hygiene**: `result.thinking` is stripped, `thinking_omitted` is always a boolean, and `answer`/`error`/`title`/`tools_used[].input` all pass the same credential redaction as approvals (no tokens) |
|
|
49
|
-
| `live-events` |
|
|
50
|
-
| `live-view` |
|
|
49
|
+
| `live-events` | 39 | **live event stream, write side**: a full run through a fake hub and fake ACP process; event order and fields, tools written only on status change, reasoning never written, redacted previews, `meta`/`off` levels, write failures don't affect runs, ask-approval decisions, failure/queue/cancel/forget (no tokens) |
|
|
50
|
+
| `live-view` | 54 | **live viewers**: byte-level line splitting (no broken CJK), call-chain merge, exited writer shown as interrupted, incremental tail (half lines/rotation/removal), `watch --once` through the bin entry, `dashboard` token / Host / read-only checks and SSE (no tokens) |
|
|
51
51
|
| `review` | 70 | **caller review**: default `advisory` tickets carry a checklist + evidence + `dsh_review` entry; `dsh_review` writes the verdict to disk and back into the run and sentinel; `fail`/`partial` require notes; running/unknown runs are refused; review still works after a restart (sentinel-backed); queue and `client_run_id` replay receipts carry the ticket; review records share the sentinel TTL; notes, previews, evidence **and checklist items** are credential-redacted; **path-traversal ids are rejected before any file I/O** (no tokens) |
|
|
52
52
|
| `forget-tombstone` | 10 | **`forget` is terminal**: `release(forget=true)` writes a tombstone, by-id lookups fail with a recovery path, lists exclude it, and deleting the tombstone restores the normal unknown-session path (no tokens) |
|
|
53
53
|
| `bootstrap-profile` | 24 | **first-start profile works in the published zero-dependency form**: copies routing with the built-in YAML subset parser, strips `apiKey`/`headers`/literal secrets, never writes `defaultPreset`, is idempotent, and leaves a user-maintained profile byte-identical (no tokens) |
|
|
@@ -81,13 +81,13 @@ Why model-backed suites do not point `DSH_HOME` at a throwaway sandbox: DSH keep
|
|
|
81
81
|
> **2026-11-06**: `node test/run.mjs` now runs **every** suite and lists all failures at the end (`--bail` restores stop-at-first-failure) — a single stale assertion in `timeout.mjs` used to hide eleven model-backed suites. Model-backed suites re-run in full against DSH 0.2.2-rc.1 on the desktop app's bundled runtime.
|
|
82
82
|
|
|
83
83
|
<!-- counts:begin (generated by scripts/sync-test-counts.mjs; do not edit by hand) -->
|
|
84
|
-
**`node test/run.mjs` ran
|
|
84
|
+
**`node test/run.mjs` ran 1510 checks in 33 offline suites on this machine (Windows, DSH installed, 5/9 client config fixtures), all passing.** Parenthesised numbers in the table depend on the environment (DSH present or not, platform, filesystem case sensitivity, how many client configs the machine has), so the total moves with it; this line records the environment of the report used for the last sync.
|
|
85
85
|
|
|
86
86
|
That is **524 assertions in the client-registration suites alone** (`setup` 45, `removal-scope` 24, `config-safety` 407, `real-configs` 48), which is where the 0.3 work actually is.
|
|
87
87
|
|
|
88
88
|
The model-backed suites (`boundary` 46, `integration` 30, `async` 21, `sentinel` 36, `timeout` 40, `lock` 79, `multi` 19, `list` 17, `permission` 11, `shared-process` 8, `cycle` 36, `concurrency` 31, `capability` 22, `workspace-effect` 24, `acceptance` 36, `reasoning-check` 8) total **464 checks** nominally; with DSH present `smoke` runs 13 more checks in its session sections. They need a working DSH and provider, so re-run them yourself before trusting a release: `node test/run.mjs --all`. `acceptance` **skips explicitly** when the DSH profile has no IDA MCP (`mcp__ida__*`); `shared-process` also skips without DSH.
|
|
89
89
|
|
|
90
|
-
> Three different totals are intentional: `node test/run.mjs` on this machine (
|
|
90
|
+
> Three different totals are intentional: `node test/run.mjs` on this machine (1510), the CI offline job (nominal 1442 on Linux/macOS, 1448 on Windows; hosted runners have no DSH and no client fixtures, so `smoke` skips its session sections and `real-configs` skips entirely), and the model-backed set (464 nominal). Counts are the numbers each suite prints itself, written back from the report by `npm run docs:counts`; don't copy them by hand and don't use one number for a different runner.
|
|
91
91
|
<!-- counts:end -->
|
|
92
92
|
|
|
93
93
|
### 🔢 After changing tests: counts are computed
|
package/docs/en/usage.md
CHANGED
|
@@ -96,6 +96,7 @@ dsh-any-agent dashboard
|
|
|
96
96
|
- `dashboard`: a **read-only** local page; prints a URL with a one-time token (binds `127.0.0.1` only, checks the Host header, has no control that changes state).
|
|
97
97
|
- Both are separate processes: they don't start the server, never touch the write lock and never talk to DSH, and they show conversations dispatched from every client (Claude Code, Codex, Cursor, …) together.
|
|
98
98
|
- A run whose writer process has exited without finishing is shown as interrupted.
|
|
99
|
+
- **Token usage**: each run's input / output / cache read-write delta, the session total and context-window use, updated while the run is going (numbers come from DSH's own session record). The dashboard's side column has a "Token 消耗" panel and a per-turn chart.
|
|
99
100
|
- Tune with `DSH_MCP_LIVE`: `detail` (default; redacted command/path/prompt previews), `meta` (tool names, status, timing, approval kinds only), `off`. **Reasoning is never written at any level.** Event files share the sentinels' 7-day TTL; `dsh_release(forget=true)` deletes them too.
|
|
100
101
|
|
|
101
102
|
## 🤫 Reasoning: hidden by default
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# 设计稿:让用户看见 DSH 在做什么(live 事件流 + 查看器)
|
|
4
4
|
|
|
5
|
-
> 状态:**P1 + P2 已实现**,随 0.6.1 发布(见 CHANGELOG)。实现与本文最初草案的差异已在对应小节标注"实现"。
|
|
5
|
+
> 状态:**P1 + P2 已实现**,随 0.6.1 发布;token 用量与查看端改进随 0.6.2 发布(见 CHANGELOG)。实现与本文最初草案的差异已在对应小节标注"实现"。
|
|
6
6
|
>
|
|
7
7
|
> 代码:`src/live.mjs`(写入)、`src/live-view.mjs`(读取 / 归并 / 终端渲染)、`src/live-cli.mjs`(`watch` / `dashboard` 子命令)、`src/live-dashboard.mjs` + `src/live-dashboard.html`(页面)。测试:`test/live-events.mjs`、`test/live-view.mjs`。
|
|
8
8
|
>
|
|
@@ -111,6 +111,7 @@ DSH 的工具活动经 ACP 的 `session/update`(`tool_call` / `tool_call_updat
|
|
|
111
111
|
- **不写** `text` 事件:正文只在哨兵里。
|
|
112
112
|
- **不写** `writer.hello` / `writer.bye`:每条事件已带 `w`,查看端看最后一个写者的 pid 是否存活即可判断"中断"(§6.5)。
|
|
113
113
|
- 排队被取消、插话等待超时、起不来的回合都补一条 `run.end`,查看端不会留下永远"排队 / 运行中"的回合。
|
|
114
|
+
- 新增 `usage`(0.6.2):本回合 token 增量 `tokens`、会话累计 `session_tokens`、上下文占用 `context {used, window}`。数据来自 DSH 投影缓存的 `tokenUsage.totals` / `contextPressure`。真机实测 DSH 在回合中每个模型步之后就更新它,所以回合开始记基线,工具状态变化 / `usage_update` 时按 2 秒节流发增量,回合结束(git 快照之后)再发一条 `final: true` 的最终值。只是计数,属于 meta 级字段。
|
|
114
115
|
- 真机验证(DSH ACP)发现:`tool_call` **不带 `name`**,工具名在 `title` 里(`read` / `glob` / `write` / `pwsh`),`kind` 恒为 `other`。所以 `title` 是纯标识符时当作 `name` 写进 meta 级字段;带空格的 `title` 仍只进 detail。查看端把路径显示成相对会话目录的形式,并隐藏 `other`。
|
|
115
116
|
|
|
116
117
|
`tool` 只在状态变化时落盘,避免 DSH 的快照式重复更新刷屏;`tool_call_id` 让查看器原地替换同一工具的行,语义与 `dsh_read` 的"按 seq 替换"一致。
|
package/docs/zh/tests.md
CHANGED
|
@@ -45,8 +45,8 @@ npm run docs:counts:check # 只检查文档与报告是否一致,不一致 exi
|
|
|
45
45
|
| `read-cursor` | 26 | **`dsh_read` 游标语义**:追加到已读条目里的文本必须再次返回(修订号游标,按 `seq` 替换全文);**每个工具调用各占一条**、状态更新原地替换(以前会拼成一条、只剩第一个工具的 id);空回合的开始/结束仍是两条;**`answer` 里被工具调用隔开的正文之间补空行**(以前首尾相接);`limit` 钳到 1..500,`0`/负数不会空转或丢尾(不花 token) |
|
|
46
46
|
| `line-decoder` | 4 | **UTF-8 行分帧**:MCP/ACP 的 chunk 边界不能把中文/emoji 截成 U+FFFD;覆盖逐字节、任意 chunk、CRLF 与 EOF 残留(不花 token) |
|
|
47
47
|
| `sentinel-redaction` | 19 | **哨兵落盘卫生**:`result.thinking` 被剥掉、`thinking_omitted` 始终是布尔值、`answer`/`error`/`title`/`tools_used[].input` 都走与审批相同的凭据脱敏(不花 token) |
|
|
48
|
-
| `live-events` |
|
|
49
|
-
| `live-view` |
|
|
48
|
+
| `live-events` | 39 | **live 事件流写入侧**:假 hub + 假 ACP 进程跑完整回合;事件序列与字段、工具只在状态变化时写、思考内容不落盘、预览脱敏、`meta`/`off` 级别、写失败不影响派活、ask 审批裁决、失败/排队/取消/forget(不花 token) |
|
|
49
|
+
| `live-view` | 54 | **live 查看端**:按字节切行(不切坏中文)、调用链归并、写者退出显示中断、增量 tail(半行/滚动/删除)、`watch --once` 走 bin 入口、`dashboard` 的 token / Host / 只读校验与 SSE(不花 token) |
|
|
50
50
|
| `review` | 70 | **调用方审核**:默认 `advisory` 审核单带清单 + 证据 + `dsh_review` 入口;`dsh_review` 把裁决落盘并回写内存 run 与哨兵;`fail`/`partial` 必须给理由;未完成/未知 run 被拒绝;重启后仍可凭哨兵审核;队列与 `client_run_id` 重放收据也带审核单;审核记录与哨兵共享 TTL;理由、预览、证据**与清单项**都凭据脱敏;**路径穿越 id 在触盘前就被拒绝**(不花 token) |
|
|
51
51
|
| `forget-tombstone` | 10 | **`forget` 是终态**:`release(forget=true)` 写墓碑,按 id 查询明确失败并给出恢复路径,列表不再展示;删除墓碑后回到常规“未知会话”路径(不花 token) |
|
|
52
52
|
| `bootstrap-profile` | 24 | **首启 profile 在零依赖发布形态下可用**:内置 YAML 子集解析器复制路由、剥掉 `apiKey`/`headers`/字面密钥、绝不写 `defaultPreset`、幂等;用户手写文件逐字节不动(不花 token) |
|
|
@@ -80,13 +80,13 @@ npm run docs:counts:check # 只检查文档与报告是否一致,不一致 exi
|
|
|
80
80
|
> **2026-11-06**:`node test/run.mjs` 现在**跑完全部套件**再在末尾列出所有失败项(`--bail` 恢复遇错即停)—— 以前 `timeout.mjs` 一条过时断言就让后面 11 个真机套件全部没跑。需要模型的套件已对桌面版自带运行时上的 DSH 0.2.2-rc.1 全量重跑。
|
|
81
81
|
|
|
82
82
|
<!-- counts:begin (generated by scripts/sync-test-counts.mjs; do not edit by hand) -->
|
|
83
|
-
**`node test/run.mjs` 在本机(Windows,装了 DSH,5/9 个客户端配置 fixture)跑了 33 个离线套件、
|
|
83
|
+
**`node test/run.mjs` 在本机(Windows,装了 DSH,5/9 个客户端配置 fixture)跑了 33 个离线套件、1510 项检查,全部通过。**表中带括号的数字随环境变化(有没有 DSH、哪个平台、文件系统是否区分大小写、本机装了几个客户端配置),套件总数也随之变化;这一行记录的是上次同步所用报告的环境。
|
|
84
84
|
|
|
85
85
|
其中 **524 项属于客户端注册器套件**(`setup` 45、`removal-scope` 24、`config-safety` 407、`real-configs` 48)—— 0.3 的活其实都在这里。
|
|
86
86
|
|
|
87
87
|
需要模型的套件(`boundary` 46、`integration` 30、`async` 21、`sentinel` 36、`timeout` 40、`lock` 79、`multi` 19、`list` 17、`permission` 11、`shared-process` 8、`cycle` 36、`concurrency` 31、`capability` 22、`workspace-effect` 24、`acceptance` 36、`reasoning-check` 8)名义合计 **464 项**;有 DSH 时 `smoke` 还会多跑 13 项会话段。它们要可用的 DSH 与 provider,发版前请自己重跑一遍:`node test/run.mjs --all`。`acceptance` 在没有 IDA MCP(`mcp__ida__*`)时会明确 **skip**;`shared-process` 没有 DSH 时也会 skip。
|
|
88
88
|
|
|
89
|
-
> 三个总数是刻意分开的:本机 `node test/run.mjs`(
|
|
89
|
+
> 三个总数是刻意分开的:本机 `node test/run.mjs`(1510)、CI 离线 job(名义 Linux/macOS 1442,Windows 1448;hosted runner 没有 DSH 也没有客户端 fixture,`smoke` 跳过会话段、`real-configs` 整体跳过)、需要模型的集合(名义 464)。数字取各套件自己打印的汇总,由 `npm run docs:counts` 从报告写回,不在这里手抄;不要把一个数字套到另一个 runner 上。
|
|
90
90
|
<!-- counts:end -->
|
|
91
91
|
|
|
92
92
|
### 🔢 改了测试之后:检查数是算出来的
|
package/docs/zh/usage.md
CHANGED
|
@@ -96,6 +96,7 @@ dsh-any-agent dashboard
|
|
|
96
96
|
- `dashboard`:起一个**只读**本地页面,打印出带一次性 token 的地址(只监听 `127.0.0.1`,校验 Host 头,没有任何能改变状态的按钮)。
|
|
97
97
|
- 两者都是独立进程:不启动服务、不碰写锁、不连 DSH,所有客户端(Claude Code、Codex、Cursor…)派出去的会话都能一起看到。
|
|
98
98
|
- 写者进程已退出而回合没有结束的,显示为"中断"。
|
|
99
|
+
- **token 用量**:每个回合的输入 / 输出 / 缓存读写增量、会话累计和上下文窗口占用,回合进行中就会更新(数字取自 DSH 自己的会话记录)。dashboard 右栏有"Token 消耗"面板和每回合用量图。
|
|
99
100
|
- 级别用 `DSH_MCP_LIVE` 调:`detail`(默认,带脱敏后的命令/路径/prompt 预览)、`meta`(只有工具名、状态、计时、审批类型)、`off`。**思考内容在任何级别都不写**。事件文件和哨兵同一个 7 天 TTL;`dsh_release(forget=true)` 会一并删掉。
|
|
100
101
|
|
|
101
102
|
## 🤫 思考内容:默认隐藏
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-any-agent",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "Drive DeepSeek Harness (DSH) as a long-lived coding agent from any MCP client — Claude Code, Claude Desktop, Codex, Cursor, Windsurf, Zed, Gemini CLI, opencode, VS Code. Resume-aware, interruptible, approval-capable, and it only ever touches the one config entry it owns.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/conversation.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import { rssMb } from './procstat.mjs';
|
|
|
40
40
|
import { readConversationMeta } from './titles.mjs';
|
|
41
41
|
import { gitStatus, sameWorkspaceSnapshot } from './git-snapshot.mjs';
|
|
42
42
|
import { writeTombstone } from './hub-store.mjs';
|
|
43
|
-
import { emitLive, livePreview, removeLive, summarizeToolInput } from './live.mjs';
|
|
43
|
+
import { emitLive, livePreview, liveSettings, removeLive, summarizeToolInput } from './live.mjs';
|
|
44
44
|
import {
|
|
45
45
|
classifyHolder,
|
|
46
46
|
describeLeaseConflict,
|
|
@@ -158,6 +158,61 @@ export class Conversation {
|
|
|
158
158
|
emitLive(this.id, type, { run: this.#liveRunId(), turn: this.currentTurn, ...fields }, detail);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/**
|
|
162
|
+
* live 事件流的 token 跟踪:`{runId, base, at, last}`。
|
|
163
|
+
* DSH 在投影缓存里维护**会话累计**的 tokenUsage.totals,并在回合中每个模型步之后更新
|
|
164
|
+
* (真机实测);回合开始时记下基线,之后发的是"本回合增量"。
|
|
165
|
+
*/
|
|
166
|
+
#liveUsage = null;
|
|
167
|
+
|
|
168
|
+
/** 读 DSH 投影缓存里的 token 累计与上下文压力;读不到返回 null。 */
|
|
169
|
+
#readTokenUsage() {
|
|
170
|
+
try {
|
|
171
|
+
const meta = readConversationMeta(this.id);
|
|
172
|
+
const t = meta?.usage?.totals;
|
|
173
|
+
const p = meta?.pressure;
|
|
174
|
+
if (!t && !p) return null;
|
|
175
|
+
const n = (v) => (Number.isFinite(v) && v > 0 ? v : 0);
|
|
176
|
+
return {
|
|
177
|
+
totals: t
|
|
178
|
+
? { input: n(t.uncachedInputTokens), output: n(t.outputTokens), cache_read: n(t.cacheReadTokens), cache_write: n(t.cacheWriteTokens) }
|
|
179
|
+
: null,
|
|
180
|
+
context: p && Number.isFinite(p.contextWindow) ? { used: n(p.pressureTokens ?? p.surfaceTokens), window: p.contextWindow } : null,
|
|
181
|
+
};
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 发一条 `usage` 事件(本回合 token 增量 + 会话累计 + 上下文占用)。
|
|
189
|
+
* 回合中节流到 2 秒一次、且只在数字变化时发;回合结束后 force 再发一次最终值。
|
|
190
|
+
*/
|
|
191
|
+
#liveUsageTick({ force = false } = {}) {
|
|
192
|
+
const u = this.#liveUsage;
|
|
193
|
+
if (!u) return;
|
|
194
|
+
const t = now();
|
|
195
|
+
if (!force && t - u.at < 2000) return;
|
|
196
|
+
u.at = t;
|
|
197
|
+
const cur = this.#readTokenUsage();
|
|
198
|
+
if (!cur?.totals && !cur?.context) return;
|
|
199
|
+
const base = u.base ?? { input: 0, output: 0, cache_read: 0, cache_write: 0 };
|
|
200
|
+
const tokens = cur.totals
|
|
201
|
+
? Object.fromEntries(Object.keys(base).map((k) => [k, Math.max(0, cur.totals[k] - base[k])]))
|
|
202
|
+
: null;
|
|
203
|
+
const sig = JSON.stringify([tokens, cur.context]);
|
|
204
|
+
if (!force && sig === u.last) return;
|
|
205
|
+
u.last = sig;
|
|
206
|
+
emitLive(this.id, 'usage', {
|
|
207
|
+
run: u.runId,
|
|
208
|
+
turn: u.turn,
|
|
209
|
+
tokens,
|
|
210
|
+
session_tokens: cur.totals,
|
|
211
|
+
context: cur.context,
|
|
212
|
+
final: force || undefined,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
161
216
|
/** 审批裁决事件(decide / 超时 / 回合结束 / 关闭 统一走这里)。 */
|
|
162
217
|
#liveApprovalDecided(approvalId, by, decision = null, effective = null) {
|
|
163
218
|
this.#live('approval.decided', { approval_id: approvalId, by, decision, effective });
|
|
@@ -598,6 +653,7 @@ export class Conversation {
|
|
|
598
653
|
}
|
|
599
654
|
case 'usage_update':
|
|
600
655
|
c.usage = u;
|
|
656
|
+
this.#liveUsageTick();
|
|
601
657
|
break;
|
|
602
658
|
case 'plan':
|
|
603
659
|
case 'plan_update':
|
|
@@ -635,6 +691,7 @@ export class Conversation {
|
|
|
635
691
|
},
|
|
636
692
|
{ title: livePreview(merged.title, 200), input_preview: summarizeToolInput(merged.input) },
|
|
637
693
|
);
|
|
694
|
+
this.#liveUsageTick();
|
|
638
695
|
}
|
|
639
696
|
|
|
640
697
|
async onPermissionRequest(params) {
|
|
@@ -789,6 +846,7 @@ export class Conversation {
|
|
|
789
846
|
runRecord.status = 'error';
|
|
790
847
|
runRecord.error = error?.message ?? String(error);
|
|
791
848
|
runRecord.elapsed_ms = 0;
|
|
849
|
+
if (this.#liveUsage?.runId === runRecord.run_id) this.#liveUsage = null;
|
|
792
850
|
emitLive(
|
|
793
851
|
this.id,
|
|
794
852
|
'run.end',
|
|
@@ -912,6 +970,10 @@ export class Conversation {
|
|
|
912
970
|
this.pushOut('user', text.length > MAX_PROMPT_ECHO ? `${text.slice(0, MAX_PROMPT_ECHO)}…(已截断)` : text);
|
|
913
971
|
this.pushOut('turn', `第 ${this.currentTurn} 轮开始`, { phase: 'start' });
|
|
914
972
|
this.#liveTools.clear();
|
|
973
|
+
// live 关闭时不读投影缓存(emitLive 会直接丢弃,读了也白读)。
|
|
974
|
+
this.#liveUsage = liveSettings().level === 'off'
|
|
975
|
+
? null
|
|
976
|
+
: { runId: runRecord.run_id, turn: this.currentTurn, base: this.#readTokenUsage()?.totals ?? null, at: 0, last: '' };
|
|
915
977
|
this.#live(
|
|
916
978
|
'run.start',
|
|
917
979
|
{ via, permission: this.permission, on_approval: this.onApproval, cwd: this.cwd },
|
|
@@ -1019,6 +1081,12 @@ export class Conversation {
|
|
|
1019
1081
|
this.#scheduleDrain();
|
|
1020
1082
|
// 到这里才允许被 git 拖慢;结果/审核单/哨兵必须等快照完成后再落。
|
|
1021
1083
|
const after = await afterPromise;
|
|
1084
|
+
// 回合结束后 DSH 的 token 累计已经落盘(真机实测在哨兵之前):发一次最终值。
|
|
1085
|
+
// 只发给本回合自己的跟踪器——下一回合可能已经在 git 快照期间开始了。
|
|
1086
|
+
if (this.#liveUsage?.runId === runRecord.run_id) {
|
|
1087
|
+
this.#liveUsageTick({ force: true });
|
|
1088
|
+
this.#liveUsage = null;
|
|
1089
|
+
}
|
|
1022
1090
|
const result = {
|
|
1023
1091
|
conversation_id: this.id,
|
|
1024
1092
|
run_id: runRecord.run_id,
|
package/src/live-cli.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { LIVE_DIR, STATE_FILE } from './config.mjs';
|
|
9
|
-
import { LiveTail, renderText } from './live-view.mjs';
|
|
9
|
+
import { LiveTail, diffFrame, layoutFrame, renderText } from './live-view.mjs';
|
|
10
10
|
import { readConversationMeta } from './titles.mjs';
|
|
11
11
|
|
|
12
12
|
function parseFlags(args) {
|
|
@@ -59,19 +59,43 @@ export async function runWatch(args, { out = process.stdout } = {}) {
|
|
|
59
59
|
return 0;
|
|
60
60
|
}
|
|
61
61
|
const interval = intFlag(flags.interval, 500, 100, 10_000);
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
const tty = Boolean(out.isTTY);
|
|
63
|
+
const clock = () => new Date().toTimeString().slice(0, 8);
|
|
64
|
+
const frameText = () =>
|
|
65
|
+
renderText(tail.view(), opts) + `\n\n\x1b[2m更新于 ${clock()} · live 目录: ${tail.dir} · Ctrl+C 退出\x1b[0m`;
|
|
66
|
+
|
|
67
|
+
// ★ 在 TTY 上用"备用屏幕"原地刷新(同 top / htop):每帧只重写变化的行。
|
|
68
|
+
// 旧实现每次 `ESC[2J` 清屏,Windows Terminal 会把被清掉的整屏推进回滚区,
|
|
69
|
+
// 往上一翻就是一摞重复的帧。备用屏幕没有回滚区,退出时原样恢复用户的终端。
|
|
70
|
+
let prev = null;
|
|
71
|
+
let lastText = '';
|
|
72
|
+
const draw = (force = false) => {
|
|
73
|
+
const text = frameText();
|
|
74
|
+
if (!tty) {
|
|
75
|
+
// 管道 / 重定向:没有光标控制,内容变了才追加一帧(去掉时间行再比较,免得每次都算变化)。
|
|
76
|
+
const body = text.slice(0, text.lastIndexOf('\n\n'));
|
|
77
|
+
if (body !== lastText) out.write(text.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '') + '\n\n');
|
|
78
|
+
lastText = body;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const next = layoutFrame(text, { cols: out.columns ?? 120, rows: out.rows ?? 40 });
|
|
82
|
+
const seq = diffFrame(force ? null : prev, next);
|
|
83
|
+
if (seq) out.write(seq);
|
|
84
|
+
prev = next;
|
|
85
|
+
};
|
|
86
|
+
const restore = () => {
|
|
87
|
+
if (tty) out.write('\x1b[0m\x1b[?25h\x1b[?1049l');
|
|
65
88
|
};
|
|
66
|
-
|
|
67
|
-
|
|
89
|
+
if (tty) out.write('\x1b[?1049h\x1b[?25l');
|
|
90
|
+
process.once('exit', restore);
|
|
91
|
+
draw(true);
|
|
92
|
+
const onResize = () => draw(true);
|
|
93
|
+
if (tty) out.on?.('resize', onResize);
|
|
68
94
|
await new Promise((resolve) => {
|
|
69
95
|
const timer = setInterval(() => {
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
lastDraw = Date.now();
|
|
74
|
-
}
|
|
96
|
+
// 有新事件才重算;时间行每秒在变,所以每次都会写,但 diffFrame 只重写真正变了的那几行。
|
|
97
|
+
tail.poll();
|
|
98
|
+
draw();
|
|
75
99
|
}, interval);
|
|
76
100
|
const stop = () => {
|
|
77
101
|
clearInterval(timer);
|
|
@@ -80,6 +104,9 @@ export async function runWatch(args, { out = process.stdout } = {}) {
|
|
|
80
104
|
process.once('SIGINT', stop);
|
|
81
105
|
process.once('SIGTERM', stop);
|
|
82
106
|
});
|
|
107
|
+
out.off?.('resize', onResize);
|
|
108
|
+
restore();
|
|
109
|
+
process.off('exit', restore);
|
|
83
110
|
return 0;
|
|
84
111
|
}
|
|
85
112
|
|
package/src/live-dashboard.html
CHANGED
|
@@ -54,7 +54,8 @@ button { font: inherit; color: inherit; }
|
|
|
54
54
|
.stats { display: flex; align-items: stretch; height: 100%; margin-left: 4px; }
|
|
55
55
|
.stat { display: flex; flex-direction: column; justify-content: center; padding: 0 16px; border-left: 1px solid var(--line); min-width: 74px; }
|
|
56
56
|
.stat b { font-size: 15px; font-weight: 650; line-height: 1.1; }
|
|
57
|
-
.stat span { font-size: 11px; color: var(--text-3); }
|
|
57
|
+
.stat span { font-size: 11px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
58
|
+
.stat { min-width: 0; }
|
|
58
59
|
.stat.hot b { color: var(--run); }
|
|
59
60
|
.stat.warn b { color: var(--warn); }
|
|
60
61
|
.stat.zero b { color: var(--text-3); font-weight: 500; }
|
|
@@ -92,7 +93,66 @@ button { font: inherit; color: inherit; }
|
|
|
92
93
|
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: .35 } }
|
|
93
94
|
|
|
94
95
|
/* ── 主区 ───────────────────────────────── */
|
|
95
|
-
|
|
96
|
+
/* 不再限制最大宽度:超宽屏上调用链和右栏一起铺满,右栏随窗口在 300–460px 之间伸缩。 */
|
|
97
|
+
.wrap { padding: 22px 28px 48px; }
|
|
98
|
+
.cols { display: grid; grid-template-columns: minmax(0, 1fr) clamp(300px, 24vw, 460px); gap: 24px; align-items: start; }
|
|
99
|
+
.inspector { position: sticky; top: 22px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; min-width: 0; }
|
|
100
|
+
.panel { min-width: 0; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r); padding: 12px 14px; }
|
|
101
|
+
.panel h3 { margin: 0 0 10px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .6px; color: var(--text-3); }
|
|
102
|
+
.kpis { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px 8px; }
|
|
103
|
+
.kpi b { display: block; font-size: 17px; font-weight: 650; line-height: 1.15; }
|
|
104
|
+
.kpi span { font-size: 11px; color: var(--text-3); }
|
|
105
|
+
.kpi.bad b { color: var(--bad); }
|
|
106
|
+
.kpi.warn b { color: var(--warn); }
|
|
107
|
+
.kpi.zero b { color: var(--text-3); font-weight: 500; }
|
|
108
|
+
.dist { display: grid; gap: 7px; }
|
|
109
|
+
.dist-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 8px; font-size: 12px; }
|
|
110
|
+
.dist-row .n { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
111
|
+
.dist-row .v { color: var(--text-3); white-space: nowrap; }
|
|
112
|
+
.dist-row .bar { grid-column: 1 / 3; height: 5px; border-radius: 3px; background: var(--sunken); overflow: hidden; }
|
|
113
|
+
.dist-row .bar b { display: block; height: 100%; border-radius: 3px; background: var(--bar-run); opacity: .75; }
|
|
114
|
+
.files { list-style: none; margin: 0; padding: 0; display: grid; gap: 5px; }
|
|
115
|
+
.files li { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 6px; align-items: baseline; font-size: 12px; }
|
|
116
|
+
.files .op { font-size: 10.5px; font-weight: 600; text-align: center; border-radius: 4px; padding: 0 3px; background: var(--sunken); color: var(--text-2); }
|
|
117
|
+
.files .op.w { background: var(--ok-soft); color: var(--ok); }
|
|
118
|
+
.files .p { font-family: var(--mono); font-size: 11.5px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }
|
|
119
|
+
.aps, .dist, .files { grid-template-columns: minmax(0, 1fr); }
|
|
120
|
+
.aps { display: grid; gap: 8px; }
|
|
121
|
+
.ap { min-width: 0; font-size: 12px; border-left: 2px solid var(--warn); padding: 2px 0 2px 8px; }
|
|
122
|
+
.ap .m { font-weight: 600; color: var(--warn); }
|
|
123
|
+
.ap .c { font-family: var(--mono); font-size: 11.5px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
124
|
+
.muted { font-size: 12px; color: var(--text-3); }
|
|
125
|
+
/* Token 面板 */
|
|
126
|
+
.tok-big { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-bottom: 12px; }
|
|
127
|
+
.tok-big b { display: block; font-size: 18px; font-weight: 650; line-height: 1.15; }
|
|
128
|
+
.tok-big span { font-size: 11px; color: var(--text-3); }
|
|
129
|
+
.stack { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: var(--sunken); margin-bottom: 6px; }
|
|
130
|
+
.stack i { display: block; height: 100%; min-width: 2px; }
|
|
131
|
+
.c-unc { background: var(--accent); }
|
|
132
|
+
.c-read { background: var(--ok); opacity: .75; }
|
|
133
|
+
.c-write { background: var(--warn); opacity: .8; }
|
|
134
|
+
.c-out { background: var(--bad); opacity: .7; }
|
|
135
|
+
.legend { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 3px 10px; font-size: 11.5px; color: var(--text-2); margin-bottom: 12px; }
|
|
136
|
+
.legend span { display: flex; align-items: center; gap: 6px; min-width: 0; white-space: nowrap; }
|
|
137
|
+
.legend i { width: 8px; height: 8px; border-radius: 2px; flex: none; }
|
|
138
|
+
.legend em { font-style: normal; margin-left: auto; color: var(--text-3); }
|
|
139
|
+
.ctx-h { display: flex; justify-content: space-between; font-size: 11.5px; color: var(--text-2); margin-bottom: 4px; }
|
|
140
|
+
.ctx-h span:last-child { color: var(--text-3); }
|
|
141
|
+
.ctx { height: 6px; border-radius: 3px; background: var(--sunken); overflow: hidden; margin-bottom: 12px; }
|
|
142
|
+
.ctx b { display: block; height: 100%; border-radius: 3px; background: var(--accent); }
|
|
143
|
+
.ctx.hi b { background: var(--warn); }
|
|
144
|
+
.ctx.crit b { background: var(--bad); }
|
|
145
|
+
.spark-h { font-size: 11.5px; color: var(--text-2); margin-bottom: 6px; display: flex; justify-content: space-between; }
|
|
146
|
+
.spark-h span:last-child { color: var(--text-3); }
|
|
147
|
+
.spark { display: flex; align-items: flex-end; gap: 3px; height: 56px; }
|
|
148
|
+
.spark i { flex: 1; min-width: 4px; max-width: 26px; border-radius: 3px 3px 0 0; background: var(--bar); position: relative; }
|
|
149
|
+
.spark i.cur { background: var(--bar-run); }
|
|
150
|
+
.spark i:hover { background: var(--accent); }
|
|
151
|
+
.tok-run { font-family: var(--mono); font-size: 11.5px; }
|
|
152
|
+
@media (max-width: 1180px) {
|
|
153
|
+
.cols { grid-template-columns: minmax(0, 1fr); }
|
|
154
|
+
.inspector { position: static; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
|
|
155
|
+
}
|
|
96
156
|
.hd h1 { font-size: 19px; font-weight: 650; margin: 0 0 10px; letter-spacing: -.1px; word-break: break-word; }
|
|
97
157
|
.facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px 24px; padding: 12px 16px; margin: 0 0 24px; border: 1px solid var(--line); border-radius: var(--r); background: var(--surface); }
|
|
98
158
|
.fact { min-width: 0; }
|
|
@@ -135,7 +195,17 @@ button { font: inherit; color: inherit; }
|
|
|
135
195
|
.collapsed .body { display: none; }
|
|
136
196
|
|
|
137
197
|
/* ── 调用链:工具 / 审批(带时间条) ─────── */
|
|
138
|
-
.spans { border-top: 1px solid var(--line); }
|
|
198
|
+
.spans-box { position: relative; border-top: 1px solid var(--line); }
|
|
199
|
+
/* 工具列表过长时在卡片内部滚动,滚动条隐藏;上下边缘用渐隐提示还有内容。 */
|
|
200
|
+
.spans { max-height: min(60vh, 520px); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: none; -ms-overflow-style: none; }
|
|
201
|
+
.spans::-webkit-scrollbar, .rail::-webkit-scrollbar, .main::-webkit-scrollbar { width: 0; height: 0; display: none; }
|
|
202
|
+
.rail, .main { scrollbar-width: none; -ms-overflow-style: none; }
|
|
203
|
+
.spans-box::before, .spans-box::after { content: ""; position: absolute; left: 0; right: 0; height: 28px; pointer-events: none; opacity: 0; transition: opacity .15s ease; z-index: 1; }
|
|
204
|
+
.spans-box::before { top: 0; background: linear-gradient(var(--surface), transparent); }
|
|
205
|
+
.spans-box::after { bottom: 0; background: linear-gradient(transparent, var(--surface)); border-radius: 0 0 var(--r) var(--r); }
|
|
206
|
+
.spans-box.more-up::before, .spans-box.more-down::after { opacity: 1; }
|
|
207
|
+
.spans-count { position: absolute; right: 12px; bottom: 6px; z-index: 2; font-size: 11px; color: var(--text-3); background: var(--surface); border: 1px solid var(--line); border-radius: 999px; padding: 0 8px; opacity: 0; transition: opacity .15s ease; pointer-events: none; }
|
|
208
|
+
.spans-box.more-down .spans-count { opacity: 1; }
|
|
139
209
|
.span { display: grid; grid-template-columns: 22px 92px minmax(0, 1fr) minmax(120px, 26%) 62px; align-items: center; gap: 10px; padding: 0 12px; height: 32px; border-bottom: 1px solid var(--line); font-size: 12.5px; }
|
|
140
210
|
.span:last-child { border-bottom: 0; }
|
|
141
211
|
.span:hover { background: var(--sunken); }
|
|
@@ -175,7 +245,7 @@ button { font: inherit; color: inherit; }
|
|
|
175
245
|
/* ── 窄屏 ───────────────────────────────── */
|
|
176
246
|
@media (max-width: 820px) {
|
|
177
247
|
.top { height: auto; flex-wrap: wrap; gap: 10px 16px; padding: 10px 16px; position: static; }
|
|
178
|
-
.stats { height: 40px; margin-left: 0; order: 3; width: 100%; }
|
|
248
|
+
.stats { height: 40px; margin-left: 0; order: 3; width: 100%; overflow: hidden; }
|
|
179
249
|
.stat { flex: 1; min-width: 0; padding: 0 10px; }
|
|
180
250
|
.stat span { white-space: nowrap; }
|
|
181
251
|
.stat:first-child { border-left: 0; padding-left: 0; }
|
|
@@ -249,6 +319,14 @@ button { font: inherit; color: inherit; }
|
|
|
249
319
|
return Math.round(s / 86400) + ' 天前';
|
|
250
320
|
}
|
|
251
321
|
const t = (ts) => (ts ? Date.parse(ts) : NaN);
|
|
322
|
+
function ftok(n) {
|
|
323
|
+
if (!isFinite(n)) return '';
|
|
324
|
+
if (n < 1000) return String(Math.round(n));
|
|
325
|
+
if (n < 1e6) return (n / 1000).toFixed(n < 10000 ? 1 : 0) + 'k';
|
|
326
|
+
return (n / 1e6).toFixed(n < 1e7 ? 2 : 1) + 'M';
|
|
327
|
+
}
|
|
328
|
+
const tin = (k) => (k ? k.input + k.cache_read + k.cache_write : 0);
|
|
329
|
+
const hit = (k) => { const a = tin(k); return a > 0 ? k.cache_read / a : null; };
|
|
252
330
|
const STATE = { running: '运行中', queued: '排队中', idle: '空闲', closed: '已释放' };
|
|
253
331
|
const RUN = { running: '运行中', queued: '排队', done: '完成', error: '出错', cancelled: '已取消', interrupted: '中断' };
|
|
254
332
|
const VIA = { send: '派活', queue: '排队', interject: '插话' };
|
|
@@ -276,6 +354,12 @@ button { font: inherit; color: inherit; }
|
|
|
276
354
|
stat(s.queued, '排队', 'warn');
|
|
277
355
|
stat(s.pending_approvals, '待审批', 'warn');
|
|
278
356
|
stat(s.escalations, '提权', 'warn');
|
|
357
|
+
if (s.tokens) {
|
|
358
|
+
const d = h('div', 'stat');
|
|
359
|
+
d.title = '全部会话 token:输入 ' + tin(s.tokens).toLocaleString() + '(缓存读 ' + s.tokens.cache_read.toLocaleString() + ')· 输出 ' + s.tokens.output.toLocaleString();
|
|
360
|
+
d.append(h('b', null, ftok(tin(s.tokens) + s.tokens.output)), h('span', null, 'token · 出 ' + ftok(s.tokens.output)));
|
|
361
|
+
box.append(d);
|
|
362
|
+
}
|
|
279
363
|
}
|
|
280
364
|
|
|
281
365
|
function renderRail() {
|
|
@@ -447,9 +531,11 @@ button { font: inherit; color: inherit; }
|
|
|
447
531
|
hd.append(facts);
|
|
448
532
|
wrap.append(hd);
|
|
449
533
|
|
|
534
|
+
const cols = h('div', 'cols');
|
|
535
|
+
const left = h('div');
|
|
450
536
|
const sh = h('div', 'sec-h');
|
|
451
537
|
sh.append(h('h2', null, '调用链'), h('span', null, c.runs.length + ' 个回合 · 最新在上'));
|
|
452
|
-
|
|
538
|
+
left.append(sh);
|
|
453
539
|
|
|
454
540
|
const turns = h('div', 'turns');
|
|
455
541
|
const runs = [...c.runs].reverse();
|
|
@@ -478,6 +564,12 @@ button { font: inherit; color: inherit; }
|
|
|
478
564
|
const rb = h('span'); rb.append(h('b', null, String(n)), ' 个工具'); right.append(rb);
|
|
479
565
|
const t0 = t(r.started_at);
|
|
480
566
|
const total = r.duration_ms != null ? r.duration_ms : r.status === 'running' && isFinite(t0) ? Date.now() - t0 : null;
|
|
567
|
+
if (r.tokens) {
|
|
568
|
+
const tk = h('span', 'tok-run');
|
|
569
|
+
tk.title = '本回合 token:输入 ' + tin(r.tokens) + '(缓存读 ' + r.tokens.cache_read + ')· 输出 ' + r.tokens.output;
|
|
570
|
+
tk.append(h('b', null, ftok(tin(r.tokens))), '↑ ', h('b', null, ftok(r.tokens.output)), '↓');
|
|
571
|
+
right.append(tk);
|
|
572
|
+
}
|
|
481
573
|
if (total != null) { const d = h('span'); d.append(h('b', null, dur(total))); right.append(d); }
|
|
482
574
|
right.append(h('span', null, ago(r.ended_at || r.started_at || r.queued_at)));
|
|
483
575
|
head.append(right);
|
|
@@ -489,19 +581,237 @@ button { font: inherit; color: inherit; }
|
|
|
489
581
|
if (r.prompt_preview) body.append(h('div', 'prompt', r.prompt_preview));
|
|
490
582
|
if (r.error) body.append(h('div', 'errline', r.error));
|
|
491
583
|
if (r.items.length) {
|
|
584
|
+
const boxEl = h('div', 'spans-box');
|
|
492
585
|
const spans = h('div', 'spans');
|
|
586
|
+
spans.dataset.key = key;
|
|
587
|
+
spans.tabIndex = 0; // 键盘也能滚(方向键 / PageDown)
|
|
588
|
+
spans.setAttribute('aria-label', '工具调用列表');
|
|
493
589
|
for (const it of r.items) spans.append(spanRow(it, r, t0, total, c.cwd));
|
|
494
|
-
|
|
590
|
+
boxEl.append(spans, h('span', 'spans-count'));
|
|
591
|
+
body.append(boxEl);
|
|
592
|
+
pendingScroll.push({ spans, box: boxEl, key, running: r.status === 'running' });
|
|
495
593
|
}
|
|
496
594
|
card.append(body);
|
|
497
595
|
turn.append(card);
|
|
498
596
|
turns.append(turn);
|
|
499
597
|
}
|
|
500
|
-
|
|
501
|
-
|
|
598
|
+
left.append(turns);
|
|
599
|
+
left.append(h('div', 'foot', '只读视图 · 数据来自本机 live 事件流 · 思考内容从不写入 · 点击回合标题折叠'));
|
|
600
|
+
cols.append(left, inspector(c));
|
|
601
|
+
wrap.append(cols);
|
|
502
602
|
main.append(wrap);
|
|
503
603
|
}
|
|
504
604
|
|
|
605
|
+
/** Token 面板:会话累计(DSH 记录)、构成、上下文窗口、每回合用量。 */
|
|
606
|
+
function tokenPanel(c) {
|
|
607
|
+
const p = h('section', 'panel');
|
|
608
|
+
p.append(h('h3', null, 'Token 消耗'));
|
|
609
|
+
const k = c.tokens;
|
|
610
|
+
if (!k) {
|
|
611
|
+
p.append(h('div', 'muted', '暂无数据:派一次活之后,这里会实时显示本会话的 token 用量。'));
|
|
612
|
+
return p;
|
|
613
|
+
}
|
|
614
|
+
const big = h('div', 'tok-big');
|
|
615
|
+
const cell = (v, label) => { const d = h('div'); d.append(h('b', null, v), h('span', null, label)); big.append(d); };
|
|
616
|
+
cell(ftok(tin(k)), '输入');
|
|
617
|
+
cell(ftok(k.output), '输出');
|
|
618
|
+
const hr = hit(k);
|
|
619
|
+
cell(hr == null ? '—' : Math.round(hr * 100) + '%', '缓存命中');
|
|
620
|
+
p.append(big);
|
|
621
|
+
|
|
622
|
+
// 构成:未命中缓存的输入 / 缓存读 / 缓存写 / 输出
|
|
623
|
+
const parts = [
|
|
624
|
+
['c-unc', '未缓存输入', k.input],
|
|
625
|
+
['c-read', '缓存读', k.cache_read],
|
|
626
|
+
['c-write', '缓存写', k.cache_write],
|
|
627
|
+
['c-out', '输出', k.output],
|
|
628
|
+
];
|
|
629
|
+
const sum = parts.reduce((n, x) => n + x[2], 0) || 1;
|
|
630
|
+
const stack = h('div', 'stack');
|
|
631
|
+
stack.setAttribute('role', 'img');
|
|
632
|
+
stack.setAttribute('aria-label', parts.map((x) => x[1] + ' ' + x[2]).join(','));
|
|
633
|
+
for (const [cls, label, v] of parts) {
|
|
634
|
+
if (!v) continue;
|
|
635
|
+
const i = h('i', cls);
|
|
636
|
+
i.style.width = (v / sum) * 100 + '%';
|
|
637
|
+
i.title = label + ' ' + v.toLocaleString();
|
|
638
|
+
stack.append(i);
|
|
639
|
+
}
|
|
640
|
+
p.append(stack);
|
|
641
|
+
const lg = h('div', 'legend');
|
|
642
|
+
for (const [cls, label, v] of parts) {
|
|
643
|
+
const s = h('span');
|
|
644
|
+
s.append(h('i', cls), label, h('em', null, ftok(v)));
|
|
645
|
+
lg.append(s);
|
|
646
|
+
}
|
|
647
|
+
p.append(lg);
|
|
648
|
+
|
|
649
|
+
if (c.context && c.context.window) {
|
|
650
|
+
const pct = Math.min(100, (c.context.used / c.context.window) * 100);
|
|
651
|
+
const hh = h('div', 'ctx-h');
|
|
652
|
+
hh.append(h('span', null, '上下文窗口'), h('span', null, ftok(c.context.used) + ' / ' + ftok(c.context.window) + '(' + (pct < 1 ? pct.toFixed(1) : Math.round(pct)) + '%)'));
|
|
653
|
+
p.append(hh);
|
|
654
|
+
const bar = h('div', 'ctx' + (pct > 85 ? ' crit' : pct > 60 ? ' hi' : ''));
|
|
655
|
+
const b = h('b'); b.style.width = Math.max(1, pct) + '%'; bar.append(b);
|
|
656
|
+
p.append(bar);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const runs = c.runs.filter((r) => r.tokens).slice(-16);
|
|
660
|
+
if (runs.length) {
|
|
661
|
+
const per = runs.map((r) => tin(r.tokens) + r.tokens.output);
|
|
662
|
+
const max = Math.max(...per) || 1;
|
|
663
|
+
const sh = h('div', 'spark-h');
|
|
664
|
+
sh.append(h('span', null, '每回合用量'), h('span', null, '最近 ' + runs.length + ' 轮 · 最高 ' + ftok(max)));
|
|
665
|
+
p.append(sh);
|
|
666
|
+
const sp = h('div', 'spark');
|
|
667
|
+
runs.forEach((r, i) => {
|
|
668
|
+
const bar = h('i', r.status === 'running' ? 'cur' : '');
|
|
669
|
+
bar.style.height = Math.max(6, (per[i] / max) * 100) + '%';
|
|
670
|
+
bar.title = (r.turn ? '第 ' + r.turn + ' 轮 · ' : '') + r.run + ':输入 ' + ftok(tin(r.tokens)) + ' · 输出 ' + ftok(r.tokens.output);
|
|
671
|
+
sp.append(bar);
|
|
672
|
+
});
|
|
673
|
+
p.append(sp);
|
|
674
|
+
}
|
|
675
|
+
p.append(h('div', 'foot', '数字来自 DSH 自己的会话记录(累计,含 live 之前的回合)。'));
|
|
676
|
+
return p;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** 读写类工具:用来从输入预览里认出"涉及的文件"。 */
|
|
680
|
+
const READ_TOOLS = /^(read|view|cat|open|glob|grep|search|list|ls)/i;
|
|
681
|
+
const WRITE_TOOLS = /^(write|edit|create|patch|apply|replace|delete|move|rename|mkdir)/i;
|
|
682
|
+
const looksLikePath = (s) => !!s && !/\s/.test(s) && (/[\\/]/.test(s) || /\.[A-Za-z0-9]{1,8}$/.test(s));
|
|
683
|
+
|
|
684
|
+
/** 右侧检查器:这个会话的总量、工具分布、涉及文件、审批。 */
|
|
685
|
+
function inspector(c) {
|
|
686
|
+
const box = h('aside', 'inspector');
|
|
687
|
+
const tools = [];
|
|
688
|
+
const approvals = [];
|
|
689
|
+
for (const r of c.runs) for (const it of r.items) {
|
|
690
|
+
if (it.kind === 'tool') tools.push(it);
|
|
691
|
+
else if (it.kind === 'approval') approvals.push(it);
|
|
692
|
+
}
|
|
693
|
+
const failed = tools.filter((x) => x.status === 'failed').length;
|
|
694
|
+
const toolMs = tools.reduce((n, x) => n + (x.elapsed_ms || 0), 0);
|
|
695
|
+
const esc = approvals.filter((x) => x.escalation).length;
|
|
696
|
+
|
|
697
|
+
const ov = h('section', 'panel');
|
|
698
|
+
ov.append(h('h3', null, '概览'));
|
|
699
|
+
const kpis = h('div', 'kpis');
|
|
700
|
+
const kpi = (v, label, cls) => {
|
|
701
|
+
const k = h('div', 'kpi ' + (v && v !== '0' ? cls || '' : 'zero'));
|
|
702
|
+
k.append(h('b', null, String(v)), h('span', null, label));
|
|
703
|
+
kpis.append(k);
|
|
704
|
+
};
|
|
705
|
+
kpi(c.runs.length, '回合');
|
|
706
|
+
kpi(tools.length, '工具调用');
|
|
707
|
+
kpi(failed, '失败', 'bad');
|
|
708
|
+
kpi(toolMs ? dur(toolMs) : '0', '工具耗时');
|
|
709
|
+
kpi(approvals.length, '审批', 'warn');
|
|
710
|
+
kpi(esc, '提权', 'warn');
|
|
711
|
+
ov.append(kpis);
|
|
712
|
+
box.append(ov);
|
|
713
|
+
box.append(tokenPanel(c));
|
|
714
|
+
|
|
715
|
+
const byName = new Map();
|
|
716
|
+
for (const x of tools) {
|
|
717
|
+
const k = x.name || x.title || 'tool';
|
|
718
|
+
const e = byName.get(k) || { n: 0, ms: 0, fail: 0 };
|
|
719
|
+
e.n++; e.ms += x.elapsed_ms || 0; if (x.status === 'failed') e.fail++;
|
|
720
|
+
byName.set(k, e);
|
|
721
|
+
}
|
|
722
|
+
const dist = h('section', 'panel');
|
|
723
|
+
dist.append(h('h3', null, '工具分布'));
|
|
724
|
+
if (!byName.size) dist.append(h('div', 'muted', '还没有工具调用'));
|
|
725
|
+
else {
|
|
726
|
+
const rows = [...byName.entries()].sort((a, b) => b[1].n - a[1].n || b[1].ms - a[1].ms);
|
|
727
|
+
const max = rows[0][1].n;
|
|
728
|
+
const list = h('div', 'dist');
|
|
729
|
+
for (const [name, e] of rows.slice(0, 8)) {
|
|
730
|
+
const row = h('div', 'dist-row');
|
|
731
|
+
row.append(h('span', 'n', name));
|
|
732
|
+
row.append(h('span', 'v', '×' + e.n + (e.ms ? ' · ' + dur(e.ms) : '') + (e.fail ? ' · ' + e.fail + ' 失败' : '')));
|
|
733
|
+
const bar = h('span', 'bar'); const b = h('b'); b.style.width = Math.max(4, (e.n / max) * 100) + '%'; bar.append(b); row.append(bar);
|
|
734
|
+
list.append(row);
|
|
735
|
+
}
|
|
736
|
+
dist.append(list);
|
|
737
|
+
}
|
|
738
|
+
box.append(dist);
|
|
739
|
+
|
|
740
|
+
const files = new Map();
|
|
741
|
+
for (const x of tools) {
|
|
742
|
+
const name = x.name || x.title || '';
|
|
743
|
+
const p = x.input_preview && x.input_preview.split(' · ')[0];
|
|
744
|
+
if (!looksLikePath(p)) continue;
|
|
745
|
+
const op = WRITE_TOOLS.test(name) ? 'w' : READ_TOOLS.test(name) ? 'r' : null;
|
|
746
|
+
if (!op) continue;
|
|
747
|
+
const key = rel(p, c.cwd) || p;
|
|
748
|
+
const prev = files.get(key);
|
|
749
|
+
files.set(key, prev === 'w' || op === 'w' ? 'w' : 'r');
|
|
750
|
+
}
|
|
751
|
+
const fp = h('section', 'panel');
|
|
752
|
+
fp.append(h('h3', null, '涉及文件'));
|
|
753
|
+
if (!files.size) fp.append(h('div', 'muted', '还没有读写文件'));
|
|
754
|
+
else {
|
|
755
|
+
const ul = h('ul', 'files');
|
|
756
|
+
for (const [p, op] of [...files.entries()].slice(-14).reverse()) {
|
|
757
|
+
const li = h('li');
|
|
758
|
+
li.append(h('span', 'op ' + op, op === 'w' ? '写' : '读'));
|
|
759
|
+
const ps = h('span', 'p'); const bdi = document.createElement('bdi'); bdi.textContent = p; ps.append(bdi); ps.title = p;
|
|
760
|
+
li.append(ps);
|
|
761
|
+
ul.append(li);
|
|
762
|
+
}
|
|
763
|
+
fp.append(ul);
|
|
764
|
+
}
|
|
765
|
+
box.append(fp);
|
|
766
|
+
|
|
767
|
+
if (approvals.length) {
|
|
768
|
+
const ap = h('section', 'panel');
|
|
769
|
+
ap.append(h('h3', null, '审批'));
|
|
770
|
+
const list = h('div', 'aps');
|
|
771
|
+
for (const a of approvals.slice(-6).reverse()) {
|
|
772
|
+
const d = h('div', 'ap');
|
|
773
|
+
const m = h('div', 'm', (MODE[a.mode] || a.mode || '') + (a.escalation ? ' · ⚠ ' + a.escalation : ''));
|
|
774
|
+
d.append(m);
|
|
775
|
+
const cmd = rel(a.command_preview || a.title || a.tool || '', c.cwd);
|
|
776
|
+
if (cmd) { const cc = h('div', 'c', cmd); cc.title = cmd; d.append(cc); }
|
|
777
|
+
if (a.decided) d.append(h('div', 'muted', '→ ' + (a.decided.decision || a.decided.effective || '') + (a.decided.by ? '(' + a.decided.by + ')' : '')));
|
|
778
|
+
list.append(d);
|
|
779
|
+
}
|
|
780
|
+
ap.append(list);
|
|
781
|
+
box.append(ap);
|
|
782
|
+
}
|
|
783
|
+
return box;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* 每个回合工具列表的滚动状态(按 会话/回合 记):整块重建 DOM 后恢复位置。
|
|
788
|
+
* 停在底部的(或从没滚过的运行中回合)跟随最新工具;用户往上翻过就停在原处。
|
|
789
|
+
*/
|
|
790
|
+
const scrollState = new Map();
|
|
791
|
+
let pendingScroll = [];
|
|
792
|
+
function updateEdges(spans, box) {
|
|
793
|
+
const max = spans.scrollHeight - spans.clientHeight;
|
|
794
|
+
box.classList.toggle('more-up', spans.scrollTop > 2);
|
|
795
|
+
box.classList.toggle('more-down', spans.scrollTop < max - 2);
|
|
796
|
+
const hiddenBelow = Math.max(0, Math.round((max - spans.scrollTop) / 32));
|
|
797
|
+
box.lastElementChild.textContent = hiddenBelow ? '↓ ' + hiddenBelow + ' 条' : '';
|
|
798
|
+
}
|
|
799
|
+
function restoreScroll() {
|
|
800
|
+
for (const { spans, box, key, running } of pendingScroll) {
|
|
801
|
+
const st = scrollState.get(key);
|
|
802
|
+
const max = spans.scrollHeight - spans.clientHeight;
|
|
803
|
+
if (st && !st.atBottom) spans.scrollTop = Math.min(st.top, max);
|
|
804
|
+
else if (st || running) spans.scrollTop = max;
|
|
805
|
+
updateEdges(spans, box);
|
|
806
|
+
spans.addEventListener('scroll', () => {
|
|
807
|
+
const m = spans.scrollHeight - spans.clientHeight;
|
|
808
|
+
scrollState.set(key, { top: spans.scrollTop, atBottom: spans.scrollTop >= m - 4 });
|
|
809
|
+
updateEdges(spans, box);
|
|
810
|
+
}, { passive: true });
|
|
811
|
+
}
|
|
812
|
+
pendingScroll = [];
|
|
813
|
+
}
|
|
814
|
+
|
|
505
815
|
function render() {
|
|
506
816
|
if (!view) return;
|
|
507
817
|
const rt = $('rail').scrollTop, mt = $('main').scrollTop, wt = window.scrollY;
|
|
@@ -513,7 +823,9 @@ button { font: inherit; color: inherit; }
|
|
|
513
823
|
}
|
|
514
824
|
renderStats();
|
|
515
825
|
renderRail();
|
|
826
|
+
pendingScroll = [];
|
|
516
827
|
renderMain();
|
|
828
|
+
restoreScroll();
|
|
517
829
|
$('rail').scrollTop = rt; $('main').scrollTop = mt; window.scrollTo(0, wt);
|
|
518
830
|
}
|
|
519
831
|
|
package/src/live-view.mjs
CHANGED
|
@@ -76,6 +76,9 @@ function newConv(id) {
|
|
|
76
76
|
opened_at: null,
|
|
77
77
|
last_ts: null,
|
|
78
78
|
closed: false,
|
|
79
|
+
/** DSH 会话累计 token(含不在 live 里的更早回合)。 */
|
|
80
|
+
session_tokens: null,
|
|
81
|
+
context: null,
|
|
79
82
|
runs: [],
|
|
80
83
|
};
|
|
81
84
|
}
|
|
@@ -98,10 +101,43 @@ function newRun(id) {
|
|
|
98
101
|
auto_approvals: null,
|
|
99
102
|
auto_escalations: null,
|
|
100
103
|
writer: null,
|
|
104
|
+
/** 本回合 token 增量 {input, output, cache_read, cache_write}(来自 usage 事件)。 */
|
|
105
|
+
tokens: null,
|
|
106
|
+
/** 上下文窗口占用 {used, window}。 */
|
|
107
|
+
context: null,
|
|
101
108
|
items: [],
|
|
102
109
|
};
|
|
103
110
|
}
|
|
104
111
|
|
|
112
|
+
const TOKEN_KEYS = ['input', 'output', 'cache_read', 'cache_write'];
|
|
113
|
+
|
|
114
|
+
/** 规整一组 token 计数;不是对象时返回 null。 */
|
|
115
|
+
function cleanTokens(t) {
|
|
116
|
+
if (!t || typeof t !== 'object') return null;
|
|
117
|
+
const out = {};
|
|
118
|
+
for (const k of TOKEN_KEYS) out[k] = Number.isFinite(t[k]) && t[k] > 0 ? t[k] : 0;
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 输入总量(未命中缓存 + 缓存读 + 缓存写)。 */
|
|
123
|
+
export function tokenInputTotal(t) {
|
|
124
|
+
return t ? t.input + t.cache_read + t.cache_write : 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 缓存命中率(缓存读 / 输入总量),没有输入时为 null。 */
|
|
128
|
+
export function cacheHitRate(t) {
|
|
129
|
+
const all = tokenInputTotal(t);
|
|
130
|
+
return all > 0 ? t.cache_read / all : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 1234 → 1.2k,1500000 → 1.5M。 */
|
|
134
|
+
export function fmtTokens(n) {
|
|
135
|
+
if (!Number.isFinite(n)) return '';
|
|
136
|
+
if (n < 1000) return String(Math.round(n));
|
|
137
|
+
if (n < 1e6) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
|
|
138
|
+
return `${(n / 1e6).toFixed(n < 1e7 ? 2 : 1)}M`;
|
|
139
|
+
}
|
|
140
|
+
|
|
105
141
|
/**
|
|
106
142
|
* 事件归并器。纯内存、与文件无关,方便单测。
|
|
107
143
|
* `apply(ev)` 吃一条事件;`view()` 给出可序列化的调用链快照。
|
|
@@ -238,6 +274,18 @@ export class LiveState {
|
|
|
238
274
|
}
|
|
239
275
|
break;
|
|
240
276
|
}
|
|
277
|
+
case 'usage': {
|
|
278
|
+
const r = this.#run(c, ev.run);
|
|
279
|
+
if (r) {
|
|
280
|
+
const t = cleanTokens(ev.tokens);
|
|
281
|
+
if (t) r.tokens = t;
|
|
282
|
+
if (ev.context && Number.isFinite(ev.context.window)) r.context = { used: ev.context.used ?? 0, window: ev.context.window };
|
|
283
|
+
}
|
|
284
|
+
const s = cleanTokens(ev.session_tokens);
|
|
285
|
+
if (s) c.session_tokens = s;
|
|
286
|
+
if (ev.context && Number.isFinite(ev.context.window)) c.context = { used: ev.context.used ?? 0, window: ev.context.window };
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
241
289
|
case 'interject': {
|
|
242
290
|
const r = this.#run(c, ev.run, { create: false });
|
|
243
291
|
if (r) this.#push(r, { kind: 'interject', at: ev.ts, mode: ev.mode ?? null, text_preview: ev.text_preview ?? null, pending_run: ev.pending_run ?? null });
|
|
@@ -307,6 +355,9 @@ export class LiveState {
|
|
|
307
355
|
opened_at: c.opened_at,
|
|
308
356
|
last_ts: c.last_ts,
|
|
309
357
|
state,
|
|
358
|
+
/** 会话累计(DSH 自己记的,含更早的回合);没有就用 live 里各回合之和。 */
|
|
359
|
+
tokens: c.session_tokens ?? sumTokens(runs.map((r) => r.tokens)),
|
|
360
|
+
context: c.context,
|
|
310
361
|
runs,
|
|
311
362
|
};
|
|
312
363
|
});
|
|
@@ -329,12 +380,25 @@ export class LiveState {
|
|
|
329
380
|
queued: convs.reduce((n, c) => n + c.runs.filter((r) => r.status === 'queued').length, 0),
|
|
330
381
|
pending_approvals: convs.reduce((n, c) => n + c.runs.reduce((m, r) => m + r.pending_approvals, 0), 0),
|
|
331
382
|
escalations: convs.reduce((n, c) => n + c.runs.reduce((m, r) => m + r.escalations, 0), 0),
|
|
383
|
+
tokens: sumTokens(convs.map((c) => c.tokens)),
|
|
332
384
|
},
|
|
333
385
|
groups: [...groups.values()],
|
|
334
386
|
};
|
|
335
387
|
}
|
|
336
388
|
}
|
|
337
389
|
|
|
390
|
+
/** 把若干组 token 计数相加;全是空时返回 null。 */
|
|
391
|
+
export function sumTokens(list) {
|
|
392
|
+
let any = false;
|
|
393
|
+
const out = { input: 0, output: 0, cache_read: 0, cache_write: 0 };
|
|
394
|
+
for (const t of list) {
|
|
395
|
+
if (!t) continue;
|
|
396
|
+
any = true;
|
|
397
|
+
for (const k of TOKEN_KEYS) out[k] += t[k] ?? 0;
|
|
398
|
+
}
|
|
399
|
+
return any ? out : null;
|
|
400
|
+
}
|
|
401
|
+
|
|
338
402
|
function rank(state) {
|
|
339
403
|
return state === 'running' ? 0 : state === 'queued' ? 1 : state === 'idle' ? 2 : 3;
|
|
340
404
|
}
|
|
@@ -437,7 +501,11 @@ export class LiveTail {
|
|
|
437
501
|
return changed;
|
|
438
502
|
}
|
|
439
503
|
|
|
440
|
-
/**
|
|
504
|
+
/**
|
|
505
|
+
* 标题:调用方给的 title_hint(注册表 > conv.open 事件)> DSH 原生标题(投影缓存)。
|
|
506
|
+
* DSH 原生标题只是首条 prompt 的截断前缀("在当前目录做一个简易的、可"),
|
|
507
|
+
* 有显式 title_hint 时它更可读。
|
|
508
|
+
*/
|
|
441
509
|
#refreshTitles() {
|
|
442
510
|
const t = Date.now();
|
|
443
511
|
if (t - this.titlesAt < TITLE_TTL_MS) return false;
|
|
@@ -452,16 +520,15 @@ export class LiveTail {
|
|
|
452
520
|
}
|
|
453
521
|
}
|
|
454
522
|
let changed = false;
|
|
455
|
-
for (const id of this.state.convs
|
|
456
|
-
let title = null;
|
|
457
|
-
if (this.readTitle) {
|
|
523
|
+
for (const [id, conv] of this.state.convs) {
|
|
524
|
+
let title = hints.get(id) ?? conv.title_hint ?? null;
|
|
525
|
+
if (!title && this.readTitle) {
|
|
458
526
|
try {
|
|
459
527
|
title = this.readTitle(id) ?? null;
|
|
460
528
|
} catch {
|
|
461
529
|
title = null;
|
|
462
530
|
}
|
|
463
531
|
}
|
|
464
|
-
title = title ?? hints.get(id) ?? null;
|
|
465
532
|
if (title && this.titles.get(id) !== title) {
|
|
466
533
|
this.titles.set(id, title);
|
|
467
534
|
changed = true;
|
|
@@ -475,6 +542,105 @@ export class LiveTail {
|
|
|
475
542
|
}
|
|
476
543
|
}
|
|
477
544
|
|
|
545
|
+
// ── 终端原地刷新(watch) ───────────────────────────────────────
|
|
546
|
+
|
|
547
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/y;
|
|
548
|
+
|
|
549
|
+
/** 单个码点在终端里占几列:CJK / 全角 / emoji 记 2,其余 1(框线字符在主流终端里是 1)。 */
|
|
550
|
+
export function charWidth(cp) {
|
|
551
|
+
if (cp < 0x1100) return 1;
|
|
552
|
+
if (
|
|
553
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
554
|
+
(cp >= 0x2e80 && cp <= 0x303e) ||
|
|
555
|
+
(cp >= 0x3041 && cp <= 0x33ff) ||
|
|
556
|
+
(cp >= 0x3400 && cp <= 0x4dbf) ||
|
|
557
|
+
(cp >= 0x4e00 && cp <= 0x9fff) ||
|
|
558
|
+
(cp >= 0xa000 && cp <= 0xa4cf) ||
|
|
559
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
560
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
561
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) ||
|
|
562
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
563
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
564
|
+
(cp >= 0x1f300 && cp <= 0x1faff) ||
|
|
565
|
+
(cp >= 0x20000 && cp <= 0x3fffd)
|
|
566
|
+
) {
|
|
567
|
+
return 2;
|
|
568
|
+
}
|
|
569
|
+
return 1;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** 可见宽度(忽略 ANSI 颜色序列)。 */
|
|
573
|
+
export function displayWidth(s) {
|
|
574
|
+
let w = 0;
|
|
575
|
+
for (let i = 0; i < s.length; ) {
|
|
576
|
+
ANSI_RE.lastIndex = i;
|
|
577
|
+
const m = ANSI_RE.exec(s);
|
|
578
|
+
if (m) {
|
|
579
|
+
i += m[0].length;
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
const cp = s.codePointAt(i);
|
|
583
|
+
w += charWidth(cp);
|
|
584
|
+
i += cp > 0xffff ? 2 : 1;
|
|
585
|
+
}
|
|
586
|
+
return w;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* 把一行截到 `cols` 列以内(保留颜色序列,超出时末尾放 `…`)。
|
|
591
|
+
* 不截的话长行会折到下一行,原地刷新时整棵树就错位了。
|
|
592
|
+
*/
|
|
593
|
+
export function clipLine(s, cols) {
|
|
594
|
+
if (!(cols > 1) || displayWidth(s) <= cols) return s;
|
|
595
|
+
let w = 0;
|
|
596
|
+
let out = '';
|
|
597
|
+
for (let i = 0; i < s.length; ) {
|
|
598
|
+
ANSI_RE.lastIndex = i;
|
|
599
|
+
const m = ANSI_RE.exec(s);
|
|
600
|
+
if (m) {
|
|
601
|
+
out += m[0];
|
|
602
|
+
i += m[0].length;
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
const cp = s.codePointAt(i);
|
|
606
|
+
const cw = charWidth(cp);
|
|
607
|
+
if (w + cw > cols - 1) break;
|
|
608
|
+
out += String.fromCodePoint(cp);
|
|
609
|
+
w += cw;
|
|
610
|
+
i += cp > 0xffff ? 2 : 1;
|
|
611
|
+
}
|
|
612
|
+
return out + '…\x1b[0m';
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* 把一帧文本排成"适合这个终端"的行:截宽、截高(超出的行数在最后一行说明)。
|
|
617
|
+
* 纯函数,方便测试。
|
|
618
|
+
*/
|
|
619
|
+
export function layoutFrame(text, { cols = 120, rows = 40 } = {}) {
|
|
620
|
+
let lines = String(text).split('\n').map((l) => clipLine(l, cols));
|
|
621
|
+
if (rows > 2 && lines.length > rows) {
|
|
622
|
+
const hidden = lines.length - (rows - 1);
|
|
623
|
+
lines = lines.slice(0, rows - 1);
|
|
624
|
+
lines.push(clipLine(`\x1b[2m…还有 ${hidden} 行没显示(给会话 id 前缀、或用 --runs / --items 收窄)\x1b[0m`, cols));
|
|
625
|
+
}
|
|
626
|
+
return lines;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* 计算从上一帧到这一帧要写的终端序列:只重写变化的行,多出来的旧行清掉。
|
|
631
|
+
* 第一帧(prev 为 null)整屏重画。返回要写出的字符串(无变化时为空串)。
|
|
632
|
+
*/
|
|
633
|
+
export function diffFrame(prev, next) {
|
|
634
|
+
let out = '';
|
|
635
|
+
if (!prev) out += '\x1b[H\x1b[2J';
|
|
636
|
+
for (let i = 0; i < next.length; i++) {
|
|
637
|
+
if (prev && prev[i] === next[i]) continue;
|
|
638
|
+
out += `\x1b[${i + 1};1H${next[i]}\x1b[0m\x1b[K`;
|
|
639
|
+
}
|
|
640
|
+
if (prev && prev.length > next.length) out += `\x1b[${next.length + 1};1H\x1b[J`;
|
|
641
|
+
return out;
|
|
642
|
+
}
|
|
643
|
+
|
|
478
644
|
// ── 终端渲染(watch) ───────────────────────────────────────────
|
|
479
645
|
|
|
480
646
|
const STATUS_MARK = {
|
|
@@ -524,7 +690,8 @@ export function renderText(view, { conv = null, runs = 3, items = 12, color = fa
|
|
|
524
690
|
`DSH live · ${t.conversations} 会话 · ${t.running} 运行中` +
|
|
525
691
|
(t.queued ? ` · ${t.queued} 排队` : '') +
|
|
526
692
|
(t.pending_approvals ? ` · ${warn(`${t.pending_approvals} 待审批`)}` : '') +
|
|
527
|
-
(t.escalations ? ` · ${warn(`⚠ ${t.escalations} 次提权`)}` : '')
|
|
693
|
+
(t.escalations ? ` · ${warn(`⚠ ${t.escalations} 次提权`)}` : '') +
|
|
694
|
+
(t.tokens ? ` · token 入 ${fmtTokens(tokenInputTotal(t.tokens))} / 出 ${fmtTokens(t.tokens.output)}` : ''),
|
|
528
695
|
);
|
|
529
696
|
let shown = 0;
|
|
530
697
|
for (const g of view.groups) {
|
|
@@ -538,7 +705,9 @@ export function renderText(view, { conv = null, runs = 3, items = 12, color = fa
|
|
|
538
705
|
const p1 = lastConv ? '└─ ' : '├─ ';
|
|
539
706
|
const p1c = lastConv ? ' ' : '│ ';
|
|
540
707
|
const stateTxt = cv.state === 'running' ? good('运行中') : cv.state === 'queued' ? warn('排队') : dim(cv.state === 'closed' ? '已释放' : '空闲');
|
|
541
|
-
|
|
708
|
+
const ctx = cv.context && cv.context.window ? ` · 上下文 ${Math.round((cv.context.used / cv.context.window) * 100)}%` : '';
|
|
709
|
+
const tok = cv.tokens ? ` · 累计 ${fmtTokens(tokenInputTotal(cv.tokens))}/${fmtTokens(cv.tokens.output)}` : '';
|
|
710
|
+
out.push(`${p1}${shortId(cv.id)} ${clip(cv.title ?? '(无标题)', 40)} ${stateTxt}${dim(tok + ctx)} ${dim(`${cv.permission ?? ''} · ${clip(cv.cwd ?? '', 50)}`)}`);
|
|
542
711
|
const rs = cv.runs.slice(-runs);
|
|
543
712
|
rs.forEach((r, ri) => {
|
|
544
713
|
const lastRun = ri === rs.length - 1;
|
|
@@ -551,6 +720,7 @@ export function renderText(view, { conv = null, runs = 3, items = 12, color = fa
|
|
|
551
720
|
r.status,
|
|
552
721
|
r.duration_ms != null ? fmtMs(r.duration_ms) : null,
|
|
553
722
|
r.tool_count != null ? `${r.tool_count} 工具` : null,
|
|
723
|
+
r.tokens ? `${fmtTokens(tokenInputTotal(r.tokens))}↑ ${fmtTokens(r.tokens.output)}↓ tok` : null,
|
|
554
724
|
r.escalations ? warn(`⚠${r.escalations} 提权`) : null,
|
|
555
725
|
r.pending_approvals ? warn(`${r.pending_approvals} 待审批`) : null,
|
|
556
726
|
].filter(Boolean);
|