From 23fc9cc22611e6e96a564ef7a053e1ae23cc929b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 23 Jul 2026 17:39:30 +0800 Subject: [PATCH 01/11] feat(tui): add path-only @file autocomplete --- ...-tui-file-reference-autocomplete.i18n.yaml | 6 + ...6-07-23-tui-file-reference-autocomplete.md | 33 ++ ...7-23-tui-file-reference-autocomplete.zh.md | 33 ++ docs/config-catalog.md | 10 +- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 39 ++- examples/tui-agent/tests/tui.snapshot.ts | 6 +- packages/ui/tui/README.md | 32 +- packages/ui/tui/src/file-autocomplete.ts | 329 ++++++++++++++++++ packages/ui/tui/src/index.ts | 120 ++++++- .../ui/tui/tests/file-autocomplete.spec.ts | 179 ++++++++++ .../snapshots/file-autocomplete.expected.txt | 24 ++ packages/ui/tui/tests/tui.snapshot.ts | 22 +- packages/ui/tui/tests/tui.spec.ts | 119 ++++++- 13 files changed, 921 insertions(+), 31 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md create mode 100644 .agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.zh.md create mode 100644 packages/ui/tui/src/file-autocomplete.ts create mode 100644 packages/ui/tui/tests/file-autocomplete.spec.ts create mode 100644 packages/ui/tui/tests/snapshots/file-autocomplete.expected.txt diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml new file mode 100644 index 0000000000..05b15028e6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml @@ -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 +2026-07-23-tui-file-reference-autocomplete.md: 1a136009213c845af28f4ac47a8b31d426ac8cf5 +2026-07-23-tui-file-reference-autocomplete.zh.md: 410f0d49dbd20a2dcf704892a192406020aaa86e diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md new file mode 100644 index 0000000000..1a13600921 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md @@ -0,0 +1,33 @@ +# Agent Note: TUI file-reference autocomplete + +Status: implemented + +English | [中文](2026-07-23-tui-file-reference-autocomplete.zh.md) + +## Problem + +The TUI offered structured `@session` references but no dependable way to discover workspace paths while composing a prompt. Requiring users to remember exact paths made file-oriented requests unnecessarily awkward, while eagerly attaching every selected file would spend context before the model knew whether its contents were relevant and would hide the normal `read` observation from the tool transcript. + +## Decision + +The TUI owns a bounded, cancellable host-workspace path index rooted at the active session's working directory. Typing `@` at a token boundary fuzzy-matches files and directories; queries containing `/` list the named directory directly, accepting a directory continues completion, and paths containing whitespace use the `@"path with spaces"` form. Configuration controls result count, index size, and excluded directory basenames. The default exclusions are `.git` and `node_modules`; traversal does not follow directory symlinks or interpret ignore files. + +Selecting a file changes only the editor text. The submitted user message retains the natural `@path` spelling and carries no injected contents, hidden context, or reference object. When the model-facing `read` tool is registered, the TUI contributes a stable system-prompt section that identifies `@` paths as explicit user references, directs the model to call `read` when contents are needed, and forbids claiming inspection before that call. Tool results invalidate the reusable fuzzy index so subsequent interactions observe likely workspace mutations. + +Structured session mentions keep their existing snapshot preparation. Unlike files, a referenced session has no general model-facing retrieval tool, so reducing `@session` to a path-like label would make its content unreachable. + +## Alternatives considered + +**Eagerly inject selected file contents.** This spends tokens before relevance is known, can capture stale content before execution reaches the reference, and bypasses the auditable `read` call/result sequence. + +**Require an external file finder.** Depending on `fd`, `rg --files`, or another executable would make baseline completion vary by host installation and complicate cancellation and cross-platform behavior. + +**Use the filesystem service's ordinary directory-list operation for discovery.** That seam is optimized for exact model-facing filesystem operations and may represent a remote namespace; recursive fuzzy indexing would multiply provider round trips and couple editor latency to tool policy. Host-side discovery keeps the terminal interaction local, while the documented namespace-alignment limitation remains explicit for non-local deployments. + +**Add a new cross-package file-search capability.** The TUI is the only current consumer and the behavior is editor presentation rather than a model capability, so a new interface, implementation, and consumer package set would split the seam prematurely. + +## Consequences + +Users can discover and insert paths without making selection itself expensive or model-visible beyond the path. The model preserves agency over whether to inspect a file, and any inspection remains reconstructable through the logged tool transcript. The fixed instruction slightly enlarges TUI system prompts when `read` is present, and content-requiring requests take an additional tool round trip. + +Completion is deliberately bounded and advisory: very large workspaces may omit paths beyond the configured index cap, ignored files may still appear, and remote or virtual filesystem deployments must align the TUI host working directory with the `read` namespace or supply a different completion surface. Package tests pin token grammar, ranking, bounds, cancellation, invalidation, and path-only submission; terminal snapshots and the real Loader PTY smoke pin the visible menu and keyboard completion. diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.zh.md b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.zh.md new file mode 100644 index 0000000000..410f0d49db --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.zh.md @@ -0,0 +1,33 @@ +# Agent Note: TUI 文件引用自动补全 + +Status: implemented + +[English](2026-07-23-tui-file-reference-autocomplete.md) | 中文 + +## 问题 + +TUI 提供结构化的 `@session` 引用,但用户在编辑提示词时无法可靠地发现工作区路径。要求用户记住准确路径会给面向文件的请求带来不必要的麻烦;如果直接附加每个选中文件,则会在模型判断其内容是否相关之前占用上下文,并在工具 transcript(文本记录)中隐藏常规的 `read` 观察结果。 + +## 决策 + +TUI 维护一个有容量上限且可取消的主机工作区路径索引,以活跃会话的工作目录为根。在 token 边界输入 `@` 会对文件和目录进行模糊匹配;查询包含 `/` 时会直接列出指定目录,接受目录后会继续补全,包含空白的路径采用 `@"path with spaces"` 形式。配置项控制结果数量、索引大小以及排除的目录基名。默认排除 `.git` 和 `node_modules`;遍历既不跟随目录符号链接,也不解析忽略文件。 + +选择文件只会改变编辑器文本。提交的用户消息保留自然的 `@path` 写法,不携带注入的内容、隐藏上下文或引用对象。注册面向模型的 `read` 工具时,TUI 会加入一个稳定的系统提示词段,说明 `@` 路径是用户的显式引用,指示模型在需要内容时调用 `read`,并禁止模型在调用前声称已检查文件。工具结果会使可复用的模糊索引失效,后续交互因而能看到工作区中可能发生的变更。 + +结构化会话提及保留现有的快照准备方式。与文件不同,被引用的会话没有通用的模型侧检索工具;如果把 `@session` 简化为类似路径的标签,模型将无法获取其内容。 + +## 备选方案 + +**直接注入选中文件的内容。** 这种方式会在确定相关性前消耗 token,可能在执行到该引用前捕获到陈旧内容,并绕过可审计的 `read` 调用与结果序列。 + +**要求使用外部文件查找器。** 依赖 `fd`、`rg --files` 或其他可执行文件,会使基础补全行为随主机安装情况而变化,也会增加取消处理和跨平台支持的复杂度。 + +**使用文件系统服务的常规目录列表操作进行发现。** 该 seam 针对面向模型的准确文件系统操作进行了优化,并且可能表示远程命名空间;递归模糊索引会增加提供方往返次数,并使编辑器延迟与工具策略耦合。主机侧发现让终端交互保留在本地,同时文档仍明确说明非本地部署中的命名空间对齐限制。 + +**新增跨包的文件搜索功能。** TUI 是目前唯一的消费方,而且该行为属于编辑器呈现而非模型功能;新增一组接口、实现和消费方包会过早拆分这条 seam。 + +## 影响 + +用户可以发现并插入路径,而选择操作本身不会带来高开销,对模型可见的内容也仅限路径。模型仍可自行决定是否检查文件,任何检查都能通过已记录的工具 transcript 重建。存在 `read` 时,固定指令会略微增大 TUI 系统提示词;需要文件内容的请求还会增加一次工具往返。 + +补全有意采用有界的提示性设计:超大型工作区可能省略超过配置索引上限的路径,被忽略的文件仍可能出现,远程或虚拟文件系统部署必须让 TUI 的主机工作目录与 `read` 命名空间对齐,否则需要提供不同的补全接口。包(package)测试固定 token 语法、排序、边界、取消、失效和仅提交路径的行为;终端快照与真实 Loader PTY 冒烟测试固定可见菜单和键盘补全。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d2b9e68ae1..67d657bbe0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1556,7 +1556,7 @@ export interface Config extends TuiConfig { resumeCommand?: string } -/** Presentation settings for the pi-tui terminal mode. */ +/** Interaction and presentation settings for the pi-tui terminal mode. */ export interface TuiConfig { /** Render model reasoning blocks. */ showReasoning?: boolean @@ -1574,6 +1574,12 @@ export interface TuiConfig { modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number + /** Maximum fuzzy file candidates displayed for one `@` query. */ + fileSearchMaxResults?: number + /** Maximum paths retained in one `@` workspace index. */ + fileSearchMaxEntries?: number + /** Directory basenames excluded from `@` traversal and completion. */ + fileSearchExcludedDirectories?: string[] /** Show the terminal's hardware cursor at the pi editor's IME marker. */ showHardwareCursor?: boolean /** Apply the built-in ANSI color palette. */ @@ -1590,7 +1596,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:216`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:245`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 624041854e..b22c1af82f 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -13,14 +13,24 @@ const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) /** - * Seed the harness workspace: personal files land in the isolated Harness home - * (`.dsh`), skill bundles under the agents home's `skills/` root — the same - * trees `$DSH_HOME` / `$DSH_AGENTS_HOME` point the child at. + * Seed the isolated process workspace: ordinary files land in `cwd`, personal + * files in the Harness home (`.dsh`), and skill bundles under the agents + * home's `skills/` root — the same trees `$DSH_HOME` / + * `$DSH_AGENTS_HOME` point the child at. */ function seedWorkspace( - files: { personal?: Record; skills?: Record }, + files: { + workspace?: Record + personal?: Record + skills?: Record + }, ): (cwd: string) => Promise { return async (cwd) => { + for (const [name, content] of Object.entries(files.workspace ?? {})) { + const file = join(cwd, name) + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, content) + } for (const [name, content] of Object.entries(files.personal ?? {})) { const file = join(cwd, '.dsh', name) await mkdir(dirname(file), { recursive: true }) @@ -168,6 +178,27 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('fuzzy-completes an @file path without reading or submitting the file', async () => { + const output = await smoke({ + label: 'tui-agent file autocomplete', + tempDirPrefix: 'tui-agent-file-autocomplete-', + prepare: seedWorkspace({ + workspace: { + 'src/terminal-special-case.ts': 'export const marker = true\n', + 'src/other.ts': 'export const other = true\n', + }, + }), + actions: [ + { waitFor: 'main-session-', send: '@tsc' }, + { waitFor: 'File · terminal-special-case.t', send: '\t' }, + { waitFor: '@src/terminal-special-case.ts', send: '\x03/exit\r' }, + ], + }) + expect(output).toContain('File · terminal-special-case.t') + expect(output).toContain('@src/terminal-special-case.ts') + expect(output).toContain('\u001B[?2004l') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('boots the Code Mode overlay tree, renders its banner, and exits cleanly', async () => { // The overlay's only keyless composition proof: the include+patch tree, // worker code runtime, and one-tool registry all mount before the banner. diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index a36b004def..5f61ab4568 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -26,7 +26,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph' import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' -import { createTuiChat } from '@deepseek-ai/dsh-tui' +import { createTuiChat, FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-tui' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-terminal.ts' @@ -302,6 +302,9 @@ async function runScenario(scenario: Scenario): Promise { } const events: SessionEvent[] = [...agent.session.events] + const firstHeader = events.find(event => event.type === 'request/header') + expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system) + .toContain(FILE_REFERENCE_PROMPT) expect(events.filter(event => event.type === 'tool/call').map(event => event.data.name)).toEqual(scenario.expectedTools) for (const [type, count] of Object.entries(scenario.expectedEventCounts ?? {})) { expect(events.filter(event => event.type === type), `${scenario.name} must emit ${type}`).toHaveLength(count) @@ -309,7 +312,6 @@ async function runScenario(scenario: Scenario): Promise { if (scenario.enterPlanMode === true) { expect(ctx.planMode.get(agent)).toEqual({ active: true }) const planMode = events.find(event => event.type === 'plan/mode') - const firstHeader = events.find(event => event.type === 'request/header') if (planMode === undefined || firstHeader === undefined) { throw new Error('plan-mode command snapshot needs plan/mode before its first request/header') } diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 1ef9170576..21601fa280 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -2,7 +2,7 @@ The interactive terminal front door for DeepSeek Harness agents, built on [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui). It requires stdin and stdout TTYs; scripts and Loader pipes should use the one-shot [`@deepseek-ai/dsh-cli-demo`](../../examples/cli-demo/README.md) app instead. -The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy. +The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [file-reference autocomplete Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md) owns path-only `@file` behavior; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy. Interactive terminals on macOS, Linux, and Windows are supported. Windows uses pi-tui's native console VT-input handling, and the [Windows support Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md) owns the platform decision and ConPTY process verification. @@ -16,7 +16,9 @@ An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling. -When optional `ctx.sessionReferences` is mounted, the existing `@` file menu also offers metadata-only session candidates, inserts `@[label](dsh-session:)`, and prepares the selected snapshots before dispatch. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.send()` from the status after that asynchronous preparation, so idle sends still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. +Typing `@` at a token boundary searches files and directories under the session working directory. A bare fuzzy query uses a reusable bounded workspace index; a query containing `/` lists that directory directly, and selecting a folder keeps completion open for descent. Whitespace-bearing paths are inserted as `@"path with spaces"`. Selecting a file inserts only its path and a trailing space: the TUI does not read it, attach hidden context, or replace it with a reference object. When a model-facing `read` tool is registered, the TUI adds one fixed system-prompt instruction telling the model to read an explicit path when its contents are needed. + +When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.send()` from the status after that asynchronous preparation, so idle sends still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. @@ -44,6 +46,9 @@ When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiti | `questionDialogMaxHeight` | `20` | Question-panel maximum rows | | `modelDialogWidth` | `72` | Model-selector width in columns | | `modelDialogMaxHeight` | `20` | Model-selector maximum rows | +| `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query | +| `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries | +| `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and direct completion | | `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker | | `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) | | `title` | `DeepSeek Harness` | Product suffix for the terminal window title. | @@ -57,6 +62,7 @@ When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiti sessionId: main-session-123 showReasoning: true maxToolOutputLines: 6 + fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist'] ``` Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops extension admission, unloads the `ctx.tui` provider and its dependent plugins, aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. @@ -81,6 +87,26 @@ Submitted text is retained under the agent loop's normal session-history and com Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. +### File-reference autocomplete + +#### What the model sees + +A selected file remains ordinary user text such as `@src/index.ts` or `@"docs/design notes.md"`; autocomplete adds no content block, durable context, or special reference payload. When `read` is registered, every request from this TUI agent also contains the following fixed system-prompt section. The model decides whether the task requires the file contents and calls `read` through the normal tool loop when it does; a path alone is not evidence that the file was inspected. + +##### Exact system-prompt text + +```markdown +Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it. +``` + +#### Token effect + +Autocomplete itself adds no tokens. The selected path contributes only its ordinary user-text tokens; the fixed instruction contributes system-prompt tokens whenever `read` is available. File contents consume context only after a model-selected `read` call returns them. + +#### KV Cache effect + +The fixed instruction is part of the stable system-prompt prefix and is reusable across turns. Each selected path is append-only user text; a later `read` result appends the requested contents through the ordinary tool transcript. + ### Session model selection #### What the model sees @@ -129,3 +155,5 @@ Append-only; newly visible content follows the reusable request prefix and does - **Tool cards are text terminal presentations** — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering. - **Non-TTY operation is intentionally unsupported** — app bundles that need automation must compose a one-shot or server front door (`dsh-cli-demo`, `dsh-acp`) rather than expecting an internal fallback. - **Manual `/skill:` invocation always reloads the full skill body** — the TUI does not detect a skill already present in the conversation, so repeated invocations append its instructions again. +- **File discovery is host-workspace discovery** — autocomplete reads the TUI process's session `cwd`, while the selected text is later interpreted by the configured `read` tool. Deployments that mount a remote or virtual filesystem must keep those namespaces aligned or provide another completion surface. +- **File search uses explicit directory exclusions, not ignore files** — `.git` and `node_modules` are excluded by default and deployments may configure more basenames, but `.gitignore` and `.ignore` are not interpreted. Directory symlinks are not traversed. diff --git a/packages/ui/tui/src/file-autocomplete.ts b/packages/ui/tui/src/file-autocomplete.ts new file mode 100644 index 0000000000..719aa5b91b --- /dev/null +++ b/packages/ui/tui/src/file-autocomplete.ts @@ -0,0 +1,329 @@ +/** + * Host-workspace discovery for TUI `@file` completion. The index contains + * paths only: selected values remain ordinary prompt text and file contents + * stay behind the model-facing `read` tool. + * + * @module @deepseek-ai/dsh-tui/file-autocomplete + */ + +import { readdir } from 'node:fs/promises' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' + +/** Default maximum file and directory candidates rendered for one query. */ +export const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20 +/** Default maximum entries retained in one workspace search index. */ +export const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 10_000 +/** Directory basenames omitted from traversal unless the deployment overrides them. */ +export const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = ['.git', 'node_modules'] as const + +/** Resolved limits and exclusions for one TUI workspace index. */ +export interface FileSearchConfig { + /** Maximum ranked candidates returned for one query. */ + maxResults: number + /** Maximum indexed files and directories. */ + maxEntries: number + /** Directory basenames never traversed or offered. */ + excludedDirectories: readonly string[] +} + +/** One path-only completion candidate inside the session cwd. */ +export interface FileSearchCandidate { + /** User-facing path accepted by the normal prompt and filesystem tools. */ + path: string + /** Directories keep completion open; files finish the mention. */ + kind: 'file' | 'directory' +} + +/** Active `@` token ending at the editor cursor. */ +export interface ActiveAtToken { + /** Complete token replaced when the user accepts a completion. */ + prefix: string + /** Path query after `@` or `@"`. */ + query: string + /** Whether the user opened a quoted path. */ + quoted: boolean +} + +interface IndexedPath extends FileSearchCandidate {} + +interface RankedPath { + candidate: FileSearchCandidate + score: number +} + +interface IndexGeneration { + controller: AbortController + promise: Promise +} + +/** + * Extract an `@path` or `@"path with spaces` token at the cursor. An `@` + * inside another token, such as an email address, is not a completion trigger. + * @param line - current editor line. + * @param cursorCol - cursor column within that line. + * @returns the active token, or `undefined` outside an `@` token. + */ +export function activeAtToken(line: string, cursorCol: number): ActiveAtToken | undefined { + const beforeCursor = line.slice(0, cursorCol) + const quoted = /(?:^|\s)(@"([^"]*))$/u.exec(beforeCursor) + if (quoted?.[1] !== undefined && quoted[2] !== undefined) { + return { prefix: quoted[1], query: quoted[2], quoted: true } + } + const plain = /(?:^|\s)(@([^\s]*))$/u.exec(beforeCursor) + if (plain?.[1] === undefined || plain[2] === undefined) return undefined + return { prefix: plain[1], query: plain[2], quoted: false } +} + +/** + * Format a selected path as prompt text. Whitespace uses Pi's quoted + * `@"path"` grammar; directories retain a trailing slash so completion can + * descend another level. + * @param candidate - selected file or directory. + * @param preserveQuote - retain an explicitly opened quote even when unnecessary. + * @returns the insertion value, or `undefined` for a path the editor grammar cannot represent safely. + */ +export function formatFileMention( + candidate: FileSearchCandidate, + preserveQuote: boolean, +): string | undefined { + const path = candidate.kind === 'directory' ? `${candidate.path}/` : candidate.path + if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined + const quoted = preserveQuote || /\s/u.test(path) + if (!quoted) return `@${path}` + return `@"${path}"` +} + +/** + * Cancellable, reusable fuzzy index rooted at one agent working directory. + * Directory-scoped queries list live state; bare fuzzy queries share one + * bounded traversal until the `@` interaction ends or a tool result invalidates it. + */ +export class WorkspaceFileSearch { + private readonly excludedDirectories: ReadonlySet + private generation: IndexGeneration | undefined + private disposed = false + + constructor( + private readonly root: string, + private readonly config: FileSearchConfig, + ) { + if (!Number.isSafeInteger(config.maxResults) || config.maxResults <= 0) { + throw new Error('file search maxResults must be a positive safe integer') + } + if (!Number.isSafeInteger(config.maxEntries) || config.maxEntries <= 0) { + throw new Error('file search maxEntries must be a positive safe integer') + } + if (config.excludedDirectories.some(name => name.length === 0 || name.includes('/') || name.includes('\\'))) { + throw new Error('file search excludedDirectories entries must be non-empty directory basenames') + } + this.excludedDirectories = new Set(config.excludedDirectories) + } + + /** + * Return ranked path candidates for the current token. + * @param rawQuery - path text following `@` or `@"`. + * @param signal - cancels this caller's wait without killing an index shared by a newer query. + * @returns at most `maxResults` deterministic candidates. + */ + async list(rawQuery: string, signal: AbortSignal): Promise { + signal.throwIfAborted() + if (this.disposed) return [] + const query = rawQuery.replaceAll('\\', '/') + const slash = query.lastIndexOf('/') + if (query === '' || slash >= 0) { + const directory = slash < 0 ? '' : query.slice(0, slash + 1) + const fragment = slash < 0 ? '' : query.slice(slash + 1) + return this.listDirectory(directory, fragment, signal) + } + const indexed = await waitForPromise(this.ensureIndex(), signal) + return rankCandidates( + indexed.filter(candidate => visibleForGlobalQuery(candidate.path, query)), + query, + this.config.maxResults, + ) + } + + /** Discard the current index so the next bare query observes a fresh tree. */ + invalidate(): void { + this.generation?.controller.abort(new Error('file search index invalidated')) + this.generation = undefined + } + + /** Abort traversal and make later queries return no candidates. */ + dispose(): void { + if (this.disposed) return + this.disposed = true + this.invalidate() + } + + private ensureIndex(): Promise { + if (this.generation !== undefined) return this.generation.promise + const controller = new AbortController() + const generation = { + controller, + promise: Promise.resolve([] as IndexedPath[]), + } satisfies IndexGeneration + generation.promise = this.scanWorkspace(controller.signal).catch((error: unknown) => { + /* v8 ignore next -- every owned abort clears `generation` synchronously; this only protects an unexpected scan failure */ + if (this.generation === generation) this.generation = undefined + throw error + }) + this.generation = generation + return generation.promise + } + + private async scanWorkspace(signal: AbortSignal): Promise { + const indexed: IndexedPath[] = [] + const directories: { absolute: string; relative: string }[] = [{ absolute: this.root, relative: '' }] + for (let cursor = 0; cursor < directories.length && indexed.length < this.config.maxEntries; cursor += 1) { + signal.throwIfAborted() + const directory = directories[cursor] + /* v8 ignore next 3 -- cursor is bounded by this exact queue's length. */ + if (directory === undefined) { + throw new Error('file search selected a missing directory') + } + const entries = await readDirectory(directory.absolute, signal) + for (const entry of entries) { + signal.throwIfAborted() + const path = directory.relative === '' ? entry.name : `${directory.relative}/${entry.name}` + if (entry.isDirectory()) { + if (this.excludedDirectories.has(entry.name)) continue + indexed.push({ path, kind: 'directory' }) + directories.push({ absolute: join(directory.absolute, entry.name), relative: path }) + } else if (entry.isFile()) { + indexed.push({ path, kind: 'file' }) + } + if (indexed.length >= this.config.maxEntries) break + } + } + return indexed + } + + private async listDirectory( + displayDirectory: string, + fragment: string, + signal: AbortSignal, + ): Promise { + if (displayDirectory.split('/').some(segment => this.excludedDirectories.has(segment))) return [] + const absolute = resolveDisplayDirectory(this.root, displayDirectory) + if (absolute === undefined) return [] + const entries = await readDirectory(absolute, signal) + const candidates: FileSearchCandidate[] = [] + for (const entry of entries) { + if (entry.name.startsWith('.') && !fragment.startsWith('.')) continue + if (entry.isDirectory()) { + if (this.excludedDirectories.has(entry.name)) continue + candidates.push({ path: `${displayDirectory}${entry.name}`, kind: 'directory' }) + } else if (entry.isFile()) { + candidates.push({ path: `${displayDirectory}${entry.name}`, kind: 'file' }) + } + } + return rankCandidates(candidates, fragment, this.config.maxResults) + } +} + +function resolveDisplayDirectory(root: string, displayDirectory: string): string | undefined { + const resolvedRoot = resolve(root) + const absolute = resolve(resolvedRoot, displayDirectory === '' ? '.' : displayDirectory) + const fromRoot = relative(resolvedRoot, absolute) + if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`)) return undefined + /* v8 ignore next -- only Windows can produce a cross-volume absolute relative path */ + if (isAbsolute(fromRoot)) return undefined + return absolute +} + +async function readDirectory(absolute: string, signal: AbortSignal) { + signal.throwIfAborted() + try { + const entries = await readdir(absolute, { withFileTypes: true }) + signal.throwIfAborted() + return entries.sort((left, right) => compareText(left.name, right.name)) + } catch (_error: unknown) { + signal.throwIfAborted() + // An unreadable/missing subtree contributes no candidates; other readable + // branches remain useful and autocomplete is advisory. + return [] + } +} + +function visibleForGlobalQuery(path: string, query: string): boolean { + if (query.startsWith('.') || query.includes('/.')) return true + return !path.split('/').some(segment => segment.startsWith('.')) +} + +function rankCandidates( + candidates: readonly FileSearchCandidate[], + query: string, + limit: number, +): FileSearchCandidate[] { + const ranked: RankedPath[] = [] + for (const candidate of candidates) { + const score = scoreCandidate(candidate, query) + if (score !== undefined) ranked.push({ candidate, score }) + } + ranked.sort((left, right) => + right.score - left.score + || kindRank(left.candidate.kind) - kindRank(right.candidate.kind) + || (query === '' ? 0 : left.candidate.path.length - right.candidate.path.length) + || compareText(left.candidate.path, right.candidate.path)) + return ranked.slice(0, limit).map(entry => entry.candidate) +} + +function scoreCandidate(candidate: FileSearchCandidate, query: string): number | undefined { + if (query === '') return 0 + const path = candidate.path.toLowerCase() + const name = path.slice(path.lastIndexOf('/') + 1) + const needle = query.toLowerCase() + const directoryBonus = candidate.kind === 'directory' ? 25 : 0 + if (name === needle) return 1_000 + directoryBonus + if (name.startsWith(needle)) return 900 + directoryBonus + if (name.includes(needle)) return 700 + directoryBonus + if (path.includes(needle)) return 500 + directoryBonus + const subsequence = subsequenceScore(path, needle) + return subsequence === undefined ? undefined : 300 + subsequence + directoryBonus +} + +function subsequenceScore(target: string, query: string): number | undefined { + let targetIndex = 0 + let gap = 0 + for (const character of query) { + const found = target.indexOf(character, targetIndex) + if (found < 0) return undefined + gap += found - targetIndex + targetIndex = found + 1 + } + return Math.max(0, 100 - gap) +} + +function kindRank(kind: FileSearchCandidate['kind']): number { + return kind === 'directory' ? 0 : 1 +} + +function compareText(left: string, right: string): number { + /* v8 ignore next -- entries and candidates are unique; host enumeration + * order determines which comparison direction sort requests. */ + return left < right ? -1 : left > right ? 1 : 0 +} + +function waitForPromise(promise: Promise, signal: AbortSignal): Promise { + /* v8 ignore next -- `list()` checks this signal immediately before its synchronous call into this helper */ + if (signal.aborted) return Promise.reject(errorReason(signal.reason, 'file search aborted')) + return new Promise((resolvePromise, rejectPromise) => { + const onAbort = (): void => { rejectPromise(errorReason(signal.reason, 'file search aborted')) } + signal.addEventListener('abort', onAbort, { once: true }) + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolvePromise(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort) + rejectPromise(errorReason(error, 'file search index failed')) + }, + ) + }) +} + +function errorReason(reason: unknown, fallback: string): Error { + return reason instanceof Error ? reason : new Error(fallback, { cause: reason }) +} diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 698879ab0a..fba724f6de 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -145,11 +145,28 @@ export abstract class TuiExtensionService extends Service { */ abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession } +import { + activeAtToken, + DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, + DEFAULT_FILE_SEARCH_MAX_ENTRIES, + DEFAULT_FILE_SEARCH_MAX_RESULTS, + formatFileMention, + WorkspaceFileSearch, +} from './file-autocomplete.ts' + +export { + DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, + DEFAULT_FILE_SEARCH_MAX_ENTRIES, + DEFAULT_FILE_SEARCH_MAX_RESULTS, +} from './file-autocomplete.ts' export const name = 'ui-tui' export const inject = ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter'] -/** Presentation settings for the pi-tui terminal mode. */ +/** Model guidance for path-only file references selected through the TUI. */ +export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.' + +/** Interaction and presentation settings for the pi-tui terminal mode. */ export interface TuiConfig { /** Render model reasoning blocks. */ showReasoning?: boolean @@ -167,6 +184,12 @@ export interface TuiConfig { modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number + /** Maximum fuzzy file candidates displayed for one `@` query. */ + fileSearchMaxResults?: number + /** Maximum paths retained in one `@` workspace index. */ + fileSearchMaxEntries?: number + /** Directory basenames excluded from `@` traversal and completion. */ + fileSearchExcludedDirectories?: string[] /** Show the terminal's hardware cursor at the pi editor's IME marker. */ showHardwareCursor?: boolean /** Apply the built-in ANSI color palette. */ @@ -190,6 +213,9 @@ const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const modelDialogWidthSchema = z.number().step(1).min(20).default(72) const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) +const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) +const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) +const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]) const showHardwareCursorSchema = z.boolean().default(false) const colorSchema = z.boolean().default(true) // No default: an unset value auto-detects truecolor from COLORTERM in `apply`. @@ -206,6 +232,9 @@ export const TuiConfigSchema: z = z.object({ questionDialogMaxHeight: questionDialogMaxHeightSchema, modelDialogWidth: modelDialogWidthSchema, modelDialogMaxHeight: modelDialogMaxHeightSchema, + fileSearchMaxResults: fileSearchMaxResultsSchema, + fileSearchMaxEntries: fileSearchMaxEntriesSchema, + fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, showHardwareCursor: showHardwareCursorSchema, color: colorSchema, truecolor: truecolorSchema, @@ -240,6 +269,9 @@ export const Config: z = z.object({ questionDialogMaxHeight: questionDialogMaxHeightSchema, modelDialogWidth: modelDialogWidthSchema, modelDialogMaxHeight: modelDialogMaxHeightSchema, + fileSearchMaxResults: fileSearchMaxResultsSchema, + fileSearchMaxEntries: fileSearchMaxEntriesSchema, + fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, showHardwareCursor: showHardwareCursorSchema, color: colorSchema, truecolor: truecolorSchema, @@ -256,6 +288,9 @@ export interface ResolvedTuiConfig { questionDialogMaxHeight: number modelDialogWidth: number modelDialogMaxHeight: number + fileSearchMaxResults: number + fileSearchMaxEntries: number + fileSearchExcludedDirectories: string[] showHardwareCursor: boolean color: boolean truecolor: boolean @@ -294,6 +329,9 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, modelDialogWidth: config?.modelDialogWidth ?? 72, modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, + fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, + fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, + fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)], showHardwareCursor: config?.showHardwareCursor ?? false, color: config?.color ?? true, truecolor: config?.truecolor ?? false, @@ -1348,11 +1386,12 @@ interface PendingQuestion { overlay: TuiOverlaySession | undefined } -/** Add session candidates to pi-tui's existing command/file provider. */ -class SessionAutocompleteProvider implements AutocompleteProvider { +/** Merge path-only file candidates and optional session snapshots with commands. */ +class ReferenceAutocompleteProvider implements AutocompleteProvider { constructor( private readonly base: CombinedAutocompleteProvider, - private readonly sessions: SessionReferenceService, + private readonly files: WorkspaceFileSearch, + private readonly sessions: SessionReferenceService | undefined, private readonly agent: Agent, ) {} @@ -1366,17 +1405,33 @@ class SessionAutocompleteProvider implements AutocompleteProvider { const currentLine = lines[cursorLine] /* v8 ignore next -- Editor always supplies its current state line. */ if (currentLine === undefined) return basePromise - const token = /(?:^|\s)(@[^\s]*)$/u.exec(currentLine.slice(0, cursorCol))?.[1] - if (token === undefined) return basePromise - let candidates - try { - candidates = await this.sessions.listCandidates(this.agent, token.slice(1), undefined, options.signal) - } catch { + const token = activeAtToken(currentLine, cursorCol) + if (token === undefined) { + this.files.invalidate() return basePromise } - const base = await basePromise + const filePromise = this.files.list(token.query, options.signal).catch(() => []) + const sessionPromise = this.sessions === undefined || token.quoted + ? Promise.resolve([]) + : this.sessions.listCandidates(this.agent, token.query, undefined, options.signal).catch(() => []) + const [base, fileCandidates, sessionCandidates] = await Promise.all([ + basePromise, + filePromise, + sessionPromise, + ]) if (options.signal.aborted) return base - const items: AutocompleteItem[] = candidates.map((candidate) => { + const fileItems: AutocompleteItem[] = fileCandidates.flatMap((candidate) => { + const value = formatFileMention(candidate, token.quoted) + if (value === undefined) return [] + const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1) + const directory = candidate.kind === 'directory' + return [{ + value, + label: `${directory ? 'Folder' : 'File'} · ${displayInlineText(name)}${directory ? '/' : ''}`, + description: displayInlineText(candidate.path), + }] + }) + const sessionItems: AutocompleteItem[] = sessionCandidates.map((candidate) => { const mentionLabel = displayInlineText(candidate.label) const sessionId = displayInlineText(candidate.sessionId) const location = candidate.cwd === undefined ? '(no cwd)' : displayInlineText(candidate.cwd) @@ -1387,8 +1442,9 @@ class SessionAutocompleteProvider implements AutocompleteProvider { description, } }) + const items = [...fileItems, ...sessionItems] if (items.length === 0) return base - return { items: [...items, ...(base?.items ?? [])], prefix: token } + return { items: [...items, ...(base?.items ?? [])], prefix: token.prefix } } applyCompletion( @@ -1557,6 +1613,11 @@ export function createTuiChat( // rather than declaring an injection that would make the TUI require them. const skills = ctx.get('skills') const cwd = agent.session.header.cwd ?? process.cwd() + const fileSearch = new WorkspaceFileSearch(cwd, { + maxResults: resolved.fileSearchMaxResults, + maxEntries: resolved.fileSearchMaxEntries, + excludedDirectories: resolved.fileSearchExcludedDirectories, + }) const skillAbort = new AbortController() const tokens = sessionTokens(agent.session) const toolCards = new Map() @@ -2326,9 +2387,12 @@ export function createTuiChat( agent.session.header.cwd ?? process.cwd(), ) const sessionReferences = ctx.get('sessionReferences') - editor.setAutocompleteProvider(sessionReferences === undefined - ? base - : new SessionAutocompleteProvider(base, sessionReferences, agent)) + editor.setAutocompleteProvider(new ReferenceAutocompleteProvider( + base, + fileSearch, + sessionReferences, + agent, + )) } const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete) refreshCommandAutocomplete() @@ -2412,6 +2476,16 @@ export function createTuiChat( handler: () => { requestExit(); return { kind: 'success' } }, }) }) + const fileReferencePromptFiber = agent.ctx.inject(['systemPrompt'], (promptCtx) => { + promptCtx.systemPrompt.section({ + name: 'ui:tui-file-reference', + order: 99, + // Tool visibility can change dynamically or by agent scope. Empty + // sections are omitted by renderPrompt, so guidance never names a tool + // that this agent cannot call. + text: () => agent.ctx.tools.get('read') === undefined ? '' : FILE_REFERENCE_PROMPT, + }) + }) const runCommand = (text: string): void => { const controller = new AbortController() @@ -2659,6 +2733,7 @@ export function createTuiChat( const disposeSessionEvents = ctx.on('session/event', (session, event) => { if (session !== agent.session) return + if (event.type === 'tool/result') fileSearch.invalidate() recordEventUsage(tokens, event) advanceTurnPhase(event) if (event.type === 'steering/message') { @@ -2707,6 +2782,7 @@ export function createTuiChat( const detachListeners = (): void => { skillAbort.abort() + fileSearch.dispose() removeInputListener() disposeCommandChanges() stopBannerReveal() @@ -2754,10 +2830,13 @@ export function createTuiChat( } catch (error: unknown) { disposed = true detachListeners() - void commandFiber.dispose().catch( + void Promise.all([ + commandFiber.dispose(), + fileReferencePromptFiber.dispose(), + ]).catch( /* v8 ignore next 2 -- command registration cleanup is non-throwing; this guards a future disposer regression */ (cleanupError: unknown) => { - ctx.logger.warn(`ui-tui: command cleanup after startup failure failed: ${errorChain(cleanupError)}`) + ctx.logger.warn(`ui-tui: scoped cleanup after startup failure failed: ${errorChain(cleanupError)}`) }, ) clearStatus() @@ -2774,7 +2853,10 @@ export function createTuiChat( async dispose(): Promise { detachListeners() await shutdown(false) - await commandFiber.dispose() + await Promise.all([ + commandFiber.dispose(), + fileReferencePromptFiber.dispose(), + ]) }, } } diff --git a/packages/ui/tui/tests/file-autocomplete.spec.ts b/packages/ui/tui/tests/file-autocomplete.spec.ts new file mode 100644 index 0000000000..d918b0d489 --- /dev/null +++ b/packages/ui/tui/tests/file-autocomplete.spec.ts @@ -0,0 +1,179 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + activeAtToken, + formatFileMention, + WorkspaceFileSearch, +} from '../src/file-autocomplete.ts' + +const searches: WorkspaceFileSearch[] = [] +const roots: string[] = [] + +async function workspace(): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-')) + roots.push(root) + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(join(root, 'docs'), { recursive: true }) + await mkdir(join(root, '.hidden'), { recursive: true }) + await mkdir(join(root, 'node_modules', 'ignored-package'), { recursive: true }) + await writeFile(join(root, 'README.md'), 'readme') + await writeFile(join(root, 'src', 'tui.spec.ts'), 'test') + await writeFile(join(root, 'src', 'terminal-view.ts'), 'view') + await writeFile(join(root, 'docs', 'design notes.md'), 'design') + await writeFile(join(root, '.hidden', 'secret.txt'), 'hidden') + await writeFile(join(root, 'node_modules', 'ignored-package', 'index.js'), 'ignored') + try { + await symlink(join(root, 'src', 'tui.spec.ts'), join(root, 'linked-test.ts')) + } catch { + // Windows may deny symlink creation without Developer Mode; the product + // still skips every non-file/non-directory Dirent on platforms that expose one. + } + return root +} + +function search(root: string, overrides: Partial[1]> = {}): WorkspaceFileSearch { + const instance = new WorkspaceFileSearch(root, { + maxResults: overrides.maxResults ?? 20, + maxEntries: overrides.maxEntries ?? 10_000, + excludedDirectories: overrides.excludedDirectories ?? ['.git', 'node_modules'], + }) + searches.push(instance) + return instance +} + +afterEach(async () => { + for (const instance of searches.splice(0)) instance.dispose() + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +describe('TUI file autocomplete grammar', () => { + it('recognizes boundary and quoted mentions without treating emails as references', () => { + expect(activeAtToken('@src/tu', 7)).toEqual({ prefix: '@src/tu', query: 'src/tu', quoted: false }) + expect(activeAtToken('read @"docs/design n', 20)).toEqual({ + prefix: '@"docs/design n', + query: 'docs/design n', + quoted: true, + }) + expect(activeAtToken('mail a@b.test', 13)).toBeUndefined() + expect(activeAtToken('done @src/x" next', 17)).toBeUndefined() + }) + + it('formats files, directories, quotes, and rejects unsafe editor values', () => { + expect(formatFileMention({ path: 'src/index.ts', kind: 'file' }, false)).toBe('@src/index.ts') + expect(formatFileMention({ path: 'src', kind: 'directory' }, false)).toBe('@src/') + expect(formatFileMention({ path: 'docs/design notes.md', kind: 'file' }, false)) + .toBe('@"docs/design notes.md"') + expect(formatFileMention({ path: 'README.md', kind: 'file' }, true)).toBe('@"README.md"') + expect(formatFileMention({ path: 'bad\nname', kind: 'file' }, false)).toBeUndefined() + expect(formatFileMention({ path: 'bad "name".md', kind: 'file' }, false)).toBeUndefined() + expect(formatFileMention({ path: 'bad"name.md', kind: 'file' }, false)).toBeUndefined() + }) +}) + +describe('WorkspaceFileSearch', () => { + it('lists live directory levels, descends, quotes spaces, and filters hidden/excluded entries', async () => { + const root = await workspace() + const files = search(root) + const signal = new AbortController().signal + + expect(await files.list('', signal)).toEqual([ + { path: 'docs', kind: 'directory' }, + { path: 'src', kind: 'directory' }, + { path: 'README.md', kind: 'file' }, + ]) + expect(await files.list('src/', signal)).toEqual([ + { path: 'src/terminal-view.ts', kind: 'file' }, + { path: 'src/tui.spec.ts', kind: 'file' }, + ]) + expect(await files.list('src/ts', signal)).toEqual([ + { path: 'src/tui.spec.ts', kind: 'file' }, + { path: 'src/terminal-view.ts', kind: 'file' }, + ]) + expect(await files.list('docs/design n', signal)).toEqual([ + { path: 'docs/design notes.md', kind: 'file' }, + ]) + expect(await files.list('node_modules/', signal)).toEqual([]) + expect(await files.list('.hidden/', signal)).toEqual([ + { path: '.hidden/secret.txt', kind: 'file' }, + ]) + const absoluteSrc = `${join(root, 'src').replaceAll('\\', '/')}/` + expect(await files.list(`${absoluteSrc}tui`, signal)).toEqual([ + { path: `${absoluteSrc}tui.spec.ts`, kind: 'file' }, + { path: `${absoluteSrc}terminal-view.ts`, kind: 'file' }, + ]) + expect(await files.list('~/.dsh-file-autocomplete-missing/', signal)).toEqual([]) + expect(await files.list('../', signal)).toEqual([]) + }) + + it('ranks basename and subsequence fuzzy matches across the bounded workspace index', async () => { + const root = await workspace() + await writeFile(join(root, 'src', 'tspc-helper.ts'), 'helper') + const files = search(root, { maxResults: 2 }) + const signal = new AbortController().signal + + expect(await files.list('tspc', signal)).toEqual([ + { path: 'src/tspc-helper.ts', kind: 'file' }, + { path: 'src/tui.spec.ts', kind: 'file' }, + ]) + expect(await files.list('README.md', signal)).toEqual([ + { path: 'README.md', kind: 'file' }, + ]) + expect(await files.list('terminal', signal)).toEqual([ + { path: 'src/terminal-view.ts', kind: 'file' }, + ]) + expect(await files.list('secret', signal)).toEqual([]) + expect(await files.list('.hidden', signal)).toEqual([ + { path: '.hidden', kind: 'directory' }, + { path: '.hidden/secret.txt', kind: 'file' }, + ]) + }) + + it('invalidates cached traversal, enforces the entry cap, and settles disposal', async () => { + const root = await workspace() + const capped = search(root, { maxEntries: 2 }) + const signal = new AbortController().signal + expect(await capped.list('README', signal)).toEqual([ + { path: 'README.md', kind: 'file' }, + ]) + + const files = search(root) + expect(await files.list('fresh-file', signal)).toEqual([]) + await writeFile(join(root, 'fresh-file.ts'), 'fresh') + expect(await files.list('fresh-file', signal)).toEqual([]) + files.invalidate() + expect(await files.list('fresh-file', signal)).toEqual([ + { path: 'fresh-file.ts', kind: 'file' }, + ]) + files.dispose() + expect(await files.list('fresh-file', signal)).toEqual([]) + files.dispose() + }) + + it('cancels individual callers, skips missing directories, and validates limits', async () => { + const root = await workspace() + expect(() => search(root, { maxResults: 0 })).toThrow('maxResults') + expect(() => search(root, { maxEntries: 1.5 })).toThrow('maxEntries') + expect(() => search(root, { excludedDirectories: ['nested/name'] })).toThrow('basenames') + + const files = search(root) + expect(await files.list('missing/', new AbortController().signal)).toEqual([]) + + const preAborted = new AbortController() + preAborted.abort(new Error('pre-aborted')) + await expect(files.list('tui', preAborted.signal)).rejects.toThrow('pre-aborted') + + files.invalidate() + const running = new AbortController() + const pending = files.list('tui', running.signal) + running.abort(new Error('superseded')) + await expect(pending).rejects.toThrow('superseded') + + files.invalidate() + const nonErrorAbort = new AbortController() + const nonErrorPending = files.list('tui', nonErrorAbort.signal) + nonErrorAbort.abort('cancelled') + await expect(nonErrorPending).rejects.toThrow('file search aborted') + }) +}) diff --git a/packages/ui/tui/tests/snapshots/file-autocomplete.expected.txt b/packages/ui/tui/tests/snapshots/file-autocomplete.expected.txt new file mode 100644 index 0000000000..174f21a0f4 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/file-autocomplete.expected.txt @@ -0,0 +1,24 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=5 viewportRow=4 bufferRow=4 +viewport +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 fg=bright-black +2| " deepseek-v4-flash • main-session" + style 1-34 dim +3| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +4| " @tsc " + style 5-5 inverse +5| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +6| " → File · terminal-special-case.t src/terminal-special-case.ts " + style 1-32 fg=bright-blue +7| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" + style 0-43 dim + style 69-95 dim +8-35| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 9a2f3b51e1..138b3fb001 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -1,4 +1,5 @@ -import { mkdir, readdir, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it, vi } from 'vitest' @@ -32,6 +33,7 @@ const CHECKPOINTS = [ 'retry-cancelled', 'retry-exhausted', 'banner-gradient', + 'file-autocomplete', 'code-mode-pending', 'dynamic-workflow-pending', 'cordis-tools-pending', @@ -337,6 +339,24 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) + it('pins fuzzy file candidates and the active path-only mention', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-file-snapshot-')) + await mkdir(join(cwd, 'src'), { recursive: true }) + await writeFile(join(cwd, 'src', 'terminal-special-case.ts'), 'export const marker = true\n') + await writeFile(join(cwd, 'src', 'terminal-state.ts'), 'export const state = true\n') + const harness = await setupSnapshot({ cwd, formatCwd: () => '/workspace/project' }) + try { + harness.terminal.send('@tsc') + await vi.waitFor(async () => { + expect(await harness.terminal.snapshot()).toContain('File · terminal-special-case.t') + }) + await checkpoint('file-autocomplete', harness.terminal) + } finally { + await disposeSnapshot(harness) + await rm(cwd, { recursive: true, force: true }) + } + }) + it('pins Code Mode run_code with its production presenter', async () => { const harness = await setupSnapshot({ configureContext: configureAdvancedTools }) const call = { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index e7d87211c9..4a8e9b5e93 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1,4 +1,5 @@ -import { homedir } from 'node:os' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' @@ -16,6 +17,7 @@ import SessionReferenceService, { formatSessionReferenceMention } from '@deepsee import type {} from '@deepseek-ai/dsh-llm-retry' import { createTuiChat, + FILE_REFERENCE_PROMPT, mountTui, renderSkillInvocation, resolveTuiConfig, @@ -23,6 +25,7 @@ import { type TuiOverlaySession, type TuiRuntime, } from '../src/index.ts' +import { WorkspaceFileSearch } from '../src/file-autocomplete.ts' import { appendAssistant, appendUser, @@ -148,6 +151,9 @@ describe('TUI config', () => { questionDialogMaxHeight: 20, modelDialogWidth: 72, modelDialogMaxHeight: 20, + fileSearchMaxResults: 20, + fileSearchMaxEntries: 10_000, + fileSearchExcludedDirectories: ['.git', 'node_modules'], showHardwareCursor: false, color: true, truecolor: false, @@ -162,6 +168,9 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + fileSearchMaxResults: 7, + fileSearchMaxEntries: 123, + fileSearchExcludedDirectories: ['.git', 'generated'], showHardwareCursor: true, color: false, truecolor: true, @@ -175,6 +184,9 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + fileSearchMaxResults: 7, + fileSearchMaxEntries: 123, + fileSearchExcludedDirectories: ['.git', 'generated'], showHardwareCursor: true, color: false, truecolor: true, @@ -1058,6 +1070,111 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) + it('fuzzy-completes files and directories while sending only the selected path text', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-file-completion-')) + await mkdir(join(cwd, 'src'), { recursive: true }) + await mkdir(join(cwd, 'docs'), { recursive: true }) + await writeFile(join(cwd, 'src', 'source-file.ts'), 'export const source = true\n') + await writeFile(join(cwd, 'docs', 'design notes.md'), '# Design\n') + await writeFile(join(cwd, 'unsafe\nfile.ts'), 'unsafe name\n') + const result = await setup({ + cwd, + tools: { + read: { + name: 'read', + description: 'Read a file.', + parameters: {}, + execute: () => Promise.resolve([]), + }, + }, + }) + try { + const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent)) + expect(assembly.sections).toContainEqual({ + name: 'ui:tui-file-reference', + text: FILE_REFERENCE_PROMPT, + }) + + result.terminal.send('@sfts') + await vi.waitFor(() => { + expect(result.terminal.output).toContain('File · source-file.ts') + }) + expect(result.terminal.output).toContain('src/source-file.ts') + result.terminal.send('\t') + await tick() + result.terminal.send('\r') + await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) }) + expect(result.agent.sent[0]).toEqual([{ type: 'text', text: '@src/source-file.ts' }]) + expect(result.agent.sentOptions[0]?.contexts).toEqual([]) + + result.terminal.send('@do') + await vi.waitFor(() => { + expect(result.terminal.output).toContain('Folder · docs/') + }) + result.terminal.send('\t') + await vi.waitFor(() => { + expect(result.terminal.output).toContain('File · design notes.md') + }) + result.terminal.send('\t') + await tick() + result.terminal.send('\r') + await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) }) + expect(result.agent.sent[1]).toEqual([{ type: 'text', text: '@"docs/design notes.md"' }]) + expect(result.agent.sentOptions[1]?.contexts).toEqual([]) + + result.terminal.send('@unsafe') + await tick() + expect(result.terminal.output).not.toContain('File · unsafe') + result.terminal.send('\x03') + } finally { + await result.controller.dispose() + const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent)) + expect(assembly.sections).not.toContainEqual({ + name: 'ui:tui-file-reference', + text: FILE_REFERENCE_PROMPT, + }) + await result.ctx.fiber.dispose() + await rm(cwd, { recursive: true, force: true }) + } + }) + + it('isolates failed file discovery from editor autocomplete', async () => { + const list = vi.spyOn(WorkspaceFileSearch.prototype, 'list').mockRejectedValue(new Error('search failed')) + const result = await setup() + try { + result.terminal.send('@failed') + await vi.waitFor(() => { expect(list).toHaveBeenCalled() }) + await tick() + expect(result.agent.sent).toEqual([]) + } finally { + list.mockRestore() + await dispose(result) + } + }) + + it('shows file-reference guidance only while read is visible to the agent', async () => { + const tools: Record = {} + const result = await setup({ tools }) + const fileReferenceText = async (): Promise => { + const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent)) + return assembly.sections.find(section => section.name === 'ui:tui-file-reference')?.text + } + try { + expect(await fileReferenceText()).toBe('') + tools.read = { + name: 'read', + description: 'Read a file.', + parameters: {}, + execute: () => Promise.resolve([]), + } + expect(await fileReferenceText()).toBe(FILE_REFERENCE_PROMPT) + delete tools.read + expect(await fileReferenceText()).toBe('') + } finally { + await dispose(result) + } + }) + it('escapes session autocomplete metadata while preserving the referenced session id', async () => { const unsafeId = SessionId('evil\x1b\x07\u009b\ns') const unsafeCwd = '/x/\x1b\x07\u009b\nf' From efe13c09d6338cc4d6e6403c98794b4f40caf97e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 23 Jul 2026 17:46:13 +0800 Subject: [PATCH 02/11] fix(tui): share config schema fields --- packages/ui/tui/src/index.ts | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index fba724f6de..67f6dc3c23 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -222,8 +222,7 @@ const colorSchema = z.boolean().default(true) const truecolorSchema = z.boolean() const titleSchema = z.string().default('DeepSeek Harness') -/** Schemastery schema for presentation settings embedded by app bundles. */ -export const TuiConfigSchema: z = z.object({ +const tuiConfigSchemaFields = { showReasoning: showReasoningSchema, maxToolOutputLines: maxToolOutputLinesSchema, maxQuestionOptions: maxQuestionOptionsSchema, @@ -239,7 +238,10 @@ export const TuiConfigSchema: z = z.object({ color: colorSchema, truecolor: truecolorSchema, title: titleSchema, -}) +} + +/** Schemastery schema for presentation settings embedded by app bundles. */ +export const TuiConfigSchema: z = z.object(tuiConfigSchemaFields) /** Serializable plugin configuration. */ export interface Config extends TuiConfig { @@ -261,21 +263,7 @@ export const Config: z = z.object({ welcome: z.string(), sessionId: z.string().default('main'), resumeCommand: z.string(), - showReasoning: showReasoningSchema, - maxToolOutputLines: maxToolOutputLinesSchema, - maxQuestionOptions: maxQuestionOptionsSchema, - maxModelOptions: maxModelOptionsSchema, - questionDialogWidth: questionDialogWidthSchema, - questionDialogMaxHeight: questionDialogMaxHeightSchema, - modelDialogWidth: modelDialogWidthSchema, - modelDialogMaxHeight: modelDialogMaxHeightSchema, - fileSearchMaxResults: fileSearchMaxResultsSchema, - fileSearchMaxEntries: fileSearchMaxEntriesSchema, - fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, - showHardwareCursor: showHardwareCursorSchema, - color: colorSchema, - truecolor: truecolorSchema, - title: titleSchema, + ...tuiConfigSchemaFields, }) /** Fully defaulted TUI presentation settings. */ From 0c678766a4bfeac4c14179eea7ab8d1be19ad1e0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:49:16 +0800 Subject: [PATCH 03/11] ci: isolate enterprise Linux critical paths --- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 6 ++- ...evidence-based-larger-hosted-runners.zh.md | 6 ++- .github/workflows/ci.yml | 42 ++++++++----------- 4 files changed, 27 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index ca37255dcf..800f9e7a54 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -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 -2026-07-22-evidence-based-larger-hosted-runners.md: 13ecbd5c74bb08d84c8fdf1140a9970235aab826 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 93d6818fdf5af826980b6f4b938fadc122722b68 +2026-07-22-evidence-based-larger-hosted-runners.md: 14554963a47f75d0679d238895a1d314950fea6f +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 157b6ee1d9c6e475111c239690fe1fe65c54fab1 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 13ecbd5c74..14554963a4 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -18,7 +18,7 @@ The required primary path depends on those enterprise pools. Standard GitHub-hos The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture. -Linux primary work uses two independent 32-core jobs. Coverage runs alone with its own worker bound. The other job starts the static scheduler alone; once it reports a successful build, lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers start against that completed tree. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. +Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. The third job produces its own build, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers against that completed tree. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the paid critical path without adding a blocking platform claim. @@ -58,6 +58,8 @@ Complete serial Linux, macOS, and Windows references run only when `master` move **Keep build behind typecheck.** This orders independent compiler invocations and turns snapshot replay into a three-stage critical chain. Build output has its own success dependency, so only snapshot and publication consumers wait for it. +**Keep static gates and post-build consumers on one runner.** Reusing one build avoids a setup wave, but build-duration variance delays every consumer and leaves their lint and snapshot tails after the static result. Independent jobs repeat the build while keeping both complete paths within the observed target. + **Keep the complete required path on standard GitHub-hosted capacity.** This avoids repository-external runner configuration, but exact-head standard-runner runs remain materially slower and can spend longer queued behind shared capacity. Standard-hosted compatibility and serial references preserve portable evidence without making that slower topology the ordinary primary path. **Keep required and observational Windows checks in separate jobs.** The split preserves status semantics at the workflow level but pays setup twice. `run-gates` preserves the same required versus non-blocking distinction inside one process. @@ -68,7 +70,7 @@ Complete serial Linux, macOS, and Windows references run only when `master` move The required topology pays one setup wave per 32-core lane and retains no shard selectors. Every ordinary pull request consumes paid enterprise Linux and Windows minutes; manual benchmarks add other sizes only when remeasurement is useful. -GitHub rounds each larger-runner execution up to a whole minute, so complete-job measurement exposes both billed time and workflow complexity. Splitting Linux repeats setup once, but isolates coverage from build, lint, and snapshot contention; consolidating Windows avoids repeating its slower setup. +GitHub rounds each larger-runner execution up to a whole minute, so complete-job measurement exposes both billed time and workflow complexity. Splitting Linux repeats setup twice and build once, but isolates coverage, static gates, and post-build consumers from each other's critical paths; consolidating Windows avoids repeating its slower setup. Performance targets are observations, not cancellation deadlines or correctness requirements. Manual all-size and serial suites remain available when image, dependency, scheduler, or pricing changes need remeasurement. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 93d6818fdf..157b6ee1d9 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -18,7 +18,7 @@ Status: implemented 原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。 -Linux 主流程使用两个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限。另一个作业先单独启动静态调度器;静态调度器报告构建成功后,lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方才基于构建完成后的工作树启动。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 +Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。第三个作业自行完成构建,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台契约。 @@ -58,6 +58,8 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 **让构建继续等待类型检查。** 此方案会给相互独立的编译器调用排定先后顺序,并把快照回放变成 3 阶段关键链。构建输出本身有独立的成功依赖关系,因此只有快照和发布消费方需要等待它。 +**将静态门禁和构建后消费方保留在同一台运行器上。** 复用一次构建可以省去一轮设置,但构建耗时的波动会延迟每个消费方,并使消费方的 lint 和快照尾段延续到静态结果之后。相互独立的作业会重复构建,但能让两条完整路径都保持在实测目标内。 + **将完整必需路径保留在 GitHub 标准托管容量上。** 此方案可以避免依赖仓库外部的运行器配置,但标准运行器上的分支头精确运行仍明显更慢,也可能因共享容量而排队更久。标准托管兼容性作业和串行参考流程保留可移植证据,无需让这套较慢的拓扑成为普通主路径。 **将必需的 Windows 检查和观测性 Windows 检查保留在不同作业中。** 这种拆分在工作流层保留状态语义,却需要支付两次设置开销。`run-gates` 在一个进程内保留了相同的必需与非阻塞区别。 @@ -68,7 +70,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 必需拓扑中的每个 32 核通道只承担 1 轮设置开销,且不保留分片选择器。每个普通拉取请求都会消耗付费的企业级 Linux 和 Windows 运行器分钟数;只有在重新测量有价值时,手动基准测试才会加入其他规格。 -GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此完整作业测量能同时呈现计费时长与工作流复杂度。拆分 Linux 会重复一次设置,但可将覆盖率同构建、lint 和快照的争用隔离;合并 Windows 则避免重复其耗时更长的设置。 +GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此完整作业测量能同时呈现计费时长与工作流复杂度。拆分 Linux 会重复两轮设置和一次构建,但能使覆盖率、静态门禁与构建后消费方不再相互进入关键路径;合并 Windows 则避免重复其耗时更长的设置。 性能目标是观测结果,而非取消截止时间或正确性要求。当映像、依赖、调度器或定价发生变化而需要重新测量时,仍可使用手动全规格和串行套件。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a1cac0e4e..f2e570e701 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,9 +27,9 @@ env: jobs: - # Two enterprise runners split the two longest primary Node paths. The - # static lane starts snapshot and artifact validation as soon as its build - # completes, while exhaustive coverage runs alone on the other runner. + # Three enterprise jobs isolate coverage, static analysis, and the + # build-backed consumer tail so setup and build variance cannot serialize + # otherwise independent primary Node paths. node-24: if: github.event_name == 'pull_request' runs-on: ${{ matrix.runner }} @@ -46,8 +46,11 @@ jobs: fail-fast: false matrix: include: - - lane: static-snapshots-artifacts - name: node 24 / static, snapshots, and artifacts + - lane: static + name: node 24 / static + runner: dsh-enterprise-ubuntu-latest-32core-test + - lane: snapshots-artifacts + name: node 24 / snapshots and artifacts runner: dsh-enterprise-ubuntu-latest-32core-test - lane: coverage name: node 24 / coverage @@ -67,7 +70,7 @@ jobs: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - uses: actions/cache/restore@v4 - if: matrix.lane == 'static-snapshots-artifacts' + if: matrix.lane == 'snapshots-artifacts' with: path: .cache/eslint key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} @@ -92,25 +95,14 @@ jobs: if (( install_status != 0 )); then exit "$install_status"; fi exit "$sandbox_status" - - name: Run static, compatibility, snapshot, and artifact gates - if: matrix.lane == 'static-snapshots-artifacts' - run: | - static_log="$RUNNER_TEMP/static-gates.log" - : > "$static_log" - pnpm run check:ci:static > >(tee "$static_log") 2>&1 & - static_pid=$! + - name: Run static gates + if: matrix.lane == 'static' + run: pnpm run check:ci:static - until grep -Fq 'run-gates: PASS build ' "$static_log"; do - if ! kill -0 "$static_pid" 2>/dev/null; then - static_status=0 - wait "$static_pid" || static_status=$? - if grep -Fq 'run-gates: PASS build ' "$static_log"; then break; fi - if (( static_status != 0 )); then exit "$static_status"; fi - echo '::error::Static gates exited without completing the build.' - exit 1 - fi - sleep 0.2 - done + - name: Build and run compatibility, snapshot, and artifact gates + if: matrix.lane == 'snapshots-artifacts' + run: | + pnpm run build pnpm run check:ci:lint & lint_pid=$! @@ -143,7 +135,7 @@ jobs: fi } for child_pid in \ - "$static_pid" "$lint_pid" "$compat_pid" "$snapshot_pid" \ + "$lint_pid" "$compat_pid" "$snapshot_pid" \ "$publint_pid" "$node_next_pid" "$built_invariants_pid" "$built_bin_pid" do capture_status "$child_pid" From d633758a308c281ecc4d5bdfb26da79dca5b9413 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 23 Jul 2026 17:50:32 +0800 Subject: [PATCH 04/11] fix(tui): keep config schema statically walkable --- docs/config-catalog.md | 2 +- packages/ui/tui/src/index.ts | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 67d657bbe0..fea5dd266f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1596,7 +1596,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:245`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:247`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 67f6dc3c23..f0781b0987 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -263,7 +263,21 @@ export const Config: z = z.object({ welcome: z.string(), sessionId: z.string().default('main'), resumeCommand: z.string(), - ...tuiConfigSchemaFields, + showReasoning: tuiConfigSchemaFields.showReasoning, + maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines, + maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions, + maxModelOptions: tuiConfigSchemaFields.maxModelOptions, + questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth, + questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight, + modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth, + modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight, + fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults, + fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries, + fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories, + showHardwareCursor: tuiConfigSchemaFields.showHardwareCursor, + color: tuiConfigSchemaFields.color, + truecolor: tuiConfigSchemaFields.truecolor, + title: tuiConfigSchemaFields.title, }) /** Fully defaulted TUI presentation settings. */ From 2dc3bf6005b4e84628685b79d3373c0a92d2f9ac Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:02:17 +0800 Subject: [PATCH 05/11] ci: reuse the primary Linux build --- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 6 +- ...evidence-based-larger-hosted-runners.zh.md | 6 +- .github/workflows/ci.yml | 140 +++++++++++++----- 4 files changed, 111 insertions(+), 45 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 800f9e7a54..9d87cb9ad3 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -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 -2026-07-22-evidence-based-larger-hosted-runners.md: 14554963a47f75d0679d238895a1d314950fea6f -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 157b6ee1d9c6e475111c239690fe1fe65c54fab1 +2026-07-22-evidence-based-larger-hosted-runners.md: aaeab4ed9ae9687598f9f1d4a862120405697672 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 72b69c85908990a9f35b60f4c0a2ce213f9c8134 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 14554963a4..aaeab4ed9a 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -18,7 +18,7 @@ The required primary path depends on those enterprise pools. Standard GitHub-hos The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture. -Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. The third job produces its own build, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers against that completed tree. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. +Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the paid critical path without adding a blocking platform claim. @@ -58,7 +58,7 @@ Complete serial Linux, macOS, and Windows references run only when `master` move **Keep build behind typecheck.** This orders independent compiler invocations and turns snapshot replay into a three-stage critical chain. Build output has its own success dependency, so only snapshot and publication consumers wait for it. -**Keep static gates and post-build consumers on one runner.** Reusing one build avoids a setup wave, but build-duration variance delays every consumer and leaves their lint and snapshot tails after the static result. Independent jobs repeat the build while keeping both complete paths within the observed target. +**Keep static gates and post-build consumers on one runner.** Reusing one workspace avoids a setup wave and artifact transfer, but build-duration variance delays every consumer and leaves their lint and snapshot tails after the static result. A run-scoped built tree preserves one exact build while independent jobs keep both complete paths within the observed target. **Keep the complete required path on standard GitHub-hosted capacity.** This avoids repository-external runner configuration, but exact-head standard-runner runs remain materially slower and can spend longer queued behind shared capacity. Standard-hosted compatibility and serial references preserve portable evidence without making that slower topology the ordinary primary path. @@ -70,7 +70,7 @@ Complete serial Linux, macOS, and Windows references run only when `master` move The required topology pays one setup wave per 32-core lane and retains no shard selectors. Every ordinary pull request consumes paid enterprise Linux and Windows minutes; manual benchmarks add other sizes only when remeasurement is useful. -GitHub rounds each larger-runner execution up to a whole minute, so complete-job measurement exposes both billed time and workflow complexity. Splitting Linux repeats setup twice and build once, but isolates coverage, static gates, and post-build consumers from each other's critical paths; consolidating Windows avoids repeating its slower setup. +GitHub rounds each larger-runner execution up to a whole minute, so complete-job measurement exposes both billed time and workflow complexity. Splitting Linux repeats setup twice and transfers one built tree, but isolates coverage, static gates, and post-build consumers from each other's critical paths without repeating the build; consolidating Windows avoids repeating its slower setup. Performance targets are observations, not cancellation deadlines or correctness requirements. Manual all-size and serial suites remain available when image, dependency, scheduler, or pricing changes need remeasurement. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 157b6ee1d9..72b69c8590 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -18,7 +18,7 @@ Status: implemented 原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。 -Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。第三个作业自行完成构建,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 +Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台契约。 @@ -58,7 +58,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 **让构建继续等待类型检查。** 此方案会给相互独立的编译器调用排定先后顺序,并把快照回放变成 3 阶段关键链。构建输出本身有独立的成功依赖关系,因此只有快照和发布消费方需要等待它。 -**将静态门禁和构建后消费方保留在同一台运行器上。** 复用一次构建可以省去一轮设置,但构建耗时的波动会延迟每个消费方,并使消费方的 lint 和快照尾段延续到静态结果之后。相互独立的作业会重复构建,但能让两条完整路径都保持在实测目标内。 +**将静态门禁和构建后消费方保留在同一台运行器上。** 复用同一个工作区可以省去一轮设置和一次产物传输,但构建耗时的波动会延迟每个消费方,并使消费方的 lint 和快照尾段延续到静态结果之后。仅供本次运行使用的已构建目录树可以保留同一份构建结果,而相互独立的作业能让两条完整路径都保持在实测目标内。 **将完整必需路径保留在 GitHub 标准托管容量上。** 此方案可以避免依赖仓库外部的运行器配置,但标准运行器上的分支头精确运行仍明显更慢,也可能因共享容量而排队更久。标准托管兼容性作业和串行参考流程保留可移植证据,无需让这套较慢的拓扑成为普通主路径。 @@ -70,7 +70,7 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 必需拓扑中的每个 32 核通道只承担 1 轮设置开销,且不保留分片选择器。每个普通拉取请求都会消耗付费的企业级 Linux 和 Windows 运行器分钟数;只有在重新测量有价值时,手动基准测试才会加入其他规格。 -GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此完整作业测量能同时呈现计费时长与工作流复杂度。拆分 Linux 会重复两轮设置和一次构建,但能使覆盖率、静态门禁与构建后消费方不再相互进入关键路径;合并 Windows 则避免重复其耗时更长的设置。 +GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此完整作业测量能同时呈现计费时长与工作流复杂度。拆分 Linux 会重复两轮设置并传输一份已构建目录树,但能使覆盖率、静态门禁与构建后消费方不再相互进入关键路径,且无需重复构建;合并 Windows 则避免重复其耗时更长的设置。 性能目标是观测结果,而非取消截止时间或正确性要求。当映像、依赖、调度器或定价发生变化而需要重新测量时,仍可使用手动全规格和串行套件。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2e570e701..2eceefa114 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,33 +28,14 @@ env: jobs: # Three enterprise jobs isolate coverage, static analysis, and the - # build-backed consumer tail so setup and build variance cannot serialize - # otherwise independent primary Node paths. + # build-backed consumer tail. The static job publishes its exact build so + # consumers do not repeat the longest part of their critical path. node-24: if: github.event_name == 'pull_request' - runs-on: ${{ matrix.runner }} - name: ${{ matrix.name }} + runs-on: dsh-enterprise-ubuntu-latest-32core-test + name: node 24 / static env: - DSH_COVERAGE_MAX_WORKERS: '24' - DSH_ESLINT_CACHE: '1' - DSH_ESLINT_CONCURRENCY: '8' DSH_GATE_CONCURRENCY: '8' - DSH_NODE_COMPAT_SKIP_TYPECHECK: '1' - DSH_PUBLINT_CONCURRENCY: '8' - DSH_SNAPSHOT_MAX_CONCURRENCY: '32' - strategy: - fail-fast: false - matrix: - include: - - lane: static - name: node 24 / static - runner: dsh-enterprise-ubuntu-latest-32core-test - - lane: snapshots-artifacts - name: node 24 / snapshots and artifacts - runner: dsh-enterprise-ubuntu-latest-32core-test - - lane: coverage - name: node 24 / coverage - runner: dsh-enterprise-ubuntu-24-04-32core-test steps: - uses: actions/checkout@v6 with: @@ -69,8 +50,104 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack and install dependencies + run: | + corepack enable + pnpm install --frozen-lockfile + + - name: Run static gates + run: pnpm run check:ci:static + + - name: Pack built tree + run: >- + tar -czf "$RUNNER_TEMP/node-24-built-tree.tar.gz" + apps/*/lib packages/*/*/lib vendor/*/lib + + - uses: actions/upload-artifact@v6 + with: + name: node-24-built-tree + path: ${{ runner.temp }}/node-24-built-tree.tar.gz + if-no-files-found: error + retention-days: 1 + compression-level: 0 + + node-24-coverage: + if: github.event_name == 'pull_request' + runs-on: dsh-enterprise-ubuntu-24-04-32core-test + name: node 24 / coverage + env: + DSH_COVERAGE_MAX_WORKERS: '24' + DSH_GATE_CONCURRENCY: '8' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: actions/cache/restore@v4 + with: + path: /home/runner/.local/share/pnpm/store/v11 + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack, install dependencies, and prepare bubblewrap + run: | + corepack enable + pnpm install --frozen-lockfile & + install_pid=$! + bash scripts/prepare-ci-bubblewrap.sh & + sandbox_pid=$! + install_status=0 + wait "$install_pid" || install_status=$? + sandbox_status=0 + wait "$sandbox_pid" || sandbox_status=$? + if (( install_status != 0 )); then exit "$install_status"; fi + exit "$sandbox_status" + + - name: Run exhaustive coverage + run: pnpm run check:ci:coverage + + node-24-consumers: + needs: node-24 + if: github.event_name == 'pull_request' + runs-on: dsh-enterprise-ubuntu-latest-32core-test + name: node 24 / snapshots and artifacts + env: + DSH_ESLINT_CACHE: '1' + DSH_ESLINT_CONCURRENCY: '8' + DSH_GATE_CONCURRENCY: '8' + DSH_NODE_COMPAT_SKIP_TYPECHECK: '1' + DSH_PUBLINT_CONCURRENCY: '8' + DSH_SNAPSHOT_MAX_CONCURRENCY: '32' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: actions/download-artifact@v8 + with: + name: node-24-built-tree + path: ${{ runner.temp }} + + - name: Restore built tree + run: tar -xzf "$RUNNER_TEMP/node-24-built-tree.tar.gz" + + - uses: actions/cache/restore@v4 + with: + path: /home/runner/.local/share/pnpm/store/v11 + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + - uses: actions/cache/restore@v4 - if: matrix.lane == 'snapshots-artifacts' with: path: .cache/eslint key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.base.client.json', 'tsconfig.host.json', 'tsconfig.client.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} @@ -95,15 +172,8 @@ jobs: if (( install_status != 0 )); then exit "$install_status"; fi exit "$sandbox_status" - - name: Run static gates - if: matrix.lane == 'static' - run: pnpm run check:ci:static - - - name: Build and run compatibility, snapshot, and artifact gates - if: matrix.lane == 'snapshots-artifacts' + - name: Run compatibility, snapshot, and artifact gates run: | - pnpm run build - pnpm run check:ci:lint & lint_pid=$! pnpm run check:node-compat & @@ -142,10 +212,6 @@ jobs: done exit "$final_status" - - name: Run exhaustive coverage - if: matrix.lane == 'coverage' - run: pnpm run check:ci:coverage - node-compat: if: github.event_name == 'pull_request' @@ -621,7 +687,7 @@ jobs: all-checks-passed: name: all checks passed runs-on: ubuntu-latest - needs: [node-24, node-compat, python-sdk, windows] + needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, windows] if: always() && github.event_name == 'pull_request' steps: - name: Fail if any needed job did not succeed From 7e81d9dad22b0227449b8b592b18c3357e19f3ac Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:25:00 +0800 Subject: [PATCH 06/11] test: bind webserver fixtures atomically --- .../host/webserver/tests/webserver.spec.ts | 27 +++++-------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 0b9d1a8978..0ea04f7da8 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -1,22 +1,10 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' -import { createServer as createNetServer, Server as NetServer, type AddressInfo } from 'node:net' +import { Server as NetServer } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { startWebServer, type RunningWebServer } from '../src/index.ts' -/** Reserve a loopback port for tests that need to address a second server. */ -function freePort(): Promise { - return new Promise((resolve, reject) => { - const probe = createNetServer() - probe.once('error', reject) - probe.listen(0, '127.0.0.1', () => { - const port = (probe.address() as AddressInfo).port - probe.close(() => { resolve(port) }) - }) - }) -} - /** dist fixture: index.html + one asset of each MIME class + a subdir. */ function makeDist(): { distIndex: string; distRoot: string } { const distRoot = mkdtempSync(join(tmpdir(), 'dsh-webserver-')) @@ -106,8 +94,7 @@ afterEach(async () => { async function boot(onError: (err: Error) => void = () => undefined): Promise { const { distIndex } = makeDist() - const port = await freePort() - server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, onError) + server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, onError) return `http://127.0.0.1:${String(server.port)}` } @@ -147,8 +134,8 @@ describe('startWebServer', () => { it('rejects when the port is already taken', async () => { const { distIndex } = makeDist() - const port = await freePort() - server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined) + server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined) + const { port } = server await expect(startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined)) .rejects.toMatchObject({ code: 'EADDRINUSE' }) }) @@ -205,9 +192,8 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti snapshot: () => rows, clientPath: (id: string) => id === rows[0]?.id ? join(distRoot, 'bundle.js') : undefined, } - const port = await freePort() server = await startWebServer( - { host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined, + { host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined, ) return `http://127.0.0.1:${String(server.port)}` } @@ -243,9 +229,8 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti snapshot: () => rows, clientPath: () => '/nonexistent/lib/client.js', } - const port = await freePort() server = await startWebServer( - { host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined, + { host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined, ) const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/@deepseek-ai/dsh-client-connection/client.js`) expect(res.status).toBe(404) From 6f624c67c4d63247905e17b18b459771073f4d65 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 23 Jul 2026 00:39:27 +0800 Subject: [PATCH 07/11] feat(web): render assistant Markdown --- ...026-07-23-web-assistant-markdown.i18n.yaml | 6 + .../2026-07-23-web-assistant-markdown.md | 35 +++ .../2026-07-23-web-assistant-markdown.zh.md | 35 +++ apps/web/tests/smoke-fixture.e2e.ts | 20 ++ .../client/connection/src/client/fixture.ts | 44 ++- .../src/client/chat/AssistantMarkdown.tsx | 4 +- .../ui-conversation/tests/chat-view.spec.tsx | 38 +++ packages/client/ui-primitives/README.md | 7 +- packages/client/ui-primitives/package.json | 4 +- packages/client/ui-primitives/src/index.ts | 1 + .../src/markdown/MarkdownText.module.css | 123 +++++++++ .../src/markdown/MarkdownText.tsx | 64 +++++ .../src/markdown/MessageText.tsx | 2 +- .../ui-primitives/tests/markdown.spec.tsx | 85 +++++- pnpm-lock.yaml | 258 ++++++++++++++++++ 15 files changed, 712 insertions(+), 14 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md create mode 100644 .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md create mode 100644 packages/client/ui-primitives/src/markdown/MarkdownText.module.css create mode 100644 packages/client/ui-primitives/src/markdown/MarkdownText.tsx diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml new file mode 100644 index 0000000000..1ff9ecac7d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml @@ -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 +2026-07-23-web-assistant-markdown.md: ce98a16fa43e2743c18826ee7f2344c38e7c70e7 +2026-07-23-web-assistant-markdown.zh.md: 0d6fd2f9e6b91f76830586ecf4c29774e5d6978a diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md new file mode 100644 index 0000000000..ce98a16fa4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -0,0 +1,35 @@ +# Agent Note: Safe assistant Markdown in the Web conversation + +Status: implemented + +English | [中文](2026-07-23-web-assistant-markdown.zh.md) + +## Problem + +The Web conversation preserves assistant Markdown source through session events, history replay, and streaming accumulation, but its terminal text primitive renders that source literally. Changing the shared primitive would also format user and steering messages, while parsing in the runtime would mix presentation state into the React-free session projection. + +## Decision + +`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal. + +`MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without `dangerouslySetInnerHTML`, raw-HTML parsing, or syntax highlighting. The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser is part of the initial browser bundle. + +## Untrusted output policy + +Assistant-authored destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images render only their alt text, so model output cannot initiate a remote image request. Raw HTML remains inert source text because no HTML parser enters the pipeline. + +The renderer uses existing `--dsw-*` typography and color tokens. Fenced code and GFM tables own horizontal overflow so long content cannot widen the conversation column. + +## Alternatives considered + +**Promote the existing mdast and micromark development dependencies and maintain a custom React walker.** This avoids a new parser family but makes the product own every node mapping, GFM extension, and security-sensitive rendering branch. The dedicated React renderer keeps that traversal upstream while preserving an AST-to-React path. + +**Replace `MessageText` with Markdown rendering.** This formats user prompts and steering as a side effect. Those authored surfaces remain literal until the product chooses that behavior explicitly. + +**Parse Markdown into session snapshots.** This would make React nodes or presentation ASTs durable runtime state and reintroduce a final-versus-streaming mode boundary. Parsing stays at the presentation leaf instead. + +**Enable raw HTML or remote images with sanitization.** Neither capability has a current product need, while both enlarge the executable or network privacy boundary. They remain disabled rather than adding sanitizer and image-policy dependencies. + +## Consequences + +Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. The initial Web shell grows by the Markdown parser and GFM runtime, and future extensions such as syntax highlighting or remote media require a separate bundle and security decision. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md new file mode 100644 index 0000000000..0d6fd2f9e6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Web 对话中安全的 assistant Markdown + +Status: implemented + +[English](2026-07-23-web-assistant-markdown.md) | 中文 + +## 问题 + +Web 对话通过会话事件、历史回放与流式累积保留 assistant Markdown 源文本,但其最末端的文本原语会按字面渲染源文本。若修改共享原语,用户消息与 steering(中途引导)消息也会被格式化;若在运行时中解析,则会把呈现状态混入不依赖 React 的会话投影。 + +## 决策 + +`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。 + +`MarkdownText` 使用 `react-markdown` 与 `remark-gfm`,从 AST 构建 React 元素。它支持 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,但不使用 `dangerouslySetInnerHTML`,不解析原始 HTML,也不进行语法高亮。`ui-primitives` 显式声明该依赖;由于这一纯库由 Web shell 预置,解析器会成为初始浏览器 bundle 的一部分。 + +## 不受信任输出策略 + +assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片仅渲染替代文本,因此模型输出无法发起远程图片请求。由于管线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。 + +渲染器使用现有的 `--dsw-*` 排版与颜色 token。围栏代码块与 GFM 表格各自处理横向溢出,因此较长内容无法撑宽对话栏。 + +## 考虑过的替代方案 + +**将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系,但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。 + +**将 `MessageText` 替换为 Markdown 渲染。**这会产生格式化用户提示词与 steering 的副作用。在产品明确选择此行为之前,这两类输入内容仍按字面渲染。 + +**将 Markdown 解析为会话快照。**这会让 React 节点或呈现层 AST 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。 + +**通过净化启用原始 HTML 或远程图片。**当前产品并不需要这两项功能,但二者都会扩大可执行行为或网络隐私边界。因此它们保持禁用,无需增加净化器与图片策略依赖。 + +## 后果 + +assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。初始 Web shell 的体积会因加入 Markdown 解析器与 GFM 运行时而增大;语法高亮或远程媒体等后续扩展需要另行作出 bundle 与安全决策。 diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 3fccde9a79..6c196fe922 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -196,6 +196,26 @@ describe('web boot chain success pass (keyless, seven real bundles, ?fixture)', expect(await writeRoot.getByText('notes/new-demo.txt', { exact: true }).count()).toBe(1) }) + it('keeps Markdown semantic while a fixture reply streams and finalizes', async () => { + onTestFailed(() => saveFailureShot(page, 'smoke-markdown-stream')) + await page.getByRole('button', { name: 'New Session', exact: true }).click() + const input = page.locator('textarea[placeholder]') + await input.waitFor({ timeout: 15_000 }) + await input.fill('render markdown') + await page.getByRole('button', { name: '发送' }).click() + + const streaming = page.locator('[data-streaming="true"]') + await streaming.getByRole('heading', { name: 'Markdown fixture' }).waitFor({ timeout: 15_000 }) + await streaming.waitFor({ state: 'detached', timeout: 15_000 }) + + const finalHeading = page.getByRole('heading', { name: 'Markdown fixture' }) + expect(await finalHeading.evaluate(element => element.tagName)).toBe('H1') + expect(await page.locator('pre code').filter({ hasText: 'const markdown = true' }).count()).toBe(1) + const external = page.getByRole('link', { name: 'DeepSeek' }) + expect(await external.getAttribute('target')).toBe('_blank') + expect(await external.getAttribute('rel')).toBe('noopener noreferrer') + }) + it('stayed clean: no page errors across the whole load chain', () => { expect(pageErrors).toEqual([]) }) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index bbf4809466..20312b41a3 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -24,6 +24,28 @@ function text(t: string): ContentBlock[] { return [{ type: 'text', text: t }] } +const MARKDOWN_FIXTURE = [ + '# Markdown fixture', + '', + 'Assistant output renders **strong text**, *emphasis*, and `inline code`.', + '', + '- first item', + ' - nested item', + '', + '| Surface | State |', + '| --- | --- |', + '| history | rendered |', + '| streaming | stable |', + '', + '[DeepSeek](https://www.deepseek.com)', + '', + '```ts', + 'const markdown = true', + '```', +].join('\n') + +const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)' + function sid(id: string): SessionId { return id as SessionId } @@ -40,7 +62,13 @@ function buildAlphaLog(): SessionEvent[] { } for (let turn = 0; turn < 60; turn++) { push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) - push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`), source: { kind: 'user' } } }) + push({ + type: 'user/message', surfaceOp: 'append', + data: { + content: text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`), + source: { kind: 'user' }, + }, + }) if (turn % 9 === 4) { push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } }) } @@ -49,7 +77,7 @@ function buildAlphaLog(): SessionEvent[] { const withReasoning = turn % 3 === 1 const blocks: ContentBlock[] = [] if (withReasoning) blocks.push({ type: 'reasoning', text: `思考过程 ${turn}:这是一段可折叠的 reasoning 内容。` }) - blocks.push({ type: 'text', text: `回答 ${turn}:这是 fixture 生成的历史回复正文。` }) + blocks.push({ type: 'text', text: turn === 59 ? MARKDOWN_FIXTURE : `回答 ${turn}:这是 fixture 生成的历史回复正文。` }) if (withTool) { const callId = `fx-call-${turn}` blocks.push({ type: 'tool-call', id: callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } as ContentBlock) @@ -343,8 +371,8 @@ export function createFixtureApi(): ApiProxy { const step = 0 append(id, { type: 'step/start', data: { turn, step } }) append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-start', index: 0, blockType: 'text' } } }) - /* v8 ignore next -- the ?? arm needs a null match, but replyText is never empty (prompt always prefixes 回声). */ - const pieces = replyText.match(/.{1,6}/gu) ?? [replyText] + /* v8 ignore next -- the ?? arm needs a null match, but every fixture reply is non-empty. */ + const pieces = replyText.match(/[\s\S]{1,6}/gu) ?? [replyText] let i = 0 const finish = (aborted: boolean): void => { replays.delete(id) @@ -410,7 +438,13 @@ export function createFixtureApi(): ApiProxy { setRunning(id, true) append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) append(id, { type: 'user/message', surfaceOp: 'append', data: { content, source: { kind: 'user' } } }) - startReply(id, turn, `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`) + startReply( + id, + turn, + userText === 'render markdown' + ? MARKDOWN_FIXTURE + : `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`, + ) return ok(request, { accepted: true as const }) }, cancel: (request) => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 7bb9a22e3e..90eb3e3bee 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -7,7 +7,7 @@ import { memo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' -import { IconThinkOutline14, JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import { ToolRow } from './ToolRow.tsx' import css from './AssistantMarkdown.module.css' @@ -44,7 +44,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
{blocks.map((block, i) => { switch (block.kind) { - case 'text': return + case 'text': return case 'reasoning': return // Tool-call heads render as tool rows in the chat view's grouping pass. case 'tool-call': return null diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 4d5cd923d6..45c3b3f6ca 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -157,6 +157,44 @@ describe('ChatView', () => { expect(view.getByText('run a')).toBeTruthy() }) + it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => { + const markdown = '# Rendered\n\n- **one**\n- `two`' + const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] }) + const view = render() + expect(view.container.querySelectorAll('h1')).toHaveLength(1) + const literal = view.getByText((_content, element) => ( + element?.tagName === 'DIV' && element.childElementCount === 0 && element.textContent === markdown + )) + expect(literal.querySelector('h1')).toBeNull() + + act(() => { + h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: markdown }] } }) + }) + expect(view.container.querySelectorAll('h1')).toHaveLength(2) + expect(view.container.querySelector('[data-streaming="true"] h1')?.textContent).toBe('Rendered') + + act(() => { + h.set({ + nodes: [user(1, markdown), assistant(2, markdown), assistant(3, markdown)], + partial: null, + }) + }) + expect(view.container.querySelectorAll('h1')).toHaveLength(2) + expect(view.container.querySelector('[data-streaming="true"]')).toBeNull() + + act(() => { + h.set({ + nodes: [ + user(1, markdown), + assistant(2, markdown), + { ...assistant(3, markdown), interrupted: true }, + ], + }) + }) + expect(view.getByText('已停止')).toBeTruthy() + expect(view.container.querySelectorAll('h1')).toHaveLength(2) + }) + it('streaming partial frames re-render only the tail (Profiler count)', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'old answer'), toolResult(3, 'a')], diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 29883df236..da6382be4b 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -1,6 +1,10 @@ # @deepseek-ai/dsh-client-ui-primitives -Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/JsonBlock). Contract: api-contracts v3 §8. +Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8. + +## Markdown rendering + +`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. ## Model Experience @@ -15,4 +19,3 @@ None; this package neither assembles nor sends a provider request. - **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists. - **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. - **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface. -- **MessageText renders plain text** — markdown support swaps this component's internals later; consumers must not assume block structure. diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 2d23ecd330..fda27fdd4d 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -21,7 +21,9 @@ "license": "BSD-3-Clause", "dependencies": { "clsx": "^2.0.0", - "react": "^18.2.0" + "react": "^18.2.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index daea4202b3..b1a7a9b1ac 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -15,5 +15,6 @@ export type { MenuItem } from './Menu.tsx' export { ConnectionBanner } from './ConnectionBanner.tsx' export { FishLogo } from './FishLogo.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' +export { MarkdownText } from './markdown/MarkdownText.tsx' export { MessageText } from './markdown/MessageText.tsx' export * from './icons/index.tsx' diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css new file mode 100644 index 0000000000..36b1dc2b55 --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -0,0 +1,123 @@ +.markdown { + display: flex; + min-width: 0; + flex-direction: column; + gap: 12px; + overflow-wrap: anywhere; + font: var(--dsw-font-markdown-base); +} + +.markdown :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote, pre, hr) { + margin: 0; +} + +.markdown h1 { + font: var(--dsw-font-markdown-h1); +} + +.markdown h2 { + font: var(--dsw-font-markdown-h2); +} + +.markdown h3 { + font: var(--dsw-font-markdown-h3); +} + +.markdown :where(h4, h5, h6) { + font: var(--dsw-font-markdown-h4); +} + +.markdown :where(strong, th) { + font-weight: var(--dsw-font-markdown-base-strong-font-weight); +} + +.markdown :where(ul, ol) { + padding-inline-start: 24px; +} + +.markdown li + li { + margin-block-start: 4px; +} + +.markdown li > :where(ul, ol) { + margin-block-start: 4px; +} + +.markdown blockquote { + padding-inline-start: 12px; + border-inline-start: 3px solid var(--dsw-alias-markdown-citation); + color: var(--dsw-alias-label-secondary); +} + +.markdown a { + color: var(--dsw-alias-state-business-primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +.markdown :not(pre) > code { + padding: 2px 4px; + border-radius: 4px; + background: var(--dsw-alias-markdown-inline-code); + font: var(--dsw-font-markdown-code); +} + +.markdown pre { + max-width: 100%; + overflow-x: auto; + overscroll-behavior-x: contain; + padding: 12px 16px; + border-radius: 8px; + background: var(--dsw-alias-markdown-code-block); + font: var(--dsw-font-markdown-code-block); +} + +.markdown pre code { + padding: 0; + background: transparent; + font: inherit; + overflow-wrap: normal; + word-break: normal; + white-space: pre; +} + +.markdown hr { + width: 100%; + border: 0; + border-block-start: 1px solid var(--dsw-alias-markdown-citation); +} + +.markdown input[type='checkbox'] { + margin: 0 8px 0 0; + accent-color: var(--dsw-alias-state-business-primary); +} + +.tableScroll { + max-width: 100%; + overflow-x: auto; + overscroll-behavior-x: contain; +} + +.tableScroll table { + width: max-content; + min-width: 100%; + border-collapse: collapse; + font: var(--dsw-font-markdown-table); +} + +.tableScroll :where(th, td) { + padding: 6px 12px; + border: 1px solid var(--dsw-alias-markdown-citation); + text-align: start; + white-space: nowrap; +} + +.tableScroll th { + background: var(--dsw-alias-markdown-code-block-banner); + font: var(--dsw-font-markdown-table-head); +} + +.imageAlt { + color: var(--dsw-alias-label-tertiary); + font-style: italic; +} diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx new file mode 100644 index 0000000000..425e3969ab --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -0,0 +1,64 @@ +import ReactMarkdown from 'react-markdown' +import type { Components, UrlTransform } from 'react-markdown' +import remarkGfm from 'remark-gfm' +import css from './MarkdownText.module.css' + +const remarkPlugins = [remarkGfm] + +function sanitizeUrl(url: string): string { + try { + switch (new URL(url).protocol) { + case 'http:': + case 'https:': + case 'mailto:': + return url + default: + return '' + } + } catch { + return '' + } +} + +const safeUrl: UrlTransform = url => sanitizeUrl(url) + +const components: Components = { + a: ({ href = '', children }) => { + const safeHref = sanitizeUrl(href) + if (safeHref === '') return <>{children} + const external = ['http:', 'https:'].includes(new URL(safeHref).protocol) + return ( + + {children} + + ) + }, + img: ({ alt = '' }) => {alt}, + table: ({ children }) => ( +
+ {children}
+
+ ), +} + +/** + * Render untrusted assistant-authored Markdown as semantic React elements. + * @param props - Markdown source text preserved by the session projection. + * @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled. + */ +export function MarkdownText({ text }: { text: string }) { + return ( +
+ + {text} + +
+ ) +} diff --git a/packages/client/ui-primitives/src/markdown/MessageText.tsx b/packages/client/ui-primitives/src/markdown/MessageText.tsx index e9fa76d823..cafe9ab3c0 100644 --- a/packages/client/ui-primitives/src/markdown/MessageText.tsx +++ b/packages/client/ui-primitives/src/markdown/MessageText.tsx @@ -1,4 +1,4 @@ -// MessageText: the single text-block rendering point (Markdown support later = swap this component's internals, zero card-structure changes). +// MessageText is the literal-text primitive for user and steering content; assistant output uses MarkdownText. import css from './MessageText.module.css' diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 6cfb5f4e8e..4e1dc292d4 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -1,14 +1,93 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) describe('MessageText', () => { it('renders the text verbatim', () => { - const { container } = render() - expect(container.textContent).toBe('line1\nline2') + const { container } = render() + expect(container.textContent).toBe('# line1\n`line2`') + expect(container.querySelector('h1')).toBeNull() + }) +}) + +describe('MarkdownText', () => { + it('renders CommonMark and GFM elements as semantic DOM', () => { + const markdown = [ + '# Heading', + '', + 'Paragraph with **strong**, *emphasis*, ~~deleted~~, `inline`, and [safe](https://example.com). ', + 'Hard break.', + '', + '> Quote', + '', + '- parent', + ' - child', + '', + '1. first', + '2. second', + '', + '- [x] done', + '- [ ] pending', + '', + '| Name | Value |', + '| --- | --- |', + '| alpha | beta |', + '', + '---', + '', + '```ts', + 'const answer = 42', + '```', + '', + '', + ].join('\n') + const { container } = render() + + expect(screen.getByRole('heading', { level: 1, name: 'Heading' })).toBeTruthy() + expect(container.querySelector('strong')?.textContent).toBe('strong') + expect(container.querySelector('em')?.textContent).toBe('emphasis') + expect(container.querySelector('del')?.textContent).toBe('deleted') + expect(container.querySelector('blockquote')?.textContent?.trim()).toBe('Quote') + expect(container.querySelectorAll('ul')).toHaveLength(3) + expect(container.querySelector('ol')).not.toBeNull() + expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(2) + expect(container.querySelector('table')?.textContent).toContain('alphabeta') + expect(container.querySelector('hr')).not.toBeNull() + expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42') + expect(container.querySelector('br')).not.toBeNull() + expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank') + expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy() + }) + + it('neutralizes raw HTML, unsafe or relative links, and remote images', () => { + const markdown = [ + '', + '', + '[script](javascript:alert(1)) [relative](/settings)', + '[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)', + '![remote diagram](https://example.com/private.png)', + ].join('\n\n') + const { container } = render() + + expect(container.querySelector('script')).toBeNull() + expect(container.querySelector('img')).toBeNull() + const neutralized = [...container.querySelectorAll('p')] + .find(paragraph => paragraph.textContent === 'script relative') + expect(neutralized?.querySelector('a')).toBeNull() + expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull() + expect(screen.getByRole('link', { name: 'web' }).getAttribute('rel')).toBe('noopener noreferrer') + expect(screen.getByRole('link', { name: 'upper' }).getAttribute('target')).toBe('_blank') + expect(screen.getByText('remote diagram')).toBeTruthy() + }) + + it('keeps incomplete streaming Markdown renderable', () => { + const { container } = render() + expect(screen.getByRole('heading', { level: 2, name: 'Streaming' })).toBeTruthy() + expect(container.querySelectorAll('li')).toHaveLength(2) + expect(screen.getByText('**unfinished')).toBeTruthy() }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a0cb4be16..66b9e9478a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -614,6 +614,12 @@ importers: react: specifier: ^18.2.0 version: 18.3.1 + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@18.3.31)(react@18.3.1) + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -6472,6 +6478,9 @@ packages: '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -6537,6 +6546,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -6830,6 +6842,9 @@ packages: ast-v8-to-istanbul@1.0.4: resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -6918,6 +6933,9 @@ packages: character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -7383,6 +7401,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -7611,6 +7632,9 @@ packages: hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} @@ -7631,6 +7655,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -7682,6 +7709,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@1.0.1: resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} @@ -7697,6 +7727,15 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -7709,6 +7748,13 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -8099,6 +8145,15 @@ packages: mdast-util-gfm@3.1.0: resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} @@ -8416,6 +8471,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -8550,6 +8608,12 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -8582,6 +8646,18 @@ packages: resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -8791,6 +8867,12 @@ packages: strnum@2.4.0: resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + stylis@4.4.0: resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} @@ -8853,6 +8935,9 @@ packages: trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} @@ -8969,6 +9054,9 @@ packages: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -11090,6 +11178,10 @@ snapshots: '@types/esrecurse@4.3.1': {} + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + '@types/estree@1.0.9': {} '@types/geojson@7946.0.16': {} @@ -11154,6 +11246,8 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@types/unist@2.0.11': {} + '@types/unist@3.0.3': {} '@types/web-bluetooth@0.0.21': {} @@ -11529,6 +11623,8 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + bail@2.0.2: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -11609,6 +11705,8 @@ snapshots: character-entities@2.0.2: {} + character-reference-invalid@2.0.1: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -12159,6 +12257,8 @@ snapshots: estraverse@5.3.0: {} + estree-util-is-identifier-name@3.0.0: {} + estree-walker@2.0.2: {} estree-walker@3.0.3: @@ -12433,6 +12533,26 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.5 @@ -12451,6 +12571,8 @@ snapshots: html-escaper@2.0.2: {} + html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} http-errors@2.0.1: @@ -12499,6 +12621,8 @@ snapshots: inherits@2.0.4: {} + inline-style-parser@0.2.7: {} + internmap@1.0.1: {} internmap@2.0.3: {} @@ -12507,6 +12631,15 @@ snapshots: ipaddr.js@1.9.1: {} + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -12515,6 +12648,10 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} is-promise@4.0.0: {} @@ -12934,6 +13071,45 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + mdast-util-phrasing@4.1.0: dependencies: '@types/mdast': 4.0.4 @@ -13401,6 +13577,16 @@ snapshots: pako@1.0.11: {} + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -13523,6 +13709,24 @@ snapshots: react-is@17.0.2: {} + react-markdown@10.1.0(@types/react@18.3.31)(react@18.3.1): + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/react': 18.3.31 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 18.3.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + react-refresh@0.17.0: {} react@18.3.1: @@ -13560,6 +13764,40 @@ snapshots: '@eslint-community/regexpp': 4.12.2 refa: 0.12.1 + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + require-from-string@2.0.2: {} resolve-pkg-maps@1.0.0: {} @@ -13844,6 +14082,14 @@ snapshots: dependencies: anynum: 1.0.0 + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + stylis@4.4.0: {} superjson@2.2.6: @@ -13891,6 +14137,8 @@ snapshots: trim-lines@3.0.1: {} + trough@2.2.0: {} + ts-algebra@2.0.0: {} ts-api-utils@2.5.0(typescript@6.0.3): @@ -13984,6 +14232,16 @@ snapshots: undici@7.28.0: {} + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 From 01dcc7920cb18a9e6d76f621b3549d87574e09ff Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 23 Jul 2026 19:21:02 +0800 Subject: [PATCH 08/11] fix(tui): honor file reference boundaries --- packages/ui/tui/src/file-autocomplete.ts | 23 ++++++++++++-- packages/ui/tui/src/index.ts | 2 +- .../ui/tui/tests/file-autocomplete.spec.ts | 18 +++++++++++ packages/ui/tui/tests/tui.spec.ts | 30 +++++++++++++------ 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/ui/tui/src/file-autocomplete.ts b/packages/ui/tui/src/file-autocomplete.ts index 719aa5b91b..23a3a7c1fa 100644 --- a/packages/ui/tui/src/file-autocomplete.ts +++ b/packages/ui/tui/src/file-autocomplete.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-tui/file-autocomplete */ -import { readdir } from 'node:fs/promises' +import { lstat, readdir } from 'node:fs/promises' import { isAbsolute, join, relative, resolve, sep } from 'node:path' /** Default maximum file and directory candidates rendered for one query. */ @@ -205,7 +205,7 @@ export class WorkspaceFileSearch { signal: AbortSignal, ): Promise { if (displayDirectory.split('/').some(segment => this.excludedDirectories.has(segment))) return [] - const absolute = resolveDisplayDirectory(this.root, displayDirectory) + const absolute = await resolveDisplayDirectory(this.root, displayDirectory, signal) if (absolute === undefined) return [] const entries = await readDirectory(absolute, signal) const candidates: FileSearchCandidate[] = [] @@ -222,13 +222,30 @@ export class WorkspaceFileSearch { } } -function resolveDisplayDirectory(root: string, displayDirectory: string): string | undefined { +async function resolveDisplayDirectory( + root: string, + displayDirectory: string, + signal: AbortSignal, +): Promise { const resolvedRoot = resolve(root) const absolute = resolve(resolvedRoot, displayDirectory === '' ? '.' : displayDirectory) const fromRoot = relative(resolvedRoot, absolute) if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`)) return undefined /* v8 ignore next -- only Windows can produce a cross-volume absolute relative path */ if (isAbsolute(fromRoot)) return undefined + let current = resolvedRoot + for (const segment of fromRoot.split(sep).filter(Boolean)) { + signal.throwIfAborted() + current = join(current, segment) + try { + const status = await lstat(current) + signal.throwIfAborted() + if (status.isSymbolicLink() || !status.isDirectory()) return undefined + } catch (_error: unknown) { + signal.throwIfAborted() + return undefined + } + } return absolute } diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index f0781b0987..6ff66b4783 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -2485,7 +2485,7 @@ export function createTuiChat( // Tool visibility can change dynamically or by agent scope. Empty // sections are omitted by renderPrompt, so guidance never names a tool // that this agent cannot call. - text: () => agent.ctx.tools.get('read') === undefined ? '' : FILE_REFERENCE_PROMPT, + text: () => agent.ctx.tools.get('read', agent) === undefined ? '' : FILE_REFERENCE_PROMPT, }) }) diff --git a/packages/ui/tui/tests/file-autocomplete.spec.ts b/packages/ui/tui/tests/file-autocomplete.spec.ts index d918b0d489..53dd1f4f1a 100644 --- a/packages/ui/tui/tests/file-autocomplete.spec.ts +++ b/packages/ui/tui/tests/file-autocomplete.spec.ts @@ -105,6 +105,24 @@ describe('WorkspaceFileSearch', () => { ]) expect(await files.list('~/.dsh-file-autocomplete-missing/', signal)).toEqual([]) expect(await files.list('../', signal)).toEqual([]) + expect(await files.list('README.md/', signal)).toEqual([]) + }) + + it('does not traverse directory symlinks during direct completion', async () => { + const root = await workspace() + const outside = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-outside-')) + roots.push(outside) + await writeFile(join(outside, 'outside-secret.txt'), 'secret') + await symlink( + outside, + join(root, 'escape'), + process.platform === 'win32' ? 'junction' : 'dir', + ) + const files = search(root) + const signal = new AbortController().signal + + expect(await files.list('escape/', signal)).toEqual([]) + expect(await files.list('escape/outside', signal)).toEqual([]) }) it('ranks basename and subsequence fuzzy matches across the bounded workspace index', async () => { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 4a8e9b5e93..e935325b16 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1153,22 +1153,34 @@ describe('pi-tui chat lifecycle and transcript', () => { }) it('shows file-reference guidance only while read is visible to the agent', async () => { - const tools: Record = {} - const result = await setup({ tools }) + const read: ToolDefinition = { + name: 'read', + description: 'Read a file.', + parameters: {}, + execute: () => Promise.resolve([]), + } + let visibility: 'none' | 'global' | 'agent' = 'none' + const result = await setup({ + async configureContext(ctx) { + ctx.provide('tools', { + get(name: string, scope?: Agent) { + if (name !== 'read' || visibility === 'none') return undefined + return (scope === undefined) === (visibility === 'global') ? read : undefined + }, + } as never) + }, + }) const fileReferenceText = async (): Promise => { const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent)) return assembly.sections.find(section => section.name === 'ui:tui-file-reference')?.text } try { expect(await fileReferenceText()).toBe('') - tools.read = { - name: 'read', - description: 'Read a file.', - parameters: {}, - execute: () => Promise.resolve([]), - } + visibility = 'global' + expect(await fileReferenceText()).toBe('') + visibility = 'agent' expect(await fileReferenceText()).toBe(FILE_REFERENCE_PROMPT) - delete tools.read + visibility = 'none' expect(await fileReferenceText()).toBe('') } finally { await dispose(result) From 0b0492e8ae402698b1c480fcd607b5963e64d1fa Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 23 Jul 2026 19:23:35 +0800 Subject: [PATCH 09/11] test(web): align Markdown smoke with master --- apps/web/tests/smoke-fixture.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 6c196fe922..4e489b264f 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -198,7 +198,7 @@ describe('web boot chain success pass (keyless, seven real bundles, ?fixture)', it('keeps Markdown semantic while a fixture reply streams and finalizes', async () => { onTestFailed(() => saveFailureShot(page, 'smoke-markdown-stream')) - await page.getByRole('button', { name: 'New Session', exact: true }).click() + await page.getByRole('button', { name: 'New session', exact: true }).click() const input = page.locator('textarea[placeholder]') await input.waitFor({ timeout: 15_000 }) await input.fill('render markdown') From d8051f82f61d606675f44ac79c262b2063d65431 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 23 Jul 2026 19:29:23 +0800 Subject: [PATCH 10/11] test(client): cover Markdown fixture reply --- packages/client/connection/tests/fixture.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index c50921a44d..c9c90f1ae0 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -113,7 +113,7 @@ describe('createFixtureApi', () => { const missing = await api.sessions.prompt(req({ sessionId: sid('ghost'), mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] })) expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'ghost' } } }) // Real prompt: replay starts (running flips true), cancel freezes it. - const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: '取消我' }] })) + const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'render markdown' }] })) expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } }) await new Promise(resolve => setTimeout(resolve, 120)) // a couple of typewriter ticks await api.sessions.cancel(req({ sessionId: id })) From a8c6dcb180c43977f982ef44fb425bf2b2804823 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:53:54 +0800 Subject: [PATCH 11/11] fix(gui): contain selector failures and key the chain boundary by entry Two hardenings on the chain outlet branch: A throwing chain selector runs before its entry's SlotErrorBoundary exists, so uncontained it blacked out the whole owner region and skipped the remaining chain. It now degrades to a decline: reported via console.error with the registrant identity, later entries still tried, all-null/all-throw passes land on the owner fallback. The elected entry's boundary is now keyed by entry identity: an unkeyed boundary that failed on entry A survived a re-election and kept a healthy entry B blacked out until the outlet unmounted. The key remounts the boundary fresh whenever the election changes. --- .../client/web-react/src/scoped-slots.tsx | 39 +++++++++++++-- .../web-react/tests/scoped-slots.spec.tsx | 49 +++++++++++++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/packages/client/web-react/src/scoped-slots.tsx b/packages/client/web-react/src/scoped-slots.tsx index da3e14fe12..603ea1091f 100644 --- a/packages/client/web-react/src/scoped-slots.tsx +++ b/packages/client/web-react/src/scoped-slots.tsx @@ -135,6 +135,26 @@ function cachedSessionInject(entry: StoredEntry, cell: SessionCell, actions: obj return props } +/** + * Entry-identity React keys for chain boundaries. A chain outlet renders ONE + * elected entry through an error boundary; without a key, a boundary that + * failed on entry A would survive a re-election and keep a healthy entry B + * blacked out. Keying by entry identity remounts the boundary fresh whenever + * the election changes (entries are identity-stable per registration, so the + * key is stable while the same entry stays elected). + */ +let nextEntryKey = 0 +const entryKeys = new WeakMap() + +function entryKeyOf(entry: StoredEntry): number { + let key = entryKeys.get(entry) + if (key === undefined) { + key = nextEntryKey++ + entryKeys.set(entry, key) + } + return key +} + /** * Per-entry isolation: one registrant crashing (component render or inject * factory) must not take down siblings. Assembly errors (missing providers) @@ -265,9 +285,22 @@ function SlotOutlet({ slotKey, ownerProps, opts }: { // pass runs per render with zero mount side effects: the first non-null // election renders, decliners never mount. for (const entry of entries) { - // Chain entries always carry select (SlotCore register validation). - const matched = (entry.select as (owner: object) => unknown)(ownerProps) - if (matched !== null) return guarded(entry, undefined, { ...ownerProps, matched }) + let matched: unknown + try { + // Chain entries always carry select (SlotCore register validation). + matched = (entry.select as (owner: object) => unknown)(ownerProps) + } catch (error) { + // A throwing selector is a registrant contract breach (select MUST be + // pure and total), but it runs before the entry's SlotErrorBoundary + // exists — uncontained it would black out the whole owner region. So + // it degrades to a decline: the chain and the fallback stay intact, + // and the breach is reported like a crashed entry. + console.error( + `chain selector crashed in '${slotKey}' (${entry.registrant ?? 'unknown registrant'}), treating as declined:`, + error) + continue + } + if (matched !== null) return guarded(entry, entryKeyOf(entry), { ...ownerProps, matched }) } return <>{opts?.fallback ?? null} } diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.spec.tsx index 40fed207f0..aab243ab47 100644 --- a/packages/client/web-react/tests/scoped-slots.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots.spec.tsx @@ -312,6 +312,55 @@ describe('chain outlets and the renderSlotChain binding', () => { expect(declinerBody).not.toHaveBeenCalled() }) + it('contains a throwing selector to its entry: reported, treated as declined, chain and fallback intact', () => { + const h = makeHost() + h.declare('k.chain', CHAIN_ROOT) + h.add('k.chain', chainEntryOf({ + component: () => never, + select: () => { throw new Error('selector boom') }, + })) + h.add('k.chain', chainEntryOf({ + component: ({ matched }: { matched?: string }) => {matched}, + select: (owner) => (owner as { pick?: string }).pick ?? null, + })) + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, (renderSlotChain) => <> +
{renderSlotChain('k.chain', { pick: 'OK' })}
+ + ) + // The breach never escapes to the owner region: later entries still get + // tried, and an all-throw/all-null pass still lands on the fallback. + expect(view.container.querySelector('main')!.textContent).toBe('OK') + expect(view.container.querySelector('aside')!.textContent).toBe('fb') + expect(spy.mock.calls.some(([msg]) => String(msg).includes('chain selector crashed'))).toBe(true) + spy.mockRestore() + }) + + it('remounts the boundary on re-election: a failed entry does not black out its replacement', () => { + const h = makeHost() + h.declare('k.chain', CHAIN_ROOT) + h.add('k.chain', chainEntryOf({ + component: () => { throw new Error('entry A boom') }, + select: (owner) => (owner as { pick?: string }).pick === 'A' ? {} : null, + })) + h.add('k.chain', chainEntryOf({ + component: () => B-ok, + select: (owner) => (owner as { pick?: string }).pick === 'B' ? {} : null, + })) + let pick = 'A' + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { view } = mountChainRoot(h, { 'k.chain': CHAIN_ROOT }, + (renderSlotChain) => renderSlotChain('k.chain', { pick })) + spy.mockRestore() + expect(view.container.querySelector('[data-slot-error]')).not.toBeNull() + // Re-elect entry B: the entry-keyed boundary remounts fresh instead of + // holding A's failed state over the healthy replacement. + pick = 'B' + act(() => { h.add('root', { component: () => null }) }) // root bump re-renders the dispatch site + expect(view.container.textContent).toBe('B-ok') + expect(view.container.querySelector('[data-slot-error]')).toBeNull() + }) + it('falls to the owner fallback when every selector declines, and re-routes live', () => { const h = makeHost() h.declare('k.chain', CHAIN_ROOT)