mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into worktree-session-hover-menu-fix
# Conflicts: # packages/client/ui-primitives/README.i18n.yaml # packages/client/ui-primitives/README.zh.md
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.md
|
||||
2026-07-31-web-cards-toolrow.md: caa18563a9e66f882873e8d7e84cc3ac20702033
|
||||
2026-07-31-web-cards-toolrow.zh.md: 4e9c429497e1265b4b39ed2479f382a2f36e7741
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note: Card tool rows collapse through one ToolRow
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-31-web-cards-toolrow.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Web client grew five card render intents over successive PRs — terminal, diff, read, search, web — each landing as a keyed toolview registrant under `packages/client/ui-conversation/src/client/toolviews/`. They diverged in two ways the earlier PRs each acknowledged but deferred:
|
||||
|
||||
- **Chrome duplication.** `read-row`, `search-row`, `web-row`, and `file-mutation-row` each hand-drew the summary row (leading state slot, visually-hidden status, title, separator dot, path-link/summary) as their own `<div className={css.root}>` with a private `.module.css`, instead of composing the shared `ToolRow`. `read-row` carried a `jscpd:ignore` marker naming the duplication and pointing at "a separate change tracked for all rows at once" — this change.
|
||||
- **Resident vs. collapsed.** Those four rows kept their card (`ReadBlock`/`SearchBlock`/`WebBlock`/`DiffBlock`) resident below the summary — always expanded — while the terminal card (via `GenericToolCard`/`BashRow`) and every text row started collapsed behind ToolRow's whole-row expand. A conversation with several read/search/web/edit calls became a wall of always-open cards, defeating the summary-surface purpose of the message flow.
|
||||
|
||||
## Decision
|
||||
|
||||
`ToolRow` owns every card kind, and every keyed card row composes it. ToolRow already took `terminal` and `diff` card material; it now also takes `read`, `search`, and `web`, rendering whichever is present in its collapsed-by-default expanded body through the matching primitive (capped at the chat `CHAT_*` bounds). A call carries at most one card kind, so the props are mutually exclusive and the body picks the first present.
|
||||
|
||||
The four keyed rows — `ReadRow`, `SearchRow`, `WebRow`, `FileMutationRow` — drop their hand-drawn chrome and private CSS and become thin `ToolRow` compositions, exactly like `AskQuestionRow`: derive the card model, pass it as the matching ToolRow prop, forward `filePath`/`onOpenFile` for the file tools and `output`/`errorSummary` for the cardless failure paths. Each row is now `ToolRowProps & PropsLocale<'conversation'>` and registers with `locale: NS`, because ToolRow needs the conversation `t` for its terminal/code body copy. `GenericToolCard` (the render-site fallback) does the same for read/search/web, so a card-declaring tool without its own keyed row collapses identically.
|
||||
|
||||
The `DetailsPanel` Output section is unchanged: the panel is the single-call reading surface, so it renders each card resident at the primitive's full height, and a capped search keeps its recovery footer there.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One expand interaction across all tool rows: collapsed one-line summary, whole row toggles the card. The card is not in the DOM until expanded (`DisclosureRow` renders `children` only when open), so tests assert absent-then-present around a `[data-expandable]` click.
|
||||
- Deleted: `read-row.module.css`, `search-row.module.css`, `web-row.module.css`, `file-mutation-row.module.css`, `GenericToolCard.module.css`. The rows carry no CSS of their own; ToolRow's module owns the chrome and the card-body indentation.
|
||||
- The cardless failure paths (an errored mutation, an errored/nested/legacy search) no longer draw their own `.failure`/recovery `<div>`; they ride ToolRow's `output` (Output section) and `errorSummary` (collapsed summary first line), which already flatten the result text with the `error.name: error.code` fallback.
|
||||
- `bash-sample` keeps its own local expand chrome deliberately (the third-party-posture exemplar that never imports the chat domain); it was already collapsed, so its behavior is unchanged.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep the rows resident, only unify chrome.** Rejected: the user's requirement is default-collapsed, and resident cards are what made the flow unscannable.
|
||||
- **A shared `CardRow` wrapper between the rows and ToolRow.** Rejected: ToolRow already is that wrapper once it takes every card kind; a second layer would be the premature extraction the package rules warn against.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note:卡片工具行通过同一个 ToolRow 折叠
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-31-web-cards-toolrow.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Web 客户端在连续几个 PR 里长出五种卡片渲染意图——terminal、diff、read、search、web,每一种都作为一个键控 toolview 注册项落在 `packages/client/ui-conversation/src/client/toolviews/` 下。它们在两处出现分歧,之前每个 PR 都承认却推迟处理:
|
||||
|
||||
- **Chrome 重复。** `read-row`、`search-row`、`web-row`、`file-mutation-row` 各自把摘要行(行首状态槽、视觉隐藏状态、标题、分隔点、路径链接/摘要)手绘成自己的 `<div className={css.root}>`,配一份私有 `.module.css`,而不是组合共享的 `ToolRow`。`read-row` 带着一个 `jscpd:ignore` 标记,点名这处重复并指向"一处针对所有行一次性处理的独立改动"——就是本次改动。
|
||||
- **常驻 vs 折叠。** 那四个行把卡片(`ReadBlock`/`SearchBlock`/`WebBlock`/`DiffBlock`)常驻在摘要下方——始终展开——而终端卡片(经 `GenericToolCard`/`BashRow`)与每个文本行都从折叠状态起步,藏在 ToolRow 的整行展开之后。一个有多个 read/search/web/edit 调用的对话就成了一堵始终打开的卡片墙,违背了消息流作为摘要面的目的。
|
||||
|
||||
## 决策
|
||||
|
||||
`ToolRow` 拥有每一种卡片,而每个键控卡片行都组合它。ToolRow 原本就接收 `terminal` 与 `diff` 卡片材料;现在还接收 `read`、`search`、`web`,在其默认折叠的展开 body 里用对应原语渲染当前存在的那一种(按 chat 的 `CHAT_*` 上限截断)。一次调用最多携带一种卡片,因此这些 prop 互斥,body 取第一个存在的。
|
||||
|
||||
四个键控行——`ReadRow`、`SearchRow`、`WebRow`、`FileMutationRow`——丢掉手绘 chrome 与私有 CSS,成为薄薄的 `ToolRow` 组合,与 `AskQuestionRow` 完全一样:推导卡片模型,作为对应的 ToolRow prop 传入,为文件工具转发 `filePath`/`onOpenFile`,为无卡片的失败路径转发 `output`/`errorSummary`。每个行现在是 `ToolRowProps & PropsLocale<'conversation'>` 并以 `locale: NS` 注册,因为 ToolRow 需要对话的 `t` 来渲染其终端/代码 body 文案。`GenericToolCard`(渲染点兜底)对 read/search/web 做同样的事,所以一个没有自己键控行的卡片声明工具也以同样方式折叠。
|
||||
|
||||
`DetailsPanel` 的 Output 区不变:面板是单次调用的阅读面,因此它以原语的完整高度常驻渲染每张卡片,被截断的搜索也把恢复脚注留在那里。
|
||||
|
||||
## 后果
|
||||
|
||||
- 所有工具行共享一套展开交互:折叠时是单行摘要,整行切换卡片。卡片在展开前不在 DOM 里(`DisclosureRow` 只在打开时渲染 `children`),因此测试围绕一次 `[data-expandable]` 点击断言"先无后有"。
|
||||
- 已删除:`read-row.module.css`、`search-row.module.css`、`web-row.module.css`、`file-mutation-row.module.css`、`GenericToolCard.module.css`。这些行不再带自己的 CSS;ToolRow 的 module 拥有 chrome 与卡片 body 的缩进。
|
||||
- 无卡片的失败路径(出错的改动,出错/嵌套/旧日志的搜索)不再画自己的 `.failure`/恢复 `<div>`;它们改走 ToolRow 的 `output`(Output 区)与 `errorSummary`(折叠摘要首行),后者已经用 `error.name: error.code` 兜底压平结果文本。
|
||||
- `bash-sample` 有意保留自己本地的展开 chrome(第三方姿态的范例,从不引入 chat 域);它本来就是折叠的,因此行为不变。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **保持行常驻,只统一 chrome。** 否决:用户的要求是默认折叠,而常驻卡片正是让流不可扫读的原因。
|
||||
- **在行与 ToolRow 之间加一层共享的 `CardRow` 包装。** 否决:ToolRow 一旦接收每一种卡片,它本身就是那层包装;再加一层就是 package 规则警告的过早抽取。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md
|
||||
2026-07-30-generated-third-party-notices.md: e480954d29d5dc09ef8ecd4069059a1f0c8b1043
|
||||
2026-07-30-generated-third-party-notices.zh.md: 78ba7250e797c57048078d1b4f62b7a9a5d9d561
|
||||
@@ -0,0 +1,53 @@
|
||||
# Agent Note: Generated third-party notices
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-generated-third-party-notices.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Open-sourcing this repository requires disclosing the third-party software it depends on, with each project's license. The disclosure has to be complete, has to stay true as dependencies change, and has to say something a reader can act on — which of these packages end up on a user's machine, and which only build and test the repository.
|
||||
|
||||
A hand-written inventory answers none of those durably. Roughly a hundred rows of names and license strings derived from manifests drift silently the moment a package is added, removed, or relicensed, and nothing would notice.
|
||||
|
||||
## Decision
|
||||
|
||||
[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, and `pnpm-workspace.yaml`. The root README pair links the file from its License section.
|
||||
|
||||
**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check.
|
||||
|
||||
One trigger gap is accepted rather than worked around: lefthook inspects only files present on disk, so **deleting** a manifest runs no job, and removing a package reaches the assertion in the test lane instead. Reconstructing the staged file list to include deletions was tried and does not work — lefthook filters the list against the working tree either way. The assertion is the backstop for exactly this case.
|
||||
|
||||
The file discloses **direct** dependencies only. The complete npm closure with pinned versions already lives in `pnpm-lock.yaml` (`pnpm licenses list` renders it) and the Python closure in `python/sdk/uv.lock`; re-materializing either as prose would be a second, worse copy.
|
||||
|
||||
**Tiering is by declaring area, not by manifest section.** A package is a runtime dependency when any manifest outside `DEV_ONLY_AREAS` — the root manifest, `packages/support/`, `packages/client/test-runtime/`, `website/`, `examples/`, `native/` — names it under `dependencies` or `optionalDependencies`. Section names alone are wrong in both directions: a test-support package declares `vitest` under `dependencies` without shipping it, and the `bin/dsh` launcher execs through `tsx`, which no manifest declares as a runtime dependency at all (the generator marks it runtime explicitly).
|
||||
|
||||
The runtime tier deliberately covers **every mountable plugin**, not just what the CLI, Web UI, and Python runtime load by default. `scripts/install.sh` installs the repository itself, so a user's `cordis.yml` can mount any plugin package; `@modelcontextprotocol/sdk` and the OpenTelemetry packages reach real users even though no default assembly imports them. Under-disclosure is the costly direction for a legal notice.
|
||||
|
||||
The manifest set is derived from the `packages:` members each `pnpm-workspace.yaml` declares — the root one and the nested Landlock workspace's — so a new member area is read the day it is declared rather than the day someone remembers to extend a list. License and repository metadata come from the installed pnpm stores, both the root one and the Landlock workspace's, so the generator requires an installed tree and fails loud when a package resolves to neither, rather than emitting an empty cell. `OVERRIDES` carries the packages whose published manifest cannot answer — Rust-built npm bins that omit `license`, and the `modelcontextprotocol/servers` packages whose repository is mid MIT→Apache-2.0 relicensing, so their effective terms are per-contribution. A runtime dependency whose license is not on the permissive list is a hard error: shipping copyleft is a distribution decision, not something a regenerated table may absorb silently. Vendored packages are cross-checked against `vendor/README.md` and rejected if any is not MIT, and `pnpm-workspace.yaml`'s `patchedDependencies` are listed under the runtime table because pnpm applies those patches at install time — shipped artifacts carry modified copies of `@earendil-works/pi-tui` and `node-pty`, and the patch files are the record of what changed.
|
||||
|
||||
## Testing
|
||||
|
||||
The same spec that asserts freshness pins the tiering rule against fixture manifests — including the two cases that motivate it, a `dependencies` entry of a test-support package and a plugin package no app mounts. It also pins the parsers against the shapes that would otherwise drop a package without a word: a `vendor/README.md` table that stops covering a vendored directory, a requirement array holding extras (`"httpx[http2]"`), a requirement with no version at all, an author-named `[dependency-groups]` table, and a workspace member area absent from any hardcoded list. Each of those is a silent-omission path, which is the failure mode a disclosure file cannot afford.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the hand-written file and review it at release time.** Reviewing a hundred derived rows by eye is exactly the work a generator does correctly, and the file's own claim — that it lists every direct dependency — would be unverified between releases.
|
||||
|
||||
**Verify through a dedicated `doc-sync` gate.** That is how every other generated artifact here is checked, and it was the first shape of this change. It costs a gate process and a scheduler slot in a matrix that is already long, and — worse — its only failure mode is telling a contributor, minutes after they pushed an unrelated dependency bump, to go rerun a generator. Regenerating at commit time removes the interruption, and the assertion inside a spec the test lane already runs keeps the guarantee at no additional CI cost.
|
||||
|
||||
**Enumerate the full transitive closure.** The closure is thousands of packages, already recorded in the lock files with exact versions, and would bury the direct dependencies that a reader actually evaluates. The file points at the lock files and the `pnpm licenses list` renderer instead.
|
||||
|
||||
**Tier by manifest section (`dependencies` vs `devDependencies`).** Mechanically simple and wrong on real data in both directions, as the tiering paragraph above records.
|
||||
|
||||
**Tier by reachability from the shipped assemblies only** (`apps/*` plus `python/sdk-runtime`). This produces a tighter runtime tier, but classifies the MCP client and the OpenTelemetry exporter as development-only even though a user running the installed repository can mount them. It understates the disclosure, which is the wrong direction to err for a legal notice.
|
||||
|
||||
**Emit the notices as a bilingual pair.** Every other root document is paired, but the file is a table of upstream package names, SPDX identifiers, and URLs; the translatable surface is a handful of section blurbs. `scripts/translation-pairing.ts` scopes discovery to `README*`, `.agents/notes/**`, `docs/**`, and `python/**`, so a root non-README file is outside the bilingual corpus by construction, and the README pair carries the bilingual entry points into it.
|
||||
|
||||
## Consequences
|
||||
|
||||
A dependency edit now carries a regenerated notices file into the same commit. Contributors pay one generator run — about a second — on commits that touch a manifest, and nothing on any other commit. Committing with hooks disabled defers the cost to a test-lane failure that names the command.
|
||||
|
||||
The generator needs an installed tree, which makes it heavier than a pure-source generator, and a new package with unusable published metadata needs an `OVERRIDES` entry rather than silently rendering a blank license. Both failures are loud and name the remedy.
|
||||
|
||||
The tiering rule is a policy encoded in one constant. Adding a workspace area that never ships — a second test-infrastructure tier, another site — requires extending `DEV_ONLY_AREAS`, or its dependencies will be disclosed as runtime.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Agent Note: Generated third-party notices
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-generated-third-party-notices.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
本仓库开源需要披露所依赖的第三方软件及各自的许可证。这份披露必须完整,必须随依赖变化保持为真,还必须给出读者用得上的信息:哪些包最终会进到用户机器上,哪些只用于构建和测试。
|
||||
|
||||
手写清单无法长期满足其中任何一条。约一百行从各清单文件推导出来的包名与许可证标识,只要有依赖新增、移除或换用许可证就会悄悄失真,而没有任何检查会察觉。
|
||||
|
||||
## Decision
|
||||
|
||||
[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区清单、`vendor/README.md`、`pyproject.toml` 与 `pnpm-workspace.yaml` 生成。根 README 双语两侧都从「许可证」一节链到该文件。
|
||||
|
||||
**新鲜度靠维护而非拦截。** 只要暂存了生成器的任一输入——任何清单文件、工作区声明、根锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成并一并入库,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。
|
||||
|
||||
有一处触发缺口是接受而非绕过的:lefthook 只检视磁盘上存在的文件,因此**删除**清单文件不会触发任何任务,移除一个包会落到测试 lane 的断言上。重构暂存文件列表以纳入删除的做法试过,不成立——无论怎么给列表,lefthook 都会拿工作树过滤一遍。这个场景正由断言兜底。
|
||||
|
||||
文件只披露**直接**依赖。完整的 npm 闭包连同锁定版本已记录在 `pnpm-lock.yaml`(`pnpm licenses list` 可渲染),Python 闭包记录在 `python/sdk/uv.lock`;再用散文誊一遍只会得到一份更差的副本。
|
||||
|
||||
**分层依据是声明方所在区域,而非清单字段名。** 只要 `DEV_ONLY_AREAS` 之外的任一清单——即根清单、`packages/support/`、`packages/client/test-runtime/`、`website/`、`examples/`、`native/` 之外——在 `dependencies` 或 `optionalDependencies` 里点名某个包,它就是运行时依赖。单看字段名在两个方向上都会出错:测试支撑包把 `vitest` 写在 `dependencies` 里却并不交付它;而 `bin/dsh` 启动器 exec 经过的 `tsx`,根本没有任何清单把它声明为运行时依赖,只能由生成器显式标记。
|
||||
|
||||
运行时层刻意覆盖**所有可挂载的插件**,而不止 CLI、Web UI 与 Python 运行时默认加载的那些。`scripts/install.sh` 安装的就是仓库本身,用户的 `cordis.yml` 可以挂载任何插件包;`@modelcontextprotocol/sdk` 与 OpenTelemetry 系列即使没有任何默认装配引入,也会触达真实用户。对法务披露而言,披露不足才是代价更高的那个方向。
|
||||
|
||||
清单集合由两个 `pnpm-workspace.yaml`——根工作区与嵌套的 Landlock 工作区——各自声明的 `packages:` 成员派生,因此新增成员区域在声明当天就会被读取,而不必等谁想起来去补一份列表。许可证与仓库地址取自已安装的 pnpm store,根 store 与 Landlock 工作区的 store 都会查;某个包两处都解析不到时直接失败,而不是留下空单元格。`OVERRIDES` 收录已发布清单答不上来的包:用 Rust 构建、发布时省略 `license` 字段的 npm 可执行包,以及 `modelcontextprotocol/servers` 系列——该仓库正处在 MIT 向 Apache-2.0 的重新许可过程中,实际条款按贡献逐条而定。运行时依赖的许可证若不在宽松清单内即为硬失败:交付 copyleft 是一项分发决策,不该被一次重新生成悄悄吸收。被源码收编的包会与 `vendor/README.md` 交叉核对,出现非 MIT 即报错;`pnpm-workspace.yaml` 的 `patchedDependencies` 列在运行时表格之后,因为 pnpm 在安装期就会打上这些补丁——交付产物携带的是改动过的 `@earendil-works/pi-tui` 与 `node-pty`,补丁文件本身就是改动的完整记录。
|
||||
|
||||
## Testing
|
||||
|
||||
断言新鲜度的同一个 spec 也用夹具清单钉住分层规则,覆盖促成该规则的两个场景:测试支撑包的 `dependencies` 条目,以及没有任何应用挂载的插件包。它还把各解析器钉在那些原本会让某个包无声消失的形态上:不再覆盖全部收编目录的 `vendor/README.md` 表、含 extras 的依赖数组(`"httpx[http2]"`)、完全不带版本的依赖、作者自取名字的 `[dependency-groups]` 表,以及任何硬编码列表都不含的工作区成员区域。这些都是静默漏报路径——正是披露文件最担不起的失败方式。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留手写文件,发版时人工过一遍。** 用肉眼审阅上百行推导数据,恰恰是生成器能做对的活;而且在两次发版之间,文件自称「列出全部直接依赖」这句话无人验证。
|
||||
|
||||
**用专门的 `doc-sync` 门禁校验。** 仓库里其他生成产物都是这么把关的,本次改动最初也是这个形态。但它要在本已冗长的矩阵里再占一个门禁进程和一个调度位;更糟的是,它唯一的失败方式,就是在别人推完一个无关的依赖升级几分钟后,通知对方回去重跑一次生成器。改为提交时重新生成消除了这次打断,而把断言放进测试 lane 本就会跑的 spec 里,则以零额外 CI 成本保住了这项保证。
|
||||
|
||||
**列出完整传递闭包。** 闭包有数千个包,锁文件里已带精确版本,铺开只会淹没读者真正要评估的直接依赖。文件转而指向锁文件与 `pnpm licenses list`。
|
||||
|
||||
**按清单字段分层(`dependencies` 与 `devDependencies`)。** 机械上最省事,但在真实数据上两个方向都会出错,理由见上文分层段落。
|
||||
|
||||
**只按已交付装配的可达性分层**(`apps/*` 加 `python/sdk-runtime`)。这样得到的运行时层更紧凑,但会把 MCP 客户端与 OpenTelemetry 导出器判为仅开发用途——而运行已安装仓库的用户完全可以挂载它们。这会低估披露,对法务通告来说错在了更危险的一侧。
|
||||
|
||||
**把披露文件做成双语对。** 其他根文档都是成对的,但这份文件是上游包名、SPDX 标识与网址构成的表格,可翻译的只有寥寥几段章节导语。`scripts/translation-pairing.ts` 的发现范围限定在 `README*`、`.agents/notes/**`、`docs/**` 与 `python/**`,根目录下的非 README 文件在构造上就不属于双语语料;双语入口由 README 对承担。
|
||||
|
||||
## Consequences
|
||||
|
||||
此后改动依赖时,重新生成的披露文件会随同一个提交入库。触及清单文件的提交多付一次生成器运行——约一秒;其余提交不受影响。若禁用钩子提交,代价推迟为一次测试 lane 失败,其报错会指明补救命令。
|
||||
|
||||
生成器需要已安装的工作树,因此比纯源码生成器更重;发布元数据不可用的新包需要补一条 `OVERRIDES`,而不是默默渲染出空白许可证。这两类失败都会明确报错并指出补救方式。
|
||||
|
||||
分层规则是编码在一个常量里的政策。若新增了不参与交付的工作区区域——第二层测试基础设施、另一个站点——就要同步扩展 `DEV_ONLY_AREAS`,否则其依赖会被当作运行时依赖披露出去。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write README.md
|
||||
README.md: 08ae0b3d5d2d7ad8e7cb62bd5e8b3242426735dc
|
||||
README.zh.md: 9587215b6b17250504877b8930fdf431fd24b2ac
|
||||
README.md: b447c9634189353854e8be9d0bf597a8b0c7e371
|
||||
README.zh.md: f8bbbc36bc670403c0b9a40977f32f598e77ee46
|
||||
|
||||
@@ -94,3 +94,5 @@ DeepSeek Harness is currently in internal testing.
|
||||
## License
|
||||
|
||||
[BSD 3-Clause](LICENSE)
|
||||
|
||||
Third-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||
|
||||
@@ -98,3 +98,5 @@ DeepSeek Harness 目前处于内测阶段。
|
||||
## 许可证
|
||||
|
||||
[BSD 3-Clause](LICENSE)
|
||||
|
||||
第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。
|
||||
|
||||
165
THIRD_PARTY_NOTICES.md
Normal file
165
THIRD_PARTY_NOTICES.md
Normal file
@@ -0,0 +1,165 @@
|
||||
<!-- Generated by scripts/gen-third-party-notices.ts — do not edit by hand.
|
||||
Run `pnpm run gen-third-party-notices` to regenerate. -->
|
||||
|
||||
# Third-Party Notices
|
||||
|
||||
DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms.
|
||||
|
||||
This file lists **direct** dependencies declared by the workspace. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check.
|
||||
|
||||
The complete npm transitive closure, with exact pinned versions, is recorded in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python closure is recorded in [`python/sdk/uv.lock`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [`native/landlock-run/pnpm-lock.yaml`](native/landlock-run/pnpm-lock.yaml).
|
||||
|
||||
## Vendored source (`vendor/`)
|
||||
|
||||
The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm. All are MIT-licensed; each directory preserves its upstream `LICENSE` file. Exact upstream commits and local modifications are recorded in [`vendor/README.md`](vendor/README.md).
|
||||
|
||||
| Package | Upstream | License |
|
||||
| --- | --- | --- |
|
||||
| `cosmokit` | [github.com/deepseek-harness/cosmokit](https://github.com/deepseek-harness/cosmokit) | MIT |
|
||||
| `schemastery` | [github.com/deepseek-harness/schemastery](https://github.com/deepseek-harness/schemastery) | MIT |
|
||||
| `cordis` | [github.com/cordiverse/cordis](https://github.com/cordiverse/cordis) | MIT |
|
||||
| `@cordisjs/plugin-loader` | [github.com/cordiverse/cordis](https://github.com/cordiverse/cordis) | MIT |
|
||||
| `@cordisjs/plugin-include` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT |
|
||||
| `@cordisjs/plugin-group` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT |
|
||||
| `@cordisjs/plugin-timer` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT |
|
||||
| `@cordisjs/plugin-hmr` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT |
|
||||
| `@cordisjs/plugin-logger-console` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT |
|
||||
|
||||
## Runtime npm dependencies
|
||||
|
||||
External packages that a workspace package resolves at runtime. `scripts/install.sh` installs this repository itself, so the tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI/TUI, the Web UI, and the Python SDK runtime load by default.
|
||||
|
||||
| Package | License |
|
||||
| --- | --- |
|
||||
| [`@agentclientprotocol/sdk`](https://github.com/agentclientprotocol/typescript-sdk) | Apache-2.0 |
|
||||
| [`@babel/code-frame`](https://github.com/babel/babel) | MIT |
|
||||
| [`@clack/core`](https://github.com/bombshell-dev/clack) | MIT |
|
||||
| [`@clack/prompts`](https://github.com/bombshell-dev/clack) | MIT |
|
||||
| [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT |
|
||||
| [`@earendil-works/pi-tui`](https://github.com/earendil-works/pi) | MIT |
|
||||
| [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT |
|
||||
| [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT |
|
||||
| [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
|
||||
| [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
|
||||
| [`@opentelemetry/exporter-logs-otlp-http`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
|
||||
| [`@opentelemetry/otlp-exporter-base`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
|
||||
| [`@opentelemetry/resources`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
|
||||
| [`@opentelemetry/sdk-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
|
||||
| [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT |
|
||||
| [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT |
|
||||
| [`anser`](https://github.com/IonicaBizau/anser) | MIT |
|
||||
| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
|
||||
| [`clsx`](https://github.com/lukeed/clsx) | MIT |
|
||||
| [`commander`](https://github.com/tj/commander.js) | MIT |
|
||||
| [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause |
|
||||
| [`dotenv`](https://github.com/motdotla/dotenv) | BSD-2-Clause |
|
||||
| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
|
||||
| [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT |
|
||||
| [`immer`](https://github.com/immerjs/immer) | MIT |
|
||||
| [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT |
|
||||
| [`jsonc-parser`](https://github.com/microsoft/node-jsonc-parser) | MIT |
|
||||
| [`koffi`](https://github.com/Koromix/koffi) | MIT |
|
||||
| [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT |
|
||||
| [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT |
|
||||
| [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT |
|
||||
| [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT |
|
||||
| [`node-pty`](https://github.com/microsoft/node-pty) | MIT |
|
||||
| [`picomatch`](https://github.com/micromatch/picomatch) | MIT |
|
||||
| [`react`](https://github.com/facebook/react) | MIT |
|
||||
| [`react-dom`](https://github.com/facebook/react) | MIT |
|
||||
| [`react-markdown`](https://github.com/remarkjs/react-markdown) | MIT |
|
||||
| [`remark-gfm`](https://github.com/remarkjs/remark-gfm) | MIT |
|
||||
| [`saxes`](https://github.com/lddubeau/saxes) | ISC |
|
||||
| [`shiki`](https://github.com/shikijs/shiki) | MIT |
|
||||
| [`supports-color`](https://github.com/chalk/supports-color) | MIT |
|
||||
| [`tsx`](https://github.com/privatenumber/tsx) | MIT |
|
||||
| [`turndown`](https://github.com/mixmark-io/turndown) | MIT |
|
||||
| [`typescript`](https://github.com/microsoft/TypeScript) | Apache-2.0 |
|
||||
| [`use-sync-external-store`](https://github.com/facebook/react) | MIT |
|
||||
| [`yaml`](https://github.com/eemeli/yaml) | ISC |
|
||||
| [`zod`](https://github.com/colinhacks/zod) | MIT |
|
||||
| [`zustand`](https://github.com/pmndrs/zustand) | MIT |
|
||||
|
||||
pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
|
||||
|
||||
- `@earendil-works/pi-tui@0.80.7` — [`patches/@earendil-works__pi-tui@0.80.7.patch`](patches/@earendil-works__pi-tui@0.80.7.patch)
|
||||
- `node-pty@1.1.0` — [`patches/node-pty@1.1.0.patch`](patches/node-pty@1.1.0.patch)
|
||||
|
||||
## Development-only npm dependencies
|
||||
|
||||
External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles.
|
||||
|
||||
| Package | License |
|
||||
| --- | --- |
|
||||
| [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT |
|
||||
| [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 |
|
||||
| [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 |
|
||||
| [`@stylistic/eslint-plugin`](https://github.com/eslint-stylistic/eslint-stylistic) | MIT |
|
||||
| [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT |
|
||||
| [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT |
|
||||
| [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
|
||||
| [`@typescript-eslint/parser`](https://github.com/typescript-eslint/typescript-eslint) | MIT |
|
||||
| [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT |
|
||||
| [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT |
|
||||
| [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT |
|
||||
| [`@yarnpkg/cli-dist`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
|
||||
| [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT |
|
||||
| [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT |
|
||||
| [`dayjs`](https://github.com/iamkun/dayjs) | MIT |
|
||||
| [`debug`](https://github.com/debug-js/debug) | MIT |
|
||||
| [`esbuild`](https://github.com/evanw/esbuild) | MIT |
|
||||
| [`eslint`](https://github.com/eslint/eslint) | MIT |
|
||||
| [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only |
|
||||
| [`execa`](https://github.com/sindresorhus/execa) | MIT |
|
||||
| [`fast-check`](https://github.com/dubzzz/fast-check) | MIT |
|
||||
| [`jscpd`](https://github.com/kucherenko/jscpd) | MIT |
|
||||
| [`jsdom`](https://github.com/jsdom/jsdom) | MIT |
|
||||
| [`knip`](https://github.com/webpro-nl/knip) | ISC |
|
||||
| [`lefthook`](https://github.com/evilmartians/lefthook) | MIT |
|
||||
| [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 |
|
||||
| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT |
|
||||
| [`oxlint`](https://github.com/oxc-project/oxc) | MIT |
|
||||
| [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT |
|
||||
| [`playwright`](https://github.com/microsoft/playwright) | Apache-2.0 |
|
||||
| [`publint`](https://github.com/publint/publint) | MIT |
|
||||
| [`smol-toml`](https://github.com/squirrelchat/smol-toml) | BSD-3-Clause |
|
||||
| [`spdx-expression-parse`](https://github.com/jslicense/spdx-expression-parse.js) | MIT |
|
||||
| [`tsdown`](https://github.com/rolldown/tsdown) | MIT |
|
||||
| [`typescript-language-server`](https://github.com/typescript-language-server/typescript-language-server) | Apache-2.0 |
|
||||
| [`vite`](https://github.com/vitejs/vite) | MIT |
|
||||
| [`vite-tsconfig-paths`](https://github.com/aleclarson/vite-tsconfig-paths) | MIT |
|
||||
| [`vitepress`](https://github.com/vuejs/vitepress) | MIT |
|
||||
| [`vitepress-plugin-mermaid`](https://github.com/emersonbottero/vitepress-plugin-mermaid) | MIT |
|
||||
| [`vitest`](https://github.com/vitest-dev/vitest) | MIT |
|
||||
|
||||
`eslint-plugin-sonarjs` (LGPL-3.0-only) and `lightningcss` (MPL-2.0) run only as development tooling; their code is not linked into or distributed with any DeepSeek Harness artifact.
|
||||
|
||||
## Python SDK dependencies (`python/`)
|
||||
|
||||
Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the development workflow tool.
|
||||
|
||||
| Package | License | Role |
|
||||
| --- | --- | --- |
|
||||
| [`hatchling`](https://github.com/pypa/hatch) | MIT | build backend |
|
||||
| [`pydantic`](https://github.com/pydantic/pydantic) | MIT | runtime dependency of `deepseek-harness` |
|
||||
| [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only |
|
||||
| [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool |
|
||||
|
||||
## Fetched at build time
|
||||
|
||||
| Package | License | Role |
|
||||
| --- | --- | --- |
|
||||
| [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg) | MIT | invoked by `scripts/build-exe-for-python-sdk.ts` to assemble the single-file SDK runtime executable |
|
||||
|
||||
## First-party sibling releases
|
||||
|
||||
`node-addon-landlock-run` (and its platform packages) is released from a DeepSeek Harness sibling repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party.
|
||||
@@ -112,11 +112,19 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
// The write/edit turns render a real diff card through the assembled graph
|
||||
// (the keyed FileMutationRow + DiffBlock), not just the fixture's raw text.
|
||||
// The write turn's `hello fixture\n` proves the terminator rule end to end: a
|
||||
// trailing newline terminates its line, so the footer reads `+1` (not a
|
||||
// phantom `+2`) and one distinct file. The `+ ` prefix is a CSS ::before, so
|
||||
// it is absent from textContent — assert on the line body and the footer.
|
||||
// (the keyed FileMutationRow composing ToolRow + DiffBlock), not just the
|
||||
// fixture's raw text. The card is collapsed by default, so expand each edit/
|
||||
// write row first. The write turn's `hello fixture\n` proves the terminator
|
||||
// rule end to end: a trailing newline terminates its line, so the footer reads
|
||||
// `+1` (not a phantom `+2`) and one distinct file. The `+ ` prefix is a CSS
|
||||
// ::before, so it is absent from textContent — assert on the line body and the
|
||||
// footer.
|
||||
const mutationRows = [...document.querySelectorAll('[data-variant="write"],[data-variant="edit"]')]
|
||||
expect(mutationRows.length).toBeGreaterThan(0)
|
||||
for (const row of mutationRows) {
|
||||
const toggle = row.querySelector('[data-expandable]')
|
||||
if (toggle !== null) act(() => { fireEvent.click(toggle) })
|
||||
}
|
||||
const diffCards = [...document.querySelectorAll('[data-diff]')]
|
||||
expect(diffCards.length).toBeGreaterThan(0)
|
||||
const footers = diffCards.map(card => card.textContent ?? '')
|
||||
@@ -125,13 +133,20 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
// The web render intent reaches the assembled boot graph: the fixture's
|
||||
// web_search / web_fetch turns render their keyed WebRow cards, proving the
|
||||
// registration, wire projection, and card rendering survive the real bundle
|
||||
// path (not just the per-package src benches). The selector pins the KEYED
|
||||
// WebRow (its own `data-variant="web"` wrapper), not the `[data-web]` attribute
|
||||
// WebBlock draws — the generic fallback renders the same WebBlock, so a silent
|
||||
// keyed-registration failure would still satisfy a bare `[data-web]` check.
|
||||
// path (not just the per-package src benches). WebRow composes ToolRow, so the
|
||||
// card is collapsed behind the row; the keyed row is pinned by its `data-tool`
|
||||
// (ToolRow sets it from the wire tool name).
|
||||
const webSearchRow = await waitFor(() => {
|
||||
const row = document.querySelector('[data-tool="web_search"]')
|
||||
expect(row).not.toBeNull()
|
||||
expect(document.querySelector('[data-tool="web_fetch"]')).not.toBeNull()
|
||||
return row!
|
||||
}, { timeout: 10_000 })
|
||||
// Expand the web_search row to prove its WebBlock card renders end to end.
|
||||
const webToggle = webSearchRow.querySelector('[data-expandable]')
|
||||
if (webToggle !== null) act(() => { fireEvent.click(webToggle) })
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-variant="web"][data-tool="web_search"]')).not.toBeNull()
|
||||
expect(document.querySelector('[data-variant="web"][data-tool="web_fetch"]')).not.toBeNull()
|
||||
expect(webSearchRow.querySelector('[data-web]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
// Every bundle injected its plugin-owned style tag (the loader's CSS path).
|
||||
|
||||
@@ -145,15 +145,21 @@ describe('assembled search card', () => {
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
// The grep turn's keyed SearchRow renders the card resident: wait for it.
|
||||
// The grep turn's keyed SearchRow composes ToolRow: the card is collapsed
|
||||
// by default, so wait for the summary row, then expand it to reach the card.
|
||||
await waitFor(() => {
|
||||
const tools = [...document.querySelectorAll('[data-tool]')].map(el => el.getAttribute('data-tool'))
|
||||
expect(tools, `tools present: ${tools.join(', ')}`).toContain('grep')
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
// `data-tool` sits on the summary row; the card and recovery footer are its
|
||||
// siblings inside the SearchRow wrapper, so shape the wrapper (its parent).
|
||||
const grepRow = document.querySelector('[data-tool="grep"]')!.parentElement!
|
||||
// `data-tool` sits on the ToolRow root; the collapsed row is the expand
|
||||
// toggle. Click it so the card and its recovery footer mount, then shape the
|
||||
// whole row (the card lives inside ToolRow's body wrapper).
|
||||
const grepRow = document.querySelector('[data-tool="grep"]')!
|
||||
act(() => { fireEvent.click(grepRow.querySelector('[data-expandable]') ?? grepRow) })
|
||||
await waitFor(() => {
|
||||
expect(grepRow.querySelector('[data-search]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
const shape = cardShape(grepRow)
|
||||
if (refreshing) {
|
||||
mkdirSync(dirname(EXPECTED), { recursive: true })
|
||||
|
||||
@@ -22,8 +22,10 @@
|
||||
- img
|
||||
- text: Code Run bash echo and catch missing file read
|
||||
- img
|
||||
- text: Bash Echo CODE_ROUND_OK 失败 Read
|
||||
- button "missing.txt"
|
||||
- text: Bash Echo CODE_ROUND_OK Failed
|
||||
- 'button "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"':
|
||||
- img
|
||||
- text: "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"
|
||||
- button "Think The program ran successfully. Let me now reply DONE as instructed.":
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -14,12 +14,16 @@
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
|
||||
- img
|
||||
- text: Read
|
||||
- button "a.txt"
|
||||
- img
|
||||
- text: Read
|
||||
- button "b.txt"
|
||||
- button "Read a.txt":
|
||||
- img
|
||||
- img
|
||||
- text: Read
|
||||
- button "a.txt"
|
||||
- button "Read b.txt":
|
||||
- img
|
||||
- img
|
||||
- text: Read
|
||||
- button "b.txt"
|
||||
- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -5,7 +5,23 @@ file=packages/client/ui-conversation/src/client/toolviews/search-row.tsx4
|
||||
line=16: export const DEFAULT_SEARCH_MAX_LINES = 16
|
||||
line=138: export function SearchBlock(props: SearchBlockProps) {
|
||||
line=141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
|
||||
line=73: const search = searchCardModel(block)
|
||||
line=90: <SearchBlock {...search.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.search} />
|
||||
line=113: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep' }, SearchRow)
|
||||
expand=… 其余 4 行
|
||||
line=35: const search = searchCardModel(block)
|
||||
line=52: search={search}
|
||||
line=73: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
|
||||
expand=… 其余 4 行
|
||||
recovery=Found 9 of 42 matches
|
||||
|
||||
packages/client/ui-primitives/src/SearchBlock.tsx
|
||||
Line 16: export const DEFAULT_SEARCH_MAX_LINES = 16
|
||||
Line 138: export function SearchBlock(props: SearchBlockProps) {
|
||||
Line 141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
|
||||
packages/client/ui-conversation/src/client/contract/search-card-model.ts
|
||||
Line 24: export const CHAT_SEARCH_MAX_LINES = 8
|
||||
Line 60: export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {
|
||||
packages/client/ui-conversation/src/client/toolviews/search-row.tsx
|
||||
Line 33: export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
|
||||
Line 35: const search = searchCardModel(block)
|
||||
Line 52: search={search}
|
||||
Line 73: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
|
||||
|
||||
(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)
|
||||
@@ -13,12 +13,16 @@
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
|
||||
- img
|
||||
- text: Read
|
||||
- button "a.txt"
|
||||
- img
|
||||
- text: Read
|
||||
- button "b.txt"
|
||||
- button "Read a.txt":
|
||||
- img
|
||||
- img
|
||||
- text: Read
|
||||
- button "a.txt"
|
||||
- button "Read b.txt":
|
||||
- img
|
||||
- img
|
||||
- text: Read
|
||||
- button "b.txt"
|
||||
- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -13,12 +13,16 @@
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
|
||||
- img
|
||||
- text: Read
|
||||
- button "a.txt"
|
||||
- img
|
||||
- text: Read
|
||||
- button "b.txt"
|
||||
- button "Read a.txt":
|
||||
- img
|
||||
- img
|
||||
- text: Read
|
||||
- button "a.txt"
|
||||
- button "Read b.txt":
|
||||
- img
|
||||
- img
|
||||
- text: Read
|
||||
- button "b.txt"
|
||||
- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
|
||||
- text: Running
|
||||
- button "Ask question waiting":
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -13,13 +13,10 @@
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- img
|
||||
- text: Search DeepSeek Harness snapshot search
|
||||
- list:
|
||||
- listitem:
|
||||
- link "Snapshot Search Result":
|
||||
- /url: https://docs.example.test/search
|
||||
- text: Snapshot search excerpt. 2026-07-31
|
||||
- button "Search DeepSeek Harness snapshot search":
|
||||
- img
|
||||
- img
|
||||
- text: Search DeepSeek Harness snapshot search
|
||||
- paragraph: SEARCH_DONE
|
||||
- button "Copy":
|
||||
- img
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/development.md
|
||||
development.md: f58cad7d361def14667fa66017cb003b74d70749
|
||||
development.zh.md: 88ddd8483c234bdf1c1fd0bcda9df3ca02ea6fa4
|
||||
development.md: 22eb7915f621883a84688d70e2ccad2fee2dbbba
|
||||
development.zh.md: 480cd323d4d2325974f472c734edab9ce7459e89
|
||||
|
||||
@@ -83,7 +83,7 @@ DEEPSEEK_BASE_URL=https://... # optional
|
||||
|
||||
lefthook is configured in `lefthook.yml` as a fast local checkpoint:
|
||||
|
||||
- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.
|
||||
- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.
|
||||
- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).
|
||||
|
||||
The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.
|
||||
|
||||
@@ -83,7 +83,7 @@ DEEPSEEK_BASE_URL=https://... # optional
|
||||
|
||||
lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:
|
||||
|
||||
- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;
|
||||
- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;
|
||||
- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。
|
||||
|
||||
vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。
|
||||
|
||||
10
lefthook.yml
10
lefthook.yml
@@ -18,6 +18,16 @@ pre-commit:
|
||||
run: node_modules/.bin/tsx scripts/run-oxlint.ts --fix --no-error-on-unmatched-pattern {staged_files}
|
||||
stage_fixed: true
|
||||
|
||||
# Regenerate rather than reject: a dependency edit that forgot the notices
|
||||
# would otherwise fail the test lane long after the commit. The glob matches
|
||||
# every input the generator reads, including the generator itself and the
|
||||
# build-time pin source. Deleting a manifest cannot trigger this job —
|
||||
# lefthook only inspects files present on disk — so that one case still
|
||||
# falls through to the freshness assertion in the test lane.
|
||||
- name: third-party notices (staged)
|
||||
glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/gen-third-party-notices.ts,scripts/build-exe-for-python-sdk.ts}'
|
||||
run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md
|
||||
|
||||
- name: whitespace (staged)
|
||||
run: git diff --cached --check
|
||||
|
||||
|
||||
@@ -92,6 +92,8 @@
|
||||
"verify-doc-graphs": "tsx scripts/gen-doc-graphs.ts --check",
|
||||
"gen-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts",
|
||||
"verify-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts --check",
|
||||
"gen-third-party-notices": "tsx scripts/gen-third-party-notices.ts",
|
||||
"verify-third-party-notices": "tsx scripts/gen-third-party-notices.ts --check",
|
||||
"gen-module-graph": "tsx scripts/gen-module-graph.ts",
|
||||
"gen-scoped-events": "tsx scripts/gen-scoped-events.ts",
|
||||
"verify-scoped-events": "tsx scripts/gen-scoped-events.ts --check",
|
||||
@@ -120,6 +122,7 @@
|
||||
"@types/jsdom": "^28.0.3",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/node": "^22.20.0",
|
||||
"@types/spdx-expression-parse": "^4.0.0",
|
||||
"@typescript-eslint/parser": "8.61.0",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@yarnpkg/cli-dist": "4.17.1",
|
||||
@@ -140,6 +143,8 @@
|
||||
"oxlint": "1.76.0",
|
||||
"oxlint-tsgolint": "7.0.2001",
|
||||
"publint": "^0.3.21",
|
||||
"smol-toml": "^1.7.1",
|
||||
"spdx-expression-parse": "^5.0.0",
|
||||
"tsdown": "^0.22.2",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^6.0.3",
|
||||
|
||||
@@ -164,10 +164,10 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin
|
||||
{
|
||||
path: 'packages/client/ui-conversation/src/client/toolviews/search-row.tsx',
|
||||
matches: [
|
||||
{ lineNumber: 71, line: 'export function SearchRow({ toolName, block }: ToolRowProps) {' },
|
||||
{ lineNumber: 73, line: ' const search = searchCardModel(block)' },
|
||||
{ lineNumber: 90, line: ' <SearchBlock {...search.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.search} />' },
|
||||
{ lineNumber: 113, line: " ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep' }, SearchRow)" },
|
||||
{ lineNumber: 33, line: 'export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {' },
|
||||
{ lineNumber: 35, line: ' const search = searchCardModel(block)' },
|
||||
{ lineNumber: 52, line: ' search={search}' },
|
||||
{ lineNumber: 73, line: " ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)" },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -197,7 +197,7 @@ const SEARCH_PATHS_FIXTURE = [
|
||||
'packages/client/ui-primitives/src/SearchBlock.module.css',
|
||||
'packages/client/ui-conversation/src/client/contract/search-card-model.ts',
|
||||
'packages/client/ui-conversation/src/client/toolviews/search-row.tsx',
|
||||
'packages/client/ui-conversation/src/client/toolviews/search-row.module.css',
|
||||
'packages/client/ui-conversation/tests/search-card.spec.tsx',
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 66120f222de4b4d4707430a1ec310c6fe801c0c5
|
||||
README.zh.md: f5e953f363733341400a292a1946b4e298858b77
|
||||
README.md: f7279d2c640d447609c4e9804af633d026b35ed9
|
||||
README.zh.md: 2e8f3483c160689cdf5ff283f2955a5c0be4fd8e
|
||||
|
||||
@@ -18,13 +18,15 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run
|
||||
|
||||
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
|
||||
|
||||
A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; a web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which grows the same resident card, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)).
|
||||
A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; it composes the shared `ToolRow`, feeding the card as ToolRow's `web` body, so the retrieval is the row's collapsed-by-default expanded card (the same unified expand every card row has). A web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which routes the card through ToolRow the same way, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)).
|
||||
|
||||
A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) carries the card resident below its summary, whose path link still opens the file through the host; the render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)).
|
||||
A `read` call declaring the `read` render intent renders the returned file window inline, at both conversation render sites, through ui-primitives' `ReadBlock` — the line-numbered, syntax-highlighted content the tool projects. `contract/read-card-model.ts` is the single derivation from the snapshot's `resultView`; the read card is result-side only (a call carries no file content until `execute` returns), so a running read shows its summary alone and it yields null — the generic path — for a non-read result view or a `card` tag this client version does not know. The keyed `ReadRow` composes the shared `ToolRow`, feeding the card as ToolRow's `read` body, so it is the row's collapsed-by-default expanded card; the summary stays a path link that opens the file through the host. The render-site fallback and the details panel are read-aware too. Rows cap at `CHAT_READ_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md)).
|
||||
|
||||
A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) composes the shared `ToolRow`, feeding the diff as ToolRow's `diff` body, so it is the row's collapsed-by-default expanded card; the summary path link still opens the file through the host, and an errored mutation (no diff card) surfaces its error text through ToolRow's Output section with the first line in the collapsed summary. The render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)).
|
||||
|
||||
The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds.
|
||||
|
||||
A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, carries the card resident below its summary; the render-site fallback keeps it behind the expand control. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) falls back to its flattened result text so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)).
|
||||
A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, composes the shared `ToolRow`, feeding the card as ToolRow's `search` body, so it is the row's collapsed-by-default expanded card; the render-site fallback routes it the same way. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) surfaces its flattened result text through ToolRow's Output section so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)).
|
||||
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
|
||||
|
||||
|
||||
@@ -16,13 +16,15 @@
|
||||
|
||||
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
|
||||
|
||||
声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它长出同一张常驻卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。
|
||||
声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;它组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `web` body 传入,因此检索成为该行默认折叠的展开卡片(与每个卡片行相同的统一展开交互)。没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它以同样方式经 ToolRow 渲染卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。
|
||||
|
||||
声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result view(write/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write` 与 `edit` 下都注册)把卡片常驻在摘要之下,其路径链接仍经 host 打开文件;渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。
|
||||
声明 `read` 渲染意图的 `read` 调用,会在两个对话渲染点上都通过 ui-primitives 的 `ReadBlock` 内联渲染返回的文件窗口——工具投影出的带行号、语法高亮的内容。`contract/read-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;read 卡片是仅结果侧的(调用在 `execute` 返回前不携带文件内容),所以运行中的 read 只显示摘要,且对非 read 的 result view 或本客户端版本不认识的 `card` 标签返回 null,落回通用路径。键控的 `ReadRow` 组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `read` body 传入,因此它是该行默认折叠的展开卡片;摘要仍是一个经 host 打开文件的路径链接。渲染点兜底行与详情面板同样感知 read。行的上限是 `CHAT_READ_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md))。
|
||||
|
||||
声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result view(write/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write` 与 `edit` 下都注册)组合共享的 `ToolRow`,把 diff 作为 ToolRow 的 `diff` body 传入,因此它是该行默认折叠的展开卡片;摘要路径链接仍经 host 打开文件,而出错的改动(没有 diff 卡片)经 ToolRow 的 Output 区呈现其错误文本,首行进入折叠摘要。渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。
|
||||
|
||||
聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。
|
||||
|
||||
声明 `search` 渲染意图的 `grep`/`glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line` 行),glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card` 或 `kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files`/`paths` 格式错误的已知 kind,它都返回 null,落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep` 与 `glob` 下,把卡片常驻在摘要行下方;渲染点兜底行则把它保持在展开控件之后。两者上限都是 `CHAT_SEARCH_MAX_LINES`(8),面板为 16。被截断的搜索会从卡片里丢掉一些行,但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则回退到其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。
|
||||
声明 `search` 渲染意图的 `grep`/`glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line` 行),glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card` 或 `kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files`/`paths` 格式错误的已知 kind,它都返回 null,落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep` 与 `glob` 下,组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `search` body 传入,因此它是该行默认折叠的展开卡片;渲染点兜底行以同样方式渲染它。两者上限都是 `CHAT_SEARCH_MAX_LINES`(8),面板为 16。被截断的搜索会从卡片里丢掉一些行,但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则经 ToolRow 的 Output 区呈现其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。
|
||||
|
||||
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall(瀑布式事件)工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@ import { InputBar } from './skeleton/InputBar.tsx'
|
||||
import { ChatView } from './chat/ChatView.tsx'
|
||||
import { StatsLine } from './chat/StatsLine.tsx'
|
||||
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
|
||||
import { searchToolview } from './toolviews/search-row.tsx'
|
||||
import { readToolview } from './toolviews/read-row.tsx'
|
||||
import { fileMutationToolview } from './toolviews/file-mutation-row.tsx'
|
||||
import { searchToolview } from './toolviews/search-row.tsx'
|
||||
import { webToolview } from './toolviews/web-row.tsx'
|
||||
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
|
||||
import { todoToolview } from './toolviews/todo-row.tsx'
|
||||
@@ -322,10 +322,6 @@ export function apply(ctx: Context): void {
|
||||
// (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
|
||||
ctx.plugin(bashToolviewSample)
|
||||
|
||||
// The grep/glob search row rides the same seam: one component registered
|
||||
// under both tool names, since both declare the same search render intent.
|
||||
ctx.plugin(searchToolview)
|
||||
|
||||
// The read row rides the same seam (a product registration, not a sample):
|
||||
// Read · {path} chrome with the file's read card resident below it.
|
||||
ctx.plugin(readToolview)
|
||||
@@ -334,6 +330,11 @@ export function apply(ctx: Context): void {
|
||||
// diff render intent, so these rows stack the applied diff card under their
|
||||
// path-link summary (the terminal card's posture, applied to diffs).
|
||||
ctx.plugin(fileMutationToolview)
|
||||
|
||||
// The grep/glob search row rides the same seam: one component registered
|
||||
// under both tool names, since both declare the same search render intent.
|
||||
ctx.plugin(searchToolview)
|
||||
|
||||
// The web rows ride the same seam: one WebRow registered under both
|
||||
// web_search and web_fetch, rendering the completed retrieval's web card
|
||||
// resident under the summary (a product registration, not a sample).
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/* GenericToolCard resident cards: a read-declaring or web-declaring tool
|
||||
without its own keyed row (e.g. web_fetch) grows a resident card under its
|
||||
summary row. A column around the ToolRow keeps the row's own 24px height, so
|
||||
the read card renders identically to the keyed ReadRow and the web card to
|
||||
the web_search/web_fetch WebRow. */
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
|
||||
and replaces the primitive's standalone vertical margin with the flow's. */
|
||||
.read,
|
||||
.web {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
@@ -7,17 +7,16 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
|
||||
IconThinkOutline14, ReadBlock, WebBlock,
|
||||
IconThinkOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { searchCardModel } from '../contract/search-card-model.ts'
|
||||
import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts'
|
||||
import { readCardModel } from '../contract/read-card-model.ts'
|
||||
import { diffCardModel } from '../contract/diff-card-model.ts'
|
||||
import { searchCardModel } from '../contract/search-card-model.ts'
|
||||
import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
|
||||
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
|
||||
import { webCardModel } from '../contract/web-card-model.ts'
|
||||
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import css from './GenericToolCard.module.css'
|
||||
|
||||
/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
|
||||
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
@@ -39,9 +38,9 @@ export interface GenericToolCardProps extends ToolRowOwnerProps {
|
||||
export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
const search = searchCardModel(block)
|
||||
const read = readCardModel(block, cwd)
|
||||
const diff = diffCardModel(block)
|
||||
const search = searchCardModel(block)
|
||||
const web = webCardModel(block)
|
||||
// A failing exit status is the terminal card's own error signal (the call
|
||||
// itself settles isError:false), surfaced as the row's red state dot.
|
||||
@@ -49,7 +48,7 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
|
||||
? 'error'
|
||||
: model.state
|
||||
const singleFile = model.filePath !== undefined
|
||||
const row = (
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
@@ -61,39 +60,20 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
|
||||
// a search result view's replacement title outranks it the same way.
|
||||
summary={terminal?.description ?? search?.title ?? model.summary}
|
||||
// Single-file tools never expose an args body — the path link is the only
|
||||
// args interaction. A diff card is not an args body: a write/edit row is
|
||||
// single-file AND carries a diff, so the card expands under the path link.
|
||||
// args interaction. A card is not an args body: a read/write/edit row is
|
||||
// single-file AND carries a card, so the card expands under the path link.
|
||||
body={singleFile ? null : model.body}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
terminal={terminal}
|
||||
search={search}
|
||||
diff={diff}
|
||||
read={read}
|
||||
search={search}
|
||||
web={web}
|
||||
state={state}
|
||||
filePath={model.filePath}
|
||||
onOpenFile={singleFile ? openFile : undefined}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
// A read-declaring tool without its own keyed row lands here (e.g. web_fetch),
|
||||
// so the file's read card is resident below the summary row exactly as the
|
||||
// keyed ReadRow draws it. Only wrap when a card is present, so every other
|
||||
// tool keeps the bare ToolRow.
|
||||
if (read !== null) {
|
||||
return (
|
||||
<div className={css.card}>
|
||||
{row}
|
||||
<ReadBlock {...read} maxLines={CHAT_READ_MAX_LINES} className={css.read} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// A web-declaring tool without its own keyed row lands here; its card is
|
||||
// resident under the summary, mirroring WebRow (and BashRow's terminal card).
|
||||
if (web === null) return row
|
||||
return (
|
||||
<div className={css.card}>
|
||||
{row}
|
||||
<WebBlock {...web} maxSources={CHAT_WEB_MAX_SOURCES} className={css.web} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -248,13 +248,18 @@
|
||||
|
||||
/* The block-shaped expanded bodies: the code variant's run_code program
|
||||
through CodeBlock (shiki-highlighted TypeScript), a terminal card's command
|
||||
output through TerminalBlock, and a search card's grouped matches or path
|
||||
list through SearchBlock. All are drawn by the shared primitive, so only the
|
||||
row's indentation is this file's concern — the margin also replaces each
|
||||
output through TerminalBlock, a diff card through DiffBlock, a read card's
|
||||
line-numbered window through ReadBlock, a search card's grouped matches or
|
||||
path list through SearchBlock, and a web card's citation/source list through
|
||||
WebBlock. All are drawn by the shared primitive, so only the row's
|
||||
indentation is this file's concern — the margin also replaces each
|
||||
primitive's own standalone vertical spacing with the flow's row rhythm. */
|
||||
.codeBody,
|
||||
.terminalBody,
|
||||
.searchBody {
|
||||
.diffBody,
|
||||
.readBody,
|
||||
.searchBody,
|
||||
.webBody {
|
||||
margin: 4px 0 4px 4px;
|
||||
}
|
||||
|
||||
@@ -269,12 +274,6 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* A write/edit diff renders through DiffBlock; like the terminal card it draws
|
||||
its own surface, so only the row indentation is this file's concern. */
|
||||
.diffBody {
|
||||
margin: 4px 0 4px 4px;
|
||||
}
|
||||
|
||||
/* In-row code renders at the smaller code size (12/18) via each primitive's
|
||||
rebindable content-font seam; standalone markdown code blocks keep 13/22. */
|
||||
.codeBody {
|
||||
@@ -290,3 +289,15 @@
|
||||
--dsl-terminal-output-max-height: 224px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
/* Visually hidden run-state label for assistive technology: the StateDot and
|
||||
the running sweep are aria-hidden / colour-only, so the text carries the
|
||||
running/failed/interrupted state to a screen reader. */
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -3,26 +3,33 @@
|
||||
// separator dot + FILL-truncated summary, drawn through the shared
|
||||
// DisclosureRow chrome with the whole row as the expand toggle (click /
|
||||
// Enter / Space, icon→chevron hover preview). The collapsed row is always
|
||||
// one line; every row with body, output, terminal, or search material is
|
||||
// expandable; the summary stays inline while open, except Think, whose body
|
||||
// opens with the same first line and would repeat it.
|
||||
// one line; every row with body, output, or a card material (terminal, diff,
|
||||
// read, search, web) is expandable; the summary stays inline while open,
|
||||
// except Think, whose body opens with the same first line and would repeat it.
|
||||
// The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for
|
||||
// text input/output, the run_code program through CodeBlock, a terminal
|
||||
// card's command output through TerminalBlock, or a search card's grouped
|
||||
// matches / path list through SearchBlock (capped at CHAT_SEARCH_MAX_LINES) —
|
||||
// lives in a max-height scroll container so a long payload scrolls internally
|
||||
// instead of taking over the message flow; Think's prose is the exception and
|
||||
// flows uncapped like message text. Expand state is component-local view state.
|
||||
// File-tool summaries are path links that open through the host (stopPropagation
|
||||
// keeps the two gestures independent); an error row's collapsed summary is the
|
||||
// failure's first line in the error color.
|
||||
// text input/output, the run_code program through CodeBlock, or a card
|
||||
// primitive (TerminalBlock, DiffBlock, ReadBlock, SearchBlock, WebBlock) for a
|
||||
// call that declared that render intent — lives in a max-height scroll
|
||||
// container so a long payload scrolls internally instead of taking over the
|
||||
// message flow; Think's prose is the exception and flows uncapped like message
|
||||
// text. Every card kind starts collapsed, so a run of tool calls stays
|
||||
// scannable; the details panel is the single-call full-height reading surface.
|
||||
// Expand state is component-local view state. File-tool summaries are path
|
||||
// links that open through the host (stopPropagation keeps the two gestures
|
||||
// independent); an error row's collapsed summary is the failure's first line in
|
||||
// the error color.
|
||||
|
||||
import { useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { CodeBlock, DiffBlock, SearchBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-card-model.ts'
|
||||
import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../contract/diff-card-model.ts'
|
||||
import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../contract/read-card-model.ts'
|
||||
import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-card-model.ts'
|
||||
import { CHAT_WEB_MAX_SOURCES } from '../contract/web-card-model.ts'
|
||||
import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { DisclosureRow } from './DisclosureRow.tsx'
|
||||
@@ -47,24 +54,34 @@ export interface ToolRowProps {
|
||||
/**
|
||||
* Terminal-card material for a call whose render intent is a terminal card
|
||||
* (derived by `terminalCardModel`); it replaces the text sections when
|
||||
* present. A row with no body, no output, and no terminal material is not
|
||||
* expandable.
|
||||
* present. A call carries at most one card kind, so the card props below are
|
||||
* mutually exclusive.
|
||||
*/
|
||||
terminal?: TerminalCardModel | null | undefined
|
||||
/**
|
||||
* Diff-card material for a call whose render intent is a diff card (derived by
|
||||
* `diffCardModel`); it replaces the text body when present, the same way
|
||||
* `terminal` does.
|
||||
*/
|
||||
diff?: DiffCardModel | null | undefined
|
||||
/**
|
||||
* Read-card material for a call whose render intent is a read card (derived by
|
||||
* `readCardModel`); it replaces the text body with the file's line-numbered,
|
||||
* syntax-highlighted window when present.
|
||||
*/
|
||||
read?: ReadCardModel | null | undefined
|
||||
/**
|
||||
* Search-card material for a call whose render intent is a search card
|
||||
* (derived by `searchCardModel`); it replaces the text body when present.
|
||||
* Null or absent leaves the text body. A call carries at most one card kind,
|
||||
* so `terminal`, `search`, and `diff` are never both present on the same row.
|
||||
* (derived by `searchCardModel`); it replaces the text body with grouped
|
||||
* matches or a path list when present.
|
||||
*/
|
||||
search?: SearchCardModel | null | undefined
|
||||
/**
|
||||
* Diff-card material for a call whose render intent is a diff card (derived by
|
||||
* `diffCardModel`); it replaces the text body when present, the same way
|
||||
* `terminal` does. A call carries at most one card intent, so the cards are
|
||||
* never both set.
|
||||
* Web-card material for a call whose render intent is a web card (derived by
|
||||
* `webCardModel`); it replaces the text body with the retrieval's citation
|
||||
* list or fetched-source card when present.
|
||||
*/
|
||||
diff?: DiffCardModel | null | undefined
|
||||
web?: WebBlockProps | null | undefined
|
||||
state: ToolRowState
|
||||
/**
|
||||
* Filesystem path from tool args; when set with onOpenFile, the summary
|
||||
@@ -101,6 +118,19 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
/** Visually hidden run-state label: the StateDot and the CSS sweep are both
|
||||
* aria-hidden / colour-only, so assistive technology needs this text to know a
|
||||
* row is running, failed, or interrupted. null in the ok state (the icon and
|
||||
* summary already describe a settled row). */
|
||||
function stateStatus(state: ToolRowState, t: TranslateNS<'conversation'>): string | null {
|
||||
switch (state) {
|
||||
case 'running': return t('row.running')
|
||||
case 'error': return t('row.failed')
|
||||
case 'stopped': return t('row.stopped')
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
export function ToolRow({
|
||||
t,
|
||||
variant,
|
||||
@@ -112,8 +142,10 @@ export function ToolRow({
|
||||
output,
|
||||
errorSummary,
|
||||
terminal,
|
||||
search,
|
||||
diff,
|
||||
read,
|
||||
search,
|
||||
web,
|
||||
state,
|
||||
filePath,
|
||||
onOpenFile,
|
||||
@@ -121,13 +153,20 @@ export function ToolRow({
|
||||
}: ToolRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const terminalBody = terminal ?? null
|
||||
const searchBody = search ?? null
|
||||
const diffBody = diff ?? null
|
||||
const readBody = read ?? null
|
||||
const searchBody = search ?? null
|
||||
const webBody = web ?? null
|
||||
const outputText = output ?? null
|
||||
// A search or diff card replaces the text body; a call carries at most one
|
||||
// card kind, so terminal, search, and diff are never both present on a row.
|
||||
const expandable = body !== null || outputText !== null || terminalBody !== null || searchBody !== null || diffBody !== null
|
||||
// A card replaces the text body; a call carries at most one card kind, so the
|
||||
// card props are mutually exclusive. Any of them, or a text body/output,
|
||||
// makes the row expandable.
|
||||
const card = terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody
|
||||
const expandable = body !== null || outputText !== null || card !== null
|
||||
const open = expanded && expandable
|
||||
// The run-state label AT needs: the StateDot and the running sweep are both
|
||||
// aria-hidden / colour-only, so a stopped or running row is otherwise silent.
|
||||
const status = stateStatus(state, t)
|
||||
// An error row's collapsed summary IS the failure: the first error line in
|
||||
// the error color outranks both the args summary and a terminal description.
|
||||
const failureLine = state === 'error' ? errorSummary ?? null : null
|
||||
@@ -141,6 +180,13 @@ export function ToolRow({
|
||||
event.stopPropagation()
|
||||
if (filePath !== undefined) onOpenFile?.(filePath)
|
||||
}
|
||||
// Keep Enter/Space on the focused path link from bubbling to the row's
|
||||
// keydown handler, which would preventDefault() the key and toggle expand
|
||||
// instead of activating the link — the keyboard analogue of openFile's
|
||||
// stopPropagation. The native button still fires its own onClick from the key.
|
||||
const fileLinkKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation()
|
||||
}
|
||||
// Think reasoning is prose, not an input payload: expanded, it renders as
|
||||
// plain indented text (no IN/OUT card) and the inline summary — the body's
|
||||
// own first line — yields to avoid repeating itself.
|
||||
@@ -153,6 +199,7 @@ export function ToolRow({
|
||||
// of losing it with the icon.
|
||||
return (
|
||||
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
<DisclosureRow
|
||||
rowClassName={css.row}
|
||||
leadingClassName={css.leading}
|
||||
@@ -175,6 +222,7 @@ export function ToolRow({
|
||||
type="button"
|
||||
className={css.fileLink}
|
||||
onClick={openFile}
|
||||
onKeyDown={fileLinkKeyDown}
|
||||
>
|
||||
{summaryText}
|
||||
</button>
|
||||
@@ -198,51 +246,55 @@ export function ToolRow({
|
||||
className={css.terminalBody}
|
||||
/>
|
||||
)
|
||||
: searchBody !== null
|
||||
? (
|
||||
<>
|
||||
<SearchBlock {...searchBody.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.searchBody} />
|
||||
{/* A capped search's recovery locator lives only in the result
|
||||
text; show it below the card so the dropped rows survive. */}
|
||||
{searchBody.recovery !== undefined && (
|
||||
<div className={css.searchRecovery}>{searchBody.recovery}</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: diffBody !== null
|
||||
? <DiffBlock {...diffBody.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.diffBody} />
|
||||
: isThink
|
||||
? <div className={css.thinkBody}>{body}</div>
|
||||
: (
|
||||
: diffBody !== null
|
||||
? <DiffBlock {...diffBody.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.diffBody} />
|
||||
: readBody !== null
|
||||
? <ReadBlock {...readBody} maxLines={CHAT_READ_MAX_LINES} className={css.readBody} />
|
||||
: searchBody !== null
|
||||
? (
|
||||
<>
|
||||
{variant === 'code' && body !== null && (
|
||||
<div className={css.bodyScroll}>
|
||||
<CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
|
||||
</div>
|
||||
)}
|
||||
{(cardBody !== null || outputText !== null) && (
|
||||
<div className={css.ioCard}>
|
||||
{cardBody !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>IN</span>
|
||||
<span className={css.ioText}>{cardBody}</span>
|
||||
</div>
|
||||
)}
|
||||
{cardBody !== null && outputText !== null && (
|
||||
<span className={css.ioDivider} aria-hidden />
|
||||
)}
|
||||
{outputText !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>OUT</span>
|
||||
<span className={css.ioText} data-error={state === 'error' || undefined}>
|
||||
{outputText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SearchBlock {...searchBody.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.searchBody} />
|
||||
{/* A capped search's recovery locator lives only in the result
|
||||
text; show it below the card so the dropped rows survive. */}
|
||||
{searchBody.recovery !== undefined && (
|
||||
<div className={css.searchRecovery}>{searchBody.recovery}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
)
|
||||
: webBody !== null
|
||||
? <WebBlock {...webBody} maxSources={CHAT_WEB_MAX_SOURCES} className={css.webBody} />
|
||||
: isThink
|
||||
? <div className={css.thinkBody}>{body}</div>
|
||||
: (
|
||||
<>
|
||||
{variant === 'code' && body !== null && (
|
||||
<div className={css.bodyScroll}>
|
||||
<CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
|
||||
</div>
|
||||
)}
|
||||
{(cardBody !== null || outputText !== null) && (
|
||||
<div className={css.ioCard}>
|
||||
{cardBody !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>IN</span>
|
||||
<span className={css.ioText}>{cardBody}</span>
|
||||
</div>
|
||||
)}
|
||||
{cardBody !== null && outputText !== null && (
|
||||
<span className={css.ioDivider} aria-hidden />
|
||||
)}
|
||||
{outputText !== null && (
|
||||
<div className={css.ioSection}>
|
||||
<span className={css.ioLabel}>OUT</span>
|
||||
<span className={css.ioText} data-error={state === 'error' || undefined}>
|
||||
{outputText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{inspect !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// Shared toolview-row helpers for the keyed rows whose card is resident below a
|
||||
// summary (SearchRow, FileMutationRow): the visually hidden run-state label and
|
||||
// the flattened settled-result text for the fallback arm a card cannot render.
|
||||
// Both are pure functions of a frozen call slice — no chat-domain imports — so a
|
||||
// row stays a thin ToolRowProps consumer.
|
||||
|
||||
import type { ToolRowProps } from './slots.ts'
|
||||
import type { ToolRowState } from './tool-call-model.ts'
|
||||
|
||||
/**
|
||||
* Visually hidden run-state label for a row's leading `StateDot` (which is
|
||||
* `aria-hidden`), so assistive technology still announces the state. Returns
|
||||
* null for the settled-ok state, which needs no spoken label.
|
||||
* @param state - the row's run state.
|
||||
* @returns the label, or null when none is needed.
|
||||
*/
|
||||
export function rowStateStatus(state: ToolRowState): string | null {
|
||||
switch (state) {
|
||||
case 'running': return '运行中'
|
||||
case 'error': return '失败'
|
||||
case 'stopped': return '已停止'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A settled result's text, flattened from its content blocks, for the fallback
|
||||
* arm a keyed row shows when its card cannot render the result — an errored call
|
||||
* (the tool emits no result view on error) or a settled call with no card view
|
||||
* (a nested `run_code` sub-dispatch, a legacy generic result). The keyed row owns
|
||||
* the render slot, so without this the model-facing text would have nowhere to
|
||||
* go. Falls back to the error name/code when the result carries no text block.
|
||||
* @param block - the frozen call slice.
|
||||
* @returns the result text, or null for a running call or an empty result.
|
||||
*/
|
||||
export function rowResultText(block: ToolRowProps['block']): string | null {
|
||||
if (!('kind' in block)) return null
|
||||
const parts: string[] = []
|
||||
for (const item of block.content) {
|
||||
if (item.type === 'text') parts.push(item.text)
|
||||
}
|
||||
if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`)
|
||||
const text = parts.join('\n')
|
||||
return text === '' ? null : text
|
||||
}
|
||||
@@ -80,6 +80,9 @@ export const zh = {
|
||||
'bash.running': '运行中',
|
||||
'bash.failed': '失败',
|
||||
'bash.stopped': '已停止',
|
||||
'row.running': '运行中',
|
||||
'row.failed': '失败',
|
||||
'row.stopped': '已停止',
|
||||
'queue.count': '{n} 条排队消息',
|
||||
'queue.edit': '编辑排队消息',
|
||||
'queue.edit.unsupported': '包含非文本内容,暂不支持编辑',
|
||||
@@ -177,6 +180,9 @@ export const en = {
|
||||
'bash.running': 'Running',
|
||||
'bash.failed': 'Failed',
|
||||
'bash.stopped': 'Stopped',
|
||||
'row.running': 'Running',
|
||||
'row.failed': 'Failed',
|
||||
'row.stopped': 'Stopped',
|
||||
'queue.count': '{n} queued messages',
|
||||
'queue.edit': 'Edit queued message',
|
||||
'queue.edit.unsupported': 'Contains non-text content; editing is not supported yet',
|
||||
|
||||
@@ -101,9 +101,9 @@
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* A card body (terminal, search, or diff) sits directly under its section
|
||||
/* A card body (terminal, diff, or search) sits directly under its section
|
||||
label, so it drops the primitive's standalone vertical margin; the section
|
||||
owns the spacing. Card-neutral: no card-specific value. */
|
||||
owns the spacing. Card-neutral: no card-kind-specific value. */
|
||||
.cardBody {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ import { CodeBlock, DiffBlock, ReadBlock, SearchBlock, TerminalBlock, WebBlock }
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DetailsSlotProps } from '../contract/slots.ts'
|
||||
import { searchCardModel } from '../contract/search-card-model.ts'
|
||||
import { readCardModel } from '../contract/read-card-model.ts'
|
||||
import { diffCardModel } from '../contract/diff-card-model.ts'
|
||||
import { searchCardModel } from '../contract/search-card-model.ts'
|
||||
import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { webCardModel } from '../contract/web-card-model.ts'
|
||||
import { resultText, type ToolCallBlock } from '../contract/tool-call-model.ts'
|
||||
@@ -131,15 +131,16 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
|
||||
* The Output section's body for the selected call. A terminal-card call — a
|
||||
* shell command's call/result views — renders through the shared TerminalBlock
|
||||
* at the primitive's own full height allowance, so column-aligned output keeps
|
||||
* its alignment and scrolls sideways instead of folding. A search-card call —
|
||||
* a `grep`/`glob` result view — renders through the shared SearchBlock at the
|
||||
* same full height allowance, with a capped search's recovery footer below it.
|
||||
* A read-card call renders through the shared ReadBlock at that same full height,
|
||||
* so the whole returned window is line-numbered and highlighted. A diff-card
|
||||
* call — a write/edit's applied change — renders through the shared DiffBlock at
|
||||
* the same full height. A web-card call — a `web_search`/`web_fetch` result —
|
||||
* renders through WebBlock at its own full source-list allowance. Every other
|
||||
* call, and a running call with no card yet, keeps the flattened text form.
|
||||
* its alignment and scrolls sideways instead of folding. A read-card call
|
||||
* renders through the shared ReadBlock at that same full height, so the whole
|
||||
* returned window is line-numbered and highlighted. A diff-card call — a
|
||||
* write/edit's applied change — renders through the shared DiffBlock at the same
|
||||
* full height. A search-card call — a `grep`/`glob` result view — renders
|
||||
* through the shared SearchBlock at the same full height allowance, with a
|
||||
* capped search's recovery footer below it. A web-card call — a
|
||||
* `web_search`/`web_fetch` result — renders through WebBlock at its own full
|
||||
* source-list allowance. Every other call, and a running call with no card yet,
|
||||
* keeps the flattened text form.
|
||||
* @param props.material - the selected call's material from {@link materialFor}.
|
||||
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
|
||||
* @param props.t - the panel's locale seat, passed down as a plain prop.
|
||||
@@ -159,6 +160,12 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
|
||||
</>
|
||||
)
|
||||
}
|
||||
const read = readCardModel(material.block, cwd)
|
||||
// The panel takes the primitive's own default cap, not the row's tighter one:
|
||||
// it is the single-call reading surface, so the whole window is available.
|
||||
if (read !== null) return <ReadBlock {...read} className={css.read} />
|
||||
const diff = diffCardModel(material.block)
|
||||
if (diff !== null) return <DiffBlock {...diff.card} className={css.cardBody} />
|
||||
const search = searchCardModel(material.block)
|
||||
if (search !== null) {
|
||||
return (
|
||||
@@ -172,12 +179,6 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
|
||||
</>
|
||||
)
|
||||
}
|
||||
const read = readCardModel(material.block, cwd)
|
||||
// The panel takes the primitive's own default cap, not the row's tighter one:
|
||||
// it is the single-call reading surface, so the whole window is available.
|
||||
if (read !== null) return <ReadBlock {...read} className={css.read} />
|
||||
const diff = diffCardModel(material.block)
|
||||
if (diff !== null) return <DiffBlock {...diff.card} className={css.cardBody} />
|
||||
const web = webCardModel(material.block)
|
||||
// Full source-list allowance here (the panel is the single-call reading
|
||||
// surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. Below the card the
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
/* File-mutation toolview: same geometry/tokens as ToolRow (figma
|
||||
{Edit,Write} · path), plus the diff card the row stacks under its summary
|
||||
line. Mirrors bash-sample.module.css, whose terminal card this replaces with
|
||||
a diff card. */
|
||||
|
||||
/* Summary line over the diff card; the summary row keeps its own 24px height,
|
||||
so the card is a column around it rather than a change to it. */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
|
||||
and replaces the primitive's standalone vertical margin with the flow's. */
|
||||
.diff {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative; /* sweep-glare overlay anchor */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
|
||||
.root[data-state='running']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: dsh-file-mutation-row-sweep 2.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes dsh-file-mutation-row-sweep {
|
||||
0% { left: -300px; }
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
.leading {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.sep {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
margin: 0 8px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* File-tool path: same geometry as .summary; hover underline + pointer. */
|
||||
.fileLink {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fileLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The result text for an errored mutation, indented to the card's own column
|
||||
(the diff card's inset) and in the error tone, since it stands in for the diff
|
||||
card the failure path does not produce. */
|
||||
.failure {
|
||||
margin: 4px 0 4px 22px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
@@ -1,74 +1,54 @@
|
||||
// File-mutation toolview registrant: third-party posture over the keyed
|
||||
// toolview hole (ctx.slots.register + ToolRowProps only — never imports the
|
||||
// chat domain), registered under both `edit` and `write`. Product chrome
|
||||
// matches ToolRow (figma: {Edit,Write} · {path}).
|
||||
//
|
||||
// A write/edit call declares the diff render intent, so this row renders the
|
||||
// applied change through DiffBlock resident below its summary line — the same
|
||||
// posture BashRow gives a terminal card. The row has no expand control and is
|
||||
// not a details-panel target (tool rows stopped being one), so the diff body
|
||||
// is resident rather than expand-gated, and the card's own copy and expand
|
||||
// controls are the row's only interactions. CHAT_DIFF_MAX_LINES caps the body
|
||||
// against the message flow; the details panel keeps the block's full default.
|
||||
// The summary stays a path link (the file-tool interaction) that opens through
|
||||
// the host.
|
||||
// File-mutation toolview registrant: the keyed toolview hole for the `edit`
|
||||
// and `write` tools. The row composes the shared ToolRow (chrome, running
|
||||
// sweep, whole-row expand) and feeds it the applied diff as ToolRow's `diff`
|
||||
// card material, so the change renders through DiffBlock in the collapsed-by-
|
||||
// default expanded body — the same unified interaction every other card row
|
||||
// has. The summary stays a path link (the file-tool interaction) that opens
|
||||
// through the host; an errored mutation (write/edit return no diff on
|
||||
// `result.isError`) keeps the model-facing error text on ToolRow's Output
|
||||
// section, its first line in the collapsed summary.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { DiffBlock, IconEditOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconEditOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../contract/diff-card-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import { rowResultText, rowStateStatus } from '../contract/toolview-status.ts'
|
||||
import css from './file-mutation-row.module.css'
|
||||
import { diffCardModel } from '../contract/diff-card-model.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../chat/ToolRow.tsx'
|
||||
import { NS } from '../locales.ts'
|
||||
|
||||
function leadingFor(state: ToolRowState) {
|
||||
switch (state) {
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
// Running keeps the icon — the row sweep carries the in-flight signal.
|
||||
default: return <IconEditOutline16 size={14} />
|
||||
}
|
||||
}
|
||||
/** Full row props: the toolview runtime share plus the standard locale seat. */
|
||||
type FileMutationRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
/**
|
||||
* File-mutation row: icon + {Edit,Write} · {path} in the shared ToolRow chrome,
|
||||
* with the applied diff resident below it. The summary is a path link (a file
|
||||
* tool's interaction); the host's `openFile` resolves it against the session
|
||||
* cwd, so this passes the tool's own path verbatim. The card's copy and expand
|
||||
* controls are the row's only other actions.
|
||||
* with the applied diff as the row's collapsed-by-default card body. The
|
||||
* summary is a path link (a file tool's interaction); the host's `openFile`
|
||||
* resolves it against the session cwd, so this passes the tool's own path
|
||||
* verbatim. An errored mutation has no diff card, so ToolRow surfaces the
|
||||
* model-facing error text through its Output section and its first line in the
|
||||
* collapsed summary instead.
|
||||
*/
|
||||
export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps) {
|
||||
export function FileMutationRow({ toolName, block, cwd, openFile, inspect, t }: FileMutationRowProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const diff = diffCardModel(block)
|
||||
const status = rowStateStatus(model.state)
|
||||
const filePath = model.filePath
|
||||
// An errored mutation has no diff card (presentResult returns undefined on
|
||||
// isError); surface its result text so the failure is more than a red dot.
|
||||
const failure = diff === null && model.state === 'error' ? rowResultText(block) : null
|
||||
return (
|
||||
<div className={css.card}>
|
||||
<div className={css.root} data-variant={model.variant} data-state={model.state}>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
<span className={css.title}>{model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
{filePath !== undefined ? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.fileLink}
|
||||
onClick={() => { openFile(filePath) }}
|
||||
>
|
||||
{model.summary}
|
||||
</button>
|
||||
) : (
|
||||
<span className={css.summary}>{model.summary}</span>
|
||||
)}
|
||||
</div>
|
||||
{diff !== null && (
|
||||
<DiffBlock {...diff.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.diff} />
|
||||
)}
|
||||
{failure !== null && <div className={css.failure}>{failure}</div>}
|
||||
</div>
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={<IconEditOutline16 size={14} />}
|
||||
title={model.title}
|
||||
summary={model.summary}
|
||||
body={null}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
diff={diff}
|
||||
state={model.state}
|
||||
filePath={model.filePath}
|
||||
onOpenFile={openFile}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,7 +67,7 @@ export const fileMutationToolview = {
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit' }, FileMutationRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write' }, FileMutationRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit', locale: NS }, FileMutationRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write', locale: NS }, FileMutationRow)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
/* Read toolview: same geometry/tokens as ToolRow (figma Read · {path}), plus
|
||||
the read card the row stacks under its summary line. */
|
||||
|
||||
/* Summary line over the read card; the summary row keeps its own 24px height,
|
||||
so the card is a column around it rather than a change to it. */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
|
||||
and replaces the primitive's standalone vertical margin with the flow's. */
|
||||
.read {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative; /* sweep-glare overlay anchor */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Running sweep glare — same pattern as BashRow/ToolRow, so a running read row
|
||||
gives the same executing feedback a running command row does. The leading
|
||||
read icon stays static (a read has no per-step state to animate); the sweep
|
||||
is the row-level running signal. */
|
||||
.root[data-state='running']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: dsh-read-row-sweep 2.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes dsh-read-row-sweep {
|
||||
0% { left: -300px; }
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
.leading {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.sep {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
margin: 0 8px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* File path: same geometry as .summary; hover underline + pointer. */
|
||||
.fileLink {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fileLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,88 +1,49 @@
|
||||
// Read toolview registrant: the keyed toolview hole for the read tool
|
||||
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
|
||||
// Product chrome matches ToolRow (figma: Read · {path}); the summary is the
|
||||
// file path as an openable link, exactly as the generic read row draws it.
|
||||
//
|
||||
// A read RESULT declares the read render intent, so this row renders the file's
|
||||
// own line-numbered, syntax-highlighted content through ReadBlock resident
|
||||
// below its summary line — the same posture BashRow gives a terminal card. The
|
||||
// card is capped at CHAT_READ_MAX_LINES (the chat flow's tighter cap over the
|
||||
// block's own default of 16) with the block's internal expander keeping a long
|
||||
// read from taking over the message flow. A running read (no result yet) and a
|
||||
// non-read result both render the summary row alone. The read intent is
|
||||
// Read toolview registrant: the keyed toolview hole for the read tool. The row
|
||||
// composes the shared ToolRow (chrome, running sweep, whole-row expand) and
|
||||
// feeds it the file's line-numbered, syntax-highlighted content as ToolRow's
|
||||
// `read` card material, so it renders through ReadBlock in the collapsed-by-
|
||||
// default expanded body — the same unified interaction every other card row
|
||||
// has. The summary path is an openable host link. A running read (no result
|
||||
// yet) and a non-read result render the summary row alone: the read intent is
|
||||
// result-side only, so there is no running-state read card to draw.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { IconBrowseOutline16, ReadBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import css from './read-row.module.css'
|
||||
import { readCardModel } from '../contract/read-card-model.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../chat/ToolRow.tsx'
|
||||
import { NS } from '../locales.ts'
|
||||
|
||||
/** Leading-slot state substitution: the tool icon yields to the state dot
|
||||
* (error = red, interrupted = amber). Running keeps the icon. */
|
||||
function leadingFor(state: ToolRowState) {
|
||||
switch (state) {
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
default: return <IconBrowseOutline16 size={14} />
|
||||
}
|
||||
}
|
||||
|
||||
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
|
||||
function stateStatus(state: ToolRowState): string | null {
|
||||
switch (state) {
|
||||
case 'running': return '运行中'
|
||||
case 'error': return '失败'
|
||||
case 'stopped': return '已停止'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
/** Full row props: the toolview runtime share plus the standard locale seat. */
|
||||
type ReadRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
/**
|
||||
* Read row: icon + Read · {path} in the shared ToolRow chrome, with the file's
|
||||
* read card resident below it. The summary path is an openable host link when
|
||||
* the row names a single file; the card's copy and expand controls plus that
|
||||
* link are the row's only interactions (tool rows are not details-panel
|
||||
* targets).
|
||||
* read card as the row's collapsed-by-default card body. The summary path is an
|
||||
* openable host link when the row names a single file.
|
||||
*/
|
||||
export function ReadRow({ toolName, block, sessionId, useSessions, openFile }: ToolRowProps) {
|
||||
// Session workspace root: the read view's path relativizes against it (a
|
||||
// workspace-rooted absolute path shows its short form), which the pure
|
||||
// presenter cannot do.
|
||||
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
|
||||
export function ReadRow({ toolName, block, cwd, openFile, inspect, t }: ReadRowProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const read = readCardModel(block, cwd)
|
||||
const status = stateStatus(model.state)
|
||||
const filePath = model.filePath
|
||||
return (
|
||||
<div className={css.card}>
|
||||
{/* jscpd:ignore-start — the summary-line chrome (leading, status, title,
|
||||
sep, path-link/summary) is the shared ToolRow row shape every keyed
|
||||
toolview draws; extracting it into one component is a separate change
|
||||
tracked for all rows at once, not this read-card PR. */}
|
||||
<div className={css.root} data-variant="read" data-state={model.state}>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
<span className={css.title}>{model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
{filePath !== undefined ? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.fileLink}
|
||||
onClick={() => { openFile(filePath) }}
|
||||
>
|
||||
{model.summary}
|
||||
</button>
|
||||
) : (
|
||||
<span className={css.summary}>{model.summary}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* jscpd:ignore-end */}
|
||||
{read !== null && (
|
||||
<ReadBlock {...read} maxLines={CHAT_READ_MAX_LINES} className={css.read} />
|
||||
)}
|
||||
</div>
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={<IconBrowseOutline16 size={14} />}
|
||||
title={model.title}
|
||||
summary={model.summary}
|
||||
body={null}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
read={read}
|
||||
state={model.state}
|
||||
filePath={model.filePath}
|
||||
onOpenFile={openFile}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,6 +61,6 @@ export const readToolview = {
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read' }, ReadRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read', locale: NS }, ReadRow)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
/* Search toolview: same geometry/tokens as ToolRow and BashRow (figma
|
||||
Search · summary), plus the search card the row stacks resident under its
|
||||
summary line. */
|
||||
|
||||
/* Summary line over the search card; the summary row keeps its own 24px
|
||||
height, so the card is a column around it rather than a change to it. */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
|
||||
and replaces the primitive's standalone vertical margin with the flow's. */
|
||||
.search {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative; /* sweep-glare overlay anchor */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow / BashRow. */
|
||||
.root[data-state='running']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: dsh-search-row-sweep 2.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes dsh-search-row-sweep {
|
||||
0% { left: -300px; }
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
.leading {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.sep {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
margin: 0 8px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The result text for an errored search, indented to the card's own column and
|
||||
in the error tone, standing in for the search card the failure path does not
|
||||
produce. */
|
||||
.failure {
|
||||
margin: 4px 0 4px 22px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* The recovery footer for a capped search: the model-facing result text (its
|
||||
`Full … stored at …` locator) shown below the card in the muted tone, since
|
||||
the card holds only the retained rows. Same column indent as the card body. */
|
||||
.recovery {
|
||||
margin: 4px 0 4px 22px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
@@ -1,76 +1,62 @@
|
||||
// Search toolview registrant: the keyed toolview hole (ctx.slots.register +
|
||||
// ToolRowProps only — never imports the chat domain). One SearchRow component
|
||||
// registered under both `grep` and `glob`, since both tools declare the same
|
||||
// `card: 'search'` render intent and render as one visual object; the row reads
|
||||
// the `kind` discriminant off the derived model to draw grouped matches or a
|
||||
// path list. Product chrome matches ToolRow / BashRow (Search · {summary}).
|
||||
//
|
||||
// A search call declares its render intent result-time only, so this row's
|
||||
// search card is resident below the summary rather than expand-gated: the row
|
||||
// itself has no expand control, and the card's own copy, per-file collapse, and
|
||||
// head/tail expand are the row's only interactions. CHAT_SEARCH_MAX_LINES is
|
||||
// passed as `maxLines` — the chat flow's tighter cap over the block's own
|
||||
// default of 16 — so a large result stays bounded in the message flow.
|
||||
// Search toolview registrant: the keyed toolview hole for the `grep` and `glob`
|
||||
// tools. One SearchRow component registered under both, since both declare the
|
||||
// same `card: 'search'` render intent and render as one visual object; the
|
||||
// derived model's `kind` decides the card shape (grouped matches or a path
|
||||
// list). The row composes the shared ToolRow (chrome, running sweep, whole-row
|
||||
// expand) and feeds it the completed search as ToolRow's `search` card
|
||||
// material, so it renders through SearchBlock in the collapsed-by-default
|
||||
// expanded body — with a capped search's recovery footer below the card. A
|
||||
// search declares its render intent result-time only, so a running row is the
|
||||
// summary line alone; a settled call with no search card (an errored search, a
|
||||
// nested run_code sub-dispatch, a legacy generic result) surfaces its
|
||||
// model-facing text through ToolRow's Output section instead.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { IconSearchOutline16, SearchBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../contract/search-card-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import { rowResultText, rowStateStatus } from '../contract/toolview-status.ts'
|
||||
import css from './search-row.module.css'
|
||||
import { searchCardModel } from '../contract/search-card-model.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../chat/ToolRow.tsx'
|
||||
import { NS } from '../locales.ts'
|
||||
|
||||
/** Leading-slot glyph substitution: the search icon yields to the terminal
|
||||
* state semantic (error = red, interrupted = amber). Running keeps the icon —
|
||||
* the row sweep carries the in-flight signal. */
|
||||
function leadingFor(state: ToolRowState) {
|
||||
switch (state) {
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
default: return <IconSearchOutline16 size={14} />
|
||||
}
|
||||
}
|
||||
/** Full row props: the toolview runtime share plus the standard locale seat. */
|
||||
type SearchRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
/**
|
||||
* Search row: icon + Search · {summary} in the shared ToolRow chrome, with the
|
||||
* completed search's card resident below it, and — when the result was capped —
|
||||
* the recovery footer below the card. The summary row is not a details-panel
|
||||
* control, so the card's copy, per-file collapse, and expand controls are the
|
||||
* row's only interactions. Registered under both `grep` and `glob`; the derived
|
||||
* model's `kind` decides the card shape.
|
||||
* completed search's card as the row's collapsed-by-default card body (a capped
|
||||
* search's recovery footer rides below it, inside ToolRow). Registered under
|
||||
* both `grep` and `glob`; the derived model's `kind` decides the card shape. A
|
||||
* settled call with no search card surfaces its model-facing text through
|
||||
* ToolRow's Output section, since the keyed SearchRow owns this render slot.
|
||||
*/
|
||||
export function SearchRow({ toolName, block }: ToolRowProps) {
|
||||
export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const search = searchCardModel(block)
|
||||
const status = rowStateStatus(model.state)
|
||||
// A settled call with no search card — an errored search (grep/glob emit no
|
||||
// result view on error), a successful nested run_code sub-dispatch, or a
|
||||
// legacy generic result — has its model-facing text nowhere else to go, since
|
||||
// the keyed SearchRow owns this render slot. Surface it as the fallback body.
|
||||
// A running call ('kind' absent) has no result to flatten; rowResultText
|
||||
// returns null for it, so the arm stays closed until settle.
|
||||
const settled = 'kind' in block
|
||||
const fallback = search === null && settled ? rowResultText(block) : null
|
||||
return (
|
||||
<div className={css.card}>
|
||||
<div className={css.root} data-variant="search" data-tool={toolName} data-state={model.state}>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
<span className={css.title}>{model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
{/* The result view's replacement title outranks the args-derived
|
||||
summary, matching the terminal card's description precedence. */}
|
||||
<span className={css.summary}>{search?.title ?? model.summary}</span>
|
||||
</div>
|
||||
{search !== null && (
|
||||
<SearchBlock {...search.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.search} />
|
||||
)}
|
||||
{/* A capped search drops rows from the card; its recovery locator (the
|
||||
`Full … stored at …` footer) lives only in the result text, so show it
|
||||
below the card so the one path to the dropped rows survives. */}
|
||||
{search?.recovery !== undefined && <div className={css.recovery}>{search.recovery}</div>}
|
||||
{fallback !== null && <div className={css.failure}>{fallback}</div>}
|
||||
</div>
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={<IconSearchOutline16 size={14} />}
|
||||
title={model.title}
|
||||
// The result view's replacement title outranks the args-derived summary,
|
||||
// matching the terminal card's description precedence.
|
||||
summary={search?.title ?? model.summary}
|
||||
body={null}
|
||||
// A settled call with no search card (errored search, nested run_code
|
||||
// sub-dispatch, legacy generic result) has its text nowhere else to go;
|
||||
// ToolRow's Output section carries it, and errorSummary its first line.
|
||||
// When a card is present ToolRow renders it instead of the output, so
|
||||
// passing model.output unconditionally is safe and keeps the four card
|
||||
// rows symmetric.
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
search={search}
|
||||
state={model.state}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,7 +76,7 @@ export const searchToolview = {
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep' }, SearchRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob' }, SearchRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob', locale: NS }, SearchRow)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
/* Web toolview: same geometry/tokens as ToolRow (figma icon · summary), plus
|
||||
the web card the row stacks under its summary line, mirroring the bash row's
|
||||
resident terminal card. */
|
||||
|
||||
/* Summary line over the web card; the summary row keeps its own 24px height,
|
||||
so the card is a column around it rather than a change to it. */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
|
||||
and replaces the primitive's standalone vertical margin with the flow's. */
|
||||
.web {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative; /* sweep-glare overlay anchor */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
|
||||
.root[data-state='running']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: dsh-web-row-sweep 2.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes dsh-web-row-sweep {
|
||||
0% { left: -300px; }
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
.leading {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.sep {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
margin: 0 8px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,24 +1,25 @@
|
||||
// Web toolview registrant: third-party posture over the keyed toolview hole
|
||||
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
|
||||
// Registered under BOTH web_search and web_fetch, since both declare the one
|
||||
// `web` render intent and render through the one WebBlock family; the row
|
||||
// discriminates on the toolName only to pick its icon and title.
|
||||
//
|
||||
// A web tool declares the `web` render intent at result time, so this row
|
||||
// renders the completed retrieval through WebBlock resident below its summary,
|
||||
// the same posture BashRow uses for the terminal card: no expand control on the
|
||||
// row itself, not a details-panel target, and the block's own expander keeps a
|
||||
// long source list from taking over the message flow (CHAT_WEB_MAX_SOURCES is
|
||||
// passed as maxSources — the chat flow's tighter cap over the block's default
|
||||
// of 16). Until the call settles there is no web card (the tools keep a generic
|
||||
// pending view), so a running row is the summary line alone.
|
||||
// Web toolview registrant: the keyed toolview hole for the `web_search` and
|
||||
// `web_fetch` tools. Registered under BOTH, since both declare the one `web`
|
||||
// render intent and render through the one WebBlock family; the row
|
||||
// discriminates on the toolName only to pick its icon and title. The row
|
||||
// composes the shared ToolRow (chrome, running sweep, whole-row expand) and
|
||||
// feeds it the completed retrieval as ToolRow's `web` card material, so it
|
||||
// renders through WebBlock in the collapsed-by-default expanded body — the same
|
||||
// unified interaction every other card row has. Until the call settles there is
|
||||
// no web card (the tools keep a generic pending view), so a running row is the
|
||||
// summary line alone.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { IconBrowseOutline16, IconSearchOutline16, StateDot, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconBrowseOutline16, IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import css from './web-row.module.css'
|
||||
import { webCardModel } from '../contract/web-card-model.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../chat/ToolRow.tsx'
|
||||
import { NS } from '../locales.ts'
|
||||
|
||||
/** Full row props: the toolview runtime share plus the standard locale seat. */
|
||||
type WebRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
/** web_fetch reads one URL; web_search queries. Titles are figma literals. */
|
||||
const WEB_TITLES: Record<string, string> = {
|
||||
@@ -26,49 +27,30 @@ const WEB_TITLES: Record<string, string> = {
|
||||
web_fetch: 'Fetch',
|
||||
}
|
||||
|
||||
/** Leading icon per tool, yielding to the state semantic while failed/stopped. */
|
||||
function leadingFor(toolName: string, state: ToolRowState) {
|
||||
switch (state) {
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
// Running keeps the icon — the row sweep carries the in-flight signal.
|
||||
default: return toolName === 'web_fetch' ? <IconBrowseOutline16 size={14} /> : <IconSearchOutline16 size={14} />
|
||||
}
|
||||
}
|
||||
|
||||
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
|
||||
function stateStatus(state: ToolRowState): string | null {
|
||||
switch (state) {
|
||||
case 'running': return '运行中'
|
||||
case 'error': return '失败'
|
||||
case 'stopped': return '已停止'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Web row: icon + Search/Fetch · {summary} in the shared ToolRow chrome, with
|
||||
* the completed retrieval's web card resident below it. The summary row is not
|
||||
* a details-panel control (tool rows stopped being one), so the card's own
|
||||
* links and expander are the row's only interactions.
|
||||
* the completed retrieval's web card as the row's collapsed-by-default card
|
||||
* body. The row discriminates on `toolName` only to pick its icon and title.
|
||||
*/
|
||||
export function WebRow({ toolName, block }: ToolRowProps) {
|
||||
export function WebRow({ toolName, block, inspect, t }: WebRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const web = webCardModel(block)
|
||||
const status = stateStatus(model.state)
|
||||
const icon = toolName === 'web_fetch' ? <IconBrowseOutline16 size={14} /> : <IconSearchOutline16 size={14} />
|
||||
return (
|
||||
<div className={css.card}>
|
||||
<div className={css.root} data-variant="web" data-tool={toolName} data-state={model.state}>
|
||||
<span className={css.leading}>{leadingFor(toolName, model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
<span className={css.title}>{WEB_TITLES[toolName] ?? model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
<span className={css.summary}>{model.summary}</span>
|
||||
</div>
|
||||
{web !== null && (
|
||||
<WebBlock {...web} maxSources={CHAT_WEB_MAX_SOURCES} className={css.web} />
|
||||
)}
|
||||
</div>
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
icon={icon}
|
||||
title={WEB_TITLES[toolName] ?? model.title}
|
||||
summary={model.summary}
|
||||
body={null}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
web={web}
|
||||
state={model.state}
|
||||
inspect={inspect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -86,7 +68,7 @@ export const webToolview = {
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search' }, WebRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch' }, WebRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search', locale: NS }, WebRow)
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch', locale: NS }, WebRow)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -88,15 +88,15 @@ describe('apply wiring', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('mounts the bash sample, the search rows, the read row, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => {
|
||||
it('mounts the bash sample, the read row, the file-mutation rows, the search rows (grep + glob), the web rows, and the product rows as keyed entries through the load-order seam', async () => {
|
||||
const b = await bench()
|
||||
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
|
||||
// service being present implies the chat entry declared the hole first. The
|
||||
// one search row registers under both grep and glob; the file-mutation
|
||||
// registrant claims both write and edit for the diff card; the web rows
|
||||
// register one component under both web tool names.
|
||||
// file-mutation registrant claims both write and edit for the diff card; the
|
||||
// one search row registers under both grep and glob; the web rows register
|
||||
// one component under both web tool names.
|
||||
const entries = b.slots.entries('conversation.chat.toolview')
|
||||
expect(entries.map(e => e.options.key)).toEqual(['bash', 'grep', 'glob', 'read', 'edit', 'write', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
|
||||
expect(entries.map(e => e.options.key)).toEqual(['bash', 'read', 'edit', 'write', 'grep', 'glob', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
|
||||
// Stats stick with the composer (not inside ChatView).
|
||||
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
|
||||
await b.runtime.dispose()
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/contract/diff-card-model.ts'
|
||||
@@ -24,6 +24,9 @@ import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
/** FileMutationRow's full prop shape (ToolRow runtime share + conversation locale seat). */
|
||||
type FileMutationRowProps = Parameters<typeof FileMutationRow>[0]
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
@@ -155,14 +158,23 @@ describe('FileMutationRow diff card', () => {
|
||||
phase: 'ready',
|
||||
})
|
||||
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): ToolRowProps => ({
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): FileMutationRowProps => ({
|
||||
callId: 'c1', toolName, block, openFile: vi.fn(), cwd: '/w/app',
|
||||
sessionId: SID, useSessions: bindSnapshotSelector(list()),
|
||||
} as unknown as ToolRowProps)
|
||||
t,
|
||||
} as unknown as FileMutationRowProps)
|
||||
|
||||
it('renders the applied diff under the summary row, without an expand gesture', () => {
|
||||
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
|
||||
const toggleRow = (view: { container: HTMLElement }) => {
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
}
|
||||
|
||||
it('collapses to the summary row; expanding reveals the applied diff card', () => {
|
||||
const view = render(<FileMutationRow {...rowProps(settled())} />)
|
||||
// The diff card is resident (no expand toggle needed).
|
||||
// The diff card is collapsed by default — not in the DOM until expanded.
|
||||
expect(view.container.querySelector('[data-diff]')).toBeNull()
|
||||
expect(view.queryByText('hello fixture')).toBeNull()
|
||||
toggleRow(view)
|
||||
expect(view.container.querySelector('[data-diff]')).not.toBeNull()
|
||||
expect(view.getByText('hello fixture')).toBeTruthy()
|
||||
expect(view.getByText('复制')).toBeTruthy()
|
||||
@@ -171,6 +183,7 @@ describe('FileMutationRow diff card', () => {
|
||||
it('the summary is a path link that opens the tool path through the host', () => {
|
||||
const openFile = vi.fn()
|
||||
const view = render(<FileMutationRow {...{ ...rowProps(settled()), openFile }} />)
|
||||
// The path link rides the collapsed summary, so it opens without expanding.
|
||||
fireEvent.click(view.getByRole('button', { name: 'notes/demo.txt' }))
|
||||
// The row passes the tool's own path; the injected openFile resolves it
|
||||
// against the session cwd (apply.ts), so the row must not resolve twice.
|
||||
@@ -184,6 +197,8 @@ describe('FileMutationRow diff card', () => {
|
||||
callView: { card: 'diff', title: 'Write notes/new.txt', diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture' }] },
|
||||
resultView: { card: 'diff', title: 'Write notes/new.txt', diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture' }] },
|
||||
}), 'write')} />)
|
||||
// The footer counts live inside the collapsed diff card.
|
||||
toggleRow(view)
|
||||
expect(view.getByText('└ +1 -0 · 1 file')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -197,13 +212,16 @@ describe('FileMutationRow diff card', () => {
|
||||
|
||||
it('a mutation call with no diff view renders the summary row alone', () => {
|
||||
const view = render(<FileMutationRow {...rowProps(settled({ callView: null, resultView: null }))} />)
|
||||
// No diff material: expanding shows the args-JSON body, never a diff card.
|
||||
expect(view.container.querySelector('[data-diff]')).toBeNull()
|
||||
toggleRow(view)
|
||||
expect(view.container.querySelector('[data-diff]')).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces the result text when an errored mutation has no diff card', () => {
|
||||
// write/edit return undefined from presentResult on isError, so the failure
|
||||
// has no diff — the row shows the model-facing error text instead of a bare
|
||||
// red dot.
|
||||
// has no diff — ToolRow shows the model-facing error text as the collapsed
|
||||
// summary's first line (errorSummary) instead of a bare red dot.
|
||||
const view = render(<FileMutationRow {...rowProps(settled({
|
||||
isError: true, callView: null, resultView: null,
|
||||
content: [{ type: 'text', text: 'old_string not found in notes/demo.txt' }],
|
||||
@@ -220,12 +238,13 @@ describe('FileMutationRow diff card', () => {
|
||||
expect(view.getByText('ToolError: sandbox_denied')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows no failure text for a successful diff or a running call', () => {
|
||||
it('shows no error summary for a successful diff or a running call', () => {
|
||||
// ToolRow's error-color summary line is set only on the error state.
|
||||
const ok = render(<FileMutationRow {...rowProps(settled())} />)
|
||||
expect(ok.container.querySelector('[class*="_failure_"]')).toBeNull()
|
||||
expect(ok.container.querySelector('[class*="_errorSummary_"]')).toBeNull()
|
||||
cleanup()
|
||||
const run = render(<FileMutationRow {...rowProps(running())} />)
|
||||
expect(run.container.querySelector('[class*="_failure_"]')).toBeNull()
|
||||
expect(run.container.querySelector('[class*="_errorSummary_"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the stopped state when the call was interrupted', () => {
|
||||
@@ -234,7 +253,8 @@ describe('FileMutationRow diff card', () => {
|
||||
error: { name: 'ToolError', code: 'interrupted' },
|
||||
}))} />)
|
||||
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
// The visually-hidden status label carries the stopped semantic for AT.
|
||||
// The amber StateDot is aria-hidden, so ToolRow carries the state to AT as
|
||||
// visually-hidden text; without it a stopped row is a colour-only signal.
|
||||
expect(view.getByText('已停止')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -250,12 +270,12 @@ describe('FileMutationRow diff card', () => {
|
||||
|
||||
describe('fileMutationToolview registration', () => {
|
||||
it('registers one component under both edit and write, and each disposes', () => {
|
||||
const registered: { key: string; disposed: boolean }[] = []
|
||||
const registered: { key: string; locale: unknown; disposed: boolean }[] = []
|
||||
const disposers: (() => void)[] = []
|
||||
const ctx = {
|
||||
slots: {
|
||||
register: ({ key }: { name: string; key: string }) => {
|
||||
const entry = { key, disposed: false }
|
||||
register: ({ key, locale }: { name: string; key: string; locale?: string }) => {
|
||||
const entry = { key, locale, disposed: false }
|
||||
registered.push(entry)
|
||||
const dispose = () => { entry.disposed = true }
|
||||
disposers.push(dispose)
|
||||
@@ -265,6 +285,8 @@ describe('fileMutationToolview registration', () => {
|
||||
}
|
||||
fileMutationToolview.apply(ctx as never)
|
||||
expect(registered.map(r => r.key).sort()).toEqual(['edit', 'write'])
|
||||
// Both keys claim the conversation locale seat ToolRow's body copy needs.
|
||||
expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation'])
|
||||
// The registrant's inject seam is the load-order contract the row relies on.
|
||||
expect(fileMutationToolview.inject).toEqual(['slots', 'conversation'])
|
||||
// Disposal removes each contribution (packages/AGENTS.md registry contract).
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// The read render intent on the web side: the pure readCardModel derivation
|
||||
// over the settled result view, and both conversation render sites that consume
|
||||
// it — the chat tool row (the keyed ReadRow and the GenericToolCard fallback,
|
||||
// each with the read card resident under the summary) and the details panel's
|
||||
// Output section. Also pins the keyed 'read' toolview registration.
|
||||
// each composing ToolRow with the read card as its collapsed-by-default expanded
|
||||
// body) and the details panel's Output section (resident, full height). Also
|
||||
// pins the keyed 'read' toolview registration.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
@@ -16,7 +17,7 @@ import type {
|
||||
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/contract/read-card-model.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
|
||||
@@ -128,11 +129,19 @@ describe('GenericToolCard read body', () => {
|
||||
callId: 'c1', toolName: 'web_fetch', block, openFile: vi.fn(), t,
|
||||
})
|
||||
|
||||
it('renders the read card resident under the summary, capped tighter than the panel', () => {
|
||||
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
|
||||
const toggleRow = (view: { container: HTMLElement }) => {
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
}
|
||||
|
||||
it('expands to the read card, capped tighter than the panel', () => {
|
||||
expect(CHAT_READ_MAX_LINES).toBeLessThan(16)
|
||||
// web_fetch lands on the read variant without its own keyed row, so the
|
||||
// fallback card owns the resident read block.
|
||||
// fallback card owns the read block once expanded.
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({ call: { name: 'web_fetch', argsRaw: WEB_FETCH_ARGS } }))} />)
|
||||
// Collapsed: no read card in the DOM yet.
|
||||
expect(view.container.querySelector('[data-read]')).toBeNull()
|
||||
toggleRow(view)
|
||||
expect(view.container.querySelector('[data-read]')).not.toBeNull()
|
||||
expect(contentTexts(view.container)).toContain('export const a = 1')
|
||||
// The gutter keeps the file's own line numbers.
|
||||
@@ -145,6 +154,7 @@ describe('GenericToolCard read body', () => {
|
||||
call: { name: 'echo', argsRaw: '{"text":"x"}' }, callView: null, resultView: null,
|
||||
}), openFile: vi.fn(), t,
|
||||
})} />)
|
||||
toggleRow(view)
|
||||
expect(view.container.querySelector('[data-read]')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -162,19 +172,34 @@ describe('ReadRow keyed toolview', () => {
|
||||
phase: 'ready',
|
||||
})
|
||||
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode): ToolRowProps => ({
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode): Parameters<typeof ReadRow>[0] => ({
|
||||
callId: 'c1', toolName: 'read', block, openFile: vi.fn(),
|
||||
sessionId: SID, useSessions: bindSnapshotSelector(list()),
|
||||
} as unknown as ToolRowProps)
|
||||
t,
|
||||
} as unknown as Parameters<typeof ReadRow>[0])
|
||||
|
||||
it('renders the file path summary and the resident read card', () => {
|
||||
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
|
||||
const toggleRow = (view: { container: HTMLElement }) => {
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
}
|
||||
|
||||
it('collapses to the path summary; the whole row toggles the read card', () => {
|
||||
const view = render(<ReadRow {...rowProps(settled())} />)
|
||||
expect(view.getByText('Read')).toBeTruthy()
|
||||
// The path appears twice: the row summary link and the card's banner label.
|
||||
// Collapsed: the path is the summary link alone, and the card is absent.
|
||||
expect(view.getAllByText('src/a.ts').length).toBe(1)
|
||||
expect(view.container.querySelector('[data-read]')).toBeNull()
|
||||
toggleRow(view)
|
||||
// Expanded: the summary link stays inline and the card's banner label adds a
|
||||
// second occurrence of the path.
|
||||
expect(view.getAllByText('src/a.ts').length).toBe(2)
|
||||
expect(view.container.querySelector('[data-read]')).not.toBeNull()
|
||||
expect(contentTexts(view.container)).toContain('export const a = 1')
|
||||
expect(view.getByText('显示 3 / 180 行')).toBeTruthy()
|
||||
// Collapse back in place: the card unmounts, the summary link returns.
|
||||
toggleRow(view)
|
||||
expect(view.container.querySelector('[data-read]')).toBeNull()
|
||||
expect(view.getAllByText('src/a.ts').length).toBe(1)
|
||||
})
|
||||
|
||||
it('the path summary opens the file through the host', () => {
|
||||
@@ -212,7 +237,8 @@ describe('ReadRow keyed toolview', () => {
|
||||
const registered: { name: unknown; key?: unknown }[] = []
|
||||
const ctx = { slots: { register: (options: { name: unknown; key?: unknown }) => { registered.push(options) } } } as unknown as Context
|
||||
readToolview.apply(ctx)
|
||||
expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read' }])
|
||||
// The row composes ToolRow, so it declares its locale namespace at the seat.
|
||||
expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read', locale: 'conversation' }])
|
||||
expect(readToolview.inject).toContain('conversation')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
// The search render intent on the web side: the pure searchCardModel derivation
|
||||
// over resultView, and the conversation render sites that consume it — the chat
|
||||
// tool row (GenericToolCard's expand-gated body and SearchRow's resident card)
|
||||
// and the details panel's Output section. The keyed registration under both grep
|
||||
// and glob is pinned here too.
|
||||
// tool row (GenericToolCard's fallback body and SearchRow, both composing the
|
||||
// shared ToolRow with the search card collapsed by default) and the details
|
||||
// panel's Output section (resident, full height). The keyed registration under
|
||||
// both grep and glob is pinned here too.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
@@ -13,7 +14,7 @@ import type {
|
||||
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/contract/search-card-model.ts'
|
||||
@@ -23,6 +24,9 @@ import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/G
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { SearchRow, searchToolview } from '../src/client/toolviews/search-row.tsx'
|
||||
|
||||
/** SearchRow now composes ToolRow, so its props include the locale `t` seat. */
|
||||
type SearchRowProps = Parameters<typeof SearchRow>[0]
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
/** Conversation-locale translate stub for the render sites' `t` seat. */
|
||||
@@ -228,21 +232,32 @@ describe('chat row search body (GenericToolCard fallback)', () => {
|
||||
})
|
||||
|
||||
describe('SearchRow keyed card', () => {
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowProps => ({
|
||||
callId: 'c1', toolName, block, openFile: vi.fn(), sessionId: SID,
|
||||
} as unknown as ToolRowProps)
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): SearchRowProps => ({
|
||||
callId: 'c1', toolName, block, openFile: vi.fn(), sessionId: SID, t,
|
||||
} as unknown as SearchRowProps)
|
||||
|
||||
it('renders the grep card resident under the summary row, without an expand gesture', () => {
|
||||
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
|
||||
const toggleRow = (view: { container: HTMLElement }) => {
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
}
|
||||
|
||||
it('collapses to the summary row; expanding reveals the grep card', () => {
|
||||
const view = render(<SearchRow {...rowProps(settledGrep(), 'grep')} />)
|
||||
expect(view.getByText('Search')).toBeTruthy()
|
||||
// Collapsed: the card is not in the DOM until the row is expanded.
|
||||
expect(searchKindOf(view.container)).toBeNull()
|
||||
expect(view.queryByText(/const foo = 1/)).toBeNull()
|
||||
toggleRow(view)
|
||||
expect(searchRows(view.container)).toContain('12: const foo = 1')
|
||||
expect(searchKindOf(view.container)).toBe('matches')
|
||||
// The card's controls are the row's only interactions.
|
||||
// The card's copy control lives inside the expanded body.
|
||||
expect(view.getByText('复制')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the glob path card resident', () => {
|
||||
it('expands to the glob path card', () => {
|
||||
const view = render(<SearchRow {...rowProps(settledGlob(), 'glob')} />)
|
||||
expect(searchKindOf(view.container)).toBeNull()
|
||||
toggleRow(view)
|
||||
expect(view.getByText('src/a.ts')).toBeTruthy()
|
||||
expect(searchKindOf(view.container)).toBe('paths')
|
||||
})
|
||||
@@ -250,7 +265,7 @@ describe('SearchRow keyed card', () => {
|
||||
it('agrees with the summary row about the run state', () => {
|
||||
const runningView = render(<SearchRow {...rowProps(runningGrep(), 'grep')} />)
|
||||
expect(runningView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('running')
|
||||
// No result view yet, so no resident card.
|
||||
// No result view yet, so no card even once material could expand.
|
||||
expect(searchKindOf(runningView.container)).toBeNull()
|
||||
cleanup()
|
||||
const errorView = render(<SearchRow {...rowProps(settledGrep({
|
||||
@@ -259,28 +274,35 @@ describe('SearchRow keyed card', () => {
|
||||
expect(errorView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('error')
|
||||
})
|
||||
|
||||
it('surfaces the result text when an errored search has no card', () => {
|
||||
it('surfaces the result text through the Output section when an errored search has no card', () => {
|
||||
// grep/glob return no presentResult on error → no card; the row shows the
|
||||
// model-facing error text instead of a bare red dot.
|
||||
// first error line as the collapsed summary and the full text once expanded.
|
||||
const view = render(<SearchRow {...rowProps(settledGrep({
|
||||
isError: true, resultView: null,
|
||||
content: [{ type: 'text', text: 'grep: invalid regular expression' }],
|
||||
}), 'grep')} />)
|
||||
expect(searchKindOf(view.container)).toBeNull()
|
||||
// Error state: the first line is the collapsed summary.
|
||||
expect(view.getByText('grep: invalid regular expression')).toBeTruthy()
|
||||
toggleRow(view)
|
||||
// Now in ToolRow's Output section too (the kept summary makes it appear twice).
|
||||
expect(view.container.querySelector('[data-error]')?.textContent).toBe('grep: invalid regular expression')
|
||||
})
|
||||
|
||||
it('surfaces the result text for a settled non-error call with no card', () => {
|
||||
it('surfaces the result text for a settled non-error call with no card once expanded', () => {
|
||||
// A successful nested run_code sub-dispatch (backend computes no
|
||||
// presentationMeta, so resultView is null) or a legacy generic result settles
|
||||
// with search === null and state ok. The keyed SearchRow owns the slot, so
|
||||
// without the widened arm the content would be lost behind a bare summary.
|
||||
// ToolRow's Output section carries the text; it is only visible expanded.
|
||||
const view = render(<SearchRow {...rowProps(settledGrep({
|
||||
isError: false, resultView: null,
|
||||
content: [{ type: 'text', text: 'nested run_code output line' }],
|
||||
}), 'grep')} />)
|
||||
expect(view.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('ok')
|
||||
expect(searchKindOf(view.container)).toBeNull()
|
||||
// Collapsed: the ok row shows its args summary, not the output text.
|
||||
expect(view.queryByText('nested run_code output line')).toBeNull()
|
||||
toggleRow(view)
|
||||
expect(view.getByText('nested run_code output line')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -290,12 +312,15 @@ describe('SearchRow keyed card', () => {
|
||||
content: [{ type: 'text', text: recovery }],
|
||||
resultView: resultMatches({ truncated: true, total: 42 }),
|
||||
}), 'grep')} />)
|
||||
toggleRow(view)
|
||||
expect(searchKindOf(view.container)).toBe('matches')
|
||||
expect(view.getByText(/Full grep result stored at: spill:\/\/grep-1/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows no recovery footer for an uncapped search', () => {
|
||||
const view = render(<SearchRow {...rowProps(settledGrep(), 'grep')} />)
|
||||
toggleRow(view)
|
||||
expect(searchKindOf(view.container)).toBe('matches')
|
||||
expect(view.container.textContent).not.toMatch(/stored at/)
|
||||
})
|
||||
|
||||
@@ -304,6 +329,7 @@ describe('SearchRow keyed card', () => {
|
||||
isError: true, resultView: null, content: [],
|
||||
error: { name: 'ToolError', code: 'timeout' },
|
||||
}), 'grep')} />)
|
||||
// Error state: the derived name/code line is the collapsed summary.
|
||||
expect(view.getByText('ToolError: timeout')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -320,16 +346,18 @@ describe('SearchRow keyed card', () => {
|
||||
})
|
||||
|
||||
it('registers the one row component under both grep and glob keys', () => {
|
||||
const registered: { key: unknown; component: unknown }[] = []
|
||||
const registered: { key: unknown; locale: unknown; component: unknown }[] = []
|
||||
const ctx = {
|
||||
slots: {
|
||||
register: (options: { name: string; key: string }, component: unknown) => {
|
||||
registered.push({ key: options.key, component })
|
||||
register: (options: { name: string; key: string; locale?: string }, component: unknown) => {
|
||||
registered.push({ key: options.key, locale: options.locale, component })
|
||||
},
|
||||
},
|
||||
} as never
|
||||
searchToolview.apply(ctx)
|
||||
expect(registered.map(r => r.key)).toEqual(['grep', 'glob'])
|
||||
// Both keys claim the conversation locale seat ToolRow's body copy needs.
|
||||
expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation'])
|
||||
// One component, two keys.
|
||||
expect(registered[0]!.component).toBe(SearchRow)
|
||||
expect(registered[1]!.component).toBe(SearchRow)
|
||||
|
||||
@@ -4,17 +4,19 @@
|
||||
// WebRow (registered under both web_search and web_fetch), the GenericToolCard
|
||||
// render-site fallback, and the details panel's Output section. Mirrors
|
||||
// terminal-card.spec.tsx: model derivation + null arms, both kinds, the chat
|
||||
// row's resident card, the panel arm, and the keyed registration.
|
||||
// row's collapsed-by-default ToolRow card, the panel arm, and the keyed
|
||||
// registration. WebRow now composes the shared ToolRow, so its web card is
|
||||
// collapsed by default and appears only once the whole row is expanded.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SelectionTarget, ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../src/client/contract/web-card-model.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
@@ -122,36 +124,49 @@ describe('chat row web body', () => {
|
||||
const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowOwnerProps => ({
|
||||
callId: block.callId, toolName, block, openFile: vi.fn(),
|
||||
})
|
||||
// WebRow reads only toolName/block off the full runtime share; the standard
|
||||
// kit is unused, so the cast supplies the owner slice alone (as BashRow's
|
||||
// tests do for the terminal card).
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowProps =>
|
||||
ownerProps(block, toolName) as unknown as ToolRowProps
|
||||
// WebRow reads only toolName/block off the full runtime share plus the locale
|
||||
// seat; the standard kit is unused, so the cast supplies the owner slice and
|
||||
// `t` alone (as BashRow's tests do for the terminal card).
|
||||
const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): Parameters<typeof WebRow>[0] =>
|
||||
({ ...ownerProps(block, toolName), t } as unknown as Parameters<typeof WebRow>[0])
|
||||
|
||||
it('the WebRow renders the search card resident under the summary, capped tighter than the panel', () => {
|
||||
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
|
||||
const toggleRow = (view: { container: HTMLElement }) => {
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
}
|
||||
|
||||
it('the WebRow collapses to the summary row, expanding to the search card capped tighter than the panel', () => {
|
||||
expect(CHAT_WEB_MAX_SOURCES).toBeLessThan(16)
|
||||
const view = render(<WebRow {...rowProps(settledSearch(), 'web_search')} />)
|
||||
// The summary row plus the resident card, without any expand gesture on the row itself.
|
||||
// Collapsed: the summary row alone, no card in the DOM.
|
||||
expect(view.getByText('Search')).toBeTruthy()
|
||||
expect(view.queryByText('Titled')).toBeNull()
|
||||
expect(view.container.querySelector('[data-web]')).toBeNull()
|
||||
toggleRow(view)
|
||||
// Expanded: the resident search card with every source field.
|
||||
expect(view.getByText('Titled')).toBeTruthy()
|
||||
expect(view.getByText('excerpt')).toBeTruthy()
|
||||
// hostname fallback for the source with no title
|
||||
expect(view.getByText('plain.example.org')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('the WebRow renders the fetch card resident, titled Fetch', () => {
|
||||
it('the WebRow expands to the fetch card, titled Fetch', () => {
|
||||
const view = render(<WebRow {...rowProps(settledFetch(), 'web_fetch')} />)
|
||||
expect(view.getByText('Fetch')).toBeTruthy()
|
||||
// The url shows in the summary row and as the card's link; scope to the card.
|
||||
expect(view.container.querySelector('[data-web]')).toBeNull()
|
||||
toggleRow(view)
|
||||
// The url shows as the card's link; scope to the card.
|
||||
const card = view.container.querySelector('[data-web="fetch"]')
|
||||
expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page')
|
||||
expect(view.getByText('HTTP 200')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a running web call is the summary row alone (no card until it settles)', () => {
|
||||
it('a running web call is the summary row alone, with nothing to expand', () => {
|
||||
const view = render(<WebRow {...rowProps(runningSearch(), 'web_search')} />)
|
||||
expect(view.getByText('Search')).toBeTruthy()
|
||||
expect(view.queryByText('Titled')).toBeNull()
|
||||
// No card material and no expandable body: clicking the row reveals nothing.
|
||||
expect(view.container.querySelector('[data-expandable]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-web]')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -165,12 +180,14 @@ describe('chat row web body', () => {
|
||||
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('the GenericToolCard fallback also renders a resident web card for a web-declaring tool', () => {
|
||||
it('the GenericToolCard fallback also expands to a web card for a web-declaring tool', () => {
|
||||
// A web-declaring tool without its own keyed row lands on the fallback; its
|
||||
// card is resident there too.
|
||||
// card routes through the same collapsed-by-default ToolRow.
|
||||
const view = render(<GenericToolCard {...ownerProps(settledSearch({
|
||||
call: { name: 'fx-web', argsRaw: SEARCH_ARGS },
|
||||
}), 'fx-web')} t={t} />)
|
||||
expect(view.container.querySelector('[data-web]')).toBeNull()
|
||||
toggleRow(view)
|
||||
expect(view.getByText('Titled')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-web="search"]')).not.toBeNull()
|
||||
})
|
||||
@@ -250,17 +267,19 @@ describe('DetailsPanel web Output section', () => {
|
||||
|
||||
describe('web toolview registration', () => {
|
||||
it('registers one WebRow under both web_search and web_fetch', () => {
|
||||
const registered: { key: string; component: unknown }[] = []
|
||||
const registered: { key: string; locale: unknown; component: unknown }[] = []
|
||||
const ctx = {
|
||||
slots: {
|
||||
register: (options: { name: string; key: string }, component: unknown) => {
|
||||
registered.push({ key: options.key, component })
|
||||
register: (options: { name: string; key: string; locale?: string }, component: unknown) => {
|
||||
registered.push({ key: options.key, locale: options.locale, component })
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
} as unknown as import('cordis').Context
|
||||
webToolview.apply(ctx)
|
||||
expect(registered.map(r => r.key)).toEqual(['web_search', 'web_fetch'])
|
||||
// Both keys claim the conversation locale seat ToolRow's body copy needs.
|
||||
expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation'])
|
||||
// One component under both keys, not two thin rows.
|
||||
expect(registered[0]?.component).toBe(WebRow)
|
||||
expect(registered[1]?.component).toBe(WebRow)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
|
||||
README.md: 7d586808ec58f10bb556c220d29b39a01da66416
|
||||
README.zh.md: a5dd4567f356075078f708222fdf48f5514c5bc7
|
||||
README.md: f06e8c014c19d17197980a232e8b42080aac2901
|
||||
README.zh.md: feb97ee9fa834fbff10052909630ffcbbce9c78a
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, SearchBlock, DiffBlock, and WebBlock. Contract: api-contracts v3 §8.
|
||||
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, DiffBlock, ReadBlock, SearchBlock, and WebBlock. Contract: api-contracts v3 §8.
|
||||
|
||||
## Hover cards
|
||||
|
||||
@@ -16,14 +16,18 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
|
||||
|
||||
`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md).
|
||||
|
||||
## Search results
|
||||
## Read rendering
|
||||
|
||||
`SearchBlock` renders a completed search, one component for both kinds (discriminated by `kind`). A `matches` (grep) shows each file as a bold path header with its `lineNumber: line` rows, the per-file group collapsible; a `paths` (glob) shows a flat path list. Both flatten to one row list the height cap slices head/tail over (default 16, the TerminalBlock split arithmetic), and neither soft-wraps — a long match line or path scrolls horizontally instead of folding. The banner summary folds the pre-cap total in when the tool capped the result (`显示 X / 共 N 处匹配 · K 个文件` for grep, `显示 X / 共 N 个路径` for glob), so the card never presents a capped result as complete; a copy control writes the whole structured result regardless of the cap or which groups are collapsed. Geometry mirrors CodeBlock/TerminalBlock. Rationale: [the web search card note](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md).
|
||||
`ReadBlock` renders a returned file window as a line-numbered, syntax-highlighted code surface: a bold path (or presenter-supplied title) banner with a copy control, then the content lines with their file line numbers in a gutter (a windowed read keeps the file's own numbering, so a read past an offset starts above 1). A `totalLines` exceeding the window count draws a `showing N of M` note, and the body collapses to a head slice plus a tail slice past `maxLines` (default 16, the TerminalBlock split arithmetic) behind an expand button. Highlighting runs through the same shiki path as `CodeBlock`. Rationale: [the web read card note](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card.md).
|
||||
|
||||
## Diff rendering
|
||||
|
||||
`DiffBlock` renders a file mutation as an inline diff surface: one bold path header per file, the removed lines (`- `, error token) above the added lines (`+ `, success token), a `⋯` gap before a same-file second hunk, and a dim `└ +A -R · N file(s)` footer. Lines are `white-space: pre` with horizontal scrolling, so a source line holds its indentation instead of soft-wrapping, and the body collapses to a head slice plus a tail slice past `maxLines` (default 16, `TerminalBlock`'s split arithmetic) behind an expand button. A create (`oldText: null`) has no removed side. The copy control writes the prefixed diff text (path headers, `- `/`+ ` lines, the gap) so a multi-file copy stays attributable, and floats in the top-right corner rather than on a banner row of its own. Geometry mirrors `CodeBlock`/`TerminalBlock`. The `+`/`-` block form mirrors the TUI transcript's diff card so a diff reads the same across front ends. Rationale: [the web diff card note](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md).
|
||||
|
||||
## Search results
|
||||
|
||||
`SearchBlock` renders a completed search, one component for both kinds (discriminated by `kind`). A `matches` (grep) shows each file as a bold path header with its `lineNumber: line` rows, the per-file group collapsible; a `paths` (glob) shows a flat path list. Both flatten to one row list the height cap slices head/tail over (default 16, the TerminalBlock split arithmetic), and neither soft-wraps — a long match line or path scrolls horizontally instead of folding. The banner summary folds the pre-cap total in when the tool capped the result (`显示 X / 共 N 处匹配 · K 个文件` for grep, `显示 X / 共 N 个路径` for glob), so the card never presents a capped result as complete; a copy control writes the whole structured result regardless of the cap or which groups are collapsed. Geometry mirrors CodeBlock/TerminalBlock. Rationale: [the web search card note](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md).
|
||||
|
||||
## Web retrieval
|
||||
|
||||
`WebBlock` renders a completed web retrieval, one component for both kinds of the `web` render intent (discriminated by `kind`). A `search` shows an optional provider answer (through `MarkdownText`) above an ordered citation list: each source is a safe external link labelled by its title, or its hostname, falling back to the raw URL when the URL does not parse or has no hostname (a `file:`/`data:` URL) so a label is never blank; its snippet and publication date render below it. Only http(s) URLs become anchors (`target`/`rel` set) — the http(s) subset of the allowlist `MarkdownText` applies to untrusted links (it also permits `mailto:`, excluded here); any other URL renders as plain text. A long list caps at `maxSources` (default 16, the TerminalBlock split arithmetic) with a head/tail collapse; the collapsed tail keeps each source's original citation number via `<li value>`, and the expand control is a marker-less `<li>` so the `<ol>` stays valid HTML. When a search legitimately returns no answer and no sources, the card shows an explicit empty-state note rather than a blank `<ol>` (the chat row does not surface the raw result content). A `fetch` shows a compact summary: the linked final URL and its HTTP status. Both mark a capped retrieval. Rationale: [the web result card note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、SearchBlock、DiffBlock,以及 WebBlock。契约:api-contracts v3 §8。
|
||||
纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、DiffBlock、ReadBlock、SearchBlock,以及 WebBlock。契约:api-contracts v3 §8。
|
||||
|
||||
## 悬浮卡片
|
||||
|
||||
@@ -11,18 +11,23 @@
|
||||
## Markdown 渲染
|
||||
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
|
||||
|
||||
## 终端输出
|
||||
|
||||
`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
|
||||
|
||||
## 搜索结果
|
||||
## Read 渲染
|
||||
|
||||
`SearchBlock` 渲染一次已完成的搜索,并通过 `kind` 判别,由一个组件处理两种结果。`matches`(grep)将每个文件显示为粗体路径头及其 `lineNumber: line` 行,各文件组均可折叠;`paths`(glob)显示扁平的路径列表。两者都摊平成一个行列表,由高度上限对其做头尾切片(默认 16,与 `TerminalBlock` 相同的切分算法),且都不软换行:较长的匹配行或路径会横向滚动而非折行。当工具截断结果时,banner 摘要会包含截断前的总数(grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径`),使卡片绝不把截断后的结果呈现为完整结果;无论是否触及上限或哪些组处于折叠状态,复制控件都会写入完整的结构化结果。几何结构与 `CodeBlock`/`TerminalBlock` 一致。原理:[Web 搜索卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)。
|
||||
`ReadBlock` 将返回的文件窗口渲染为带行号、语法高亮的代码表层:一个粗体路径(或 presenter 提供的标题)横幅加复制控件,其下是内容行,行号槽里是文件自身的行号(窗口化的 read 保留文件本身的编号,因此偏移之后的 read 从大于 1 处起始)。`totalLines` 超过窗口行数时画出 `showing N of M` 提示;超过 `maxLines`(默认 16,与 TerminalBlock 相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。高亮走与 `CodeBlock` 相同的 shiki 路径。原理:[Web read 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card.md)。
|
||||
|
||||
## Diff 渲染
|
||||
|
||||
`DiffBlock` 将一次文件改动渲染为内联 diff 表层:每个文件一个粗体路径头、删除行(`- `,error token)在新增行(`+ `,success token)之上、同文件第二个 hunk 前一个 `⋯` gap,以及暗色 `└ +A -R · N file(s)` 页脚。各行使用 `white-space: pre` 并横向滚动,因此源码行保留其缩进而不软换行;超过 `maxLines`(默认 16,与 `TerminalBlock` 相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。新建(`oldText: null`)没有删除侧。复制控件写入带前缀的 diff 文本(路径头、`- `/`+ ` 行、gap),使多文件复制保持可归属,并浮在右上角而非占据自己的 banner 行。几何镜像 `CodeBlock`/`TerminalBlock`。`+`/`-` 块形式镜像 TUI 转录的 diff 卡片,使 diff 在两个前端读起来一致。原理:[Web diff 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)。
|
||||
|
||||
## 搜索结果
|
||||
|
||||
`SearchBlock` 渲染一次已完成的搜索,并通过 `kind` 判别,由一个组件处理两种结果。`matches`(grep)将每个文件显示为粗体路径头及其 `lineNumber: line` 行,各文件组均可折叠;`paths`(glob)显示扁平的路径列表。两者都摊平成一个行列表,由高度上限对其做头尾切片(默认 16,与 `TerminalBlock` 相同的切分算法),且都不软换行:较长的匹配行或路径会横向滚动而非折行。当工具截断结果时,banner 摘要会包含截断前的总数(grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径`),使卡片绝不把截断后的结果呈现为完整结果;无论是否触及上限或哪些组处于折叠状态,复制控件都会写入完整的结构化结果。几何结构与 `CodeBlock`/`TerminalBlock` 一致。原理:[Web 搜索卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)。
|
||||
|
||||
## Web 检索
|
||||
|
||||
`WebBlock` 渲染一次已完成的 web 检索,用一个组件绘制 `web` 渲染意图的两种 kind(由 `kind` 判别)。`search` 在有序引用列表上方显示可选的 provider answer(通过 `MarkdownText`):每个 source 是一个安全外链,以其标题为标签,或以其主机名为标签,当 URL 无法解析或没有主机名(`file:`/`data:` URL)时回退到原始 URL,因此标签绝不为空;其下渲染 snippet 与发布日期。只有 http(s) URL 会成为锚点(设置 `target`/`rel`)——这是 `MarkdownText` 对不受信任链接所用 allowlist 的 http(s) 子集(该 allowlist 还允许 `mailto:`,此处排除);任何其他 URL 渲染为纯文本。长列表在 `maxSources`(默认 16,即 TerminalBlock 的切分算术)处折叠为头部/尾部;折叠的尾部通过 `<li value>` 保留每个 source 原始的引用编号,展开控件是无 marker 的 `<li>`,使 `<ol>` 保持为合法 HTML。当一次 search 合法地返回无 answer 且无 source 时,卡片显示一个明确的空状态提示,而不是空的 `<ol>`(chat 行不呈现原始 result content)。`fetch` 显示一个紧凑摘要:带链接的最终 URL 及其 HTTP 状态。两者都会标记一次被截断的检索。原理:[Web result 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)。
|
||||
|
||||
@@ -24,14 +24,14 @@ export { JsonTree } from './JsonTree.tsx'
|
||||
export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'
|
||||
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
|
||||
export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx'
|
||||
export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx'
|
||||
export type {
|
||||
SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch,
|
||||
} from './SearchBlock.tsx'
|
||||
export { ReadBlock, DEFAULT_READ_MAX_LINES } from './ReadBlock.tsx'
|
||||
export type { ReadBlockProps, ReadBlockLine } from './ReadBlock.tsx'
|
||||
export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx'
|
||||
export type { DiffBlockProps, DiffHunk } from './DiffBlock.tsx'
|
||||
export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx'
|
||||
export type {
|
||||
SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch,
|
||||
} from './SearchBlock.tsx'
|
||||
export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx'
|
||||
export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView } from './WebBlock.tsx'
|
||||
export { CodeBlock } from './markdown/CodeBlock.tsx'
|
||||
|
||||
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
@@ -39,6 +39,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^22.20.0
|
||||
version: 22.20.0
|
||||
'@types/spdx-expression-parse':
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
'@typescript-eslint/parser':
|
||||
specifier: 8.61.0
|
||||
version: 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
|
||||
@@ -99,6 +102,12 @@ importers:
|
||||
publint:
|
||||
specifier: ^0.3.21
|
||||
version: 0.3.21
|
||||
smol-toml:
|
||||
specifier: ^1.7.1
|
||||
version: 1.7.1
|
||||
spdx-expression-parse:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
tsdown:
|
||||
specifier: ^0.22.2
|
||||
version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3)
|
||||
@@ -8804,6 +8813,9 @@ packages:
|
||||
'@types/retry@0.12.0':
|
||||
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
|
||||
|
||||
'@types/spdx-expression-parse@4.0.0':
|
||||
resolution: {integrity: sha512-odQzy87phelGS4inXOzjmusx4hoCVD0IbxUANxHzVkmTzMRTNnUPoq1urIl7S1qf09KcDWKLFIftPmLtgbsAHA==}
|
||||
|
||||
'@types/tough-cookie@4.0.5':
|
||||
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
|
||||
|
||||
@@ -11151,6 +11163,10 @@ packages:
|
||||
resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
smol-toml@1.7.1:
|
||||
resolution: {integrity: sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -11162,6 +11178,15 @@ packages:
|
||||
space-separated-tokens@2.0.2:
|
||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
|
||||
spdx-exceptions@2.5.0:
|
||||
resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==}
|
||||
|
||||
spdx-expression-parse@5.0.0:
|
||||
resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==}
|
||||
|
||||
spdx-license-ids@3.0.23:
|
||||
resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==}
|
||||
|
||||
speakingurl@14.0.1:
|
||||
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -13788,6 +13813,8 @@ snapshots:
|
||||
|
||||
'@types/retry@0.12.0': {}
|
||||
|
||||
'@types/spdx-expression-parse@4.0.0': {}
|
||||
|
||||
'@types/tough-cookie@4.0.5': {}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
@@ -16654,12 +16681,23 @@ snapshots:
|
||||
|
||||
smol-toml@1.6.1: {}
|
||||
|
||||
smol-toml@1.7.1: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map@0.6.1: {}
|
||||
|
||||
space-separated-tokens@2.0.2: {}
|
||||
|
||||
spdx-exceptions@2.5.0: {}
|
||||
|
||||
spdx-expression-parse@5.0.0:
|
||||
dependencies:
|
||||
spdx-exceptions: 2.5.0
|
||||
spdx-license-ids: 3.0.23
|
||||
|
||||
spdx-license-ids@3.0.23: {}
|
||||
|
||||
speakingurl@14.0.1: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
208
scripts/gen-third-party-notices.spec.ts
Normal file
208
scripts/gen-third-party-notices.spec.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { collectPythonDependencies, isPermissive, type Manifest, manifestPatterns, parsePyprojectRequirements, parseVendoredRows, render, tierExternalDeps } from './gen-third-party-notices.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
describe('THIRD_PARTY_NOTICES.md', () => {
|
||||
// Freshness lives here rather than in its own doc-sync gate: this spec file
|
||||
// already runs in the test lane, so the check costs no extra CI process.
|
||||
// Pre-commit regenerates the file whenever a manifest is staged, so reaching
|
||||
// this assertion means the notices were committed without that hook.
|
||||
it('matches what the generator produces from the current manifests', () => {
|
||||
expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(render())
|
||||
})
|
||||
})
|
||||
|
||||
/** Build the (manifests, names) pair `tierExternalDeps` consumes. */
|
||||
function workspace(entries: Record<string, Manifest>): { manifests: Map<string, Manifest>; names: Set<string> } {
|
||||
const manifests = new Map(Object.entries(entries))
|
||||
const names = new Set<string>()
|
||||
for (const manifest of manifests.values()) {
|
||||
if (manifest.name !== undefined) names.add(manifest.name)
|
||||
}
|
||||
return { manifests, names }
|
||||
}
|
||||
|
||||
describe('tierExternalDeps', () => {
|
||||
it('tiers by declaring area, not by the declaring section name', () => {
|
||||
const { manifests, names } = workspace({
|
||||
// Root tooling and test infrastructure never ship, whichever section declares them.
|
||||
'package.json': { dependencies: { 'root-runtime-looking': '^1' }, devDependencies: { 'lint-tool': '^1' } },
|
||||
'packages/support/loader-smoke/package.json': { name: '@deepseek-ai/dsh-loader-smoke', dependencies: { 'smoke-helper': '^1' } },
|
||||
'packages/client/test-runtime/package.json': { name: '@deepseek-ai/dsh-client-test-runtime', dependencies: { 'test-lib': '^1' } },
|
||||
'website/package.json': { devDependencies: { 'site-tool': '^1' } },
|
||||
// A plugin package's runtime dependency ships even when no app mounts it by default.
|
||||
'packages/mcp/mcp-client/package.json': { name: '@deepseek-ai/dsh-mcp-client', dependencies: { 'protocol-sdk': '^1' }, devDependencies: { 'protocol-fixture-server': '^1' } },
|
||||
'apps/cli/package.json': { name: '@deepseek-ai/dsh-cli', dependencies: { 'cli-lib': '^1', '@deepseek-ai/dsh-mcp-client': 'workspace:^' } },
|
||||
})
|
||||
|
||||
expect(tierExternalDeps(manifests, names)).toEqual(new Map([
|
||||
['tsx', true],
|
||||
['root-runtime-looking', false],
|
||||
['lint-tool', false],
|
||||
['smoke-helper', false],
|
||||
['test-lib', false],
|
||||
['site-tool', false],
|
||||
['protocol-sdk', true],
|
||||
['protocol-fixture-server', false],
|
||||
['cli-lib', true],
|
||||
]))
|
||||
})
|
||||
|
||||
it('keeps a package runtime when any shipping area declares it, and excludes workspace links', () => {
|
||||
const { manifests, names } = workspace({
|
||||
'package.json': { devDependencies: { shared: '^1' } },
|
||||
'packages/ui/tui/package.json': { name: '@deepseek-ai/dsh-tui', dependencies: { shared: '^1', '@deepseek-ai/dsh-cli': 'workspace:^' } },
|
||||
'apps/cli/package.json': { name: '@deepseek-ai/dsh-cli' },
|
||||
})
|
||||
|
||||
expect(tierExternalDeps(manifests, names).get('shared')).toBe(true)
|
||||
expect(tierExternalDeps(manifests, names).has('@deepseek-ai/dsh-cli')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseVendoredRows', () => {
|
||||
it('reads the committed vendor manifest table', () => {
|
||||
const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8'))
|
||||
|
||||
expect(rows.length).toBeGreaterThan(0)
|
||||
expect(rows).toContainEqual({ npmName: 'cordis', upstream: 'https://github.com/cordiverse/cordis' })
|
||||
// The upstream column carries a trailing package path for some rows; it is not part of the URL.
|
||||
expect(rows.every(row => /^https:\/\/\S+$/.test(row.upstream))).toBe(true)
|
||||
})
|
||||
|
||||
it('yields nothing when the table shape changes, so the generator fails loud', () => {
|
||||
expect(parseVendoredRows('| `cordis/` | cordis | 4.0.0 | https://example.com | `abc123` |\n')).toEqual([])
|
||||
})
|
||||
|
||||
it('covers every vendored directory, so no package can drop out of the notices', () => {
|
||||
const parsed = new Set(parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8')).map(row => row.npmName))
|
||||
const onDisk = readdirSync(resolve(root, 'vendor'), { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => (JSON.parse(readFileSync(resolve(root, 'vendor', entry.name, 'package.json'), 'utf8')) as Manifest).name)
|
||||
|
||||
expect([...onDisk].sort()).toEqual([...parsed].sort())
|
||||
})
|
||||
})
|
||||
|
||||
describe('parsePyprojectRequirements', () => {
|
||||
it('reads the committed manifests', () => {
|
||||
expect(parsePyprojectRequirements(readFileSync(resolve(root, 'python/sdk/pyproject.toml'), 'utf8'))).toContain('pydantic')
|
||||
})
|
||||
|
||||
it('locates requirement arrays by TOML table, so author-named groups are not missed', () => {
|
||||
expect(parsePyprojectRequirements([
|
||||
'[build-system]',
|
||||
'requires = ["hatchling>=1.24.0"]',
|
||||
'',
|
||||
'[project]',
|
||||
'name = "not-a-requirement"',
|
||||
'dependencies = ["pydantic>=2.12"]',
|
||||
'',
|
||||
'[project.optional-dependencies]',
|
||||
'cli = ["click"]',
|
||||
'',
|
||||
'[dependency-groups]',
|
||||
'docs = ["sphinx>=7"]',
|
||||
'',
|
||||
'[tool.hatch.build.targets.wheel]',
|
||||
'packages = ["src/deepseek_harness"]',
|
||||
'',
|
||||
'[tool.pytest.ini_options]',
|
||||
'testpaths = ["tests"]',
|
||||
].join('\n'))).toEqual(['hatchling', 'pydantic', 'click', 'sphinx'])
|
||||
})
|
||||
|
||||
it('does not truncate an array at a bracket inside extras', () => {
|
||||
expect(parsePyprojectRequirements('[project]\ndependencies = ["httpx[http2]", "requests"]\n'))
|
||||
.toEqual(['httpx', 'requests'])
|
||||
})
|
||||
|
||||
it('reads names whether or not requirements carry versions, extras, or markers', () => {
|
||||
expect(parsePyprojectRequirements("[project]\ndependencies = [\"pydantic>=2.12\", \"requests\", \"httpx[http2]\", \"tomli ; python_version < '3.11'\", \"hatchling >= 1.24.0\"]\n"))
|
||||
.toEqual(['pydantic', 'requests', 'httpx', 'tomli', 'hatchling'])
|
||||
})
|
||||
|
||||
it('reads single-quoted TOML literals and rejects an unreadable requirement', () => {
|
||||
expect(parsePyprojectRequirements("[project]\ndependencies = ['requests', \"pydantic>=2\"]\n")).toEqual(['requests', 'pydantic'])
|
||||
expect(() => parsePyprojectRequirements('[project]\ndependencies = ["!!broken"]\n')).toThrow(/cannot read a distribution name/)
|
||||
})
|
||||
|
||||
it('reads a multi-line array', () => {
|
||||
expect(parsePyprojectRequirements('[project]\ndependencies = [\n "pydantic>=2.12",\n "typing-extensions",\n]\n'))
|
||||
.toEqual(['pydantic', 'typing-extensions'])
|
||||
})
|
||||
|
||||
it('obeys TOML comments, quoted keys, and escaped strings', () => {
|
||||
expect(parsePyprojectRequirements([
|
||||
'[project] # a legal header comment',
|
||||
'dependencies = [',
|
||||
' "pydantic", # ] does not close the array',
|
||||
' # "old-package" is not a dependency',
|
||||
' "tomli; python_version < \'3.11\'",',
|
||||
']',
|
||||
'',
|
||||
'[dependency-groups]',
|
||||
'"test.docs" = ["pytest"]',
|
||||
].join('\n'))).toEqual(['pydantic', 'tomli', 'pytest'])
|
||||
})
|
||||
|
||||
it('accepts dependency-group includes and rejects unsupported requirement shapes', () => {
|
||||
expect(parsePyprojectRequirements('[dependency-groups]\nbase = ["pytest"]\nall = [{ include-group = "base" }]\n'))
|
||||
.toEqual(['pytest'])
|
||||
expect(() => parsePyprojectRequirements('[project]\ndependencies = "pytest"\n')).toThrow(/must be an array/)
|
||||
expect(() => parsePyprojectRequirements('[dependency-groups]\ntest = [{ unknown = "pytest" }]\n')).toThrow(/unsupported requirement entry/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectPythonDependencies', () => {
|
||||
it('excludes normalized local project names without exempting a third-party prefix', () => {
|
||||
const pyprojects = [
|
||||
'[project]\nname = "deepseek-harness-runtime-bin"\ndependencies = ["pydantic"]\n',
|
||||
'[project]\nname = "deepseek-harness"\ndependencies = ["DeepSeek.Harness_Runtime-Bin", "deepseek-unrelated"]\n',
|
||||
]
|
||||
expect(() => collectPythonDependencies(pyprojects)).toThrow(
|
||||
'python dependency deepseek-unrelated is missing from PYTHON_METADATA',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPermissive', () => {
|
||||
it('accepts the licenses this project ships and rejects copyleft or unknown ones', () => {
|
||||
expect(['MIT', 'ISC', 'BSD-3-Clause', 'Apache-2.0', 'MIT / Apache-2.0', '(MIT OR CC0-1.0)'].every(isPermissive)).toBe(true)
|
||||
expect(['LGPL-3.0-only', 'MPL-2.0', 'GPL-3.0-or-later', 'SEE LICENSE IN LICENSE'].some(isPermissive)).toBe(false)
|
||||
})
|
||||
|
||||
it('requires every operand of an AND, so a copyleft conjunct cannot ride along', () => {
|
||||
expect(isPermissive('(MIT OR Apache-2.0) AND GPL-3.0-only')).toBe(false)
|
||||
expect(isPermissive('MIT AND ISC')).toBe(true)
|
||||
// An exception clause is not a recognized identifier, so it fails closed.
|
||||
expect(isPermissive('GPL-2.0-only WITH Classpath-exception-2.0')).toBe(false)
|
||||
})
|
||||
|
||||
it('honors grouping and SPDX precedence', () => {
|
||||
expect(isPermissive('MIT OR (GPL-3.0-only AND GPL-2.0-only)')).toBe(true)
|
||||
expect(isPermissive('(MIT OR Apache-2.0) AND ISC')).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed for malformed expressions, additions, and exceptions', () => {
|
||||
expect(['MIT)', '((MIT', '(MIT OR GPL-3.0-only', 'MIT OR OR GPL-3.0-only'].some(isPermissive)).toBe(false)
|
||||
expect(isPermissive('MIT+')).toBe(false)
|
||||
expect(isPermissive('GPL-2.0-only WITH Classpath-exception-2.0')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('manifestPatterns', () => {
|
||||
it('derives globs from the declared members, so a new member area is read', () => {
|
||||
expect(manifestPatterns(['packages/*/*', 'tools/*'], ['packages/*'])).toEqual([
|
||||
'package.json',
|
||||
'packages/*/*/package.json',
|
||||
'tools/*/package.json',
|
||||
'examples/*/package.json',
|
||||
'native/landlock-run/package.json',
|
||||
'native/landlock-run/packages/*/package.json',
|
||||
])
|
||||
})
|
||||
})
|
||||
596
scripts/gen-third-party-notices.ts
Normal file
596
scripts/gen-third-party-notices.ts
Normal file
@@ -0,0 +1,596 @@
|
||||
/**
|
||||
* Generate `THIRD_PARTY_NOTICES.md` from the workspace manifests: every
|
||||
* external dependency named by a workspace `package.json`, the vendored-package
|
||||
* manifest in `vendor/README.md`, the Python `pyproject.toml` files, and the
|
||||
* pnpm patch list. License and repository metadata come from the installed
|
||||
* store, so the tree must be installed. `--check` verifies the committed
|
||||
* artifact. Tier policy and ownership live in
|
||||
* `.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md`.
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml'
|
||||
import parseSpdx from 'spdx-expression-parse'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'THIRD_PARTY_NOTICES.md'
|
||||
|
||||
/** Dependency-declaration kinds a consumer resolves at runtime. */
|
||||
const RUNTIME_KINDS = ['dependencies', 'optionalDependencies'] as const
|
||||
/** All manifest sections that name an external package this file must disclose. */
|
||||
const ALL_KINDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const
|
||||
|
||||
/**
|
||||
* Workspace areas that never reach a user: repository tooling and gates (the
|
||||
* root manifest), test infrastructure, the documentation site, the runnable
|
||||
* demo leaves, and the native launcher's build workspace. A runtime
|
||||
* declaration by anything outside these areas is a disclosure-relevant
|
||||
* runtime dependency, because `scripts/install.sh` installs the repository
|
||||
* itself and any plugin package can be mounted from a user's `cordis.yml`.
|
||||
*/
|
||||
const DEV_ONLY_AREAS = [
|
||||
'package.json',
|
||||
'packages/support/',
|
||||
'packages/client/test-runtime/',
|
||||
'website/',
|
||||
'examples/',
|
||||
'native/',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* First-party packages released from sibling repositories under the project's
|
||||
* own license: reachable from workspace manifests but not third-party.
|
||||
*/
|
||||
const FIRST_PARTY = new Set([
|
||||
'node-addon-landlock-run',
|
||||
'node-addon-landlock-run-linux-arm64',
|
||||
'node-addon-landlock-run-linux-x64',
|
||||
])
|
||||
|
||||
/**
|
||||
* Metadata overrides where the installed manifest is wrong or unreachable.
|
||||
* Each entry documents why the store cannot answer.
|
||||
*/
|
||||
const OVERRIDES: Record<string, { license?: string; repo?: string }> = {
|
||||
// Rust workspaces publishing npm bins without `license` in package.json.
|
||||
'oxlint': { license: 'MIT', repo: 'https://github.com/oxc-project/oxc' },
|
||||
'oxlint-tsgolint': { license: 'MIT', repo: 'https://github.com/oxc-project/tsgolint' },
|
||||
// `license: SEE LICENSE IN LICENSE`: the servers repo is mid MIT→Apache-2.0
|
||||
// relicensing, so the effective terms are per-contribution.
|
||||
'@modelcontextprotocol/server-everything': { license: 'MIT / Apache-2.0', repo: 'https://github.com/modelcontextprotocol/servers' },
|
||||
'@modelcontextprotocol/server-filesystem': { license: 'MIT / Apache-2.0', repo: 'https://github.com/modelcontextprotocol/servers' },
|
||||
// No repository field in the published manifest.
|
||||
'node-addon-require-builtin': { repo: 'https://www.npmjs.com/package/node-addon-require-builtin' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Python dependencies are few and named directly in `pyproject.toml` files
|
||||
* without installed metadata to harvest, so license/repo are recorded here and
|
||||
* the generator fails when a manifest names a package this map misses.
|
||||
*/
|
||||
const PYTHON_METADATA: Record<string, { license: string; repo: string; role: string }> = {
|
||||
pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness`' },
|
||||
hatchling: { license: 'MIT', repo: 'https://github.com/pypa/hatch', role: 'build backend' },
|
||||
pytest: { license: 'MIT', repo: 'https://github.com/pytest-dev/pytest', role: 'test-only' },
|
||||
}
|
||||
|
||||
type PythonMetadata = typeof PYTHON_METADATA
|
||||
|
||||
/** Tools fetched by scripts at build time, keyed by the pin the script owns. */
|
||||
const BUILD_TIME_TOOLS = [
|
||||
{
|
||||
name: '@yao-pkg/pkg',
|
||||
license: 'MIT',
|
||||
repo: 'https://github.com/yao-pkg/pkg',
|
||||
role: 'invoked by `scripts/build-exe-for-python-sdk.ts` to assemble the single-file SDK runtime executable',
|
||||
pinSource: 'scripts/build-exe-for-python-sdk.ts',
|
||||
},
|
||||
]
|
||||
|
||||
/** The `package.json` fields this generator reads. */
|
||||
export interface Manifest {
|
||||
name?: string
|
||||
private?: boolean
|
||||
license?: string
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
optionalDependencies?: Record<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
/** One disclosed external npm dependency. */
|
||||
interface ExternalDep {
|
||||
name: string
|
||||
license: string
|
||||
repo: string
|
||||
/** True when some shipped workspace consumer reaches it through runtime dependency edges. */
|
||||
runtime: boolean
|
||||
}
|
||||
|
||||
/** Read and parse a workspace-relative `package.json`. */
|
||||
function readManifest(rel: string): Manifest {
|
||||
return JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as Manifest
|
||||
}
|
||||
|
||||
/**
|
||||
* Manifest globs, derived from the workspace declarations rather than listed
|
||||
* here, so a new member area (`tools/*`) is read the day it is declared.
|
||||
* @returns one glob per manifest-bearing location, repository-relative.
|
||||
*/
|
||||
export function manifestPatterns(rootMembers: readonly string[], nativeMembers: readonly string[]): string[] {
|
||||
return [
|
||||
'package.json',
|
||||
...rootMembers.map(member => `${member}/package.json`),
|
||||
// The demo leaves join the workspace through `examples/package.json`, so
|
||||
// their own manifests are members of nothing and no glob above reaches them.
|
||||
'examples/*/package.json',
|
||||
// `native/landlock-run` is a nested workspace with its own lock file.
|
||||
'native/landlock-run/package.json',
|
||||
...nativeMembers.map(member => `native/landlock-run/${member}/package.json`),
|
||||
]
|
||||
}
|
||||
|
||||
/** The `packages:` member globs declared by one pnpm workspace file. */
|
||||
function workspaceMembers(rel: string): string[] {
|
||||
const declared = (yaml.load(readFileSync(resolve(root, rel), 'utf8')) as { packages?: unknown }).packages
|
||||
if (!Array.isArray(declared) || declared.length === 0) {
|
||||
throw new Error(`gen-third-party-notices: ${rel} declares no workspace members; the manifest set cannot be derived.`)
|
||||
}
|
||||
return declared.map(member => String(member))
|
||||
}
|
||||
|
||||
/** Every workspace manifest, keyed by path, plus the set of workspace package names. */
|
||||
function loadWorkspaceManifests(): { manifests: Map<string, Manifest>; names: Set<string> } {
|
||||
const patterns = manifestPatterns(workspaceMembers('pnpm-workspace.yaml'), workspaceMembers('native/landlock-run/pnpm-workspace.yaml'))
|
||||
const manifests = new Map<string, Manifest>()
|
||||
const names = new Set<string>()
|
||||
for (const pattern of patterns) {
|
||||
for (const path of globSync(pattern, { cwd: root })) {
|
||||
const manifest = readManifest(path)
|
||||
manifests.set(path, manifest)
|
||||
if (manifest.name !== undefined) names.add(manifest.name)
|
||||
}
|
||||
}
|
||||
if (manifests.size < 100) throw new Error(`gen-third-party-notices: only ${manifests.size} workspace manifests found; the glob set is stale.`)
|
||||
return { manifests, names }
|
||||
}
|
||||
|
||||
/** License and repository URL for an installed external package, from the pnpm store. */
|
||||
function installedMetadata(name: string): { license: string; repo: string } {
|
||||
const override = OVERRIDES[name]
|
||||
let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined
|
||||
// The nested Landlock workspace installs into its own store, so a package
|
||||
// only that workspace depends on is unreachable from the root one.
|
||||
for (const store of ['node_modules', 'native/landlock-run/node_modules']) {
|
||||
const direct = resolve(root, store, name, 'package.json')
|
||||
if (existsSync(direct)) {
|
||||
manifest = JSON.parse(readFileSync(direct, 'utf8')) as typeof manifest
|
||||
break
|
||||
}
|
||||
const virtual = resolve(root, store, '.pnpm')
|
||||
if (!existsSync(virtual)) continue
|
||||
const prefix = `${name.replace('/', '+')}@`
|
||||
const entry = readdirSync(virtual).find(dir => dir.startsWith(prefix))
|
||||
if (entry === undefined) continue
|
||||
manifest = JSON.parse(readFileSync(resolve(virtual, entry, 'node_modules', name, 'package.json'), 'utf8')) as typeof manifest
|
||||
break
|
||||
}
|
||||
const license = override?.license ?? manifest?.license
|
||||
const rawRepo = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url ?? manifest?.homepage
|
||||
const repo = override?.repo ?? normalizeRepo(rawRepo)
|
||||
if (license === undefined || repo === undefined) {
|
||||
throw new Error(`gen-third-party-notices: cannot resolve ${license === undefined ? 'license' : 'repository'} for ${name}; run \`pnpm install\` (or, for a Landlock-only dependency, \`pnpm --dir native/landlock-run install\`), or add an OVERRIDES entry.`)
|
||||
}
|
||||
return { license, repo }
|
||||
}
|
||||
|
||||
/** Normalize a manifest repository/homepage value to a browsable https URL. */
|
||||
function normalizeRepo(raw: string | undefined): string | undefined {
|
||||
if (raw === undefined || raw === '') return undefined
|
||||
let url = raw
|
||||
.replace(/^git\+ssh:\/\/git@/, 'https://')
|
||||
.replace(/^git\+/, '')
|
||||
.replace(/^git:\/\//, 'https://')
|
||||
.replace(/^github:/, 'https://github.com/')
|
||||
.replace(/\.git$/, '')
|
||||
if (!url.startsWith('http')) url = `https://github.com/${url}`
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* External npm dependencies, tiered by which workspace area declares them at
|
||||
* runtime: a package is runtime when any manifest outside `DEV_ONLY_AREAS`
|
||||
* names it in `dependencies`/`optionalDependencies`. A package declared only
|
||||
* by tooling, test infrastructure, the website, or the demo leaves — whatever
|
||||
* the declaring section is called — is development-only.
|
||||
*/
|
||||
function collectNpmDeps(): ExternalDep[] {
|
||||
const { manifests, names } = loadWorkspaceManifests()
|
||||
return [...tierExternalDeps(manifests, names)]
|
||||
.filter(([name]) => !FIRST_PARTY.has(name))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([name, runtime]) => ({ name, ...installedMetadata(name), runtime }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier every external dependency the workspace declares.
|
||||
* @param manifests - workspace manifests keyed by repository-relative path.
|
||||
* @param names - every workspace package name, which never counts as external.
|
||||
* @returns each external package mapped to whether it is a runtime dependency.
|
||||
*/
|
||||
export function tierExternalDeps(manifests: Map<string, Manifest>, names: Set<string>): Map<string, boolean> {
|
||||
const tiers = new Map<string, boolean>()
|
||||
// `tsx` is runtime by fiat: `bin/dsh` execs the CLI through its ESM hook.
|
||||
tiers.set('tsx', true)
|
||||
for (const [path, manifest] of manifests) {
|
||||
const devOnly = DEV_ONLY_AREAS.some(area => (area.endsWith('/') ? path.startsWith(area) : path === area))
|
||||
for (const kind of ALL_KINDS) {
|
||||
for (const [dep, range] of Object.entries(manifest[kind] ?? {})) {
|
||||
if (names.has(dep) || range.startsWith('workspace:')) continue
|
||||
const runtime = !devOnly && (RUNTIME_KINDS as readonly string[]).includes(kind)
|
||||
tiers.set(dep, (tiers.get(dep) ?? false) || runtime)
|
||||
}
|
||||
}
|
||||
}
|
||||
return tiers
|
||||
}
|
||||
|
||||
/** A vendored package row parsed out of the `vendor/README.md` manifest table. */
|
||||
export interface VendoredRow {
|
||||
npmName: string
|
||||
upstream: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the vendored-package manifest table out of `vendor/README.md`.
|
||||
* @param text - the complete `vendor/README.md` contents.
|
||||
* @returns one row per manifest-table entry, in table order.
|
||||
*/
|
||||
export function parseVendoredRows(text: string): VendoredRow[] {
|
||||
const rows: VendoredRow[] = []
|
||||
for (const line of text.split('\n')) {
|
||||
const match = /^\| \x60\S+\/\x60 \| \x60([^\x60]+)\x60 \| \S+ \| (https:\/\/\S+?)(?: \([^)]*\))? \| \x60[0-9a-f]+\x60 \|$/.exec(line)
|
||||
if (match === null) continue
|
||||
const [, npmName, upstream] = match
|
||||
if (npmName === undefined || upstream === undefined) continue
|
||||
rows.push({ npmName, upstream })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the vendored manifest table and confirm it accounts for every vendored
|
||||
* directory. The `vendor/` tree — not the table — is the set that must be
|
||||
* disclosed, so a row that stops matching the table format is a hard error
|
||||
* rather than a package that quietly vanishes from the notices.
|
||||
*/
|
||||
function collectVendored(): VendoredRow[] {
|
||||
const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8'))
|
||||
const onDisk = new Map<string, string>()
|
||||
for (const entry of readdirSync(resolve(root, 'vendor'), { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const manifest = readManifest(`vendor/${entry.name}/package.json`)
|
||||
if (manifest.name !== undefined) onDisk.set(manifest.name, entry.name)
|
||||
}
|
||||
|
||||
const parsed = new Set(rows.map(row => row.npmName))
|
||||
const missing = [...onDisk.keys()].filter(name => !parsed.has(name))
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`gen-third-party-notices: vendor/README.md has no manifest-table row for ${missing.join(', ')}; its table format changed or the sync is incomplete.`)
|
||||
}
|
||||
for (const row of rows) {
|
||||
const dir = onDisk.get(row.npmName)
|
||||
if (dir === undefined) throw new Error(`gen-third-party-notices: vendored package ${row.npmName} from vendor/README.md has no vendor/ directory.`)
|
||||
const license = readManifest(`vendor/${dir}/package.json`).license
|
||||
if (license !== 'MIT') {
|
||||
throw new Error(`gen-third-party-notices: vendored ${row.npmName} declares license ${JSON.stringify(license)}; the vendored section assumes MIT throughout.`)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** Whether a parsed TOML value is a table rather than an array or scalar. */
|
||||
function isTomlTable(value: TomlValueWithoutBigInt | undefined): value is TomlTableWithoutBigInt {
|
||||
return value !== undefined && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** Parse one PEP 508 requirement string into its distribution name. */
|
||||
function parsePythonRequirement(requirement: string): string {
|
||||
const name = /^\s*([a-zA-Z][a-zA-Z0-9._-]*)\s*(?:\[[^\]]*\])?\s*(?:[<>=!~;@].*)?$/.exec(requirement)?.[1]
|
||||
if (name === undefined) {
|
||||
throw new Error(`gen-third-party-notices: cannot read a distribution name from the requirement ${JSON.stringify(requirement)}.`)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/** Add the string requirements from one parsed TOML array. */
|
||||
function collectPythonRequirementArray(
|
||||
names: string[],
|
||||
value: TomlValueWithoutBigInt | undefined,
|
||||
location: string,
|
||||
allowGroupIncludes = false,
|
||||
): void {
|
||||
if (value === undefined) return
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`gen-third-party-notices: ${location} must be an array.`)
|
||||
}
|
||||
for (const item of value) {
|
||||
if (typeof item === 'string') {
|
||||
names.push(parsePythonRequirement(item))
|
||||
continue
|
||||
}
|
||||
if (allowGroupIncludes && isTomlTable(item) && typeof item['include-group'] === 'string' && Object.keys(item).length === 1) {
|
||||
continue
|
||||
}
|
||||
throw new Error(`gen-third-party-notices: ${location} contains an unsupported requirement entry.`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read an optional TOML table and reject a present value of another shape. */
|
||||
function optionalTomlTable(value: TomlValueWithoutBigInt | undefined, location: string): TomlTableWithoutBigInt | undefined {
|
||||
if (value === undefined || isTomlTable(value)) return value
|
||||
throw new Error(`gen-third-party-notices: ${location} must be a table.`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `pyproject.toml` project identity and every requirement it declares:
|
||||
* `requires` under
|
||||
* `[build-system]`, `dependencies` under `[project]`, and every key under
|
||||
* `[project.optional-dependencies]` and `[dependency-groups]`. A TOML parser
|
||||
* owns comments, quoted keys, escapes, and array boundaries; unsupported
|
||||
* requirement shapes fail instead of disappearing from the notices.
|
||||
* @param text - the complete `pyproject.toml` contents.
|
||||
* @returns the local project name and declared requirement names.
|
||||
*/
|
||||
function parsePyproject(text: string): { projectName?: string; requirements: string[] } {
|
||||
const names: string[] = []
|
||||
const document = parseToml(text, { integersAsBigInt: false })
|
||||
const buildSystem = optionalTomlTable(document['build-system'], '[build-system]')
|
||||
const project = optionalTomlTable(document.project, '[project]')
|
||||
const projectName = project?.name
|
||||
if (projectName !== undefined && typeof projectName !== 'string') {
|
||||
throw new Error('gen-third-party-notices: [project].name must be a string.')
|
||||
}
|
||||
collectPythonRequirementArray(names, buildSystem?.requires, '[build-system].requires')
|
||||
collectPythonRequirementArray(names, project?.dependencies, '[project].dependencies')
|
||||
|
||||
const optional = optionalTomlTable(project?.['optional-dependencies'], '[project.optional-dependencies]')
|
||||
for (const [group, requirements] of Object.entries(optional ?? {})) {
|
||||
collectPythonRequirementArray(names, requirements, `[project.optional-dependencies].${group}`)
|
||||
}
|
||||
|
||||
const groups = optionalTomlTable(document['dependency-groups'], '[dependency-groups]')
|
||||
for (const [group, requirements] of Object.entries(groups ?? {})) {
|
||||
collectPythonRequirementArray(names, requirements, `[dependency-groups].${group}`, true)
|
||||
}
|
||||
return projectName === undefined
|
||||
? { requirements: names }
|
||||
: { projectName, requirements: names }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every requirement name declared by one `pyproject.toml`.
|
||||
* @param text - the complete `pyproject.toml` contents.
|
||||
* @returns each declared requirement's distribution name, in file order.
|
||||
*/
|
||||
export function parsePyprojectRequirements(text: string): string[] {
|
||||
return parsePyproject(text).requirements
|
||||
}
|
||||
|
||||
/** Normalize a Python distribution name according to the packaging name rule. */
|
||||
function normalizePythonDistributionName(name: string): string {
|
||||
return name.toLowerCase().replace(/[-_.]+/g, '-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve external Python dependencies after excluding local project names.
|
||||
* @param pyprojects - complete local `pyproject.toml` contents.
|
||||
* @param metadata - disclosure metadata for every external dependency.
|
||||
* @returns disclosed dependencies in normalized name order.
|
||||
*/
|
||||
export function collectPythonDependencies(
|
||||
pyprojects: string[],
|
||||
metadata: PythonMetadata = PYTHON_METADATA,
|
||||
): { name: string; license: string; repo: string; role: string }[] {
|
||||
const parsed = pyprojects.map(parsePyproject)
|
||||
const firstParty = new Set(parsed.flatMap(({ projectName }) => (
|
||||
projectName === undefined ? [] : [normalizePythonDistributionName(projectName)]
|
||||
)))
|
||||
const found = new Set(parsed
|
||||
.flatMap(({ requirements }) => requirements.map(normalizePythonDistributionName))
|
||||
.filter(name => !firstParty.has(name)))
|
||||
return [...found].sort((a, b) => a.localeCompare(b)).map((name) => {
|
||||
const entry = metadata[name]
|
||||
if (entry === undefined) throw new Error(`gen-third-party-notices: python dependency ${name} is missing from PYTHON_METADATA.`)
|
||||
return { name, ...entry }
|
||||
})
|
||||
}
|
||||
|
||||
/** Direct Python dependencies named by the `pyproject.toml` manifests under `python/`. */
|
||||
function collectPython(): { name: string; license: string; repo: string; role: string }[] {
|
||||
const manifests = globSync('python/*/pyproject.toml', { cwd: root })
|
||||
if (manifests.length === 0) throw new Error('gen-third-party-notices: no python/*/pyproject.toml found; the Python tree moved.')
|
||||
return collectPythonDependencies(manifests.map(path => readFileSync(resolve(root, path), 'utf8')))
|
||||
}
|
||||
|
||||
/** pnpm-patched external packages, from `pnpm-workspace.yaml`. */
|
||||
function collectPatched(): { spec: string; patch: string }[] {
|
||||
const workspace = yaml.load(readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8')) as { patchedDependencies?: Record<string, string> }
|
||||
return Object.entries(workspace.patchedDependencies ?? {}).map(([spec, patch]) => ({ spec, patch }))
|
||||
}
|
||||
|
||||
/** Verify each build-time tool pin still appears in its owning script. */
|
||||
function verifyBuildTimePins(): void {
|
||||
for (const tool of BUILD_TIME_TOOLS) {
|
||||
const text = readFileSync(resolve(root, tool.pinSource), 'utf8')
|
||||
if (!text.includes(tool.name)) {
|
||||
throw new Error(`gen-third-party-notices: ${tool.pinSource} no longer references ${tool.name}; update BUILD_TIME_TOOLS.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** SPDX identifiers this project may ship without further review. */
|
||||
const PERMISSIVE_LICENSES = new Set(['MIT', 'ISC', 'BSD-2-Clause', 'BSD-3-Clause', 'Apache-2.0', '0BSD', 'Unlicense', 'CC0-1.0', 'BlueOak-1.0.0', 'Python-2.0'])
|
||||
|
||||
/** Evaluate a parsed SPDX expression under the repository's license policy. */
|
||||
function isPermissiveSpdx(expression: ReturnType<typeof parseSpdx>): boolean {
|
||||
if ('conjunction' in expression) {
|
||||
return expression.conjunction === 'and'
|
||||
? isPermissiveSpdx(expression.left) && isPermissiveSpdx(expression.right)
|
||||
: isPermissiveSpdx(expression.left) || isPermissiveSpdx(expression.right)
|
||||
}
|
||||
return expression.plus !== true
|
||||
&& expression.exception === undefined
|
||||
&& PERMISSIVE_LICENSES.has(expression.license)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an SPDX expression grants terms this project may ship under.
|
||||
* `OR` needs one permissive alternative, because the consumer chooses; `AND`
|
||||
* needs all of them, because every obligation applies. Anything that is not a
|
||||
* recognized permissive identifier — copyleft, an exception clause, or a
|
||||
* license this list has never seen — evaluates to false, so an unfamiliar
|
||||
* expression fails closed rather than passing on a partial match.
|
||||
* @param license - the SPDX expression from the package manifest.
|
||||
* @returns true when the expression's obligations are all permissive.
|
||||
*/
|
||||
export function isPermissive(license: string): boolean {
|
||||
// Some npm manifests use a slash for a choice despite SPDX requiring `OR`.
|
||||
const normalized = license.replace(/\s*\/\s*/g, ' OR ').trim()
|
||||
try {
|
||||
return isPermissiveSpdx(parseSpdx(normalized))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the sentence that isolates non-permissive development tooling, or
|
||||
* nothing at all when every development dependency is permissive.
|
||||
* @param deps - development dependencies whose license is not permissive.
|
||||
* @returns the paragraph to place after the development table.
|
||||
*/
|
||||
function renderNonPermissiveNote(deps: ExternalDep[]): string {
|
||||
if (deps.length === 0) return ''
|
||||
const named = deps.map(dep => `\`${dep.name}\` (${dep.license})`)
|
||||
const subject = named.length === 1 ? named[0] : `${named.slice(0, -1).join(', ')} and ${named.at(-1)}`
|
||||
return `\n${subject} ${named.length === 1 ? 'runs' : 'run'} only as development tooling; their code is not linked into or distributed with any DeepSeek Harness artifact.\n`
|
||||
}
|
||||
|
||||
/** Render one npm dependency table. */
|
||||
function renderNpmTable(deps: ExternalDep[]): string {
|
||||
const lines = ['| Package | License |', '| --- | --- |']
|
||||
for (const dep of deps) lines.push(`| [\`${dep.name}\`](${dep.repo}) | ${dep.license} |`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the complete notices document.
|
||||
* @returns the exact bytes `THIRD_PARTY_NOTICES.md` must hold.
|
||||
*/
|
||||
export function render(): string {
|
||||
verifyBuildTimePins()
|
||||
const npm = collectNpmDeps()
|
||||
const runtimeDeps = npm.filter(dep => dep.runtime)
|
||||
const devDeps = npm.filter(dep => !dep.runtime)
|
||||
const vendored = collectVendored()
|
||||
const python = collectPython()
|
||||
const patched = collectPatched()
|
||||
|
||||
const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license))
|
||||
// A copyleft license reaching a shipped surface is a distribution decision,
|
||||
// not a rendering detail; the notices cannot quietly absorb it.
|
||||
const nonPermissiveRuntime = runtimeDeps.filter(dep => !isPermissive(dep.license))
|
||||
if (nonPermissiveRuntime.length > 0) {
|
||||
throw new Error(`gen-third-party-notices: runtime ${nonPermissiveRuntime.map(dep => `${dep.name} (${dep.license})`).join(', ')} is not a permissive license; review the distribution terms and record the decision before regenerating.`)
|
||||
}
|
||||
const patchedLines = patched.map(({ spec, patch }) => `- \`${spec}\` — [\`${patch}\`](${patch})`)
|
||||
|
||||
return `<!-- Generated by scripts/gen-third-party-notices.ts — do not edit by hand.
|
||||
Run \`pnpm run gen-third-party-notices\` to regenerate. -->
|
||||
|
||||
# Third-Party Notices
|
||||
|
||||
DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms.
|
||||
|
||||
This file lists **direct** dependencies declared by the workspace. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check.
|
||||
|
||||
The complete npm transitive closure, with exact pinned versions, is recorded in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python closure is recorded in [\`python/sdk/uv.lock\`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [\`native/landlock-run/pnpm-lock.yaml\`](native/landlock-run/pnpm-lock.yaml).
|
||||
|
||||
## Vendored source (\`vendor/\`)
|
||||
|
||||
The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm. All are MIT-licensed; each directory preserves its upstream \`LICENSE\` file. Exact upstream commits and local modifications are recorded in [\`vendor/README.md\`](vendor/README.md).
|
||||
|
||||
| Package | Upstream | License |
|
||||
| --- | --- | --- |
|
||||
${vendored.map(row => `| \`${row.npmName}\` | [${row.upstream.replace('https://', '')}](${row.upstream}) | MIT |`).join('\n')}
|
||||
|
||||
## Runtime npm dependencies
|
||||
|
||||
External packages that a workspace package resolves at runtime. \`scripts/install.sh\` installs this repository itself, so the tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI/TUI, the Web UI, and the Python SDK runtime load by default.
|
||||
|
||||
${renderNpmTable(runtimeDeps)}
|
||||
|
||||
pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
|
||||
|
||||
${patchedLines.join('\n')}
|
||||
|
||||
## Development-only npm dependencies
|
||||
|
||||
External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — \`pnpm-lock.yaml\` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles.
|
||||
|
||||
${renderNpmTable(devDeps)}
|
||||
${renderNonPermissiveNote(nonPermissiveDev)}
|
||||
## Python SDK dependencies (\`python/\`)
|
||||
|
||||
Direct dependencies of the \`pyproject.toml\` manifests, plus \`uv\` as the development workflow tool.
|
||||
|
||||
| Package | License | Role |
|
||||
| --- | --- | --- |
|
||||
${python.map(dep => `| [\`${dep.name}\`](${dep.repo}) | ${dep.license} | ${dep.role} |`).join('\n')}
|
||||
| [\`uv\`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool |
|
||||
|
||||
## Fetched at build time
|
||||
|
||||
| Package | License | Role |
|
||||
| --- | --- | --- |
|
||||
${BUILD_TIME_TOOLS.map(tool => `| [\`${tool.name}\`](${tool.repo}) | ${tool.license} | ${tool.role} |`).join('\n')}
|
||||
|
||||
## First-party sibling releases
|
||||
|
||||
\`node-addon-landlock-run\` (and its platform packages) is released from a DeepSeek Harness sibling repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party.
|
||||
`
|
||||
}
|
||||
|
||||
/** CLI entry: default writes the notices, `--check` fails if the committed copy
|
||||
* is stale. Guarded behind an entry-point check so importing this module for
|
||||
* tests neither regenerates the committed file nor calls process.exit. */
|
||||
function main(): void {
|
||||
const content = render()
|
||||
if (process.argv.includes('--check')) {
|
||||
let committed: string | null = null
|
||||
try {
|
||||
committed = readFileSync(resolve(root, OUT), 'utf8')
|
||||
} catch {
|
||||
// Only ENOENT (not yet generated) is expected; a present-but-unreadable
|
||||
// file is not a state this repo produces, and the remedy is the same.
|
||||
committed = null
|
||||
}
|
||||
if (committed === content) {
|
||||
console.log(`gen-third-party-notices: ${OUT} is up to date.`)
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(`gen-third-party-notices: ${OUT} is stale. Run \`pnpm run gen-third-party-notices\` and commit ${OUT}.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
writeFileSync(resolve(root, OUT), content)
|
||||
console.log(`gen-third-party-notices: wrote ${OUT}.`)
|
||||
}
|
||||
|
||||
// Run only when invoked as a script, not when imported by a test.
|
||||
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
|
||||
main()
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user