From cea837e06525384855e3b8b8231c2400bc3cb85b Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 11 Sep 2026 18:40:06 +0800 Subject: [PATCH 01/60] perf(terminal-bash): retain scrollback incrementally --- ...1-incremental-terminal-retention.i18n.yaml | 6 + ...26-09-11-incremental-terminal-retention.md | 60 ++++ ...09-11-incremental-terminal-retention.zh.md | 60 ++++ benchmarks/package.json | 3 + benchmarks/terminal-io/session-adapter.ts | 2 + benchmarks/terminal-io/terminal-io.bench.ts | 63 ++++ benchmarks/terminal-io/terminal-io.worker.ts | 100 ++++++ benchmarks/tsdown.config.ts | 7 + .../terminal/terminal-bash/README.i18n.yaml | 4 +- packages/terminal/terminal-bash/README.md | 2 + packages/terminal/terminal-bash/README.zh.md | 2 + .../terminal/terminal-bash/src/session.ts | 97 +++++- .../tests/session-buffer.spec.ts | 294 ++++++++++++++++++ pnpm-lock.yaml | 9 + 14 files changed, 692 insertions(+), 17 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.zh.md create mode 100644 benchmarks/terminal-io/session-adapter.ts create mode 100644 benchmarks/terminal-io/terminal-io.bench.ts create mode 100644 benchmarks/terminal-io/terminal-io.worker.ts create mode 100644 packages/terminal/terminal-bash/tests/session-buffer.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.i18n.yaml new file mode 100644 index 0000000000..aaca19caaa --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.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 .agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md +2026-09-11-incremental-terminal-retention.md: 4db0e8219b7a24808c4f53538b443d871407fd96 +2026-09-11-incremental-terminal-retention.zh.md: ae70eb33f923e8b65b8fb22789d11f62a5eb8209 diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md b/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md new file mode 100644 index 0000000000..4db0e8219b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md @@ -0,0 +1,60 @@ +# Agent Note: incremental terminal retention + +Status: implemented + +English | [中文](2026-09-11-incremental-terminal-retention.zh.md) + +## Problem + +Persistent terminal output passes through scrollback and unread-send byte limits on every PTY callback. Rebuilding the entire retained string to enforce those limits makes callback cost grow with retained output. A 4 MiB scrollback window makes this repeated work substantial even when each incoming chunk is small. + +## Decision + +The private buffer in [terminal-bash](../../../../packages/terminal/terminal-bash/src/session.ts) retains a linked sequence of strings, a head offset, and aggregate UTF-8 byte and newline counts. Appends inspect incoming text and evict only the oldest code points until both limits hold. Every evicted code point is charged to an earlier append, so total retention work is linear in input size. Reads assemble the retained strings; they remain proportional to retained output. + +The retained suffix matches line trimming followed by UTF-8 trimming. A trailing newline contributes an empty logical line. Truncation stays sticky until consumption clears the buffer. Adjacent surrogate halves across chunks count as one four-byte code point and are evicted together; unpaired halves retain JavaScript string identity and count as three UTF-8 bytes. The read-time `utf8Tail()` remains independent and unchanged. + +After at least half of the leading string is discarded, its suffix is copied through UTF-16 to release the original backing storage without replacing unpaired surrogates. The copy costs no more than the discarded prefix, preserving amortized linear work and bounding retained string storage by the retained window. Linked nodes avoid array shifts or periodic scans of all retained chunks. Small inputs coalesce in the non-head tail up to 4096 UTF-16 units; allocating its successor seals that tail into a flat string. The head never grows during appends, and a cached last code unit avoids flattening pending fragments to inspect a cross-chunk surrogate pair. This bounds fragment metadata even for one-byte callbacks without rescanning retained text. + +The [persistent PTY decision](../feature/2026-07-16-persistent-pty-sessions.md) continues to own session lifecycle, model-visible output, and retention semantics. This decision specializes storage and performance; it supersedes no active decision record. + +## Measurement design + +The terminal I/O benchmark drives `LocalPtySession` with a synthetic subprocess handle. Fixed 16 KiB ASCII chunks without newlines exercise the byte limit with a long logical line. Steady-state cases fill either a 128 KiB or 4 MiB window, then append the same additional 1 MiB. A separate empty-window case sends 5 MiB. The line limit is 10,000; unread output is limited to the smaller of 256 KiB and the scrollback capacity. + +Synchronous ingestion measures the send start and provider callbacks. Completion additionally waits for emulator processing and readiness, then reads the bounded terminal result. Retained heap is sampled after explicit GC while the session and returned output remain reachable. Built JavaScript runs under plain Node. These measurements exclude shell startup, operating-system PTY transport, model latency, and browser rendering. + +### Local reference measurements + +On Apple M5 Pro, macOS arm64, Node v26.5.0, five fresh workers per case compare base `ac8f6f1f228eae84f56856042811162cd00141fe` with incremental retention. The same worker and inputs measure both versions; only the private session implementation differs. Times below are milliseconds in sample order. + +| Case / metric | Eager retention samples | Incremental retention samples | +|---|---|---| +| 128 KiB steady / ingestion | 232.164, 234.564, 219.511, 219.615, 221.434 | 9.094, 8.756, 8.949, 8.867, 8.974 | +| 128 KiB steady / completion | 245.365, 247.826, 233.322, 232.854, 234.926 | 18.800, 18.329, 19.348, 18.439, 18.414 | +| 4 MiB steady / ingestion | 4159.780, 4271.184, 4179.700, 4223.583, 4128.541 | 4.572, 4.526, 4.697, 5.630, 4.780 | +| 4 MiB steady / completion | 4183.374, 4296.630, 4205.122, 4247.787, 4151.634 | 30.130, 30.521, 32.170, 37.161, 27.441 | +| 5 MiB send / ingestion | 5186.998, 5122.875, 5127.790, 5177.788, 5157.274 | 20.994, 19.533, 21.156, 24.193, 20.366 | +| 5 MiB send / completion | 5297.536, 5181.298, 5182.416, 5234.920, 5211.802 | 76.656, 78.543, 80.052, 82.274, 76.274 | + +The large/small steady-ingestion median ratio is 18.88 for eager retention and 0.52 for incremental retention. The 5 MiB completion median falls from 5211.802 ms to 78.543 ms (66.4×). Maximum retained heap for the large steady case rises from 4,512,656 to 5,661,016 bytes; this measures live session and result allocations together, not just buffer strings. + +A separate memory case sends 5 MiB in 16-byte callbacks and samples retained heap once after completion. It retains 5,793,048 bytes with tail aggregation. The same assertion with uncoalesced linked nodes fails at 22,969,720 bytes against the 16 MiB bound. This case has no performance timing verdict. + +A real PTY diagnostic runs `node -e 'process.stdout.write("x".repeat(5*1024*1024))'` through the built local subprocess provider. One baseline sample takes 106962.523 ms; one final candidate sample takes 222.546 ms. Timing begins before PTY/process spawn and ends after `session_exit` and the bounded read. Both samples exit with code 0, no signal, and truncated 256 KiB viewport/read payloads. This includes native PTY transport and Node startup, but excludes an interactive shell and prompt-readiness round trip. + +The [required benchmark](../../../../benchmarks/terminal-io/terminal-io.bench.ts) applies the shared CI scale and headroom to reference expectations of 20 ms steady ingestion, 50 ms steady completion, and 120 ms full completion, yielding limits of 50/125/300 ms. Median capacity scaling must stay below 4×; maximum retained heap is 16 MiB. Ratios and memory limits are unscaled. Substituting the original compiled session worker makes both timing cases fail: capacity ratio 18.977 exceeds 4, and full completion 5066.719 ms exceeds 300 ms. The final worker passes all three cases. The local benchmark command is `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/terminal-io/terminal-io.bench.ts` after the benchmark build. + +## Alternatives considered + +**Cache only the byte count.** This leaves the per-append line split and full-string prefix deletion dependent on retained output. Both limits need incremental accounting. + +**Keep the complete output until a read.** This makes producer cost small but permits unbounded retention between reads. The configured limits apply during production. + +**Retain one node per callback.** Tiny callbacks make node metadata much larger than the bounded text. Bounded tail aggregation keeps node count tied to stored text blocks. + +**Store encoded UTF-8 chunks.** Encoding replaces unpaired UTF-16 surrogates. String chunks preserve the existing buffer semantics without adding a second text representation. + +## Consequences + +Append-time work no longer depends on repeatedly scanning the retained window. Snapshot and consume still allocate a combined string. Retention adds linked nodes and a bounded collection of pending small fragments. Functional tests cover byte/line interactions, consumption, split surrogate pairs, and 4 MiB retention. Performance evidence complements these output assertions; a synthetic provider does not establish real-shell command latency. diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.zh.md b/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.zh.md new file mode 100644 index 0000000000..ae70eb33f9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.zh.md @@ -0,0 +1,60 @@ +# Agent Note: 增量终端保留策略 + +Status: implemented + +[English](2026-09-11-incremental-terminal-retention.md) | 中文 + +## 问题 + +持久终端输出在每次 PTY 回调中都受 scrollback 和未读发送输出的字节上限约束。如果每次执行这些限制都重建完整的保留字符串,回调成本就会随保留输出量增长。即使每个输入分片很小,4 MiB scrollback 窗口也会使这项重复工作产生显著开销。 + +## 决策 + +[terminal-bash](../../../../packages/terminal/terminal-bash/src/session.ts) 的私有缓冲区保留字符串链表、头部偏移,以及 UTF-8 字节数与换行符数的汇总值。追加操作检查输入文本,并仅淘汰最旧的码点,直到两个限制都满足。每个被淘汰码点的成本可归于之前的追加操作,因此保留策略的总工作量与输入量呈线性关系。读取时拼接保留字符串,成本仍与保留输出量成正比。 + +保留后缀与先按行数裁剪、再按 UTF-8 字节数裁剪的结果一致。末尾换行符贡献一个空逻辑行。截断标志保持为真,直到消费操作清空缓冲区。跨分片相邻的代理项两半按一个四字节码点计数,并共同淘汰;未配对代理项保留 JavaScript 字符串原值,按三个 UTF-8 字节计数。读取时的 `utf8Tail()` 保持独立且不变。 + +头部字符串至少一半被丢弃后,其后缀通过 UTF-16 复制,以释放原始底层存储,同时保留未配对代理项。复制成本不超过已丢弃前缀,因此维持摊还线性工作量,并使保留字符串存储受保留窗口约束。链表节点避免数组头部移除或定期扫描所有保留分片。小输入在非头部的尾节点合并,最多积累 4096 个 UTF-16 单元;分配后继节点时,将该尾节点固化为平坦字符串。追加期间头节点不会增长,缓存的末尾码元也避免了为检查跨分片代理对而将待处理片段展平。这样即使每次回调只有一个字节,片段元数据也有界,且无需重新扫描保留文本。 + +[持久 PTY 决策](../feature/2026-07-16-persistent-pty-sessions.zh.md)继续负责会话生命周期、模型可见输出与保留语义。本决策细化存储与性能,不取代任何活跃决策记录。 + +## 测量设计 + +终端 I/O 基准通过合成子进程句柄驱动 `LocalPtySession`。固定的 16 KiB ASCII 分片不含换行符,以长逻辑行触发字节上限。稳态场景先填满 128 KiB 或 4 MiB 窗口,再追加同样的 1 MiB。独立的空窗口场景发送 5 MiB。行数上限为 10,000;未读输出上限为 256 KiB 与 scrollback 容量中的较小值。 + +同步接收计时覆盖发送启动与提供方回调。完成计时还等待终端模拟器处理与就绪,再读取有界终端结果。保留堆在显式 GC 后采样,此时会话与返回输出仍可达。构建后的 JavaScript 在普通 Node 下运行。这些测量不含 shell 启动、操作系统 PTY 传输、模型延迟或浏览器渲染。 + +### 本地参考测量 + +在 Apple M5 Pro、macOS arm64、Node v26.5.0 上,每个场景用五个全新 worker 比较基线 `ac8f6f1f228eae84f56856042811162cd00141fe` 与增量保留策略。两个版本使用相同 worker 和输入,仅私有会话实现不同。下表时间单位为毫秒,按采样顺序排列。 + +| 场景 / 指标 | 全量保留计算样本 | 增量保留计算样本 | +|---|---|---| +| 128 KiB 稳态 / 接收 | 232.164, 234.564, 219.511, 219.615, 221.434 | 9.094, 8.756, 8.949, 8.867, 8.974 | +| 128 KiB 稳态 / 完成 | 245.365, 247.826, 233.322, 232.854, 234.926 | 18.800, 18.329, 19.348, 18.439, 18.414 | +| 4 MiB 稳态 / 接收 | 4159.780, 4271.184, 4179.700, 4223.583, 4128.541 | 4.572, 4.526, 4.697, 5.630, 4.780 | +| 4 MiB 稳态 / 完成 | 4183.374, 4296.630, 4205.122, 4247.787, 4151.634 | 30.130, 30.521, 32.170, 37.161, 27.441 | +| 5 MiB 发送 / 接收 | 5186.998, 5122.875, 5127.790, 5177.788, 5157.274 | 20.994, 19.533, 21.156, 24.193, 20.366 | +| 5 MiB 发送 / 完成 | 5297.536, 5181.298, 5182.416, 5234.920, 5211.802 | 76.656, 78.543, 80.052, 82.274, 76.274 | + +大/小窗口稳态接收时间的中位数比值在全量保留计算下为 18.88,在增量保留计算下为 0.52。5 MiB 完成时间的中位数从 5211.802 ms 降至 78.543 ms(66.4×)。大窗口稳态场景的最大保留堆从 4,512,656 字节升至 5,661,016 字节;该指标同时测量活跃会话与结果分配,并非仅缓冲区字符串。 + +独立的内存场景以 16 字节回调发送 5 MiB,在完成后对保留堆采样一次。尾部合并时保留 5,793,048 字节。相同断言在未合并链表节点下失败:22,969,720 字节超过 16 MiB 上限。此场景不判定性能耗时。 + +真实 PTY 诊断通过构建后的本地子进程提供方运行 `node -e 'process.stdout.write("x".repeat(5*1024*1024))'`。一次基线样本耗时 106962.523 ms;一次最终候选样本耗时 222.546 ms。计时从 PTY/进程 spawn 前开始,到 `session_exit` 和有界读取完成后结束。两个样本均以代码 0 退出,无信号,viewport/read 载荷均为已截断的 256 KiB。这包括原生 PTY 传输与 Node 启动,不包括交互式 shell 及提示符就绪往返。 + +[必需基准](../../../../benchmarks/terminal-io/terminal-io.bench.ts)对参考预期值应用共享 CI 系数与余量:稳态接收 20 ms、稳态完成 50 ms、完整发送完成 120 ms,对应限制为 50/125/300 ms。容量扩展的中位数比值必须低于 4×;最大保留堆为 16 MiB。比值与内存限制不缩放。替换为原始编译后会话 worker 时,两个计时场景都失败:容量比值 18.977 超过 4,完整发送完成时间 5066.719 ms 超过 300 ms。最终 worker 的三个场景均通过。本地命令为 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/terminal-io/terminal-io.bench.ts`,在基准构建完成后执行。 + +## 考虑过的替代方案 + +**仅缓存字节数。** 这仍会使每次追加的按行拆分和完整字符串前缀删除依赖保留输出量。两个限制都需要增量计数。 + +**在读取前保留全部输出。** 这使生产成本较小,但允许读取之间的保留量无限增长。配置的限制必须在产生输出时生效。 + +**每次回调保留一个节点。** 微小回调会使节点元数据远大于有界文本。受限的尾部合并使节点数与存储的文本块相关。 + +**保留编码后的 UTF-8 分片。** 编码会替换未配对 UTF-16 代理项。字符串分片无需引入第二种文本表示,即可保留现有缓冲区语义。 + +## 影响 + +追加工作不再依赖重复扫描保留窗口。Snapshot 和 consume 仍分配拼接后的字符串。保留策略额外维护链表节点与有界的待处理小片段集合。功能测试覆盖字节数与行数的交互、消费操作、跨分片代理对,以及 4 MiB 保留窗口。性能证据补充这些输出断言;合成提供方不能证明真实 shell 命令延迟。 diff --git a/benchmarks/package.json b/benchmarks/package.json index cc8c60cf71..3ce60f1d2d 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -6,6 +6,9 @@ "type": "module", "devDependencies": { "playwright": "^1.49.0", + "@xterm/headless": "^6.0.0", + "@deepseek-ai/dsh-terminal": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-llm-replay": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", diff --git a/benchmarks/terminal-io/session-adapter.ts b/benchmarks/terminal-io/session-adapter.ts new file mode 100644 index 0000000000..7ed987aabc --- /dev/null +++ b/benchmarks/terminal-io/session-adapter.ts @@ -0,0 +1,2 @@ +/** Private production entry bundled into the plain-Node terminal I/O worker. */ +export { LocalPtySession } from '../../packages/terminal/terminal-bash/src/session.ts' diff --git a/benchmarks/terminal-io/terminal-io.bench.ts b/benchmarks/terminal-io/terminal-io.bench.ts new file mode 100644 index 0000000000..d31a84ae37 --- /dev/null +++ b/benchmarks/terminal-io/terminal-io.bench.ts @@ -0,0 +1,63 @@ +/** Bounded terminal output must not rescan a full retained window on every chunk. */ +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { runBuiltBenchmarkWorker } from '../support/built-worker.ts' +import { ciTimeBudget } from '../support/calibration.ts' +import type { TerminalIoReport } from './terminal-io.worker.ts' + +const MIB = 1024 * 1024 +const ATTEMPTS = 5 +const WORKER = join(import.meta.dirname, '..', '.dsh-build', 'terminal-io', 'terminal-io.worker.js') +/** M5 Pro / Node 26.5 reference expectations, before shared CI scaling and headroom. */ +const EXPECTED_MS = { steadyIngest: 20, steadyComplete: 50, fullComplete: 120 } +const MAX_CAPACITY_RATIO = 4 +const MAX_RETAINED_HEAP_BYTES = 16 * MIB + +function median(values: readonly number[]): number { + return [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)] as number +} + +async function sample(capacity: number, mode: 'steady' | 'full' | 'tiny'): Promise { + const outcome = await runBuiltBenchmarkWorker({ + worker: WORKER, args: [String(capacity), mode], timeoutMs: 120_000, exposeGc: true, + }) + if (outcome.timedOut || outcome.signal !== null || outcome.exitCode !== 0 || outcome.report === undefined) { + throw new Error('terminal I/O worker failed: ' + JSON.stringify(outcome)) + } + return outcome.report +} + +it('bounds steady overflow cost as retained terminal capacity grows 32 times', async () => { + const small: TerminalIoReport[] = [] + const large: TerminalIoReport[] = [] + for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) { + small.push(await sample(128 * 1024, 'steady')) + large.push(await sample(4 * MIB, 'steady')) + } + const capacityRatio = median(large.map(row => row.ingestMs)) / median(small.map(row => row.ingestMs)) + const ingestBudgetMs = ciTimeBudget(EXPECTED_MS.steadyIngest) + const completeBudgetMs = ciTimeBudget(EXPECTED_MS.steadyComplete) + console.log(JSON.stringify({ scenario: 'terminal-steady', small, large, capacityRatio, ingestBudgetMs, completeBudgetMs })) + expect(capacityRatio).toBeLessThanOrEqual(MAX_CAPACITY_RATIO) + for (const rows of [small, large]) { + expect(median(rows.map(row => row.ingestMs))).toBeLessThanOrEqual(ingestBudgetMs) + expect(median(rows.map(row => row.completeMs))).toBeLessThanOrEqual(completeBudgetMs) + expect(Math.max(...rows.map(row => row.retainedHeapBytes))).toBeLessThanOrEqual(MAX_RETAINED_HEAP_BYTES) + } +}) + +it('bounds retained memory when five MiB arrives in sixteen-byte chunks', async () => { + const report = await sample(4 * MIB, 'tiny') + console.log(JSON.stringify({ scenario: 'terminal-tiny-chunks', report, heapBudgetBytes: MAX_RETAINED_HEAP_BYTES })) + expect(report.retainedHeapBytes).toBeLessThanOrEqual(MAX_RETAINED_HEAP_BYTES) +}) + +it('completes a five MiB terminal send with bounded retained output', async () => { + const samples: TerminalIoReport[] = [] + for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) samples.push(await sample(4 * MIB, 'full')) + const completeMedianMs = median(samples.map(row => row.completeMs)) + const completeBudgetMs = ciTimeBudget(EXPECTED_MS.fullComplete) + console.log(JSON.stringify({ scenario: 'terminal-five-mib', samples, completeMedianMs, completeBudgetMs })) + expect(completeMedianMs).toBeLessThanOrEqual(completeBudgetMs) + expect(Math.max(...samples.map(row => row.retainedHeapBytes))).toBeLessThanOrEqual(MAX_RETAINED_HEAP_BYTES) +}) diff --git a/benchmarks/terminal-io/terminal-io.worker.ts b/benchmarks/terminal-io/terminal-io.worker.ts new file mode 100644 index 0000000000..2e62dfd6f2 --- /dev/null +++ b/benchmarks/terminal-io/terminal-io.worker.ts @@ -0,0 +1,100 @@ +/** Terminal output ingestion and readiness with a deterministic provider boundary. */ +import { Buffer } from 'node:buffer' +import { performance } from 'node:perf_hooks' +import { Readable } from 'node:stream' +import type { SubprocessOutcome, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' +import { LocalPtySession } from './session-adapter.ts' + +const MIB = 1024 * 1024 +const CHUNK_BYTES = 16 * 1024 + +/** One fresh-session sample; retained heap includes the live session and returned output. */ +export interface TerminalIoReport { + mode: string + capacityBytes: number + chunkBytes: number + prefillBytes: number + timedBytes: number + ingestMs: number + completeMs: number + retainedHeapBytes: number + viewportBytes: number + readBytes: number + truncated: boolean +} + +async function measure(capacityBytes: number, mode: string): Promise { + const chunkBytes = mode === 'tiny' ? 16 : CHUNK_BYTES + const chunk = Buffer.alloc(chunkBytes, 'x') + const prefillBytes = mode === 'steady' ? capacityBytes : 0 + const timedBytes = mode === 'steady' ? MIB : 5 * MIB + const output = new Readable({ read() {} }) + const ended = Promise.withResolvers() + const writeReady = Promise.withResolvers() + const terminal: SubprocessTerminalHandle = { + pid: 1, + output, + done: ended.promise, + async write() { writeReady.resolve() }, + async inspectForeground() { return { processGroupId: 1, inputWaiting: false } }, + async signalForeground() { return 1 }, + async terminate() { + output.emit('end') + ended.resolve({ exitCode: 0, signal: null }) + await ended.promise + }, + } + global.gc?.() + const heapBefore = process.memoryUsage().heapUsed + const session = new LocalPtySession(terminal, { + backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], + rows: 40, cols: 160, scrollbackLines: 10_000, scrollbackMaxBytes: capacityBytes, + maxReadBytes: Math.min(256 * 1024, capacityBytes), + pollIntervalMs: 1, exactProbeAfterMs: 150, idleSilenceMs: 1, + handoffGraceMs: 1, timeoutMs: 120_000, disposeGraceMs: 1, + }) + try { + if (prefillBytes > 0) { + const prefill = session.startSend({ text: 'prefill', submit: false }) + await writeReady.promise + for (let bytes = 0; bytes < prefillBytes; bytes += chunkBytes) output.emit('data', chunk) + const ready = await prefill.done + if (ready.waitReason !== 'inferred_idle') throw new Error('prefill did not reach readiness') + } + const start = performance.now() + const operation = session.startSend({ text: '', submit: false }) + for (let bytes = 0; bytes < timedBytes; bytes += chunkBytes) output.emit('data', chunk) + const ingestMs = performance.now() - start + const result = await operation.done + const read = session.read({ count: 10_000 }) + const completeMs = performance.now() - start + if (result.waitReason !== 'inferred_idle' || !result.truncated || !read.truncated) { + throw new Error('output did not reach bounded ready endpoint') + } + const expectedBytes = Math.min(256 * 1024, capacityBytes) + if (Buffer.byteLength(result.viewport) !== expectedBytes || Buffer.byteLength(read.text) !== expectedBytes) { + throw new Error('bounded output endpoint has unexpected byte count') + } + global.gc?.() + return { + mode, capacityBytes, chunkBytes, prefillBytes, timedBytes, ingestMs, completeMs, + retainedHeapBytes: process.memoryUsage().heapUsed - heapBefore, + viewportBytes: Buffer.byteLength(result.viewport), readBytes: Buffer.byteLength(read.text), + truncated: result.truncated && read.truncated, + } + } finally { + await session.close('benchmark complete') + output.destroy() + } +} + +assertBuiltBenchmarkRuntime(import.meta.url, { + '@deepseek-ai/dsh-terminal': import.meta.resolve('@deepseek-ai/dsh-terminal'), +}) +const capacityBytes = Number(process.argv[2]) +const mode = process.argv[3] +if (![128 * 1024, 4 * MIB].includes(capacityBytes) || (mode !== 'steady' && mode !== 'full' && mode !== 'tiny')) { + throw new Error('usage: terminal-io.worker.js <131072|4194304> ') +} +console.log(JSON.stringify(await measure(capacityBytes, mode))) diff --git a/benchmarks/tsdown.config.ts b/benchmarks/tsdown.config.ts index 2b7a7d2bb9..18e2928c0e 100644 --- a/benchmarks/tsdown.config.ts +++ b/benchmarks/tsdown.config.ts @@ -14,6 +14,13 @@ const shared = { /** Compile measured benchmark workers while keeping workspace packages on their built `lib` entries. */ export default defineConfig([ + { + ...shared, + entry: { 'terminal-io.worker': 'terminal-io/terminal-io.worker.ts' }, + outDir: '.dsh-build/terminal-io', + clean: true, + tsconfig: 'tsconfig.host.json', + }, { ...shared, entry: { 'reconnect.worker': 'active-stream-reconnect/reconnect.worker.client.ts' }, diff --git a/packages/terminal/terminal-bash/README.i18n.yaml b/packages/terminal/terminal-bash/README.i18n.yaml index 1001accbfc..94a962d8a5 100644 --- a/packages/terminal/terminal-bash/README.i18n.yaml +++ b/packages/terminal/terminal-bash/README.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 packages/terminal/terminal-bash/README.md -README.md: 5dc66b97f550581df18efba55771d30057ab9aa0 -README.zh.md: 8abf0c31b53710e91bd8a08f61247f94a1a55212 +README.md: d0defd5f0395fc3bceca357f908d5fa185dc7fed +README.zh.md: 0b51e83bafb73205296509f8b592b590d0ff21a6 diff --git a/packages/terminal/terminal-bash/README.md b/packages/terminal/terminal-bash/README.md index 5dc66b97f5..d0defd5f03 100644 --- a/packages/terminal/terminal-bash/README.md +++ b/packages/terminal/terminal-bash/README.md @@ -85,6 +85,8 @@ This section explains the design behind the backend and points at the code that One backend serves both dialects: bash and pwsh share the same session machinery — sanitizer, bounded buffers, readiness polling, cancellation, and teardown — and differ only in argv, environment, and prompt installation. Bash receives a private marker through `PS1` plus `PROMPT_COMMAND`. Pwsh writes a prompt function, pins UTF-8 console encoding, and publishes startup only after the backend reports `stdin_read`; echoed setup text cannot publish the shell. A zero-scrollback `@xterm/headless` instance consumes raw PTY data and returns terminal-protocol replies through the same handle, while the line sanitizer remains the only output projection. +Scrollback and unread send output retain chunks with incremental byte and newline counts. Appending and evicting text takes amortized time proportional to incoming text; reads assemble the retained chunks. Retention preserves code-point boundaries and counts the empty line after a trailing newline. The [retention decision](../../../.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md) owns the complexity and measurement rationale. + ### Source map | File | Role | diff --git a/packages/terminal/terminal-bash/README.zh.md b/packages/terminal/terminal-bash/README.zh.md index 8abf0c31b5..0b51e83baf 100644 --- a/packages/terminal/terminal-bash/README.zh.md +++ b/packages/terminal/terminal-bash/README.zh.md @@ -85,6 +85,8 @@ shell 在整个生命周期内运行在有效的沙箱边界之下。当所有 一个后端服务两种方言:bash 与 pwsh 共享同一套会话机制——清理器、有界缓冲区、就绪轮询、取消与关闭——只在 argv、环境与提示符安装方式上不同。bash 通过 `PS1` 加 `PROMPT_COMMAND` 接收私有标记。pwsh 会写入提示符函数、固定 UTF-8 控制台编码,并只在后端报告 `stdin_read` 后发布启动;回显的设置文本不能发布 shell。一个不保留 scrollback 的 `@xterm/headless` 实例会消费原始 PTY 数据,并通过同一句柄返回终端协议响应;逐行 sanitizer 仍是唯一输出投影。 +Scrollback 和尚未读取的发送输出保留分片,并增量维护字节数与换行符数。追加与淘汰文本的摊还耗时与输入文本量成正比;读取时才拼接保留的分片。保留策略维持码点边界,并将末尾换行符之后的空行计入行数。[保留策略决策](../../../.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.zh.md)记录复杂度与测量依据。 + ### 源码地图 | 文件 | 职责 | diff --git a/packages/terminal/terminal-bash/src/session.ts b/packages/terminal/terminal-bash/src/session.ts index d40db28c10..3d7a261455 100644 --- a/packages/terminal/terminal-bash/src/session.ts +++ b/packages/terminal/terminal-bash/src/session.ts @@ -42,8 +42,22 @@ function utf8Tail(text: string, maxBytes: number): { text: string; truncated: bo return { text: chars.slice(start).join(''), truncated: true } } +// Bound pending string fragments independently of deployment retention limits. +const COALESCED_CHUNK_UNITS = 4096 + +interface TextChunk { + text: string + start: number + next: TextChunk | undefined +} + +/** Retention work is amortized over appended text; reads assemble the retained chunks. */ class BoundedTextBuffer { - private value = '' + private head: TextChunk | undefined + private tail: TextChunk | undefined + private bytes = 0 + private newlines = 0 + private lastCodeUnit = 0 private dropped = false constructor( @@ -53,29 +67,82 @@ class BoundedTextBuffer { append(text: string): void { if (text.length === 0) return - this.value += text - if (this.maxLines !== undefined) { - const lines = this.value.split('\n') - if (lines.length > this.maxLines) { - this.value = lines.slice(lines.length - this.maxLines).join('\n') - this.dropped = true - } + this.bytes += Buffer.byteLength(text) + const tail = this.tail + if (tail !== undefined) { + const last = this.lastCodeUnit + const first = text.charCodeAt(0) + // Concatenation can turn two three-byte lone surrogates into one four-byte code point. + if (last >= 0xd800 && last <= 0xdbff && first >= 0xdc00 && first <= 0xdfff) this.bytes -= 2 } - const tail = utf8Tail(this.value, this.maxBytes) - this.value = tail.text - this.dropped ||= tail.truncated + for (let index = text.indexOf('\n'); index !== -1; index = text.indexOf('\n', index + 1)) { + this.newlines += 1 + } + this.lastCodeUnit = text.charCodeAt(text.length - 1) + // The head never grows, so eviction never rescans a growing string. + if (tail !== undefined && tail !== this.head && tail.text.length + text.length <= COALESCED_CHUNK_UNITS) { + tail.text += text + } else { + if (tail !== undefined && tail.text.length <= COALESCED_CHUNK_UNITS) { + // Seal small fragments into owned storage before retaining another chunk. + tail.text = Buffer.from(tail.text, 'utf16le').toString('utf16le') + } + const chunk: TextChunk = { text, start: 0, next: undefined } + if (tail === undefined) this.head = chunk + else tail.next = chunk + this.tail = chunk + } + + while (this.head !== undefined + && (this.bytes > this.maxBytes || (this.maxLines !== undefined && this.newlines >= this.maxLines))) { + const head = this.head + const first = head.text.charCodeAt(head.start) + const second = head.start + 1 < head.text.length + ? head.text.charCodeAt(head.start + 1) + : head.next?.text.charCodeAt(0) + const paired = first >= 0xd800 && first <= 0xdbff + && second !== undefined && second >= 0xdc00 && second <= 0xdfff + this.bytes -= paired ? 4 : first < 0x80 ? 1 : first < 0x800 ? 2 : 3 + if (first === 10) this.newlines -= 1 + this.advance(paired ? 2 : 1) + this.dropped = true + } + const head = this.head + if (head !== undefined && head.start >= head.text.length / 2) { + // Copy UTF-16 verbatim so a small suffix cannot retain an oversized input's backing store. + // Copying only after discarding at least half keeps this work amortized over discarded text. + head.text = Buffer.from(head.text.slice(head.start), 'utf16le').toString('utf16le') + head.start = 0 + } + } + + private advance(units: number): void { + while (units > 0 && this.head !== undefined) { + const head = this.head + const count = Math.min(units, head.text.length - head.start) + head.start += count + units -= count + if (head.start === head.text.length) this.head = head.next + } + if (this.head === undefined) this.tail = undefined } consume(): TerminalSendRead { - const delta = this.value - const truncated = this.dropped - this.value = '' + const { text: delta, truncated } = this.snapshot() + this.head = undefined + this.tail = undefined + this.bytes = 0 + this.newlines = 0 this.dropped = false return { delta, truncated } } snapshot(): { text: string; truncated: boolean } { - return { text: this.value, truncated: this.dropped } + const chunks: string[] = [] + for (let chunk = this.head; chunk !== undefined; chunk = chunk.next) { + chunks.push(chunk.text.slice(chunk.start)) + } + return { text: chunks.join(''), truncated: this.dropped } } } diff --git a/packages/terminal/terminal-bash/tests/session-buffer.spec.ts b/packages/terminal/terminal-bash/tests/session-buffer.spec.ts new file mode 100644 index 0000000000..293bf780c8 --- /dev/null +++ b/packages/terminal/terminal-bash/tests/session-buffer.spec.ts @@ -0,0 +1,294 @@ +import { Buffer } from 'node:buffer' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it } from 'vitest' +import type { SubprocessOutcome, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import type { TerminalReadRequest } from '@deepseek-ai/dsh-terminal' +import type { ResolvedConfig } from '../src/config.ts' +import { LocalPtySession } from '../src/session.ts' + +class OutputProducer implements SubprocessTerminalHandle { + readonly pid = 123 + readonly output = new PassThrough() + private readonly outcome = Promise.withResolvers() + readonly done = this.outcome.promise + + emit(text: string | Uint8Array): void { + this.output.write(typeof text === 'string' ? Buffer.from(text) : text) + } + + exit(): void { + this.output.end() + this.outcome.resolve({ exitCode: 0, signal: null }) + } + + async write(): Promise {} + async inspectForeground() { return undefined } + async signalForeground(): Promise { return this.pid } + async terminate(): Promise { + this.exit() + await this.done + } +} + +const sessions: LocalPtySession[] = [] + +afterEach(async () => { + await Promise.all(sessions.splice(0).map(session => session.close('buffer test cleanup'))) +}) + +function fixture(overrides: Partial = {}) { + const config: ResolvedConfig = { + backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80, + scrollbackLines: 10_000, scrollbackMaxBytes: 4 * 1024 * 1024, maxReadBytes: 256 * 1024, + pollIntervalMs: 60_000, exactProbeAfterMs: 60_000, idleSilenceMs: 60_000, + handoffGraceMs: 60_000, timeoutMs: 60_000, disposeGraceMs: 60_000, + ...overrides, + } + const producer = new OutputProducer() + const session = new LocalPtySession(producer, config) + sessions.push(session) + return { producer, session } +} + +// The reference deliberately retains the eager line-first, then UTF-8-tail algorithm. +function referenceTail(text: string, maxBytes: number) { + if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false } + const chars = Array.from(text) + let bytes = 0 + let start = chars.length + while (start > 0) { + const size = Buffer.byteLength(chars[start - 1] as string) + if (bytes + size > maxBytes) break + bytes += size + start -= 1 + } + return { text: chars.slice(start).join(''), truncated: true } +} + +class ReferenceBuffer { + private text = '' + private truncated = false + + constructor(private readonly maxBytes: number, private readonly maxLines?: number) {} + + append(chunk: string): void { + if (chunk.length === 0) return + this.text += chunk + if (this.maxLines !== undefined) { + const lines = this.text.split('\n') + if (lines.length > this.maxLines) { + this.text = lines.slice(-this.maxLines).join('\n') + this.truncated = true + } + } + const bounded = referenceTail(this.text, this.maxBytes) + this.text = bounded.text + this.truncated ||= bounded.truncated + } + + snapshot() { return { text: this.text, truncated: this.truncated } } + + consume() { + const result = { delta: this.text, truncated: this.truncated } + this.text = '' + this.truncated = false + return result + } +} + +function referenceRead(buffer: ReferenceBuffer, maxBytes: number, request: TerminalReadRequest = {}) { + const snapshot = buffer.snapshot() + const lines = snapshot.text.split('\n') + const totalLines = snapshot.text.length === 0 ? 0 : lines.length + const offset = request.offset ?? 0 + if (offset >= totalLines) { + return { text: '', totalLines, lineBegin: offset, lineEnd: offset, truncated: snapshot.truncated } + } + const end = totalLines - offset + const bounded = referenceTail(lines.slice(Math.max(0, end - (request.count ?? 500)), end).join('\n'), maxBytes) + const returnedLines = bounded.text.length === 0 ? 0 : bounded.text.split('\n').length + return { + text: bounded.text, totalLines, lineBegin: offset, lineEnd: offset + returnedLines, + truncated: snapshot.truncated || bounded.truncated, + } +} + +function deterministicChunks(alphabet: readonly string[], count: number): string[] { + let state = 0x12345678 + return Array.from({ length: count }, () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0 + return alphabet[state % alphabet.length] as string + }) +} + +describe('LocalPtySession incremental output compatibility', () => { + it('retains the exact tail across the default 4 MiB scrollback limit while consuming active output', async () => { + const limit = 4 * 1024 * 1024 + const { producer, session } = fixture({ maxReadBytes: limit }) + const operation = session.startSend({ text: '', submit: false }) + const chunk = 'a'.repeat(4096) + for (let index = 0; index < limit / chunk.length; index += 1) producer.emit(chunk) + expect(session.read({})).toEqual({ + text: 'a'.repeat(limit), totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: false, + }) + + producer.emit('界😀TAIL') + const retained = `${'a'.repeat(limit - 11)}界😀TAIL` + expect(session.read({})).toEqual({ + text: retained, totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: true, + }) + expect(operation.readOutput()).toEqual({ delta: retained, truncated: true }) + expect(operation.readOutput()).toEqual({ delta: '', truncated: false }) + producer.emit('é') + expect(operation.readOutput()).toEqual({ delta: 'é', truncated: false }) + producer.emit('done') + producer.exit() + await expect(operation.done).resolves.toEqual({ + viewport: 'done', waitReason: 'session_exit', + sessionStatus: { kind: 'exited', exitCode: 0, signal: null }, truncated: true, + }) + expect(operation.readOutput()).toEqual({ delta: 'done', truncated: false }) + expect(operation.readOutput()).toEqual({ delta: '', truncated: false }) + }) + + it('counts trailing empty lines and keeps scrollback truncation sticky after empty reads', () => { + const { producer, session } = fixture({ scrollbackLines: 3, scrollbackMaxBytes: 64, maxReadBytes: 64 }) + producer.emit('a\nb\n') + expect(session.read({})).toEqual({ text: 'a\nb\n', totalLines: 3, lineBegin: 0, lineEnd: 3, truncated: false }) + producer.emit('\n') + expect(session.read({})).toEqual({ text: 'b\n\n', totalLines: 3, lineBegin: 0, lineEnd: 3, truncated: true }) + expect(session.read({ count: 1 })).toEqual({ text: '', totalLines: 3, lineBegin: 0, lineEnd: 0, truncated: true }) + expect(session.read({ offset: 2, count: 1 }).text).toBe('b') + expect(session.read({ offset: 3 }).truncated).toBe(true) + producer.emit('') + producer.emit('c') + expect(session.read({}).text).toBe('b\n\nc') + expect(session.read({}).truncated).toBe(true) + }) + + it('decodes split UTF-8 before byte eviction and bounds an oversized multibyte chunk', async () => { + const { producer, session } = fixture({ scrollbackMaxBytes: 11, maxReadBytes: 11 }) + const operation = session.startSend({ text: '', submit: false }) + producer.emit('abc') + const encoded = Buffer.from('界😀éz') + producer.emit(encoded.subarray(0, 5)) + producer.emit(encoded.subarray(5, 7)) + producer.emit(encoded.subarray(7)) + expect(session.read({}).text).toBe('c界😀éz') + expect(operation.readOutput()).toEqual({ delta: 'c界😀éz', truncated: true }) + producer.emit('界😀'.repeat(1000) + 'éEND') + expect(session.read({}).text).toBe('😀éEND') + expect(operation.readOutput()).toEqual({ delta: '😀éEND', truncated: true }) + expect(operation.readOutput()).toEqual({ delta: '', truncated: false }) + producer.emit('ok') + producer.exit() + expect((await operation.done).viewport).toBe('ok') + }) + + it('resets operation truncation independently of retained scrollback', async () => { + const { producer, session } = fixture({ scrollbackMaxBytes: 128, maxReadBytes: 5 }) + const operation = session.startSend({ text: '', submit: false }) + for (let index = 0; index < 4; index += 1) { + producer.emit('123456') + expect(operation.readOutput()).toEqual({ delta: '23456', truncated: true }) + expect(operation.readOutput()).toEqual({ delta: '', truncated: false }) + producer.emit('é') + expect(operation.readOutput()).toEqual({ delta: 'é', truncated: false }) + } + producer.exit() + expect(await operation.done).toMatchObject({ viewport: '', truncated: false, waitReason: 'session_exit' }) + expect(operation.readOutput()).toEqual({ delta: '', truncated: false }) + }) + + it.each([[1, 1, 1], [17, 7, 3], [64, 13, 5]])( + 'matches eager reads and active output with byte caps %i/%i and %i lines', + async (scrollbackMaxBytes, maxReadBytes, scrollbackLines) => { + const { producer, session } = fixture({ scrollbackMaxBytes, maxReadBytes, scrollbackLines }) + const scrollback = new ReferenceBuffer(scrollbackMaxBytes, scrollbackLines) + const output = new ReferenceBuffer(maxReadBytes) + const operation = session.startSend({ text: '', submit: false }) + const chunks = deterministicChunks(['a', 'bc', '\n', '\n\n', '界', '😀', 'éz', '', 'long line\nend\n'], 120) + for (const [index, chunk] of chunks.entries()) { + producer.emit(chunk) + scrollback.append(chunk) + output.append(chunk) + for (const request of [{}, { offset: 1, count: 2 }, { offset: 9, count: 1 }]) { + expect(session.read(request)).toEqual(referenceRead(scrollback, maxReadBytes, request)) + } + if (index % 7 === 0) expect(operation.readOutput()).toEqual(output.consume()) + } + producer.exit() + const expected = output.snapshot() + expect(await operation.done).toMatchObject({ + viewport: expected.text, truncated: expected.truncated || scrollback.snapshot().truncated, + waitReason: 'session_exit', + }) + expect(operation.readOutput()).toEqual(output.consume()) + expect(operation.readOutput()).toEqual(output.consume()) + }, + ) + + it('retains tiny producer chunks through multiple coalesced-node rollovers', async () => { + const limit = 5000 + const { producer, session } = fixture({ scrollbackMaxBytes: limit, maxReadBytes: limit }) + const operation = session.startSend({ text: '', submit: false }) + const text = '0123456789'.repeat(1000) + const checkpoints = new Set([1, 4096, 4097, 5000, 5001, 8193, text.length]) + for (let index = 0; index < text.length; index += 1) { + producer.emit(text[index] as string) + if (checkpoints.has(index + 1)) { + expect(session.read({})).toEqual({ + text: text.slice(Math.max(0, index + 1 - limit), index + 1), + totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: index + 1 > limit, + }) + } + } + expect(operation.readOutput()).toEqual({ delta: text.slice(-limit), truncated: true }) + expect(operation.readOutput()).toEqual({ delta: '', truncated: false }) + producer.emit('fresh') + producer.exit() + expect(await operation.done).toMatchObject({ viewport: 'fresh', truncated: true, waitReason: 'session_exit' }) + }) + + it('preserves split surrogate pairs when sealed coalesced text reaches the eviction head', () => { + const limit = 4100 + const { session } = fixture({ scrollbackMaxBytes: limit, maxReadBytes: limit }) + // Lone UTF-16 halves cannot pass through the session's TextDecoder unchanged. + const buffer = session['scrollback'] + const reference = new ReferenceBuffer(limit, 10_000) + const chunks = ['H', 'a'.repeat(4095), '\ud83d', '\ude00', 'xy', 'b'.repeat(4094), 'z', '\ud800', '\udfff'] + for (const chunk of chunks) { + buffer.append(chunk) + reference.append(chunk) + expect(buffer.snapshot()).toEqual(reference.snapshot()) + } + expect(buffer.snapshot()).toEqual({ text: `y${'b'.repeat(4094)}z\ud800\udfff`, truncated: true }) + expect(buffer.consume()).toEqual(reference.consume()) + expect(buffer.consume()).toEqual(reference.consume()) + }) + + it.each([1, 2, 3, 4, 5, 8, 17])('preserves lone and split surrogates with a %i-byte limit', (maxBytes) => { + const { session } = fixture({ scrollbackMaxBytes: maxBytes, maxReadBytes: maxBytes, scrollbackLines: 3 }) + // TextDecoder replaces lone surrogates; only these UTF-16 cases use the private buffer. + const buffer = session['scrollback'] + const reference = new ReferenceBuffer(maxBytes, 3) + const chunks = [ + '\ud83d', '\ude00', 'x', '\ud83d', '', '\ude00', '\n', '\ud800', 'abc', '\udfff', + 'prefix'.repeat(20) + '\ud800', '\udfff', '\n\n\n', + ...deterministicChunks(['a', '\ud800', '\udfff', '\ud83d\ude00', '\n', 'é', '界', '', '\n\n'], 150), + ] + for (const [index, chunk] of chunks.entries()) { + buffer.append(chunk) + reference.append(chunk) + expect(buffer.snapshot()).toEqual(reference.snapshot()) + if (index % 19 === 18) { + expect(buffer.consume()).toEqual(reference.consume()) + expect(buffer.consume()).toEqual(reference.consume()) + } + } + expect(buffer.consume()).toEqual(reference.consume()) + buffer.append('x') + reference.append('x') + expect(buffer.snapshot()).toEqual(reference.snapshot()) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52065b81cc..b9531051c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -724,6 +724,12 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../packages/subagent/subagent + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../packages/subprocess/subprocess + '@deepseek-ai/dsh-terminal': + specifier: workspace:^ + version: link:../packages/terminal/terminal '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../packages/llm/token-meter @@ -733,6 +739,9 @@ importers: '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../packages/typert/protocol + '@xterm/headless': + specifier: ^6.0.0 + version: 6.0.0 playwright: specifier: ^1.49.0 version: 1.61.1 From 68f9708e02efc0c65b74e4ce77af857898b123eb Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 11 Sep 2026 19:12:10 +0800 Subject: [PATCH 02/60] fix(terminal-bash): bound retained storage and status reads --- ...1-incremental-terminal-retention.i18n.yaml | 4 +-- ...26-09-11-incremental-terminal-retention.md | 26 +++++++++++-------- ...09-11-incremental-terminal-retention.zh.md | 26 +++++++++++-------- benchmarks/terminal-io/terminal-io.bench.ts | 8 +++++- benchmarks/terminal-io/terminal-io.worker.ts | 13 +++++++--- .../terminal/terminal-bash/README.i18n.yaml | 4 +-- packages/terminal/terminal-bash/README.md | 2 +- packages/terminal/terminal-bash/README.zh.md | 2 +- .../terminal/terminal-bash/src/session.ts | 16 +++++++++--- .../tests/session-buffer.spec.ts | 2 +- .../terminal-bash/tests/session.spec.ts | 21 +++++++++++++++ 11 files changed, 87 insertions(+), 37 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.i18n.yaml index aaca19caaa..4aa10b6ffa 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.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 .agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md -2026-09-11-incremental-terminal-retention.md: 4db0e8219b7a24808c4f53538b443d871407fd96 -2026-09-11-incremental-terminal-retention.zh.md: ae70eb33f923e8b65b8fb22789d11f62a5eb8209 +2026-09-11-incremental-terminal-retention.md: bc2a92f9f5c5e199d9abdb7ad068471162e1e0ef +2026-09-11-incremental-terminal-retention.zh.md: 7b72a3afdabfbe7fe3a39f417b9020096b5c3ab5 diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md b/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md index 4db0e8219b..bc2a92f9f5 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md +++ b/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md @@ -14,7 +14,9 @@ The private buffer in [terminal-bash](../../../../packages/terminal/terminal-bas The retained suffix matches line trimming followed by UTF-8 trimming. A trailing newline contributes an empty logical line. Truncation stays sticky until consumption clears the buffer. Adjacent surrogate halves across chunks count as one four-byte code point and are evicted together; unpaired halves retain JavaScript string identity and count as three UTF-8 bytes. The read-time `utf8Tail()` remains independent and unchanged. -After at least half of the leading string is discarded, its suffix is copied through UTF-16 to release the original backing storage without replacing unpaired surrogates. The copy costs no more than the discarded prefix, preserving amortized linear work and bounding retained string storage by the retained window. Linked nodes avoid array shifts or periodic scans of all retained chunks. Small inputs coalesce in the non-head tail up to 4096 UTF-16 units; allocating its successor seals that tail into a flat string. The head never grows during appends, and a cached last code unit avoids flattening pending fragments to inspect a cross-chunk surrogate pair. This bounds fragment metadata even for one-byte callbacks without rescanning retained text. +Every nonempty input is copied through UTF-16 before retention, preserving unpaired surrogates while detaching slices returned by the sanitizer from discarded control text. This applies to small pending fragments as well as large chunks. Private `truncated` and `isEmpty` getters let send settlement and startup polling inspect status without assembling scrollback. + +After at least half of the leading string is discarded, its suffix is copied to release the original backing storage. The copy costs no more than the discarded prefix, preserving amortized linear work and bounding retained string storage by the retained window. Linked nodes avoid array shifts or periodic scans of all retained chunks. Small inputs coalesce in the non-head tail up to 4096 UTF-16 units; allocating its successor copies those fragments into one owned string. Large tails already own their storage and are not copied again at this point. The head never grows during appends, and a cached last code unit avoids flattening pending fragments to inspect a cross-chunk surrogate pair. This bounds fragment metadata even for one-byte callbacks without rescanning retained text. The [persistent PTY decision](../feature/2026-07-16-persistent-pty-sessions.md) continues to own session lifecycle, model-visible output, and retention semantics. This decision specializes storage and performance; it supersedes no active decision record. @@ -30,20 +32,22 @@ On Apple M5 Pro, macOS arm64, Node v26.5.0, five fresh workers per case compare | Case / metric | Eager retention samples | Incremental retention samples | |---|---|---| -| 128 KiB steady / ingestion | 232.164, 234.564, 219.511, 219.615, 221.434 | 9.094, 8.756, 8.949, 8.867, 8.974 | -| 128 KiB steady / completion | 245.365, 247.826, 233.322, 232.854, 234.926 | 18.800, 18.329, 19.348, 18.439, 18.414 | -| 4 MiB steady / ingestion | 4159.780, 4271.184, 4179.700, 4223.583, 4128.541 | 4.572, 4.526, 4.697, 5.630, 4.780 | -| 4 MiB steady / completion | 4183.374, 4296.630, 4205.122, 4247.787, 4151.634 | 30.130, 30.521, 32.170, 37.161, 27.441 | -| 5 MiB send / ingestion | 5186.998, 5122.875, 5127.790, 5177.788, 5157.274 | 20.994, 19.533, 21.156, 24.193, 20.366 | -| 5 MiB send / completion | 5297.536, 5181.298, 5182.416, 5234.920, 5211.802 | 76.656, 78.543, 80.052, 82.274, 76.274 | +| 128 KiB steady / ingestion | 232.164, 234.564, 219.511, 219.615, 221.434 | 10.493, 10.474, 9.917, 10.524, 10.327 | +| 128 KiB steady / completion | 245.365, 247.826, 233.322, 232.854, 234.926 | 19.600, 19.991, 19.290, 20.049, 19.773 | +| 4 MiB steady / ingestion | 4159.780, 4271.184, 4179.700, 4223.583, 4128.541 | 8.752, 8.584, 8.808, 8.582, 10.073 | +| 4 MiB steady / completion | 4183.374, 4296.630, 4205.122, 4247.787, 4151.634 | 29.760, 30.679, 31.510, 31.781, 37.748 | +| 5 MiB send / ingestion | 5186.998, 5122.875, 5127.790, 5177.788, 5157.274 | 26.440, 27.453, 26.710, 26.328, 27.334 | +| 5 MiB send / completion | 5297.536, 5181.298, 5182.416, 5234.920, 5211.802 | 89.475, 83.310, 88.491, 89.440, 89.339 | -The large/small steady-ingestion median ratio is 18.88 for eager retention and 0.52 for incremental retention. The 5 MiB completion median falls from 5211.802 ms to 78.543 ms (66.4×). Maximum retained heap for the large steady case rises from 4,512,656 to 5,661,016 bytes; this measures live session and result allocations together, not just buffer strings. +The large/small steady-ingestion median ratio is 18.88 for eager retention and 0.84 for incremental retention. The 5 MiB completion median falls from 5211.802 ms to 89.339 ms (58.3×). Maximum retained heap for the large steady case rises from 4,512,656 to 6,219,296 bytes; this measures live session and result allocations together, not just buffer strings. -A separate memory case sends 5 MiB in 16-byte callbacks and samples retained heap once after completion. It retains 5,793,048 bytes with tail aggregation. The same assertion with uncoalesced linked nodes fails at 22,969,720 bytes against the 16 MiB bound. This case has no performance timing verdict. +A separate memory case sends 5 MiB in 16-byte callbacks and samples retained heap once after completion. It retains 5,802,840 bytes with tail aggregation. The same assertion with uncoalesced linked nodes fails at 22,969,720 bytes against the 16 MiB bound. This case has no performance timing verdict. -A real PTY diagnostic runs `node -e 'process.stdout.write("x".repeat(5*1024*1024))'` through the built local subprocess provider. One baseline sample takes 106962.523 ms; one final candidate sample takes 222.546 ms. Timing begins before PTY/process spawn and ends after `session_exit` and the bounded read. Both samples exit with code 0, no signal, and truncated 256 KiB viewport/read payloads. This includes native PTY transport and Node startup, but excludes an interactive shell and prompt-readiness round trip. +The filtered-output memory case emits 513 callbacks of 64 KiB each, containing a complete 56 KiB OSC sequence followed by 8 KiB of visible text. This passes through the production sanitizer before filling a 4 MiB visible window and taking a bounded read. With incoming slices retained directly (`0b81fd50e4`), the assertion fails at 36,706,592 bytes. Copying inputs into independent storage reduces retained heap to 7,635,440 bytes, below the unchanged 16 MiB limit. These measurements use Node v26.5.0 and fresh workers. -The [required benchmark](../../../../benchmarks/terminal-io/terminal-io.bench.ts) applies the shared CI scale and headroom to reference expectations of 20 ms steady ingestion, 50 ms steady completion, and 120 ms full completion, yielding limits of 50/125/300 ms. Median capacity scaling must stay below 4×; maximum retained heap is 16 MiB. Ratios and memory limits are unscaled. Substituting the original compiled session worker makes both timing cases fail: capacity ratio 18.977 exceeds 4, and full completion 5066.719 ms exceeds 300 ms. The final worker passes all three cases. The local benchmark command is `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/terminal-io/terminal-io.bench.ts` after the benchmark build. +A real PTY diagnostic runs `node -e 'process.stdout.write("x".repeat(5*1024*1024))'` through the built local subprocess provider. One baseline sample takes 106962.523 ms; one final candidate sample takes 249.007 ms. Timing begins before PTY/process spawn and ends after `session_exit` and the bounded read. Both samples exit with code 0, no signal, and truncated 256 KiB viewport/read payloads. This includes native PTY transport and Node startup, but excludes an interactive shell and prompt-readiness round trip. + +The [required benchmark](../../../../benchmarks/terminal-io/terminal-io.bench.ts) applies the shared CI scale and headroom to reference expectations of 20 ms steady ingestion, 50 ms steady completion, and 120 ms full completion, yielding limits of 50/125/300 ms. Median capacity scaling must stay below 4×; maximum retained heap is 16 MiB. Ratios and memory limits are unscaled. Substituting the original compiled session worker makes both timing cases fail: capacity ratio 18.977 exceeds 4, and full completion 5066.719 ms exceeds 300 ms. The final worker passes all four cases. The local benchmark command is `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/terminal-io/terminal-io.bench.ts` after the benchmark build. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.zh.md b/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.zh.md index ae70eb33f9..7b72a3afda 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.zh.md @@ -14,7 +14,9 @@ Status: implemented 保留后缀与先按行数裁剪、再按 UTF-8 字节数裁剪的结果一致。末尾换行符贡献一个空逻辑行。截断标志保持为真,直到消费操作清空缓冲区。跨分片相邻的代理项两半按一个四字节码点计数,并共同淘汰;未配对代理项保留 JavaScript 字符串原值,按三个 UTF-8 字节计数。读取时的 `utf8Tail()` 保持独立且不变。 -头部字符串至少一半被丢弃后,其后缀通过 UTF-16 复制,以释放原始底层存储,同时保留未配对代理项。复制成本不超过已丢弃前缀,因此维持摊还线性工作量,并使保留字符串存储受保留窗口约束。链表节点避免数组头部移除或定期扫描所有保留分片。小输入在非头部的尾节点合并,最多积累 4096 个 UTF-16 单元;分配后继节点时,将该尾节点固化为平坦字符串。追加期间头节点不会增长,缓存的末尾码元也避免了为检查跨分片代理对而将待处理片段展平。这样即使每次回调只有一个字节,片段元数据也有界,且无需重新扫描保留文本。 +每个非空输入都在保留前通过 UTF-16 复制,在保留未配对代理项的同时,使清理器返回的切片脱离已丢弃的控制文本。待处理的小片段与大分片都遵循这一规则。私有 `truncated` 和 `isEmpty` getter 让发送结算和启动轮询无需拼接 scrollback 即可检查状态。 + +头部字符串至少一半被丢弃后,其后缀会被复制,以释放原始底层存储。复制成本不超过已丢弃前缀,因此维持摊还线性工作量,并使保留字符串存储受保留窗口约束。链表节点避免数组头部移除或定期扫描所有保留分片。小输入在非头部的尾节点合并,最多积累 4096 个 UTF-16 单元;分配后继节点时,将这些片段复制为一个独立字符串。大尾块已经拥有独立存储,此处不会再次复制。追加期间头节点不会增长,缓存的末尾码元也避免了为检查跨分片代理对而将待处理片段展平。这样即使每次回调只有一个字节,片段元数据也有界,且无需重新扫描保留文本。 [持久 PTY 决策](../feature/2026-07-16-persistent-pty-sessions.zh.md)继续负责会话生命周期、模型可见输出与保留语义。本决策细化存储与性能,不取代任何活跃决策记录。 @@ -30,20 +32,22 @@ Status: implemented | 场景 / 指标 | 全量保留计算样本 | 增量保留计算样本 | |---|---|---| -| 128 KiB 稳态 / 接收 | 232.164, 234.564, 219.511, 219.615, 221.434 | 9.094, 8.756, 8.949, 8.867, 8.974 | -| 128 KiB 稳态 / 完成 | 245.365, 247.826, 233.322, 232.854, 234.926 | 18.800, 18.329, 19.348, 18.439, 18.414 | -| 4 MiB 稳态 / 接收 | 4159.780, 4271.184, 4179.700, 4223.583, 4128.541 | 4.572, 4.526, 4.697, 5.630, 4.780 | -| 4 MiB 稳态 / 完成 | 4183.374, 4296.630, 4205.122, 4247.787, 4151.634 | 30.130, 30.521, 32.170, 37.161, 27.441 | -| 5 MiB 发送 / 接收 | 5186.998, 5122.875, 5127.790, 5177.788, 5157.274 | 20.994, 19.533, 21.156, 24.193, 20.366 | -| 5 MiB 发送 / 完成 | 5297.536, 5181.298, 5182.416, 5234.920, 5211.802 | 76.656, 78.543, 80.052, 82.274, 76.274 | +| 128 KiB 稳态 / 接收 | 232.164, 234.564, 219.511, 219.615, 221.434 | 10.493, 10.474, 9.917, 10.524, 10.327 | +| 128 KiB 稳态 / 完成 | 245.365, 247.826, 233.322, 232.854, 234.926 | 19.600, 19.991, 19.290, 20.049, 19.773 | +| 4 MiB 稳态 / 接收 | 4159.780, 4271.184, 4179.700, 4223.583, 4128.541 | 8.752, 8.584, 8.808, 8.582, 10.073 | +| 4 MiB 稳态 / 完成 | 4183.374, 4296.630, 4205.122, 4247.787, 4151.634 | 29.760, 30.679, 31.510, 31.781, 37.748 | +| 5 MiB 发送 / 接收 | 5186.998, 5122.875, 5127.790, 5177.788, 5157.274 | 26.440, 27.453, 26.710, 26.328, 27.334 | +| 5 MiB 发送 / 完成 | 5297.536, 5181.298, 5182.416, 5234.920, 5211.802 | 89.475, 83.310, 88.491, 89.440, 89.339 | -大/小窗口稳态接收时间的中位数比值在全量保留计算下为 18.88,在增量保留计算下为 0.52。5 MiB 完成时间的中位数从 5211.802 ms 降至 78.543 ms(66.4×)。大窗口稳态场景的最大保留堆从 4,512,656 字节升至 5,661,016 字节;该指标同时测量活跃会话与结果分配,并非仅缓冲区字符串。 +大/小窗口稳态接收时间的中位数比值在全量保留计算下为 18.88,在增量保留计算下为 0.84。5 MiB 完成时间的中位数从 5211.802 ms 降至 89.339 ms(58.3×)。大窗口稳态场景的最大保留堆从 4,512,656 字节升至 6,219,296 字节;该指标同时测量活跃会话与结果分配,并非仅缓冲区字符串。 -独立的内存场景以 16 字节回调发送 5 MiB,在完成后对保留堆采样一次。尾部合并时保留 5,793,048 字节。相同断言在未合并链表节点下失败:22,969,720 字节超过 16 MiB 上限。此场景不判定性能耗时。 +独立的内存场景以 16 字节回调发送 5 MiB,在完成后对保留堆采样一次。尾部合并时保留 5,802,840 字节。相同断言在未合并链表节点下失败:22,969,720 字节超过 16 MiB 上限。此场景不判定性能耗时。 -真实 PTY 诊断通过构建后的本地子进程提供方运行 `node -e 'process.stdout.write("x".repeat(5*1024*1024))'`。一次基线样本耗时 106962.523 ms;一次最终候选样本耗时 222.546 ms。计时从 PTY/进程 spawn 前开始,到 `session_exit` 和有界读取完成后结束。两个样本均以代码 0 退出,无信号,viewport/read 载荷均为已截断的 256 KiB。这包括原生 PTY 传输与 Node 启动,不包括交互式 shell 及提示符就绪往返。 +过滤输出的内存场景发送 513 个 64 KiB 回调,每个含完整的 56 KiB OSC 序列及其后的 8 KiB 可见文本。数据经过生产清理器,填满 4 MiB 可见窗口后进行有界读取。直接保留输入切片时(`0b81fd50e4`),断言以 36,706,592 字节失败。将输入复制到独立存储后,保留堆降至 7,635,440 字节,低于不变的 16 MiB 上限。这些测量使用 Node v26.5.0 和全新 worker。 -[必需基准](../../../../benchmarks/terminal-io/terminal-io.bench.ts)对参考预期值应用共享 CI 系数与余量:稳态接收 20 ms、稳态完成 50 ms、完整发送完成 120 ms,对应限制为 50/125/300 ms。容量扩展的中位数比值必须低于 4×;最大保留堆为 16 MiB。比值与内存限制不缩放。替换为原始编译后会话 worker 时,两个计时场景都失败:容量比值 18.977 超过 4,完整发送完成时间 5066.719 ms 超过 300 ms。最终 worker 的三个场景均通过。本地命令为 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/terminal-io/terminal-io.bench.ts`,在基准构建完成后执行。 +真实 PTY 诊断通过构建后的本地子进程提供方运行 `node -e 'process.stdout.write("x".repeat(5*1024*1024))'`。一次基线样本耗时 106962.523 ms;一次最终候选样本耗时 249.007 ms。计时从 PTY/进程 spawn 前开始,到 `session_exit` 和有界读取完成后结束。两个样本均以代码 0 退出,无信号,viewport/read 载荷均为已截断的 256 KiB。这包括原生 PTY 传输与 Node 启动,不包括交互式 shell 及提示符就绪往返。 + +[必需基准](../../../../benchmarks/terminal-io/terminal-io.bench.ts)对参考预期值应用共享 CI 系数与余量:稳态接收 20 ms、稳态完成 50 ms、完整发送完成 120 ms,对应限制为 50/125/300 ms。容量扩展的中位数比值必须低于 4×;最大保留堆为 16 MiB。比值与内存限制不缩放。替换为原始编译后会话 worker 时,两个计时场景都失败:容量比值 18.977 超过 4,完整发送完成时间 5066.719 ms 超过 300 ms。最终 worker 的四个场景均通过。本地命令为 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/terminal-io/terminal-io.bench.ts`,在基准构建完成后执行。 ## 考虑过的替代方案 diff --git a/benchmarks/terminal-io/terminal-io.bench.ts b/benchmarks/terminal-io/terminal-io.bench.ts index d31a84ae37..631a00a24a 100644 --- a/benchmarks/terminal-io/terminal-io.bench.ts +++ b/benchmarks/terminal-io/terminal-io.bench.ts @@ -17,7 +17,7 @@ function median(values: readonly number[]): number { return [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)] as number } -async function sample(capacity: number, mode: 'steady' | 'full' | 'tiny'): Promise { +async function sample(capacity: number, mode: 'steady' | 'full' | 'tiny' | 'filtered'): Promise { const outcome = await runBuiltBenchmarkWorker({ worker: WORKER, args: [String(capacity), mode], timeoutMs: 120_000, exposeGc: true, }) @@ -52,6 +52,12 @@ it('bounds retained memory when five MiB arrives in sixteen-byte chunks', async expect(report.retainedHeapBytes).toBeLessThanOrEqual(MAX_RETAINED_HEAP_BYTES) }) +it('releases filtered OSC storage behind retained visible string slices', async () => { + const report = await sample(4 * MIB, 'filtered') + console.log(JSON.stringify({ scenario: 'terminal-filtered-chunks', report, heapBudgetBytes: MAX_RETAINED_HEAP_BYTES })) + expect(report.retainedHeapBytes).toBeLessThanOrEqual(MAX_RETAINED_HEAP_BYTES) +}) + it('completes a five MiB terminal send with bounded retained output', async () => { const samples: TerminalIoReport[] = [] for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) samples.push(await sample(4 * MIB, 'full')) diff --git a/benchmarks/terminal-io/terminal-io.worker.ts b/benchmarks/terminal-io/terminal-io.worker.ts index 2e62dfd6f2..b198a26f12 100644 --- a/benchmarks/terminal-io/terminal-io.worker.ts +++ b/benchmarks/terminal-io/terminal-io.worker.ts @@ -25,10 +25,15 @@ export interface TerminalIoReport { } async function measure(capacityBytes: number, mode: string): Promise { - const chunkBytes = mode === 'tiny' ? 16 : CHUNK_BYTES + const chunkBytes = mode === 'filtered' ? 64 * 1024 : mode === 'tiny' ? 16 : CHUNK_BYTES const chunk = Buffer.alloc(chunkBytes, 'x') + if (mode === 'filtered') { + // Each decoded callback has 56 KiB of discarded OSC followed by an 8 KiB string slice. + chunk.write('\x1b]0;', 0) + chunk[56 * 1024 - 1] = 7 + } const prefillBytes = mode === 'steady' ? capacityBytes : 0 - const timedBytes = mode === 'steady' ? MIB : 5 * MIB + const timedBytes = mode === 'filtered' ? 513 * chunkBytes : mode === 'steady' ? MIB : 5 * MIB const output = new Readable({ read() {} }) const ended = Promise.withResolvers() const writeReady = Promise.withResolvers() @@ -94,7 +99,7 @@ assertBuiltBenchmarkRuntime(import.meta.url, { }) const capacityBytes = Number(process.argv[2]) const mode = process.argv[3] -if (![128 * 1024, 4 * MIB].includes(capacityBytes) || (mode !== 'steady' && mode !== 'full' && mode !== 'tiny')) { - throw new Error('usage: terminal-io.worker.js <131072|4194304> ') +if (![128 * 1024, 4 * MIB].includes(capacityBytes) || (mode !== 'steady' && mode !== 'full' && mode !== 'tiny' && mode !== 'filtered')) { + throw new Error('usage: terminal-io.worker.js <131072|4194304> ') } console.log(JSON.stringify(await measure(capacityBytes, mode))) diff --git a/packages/terminal/terminal-bash/README.i18n.yaml b/packages/terminal/terminal-bash/README.i18n.yaml index 94a962d8a5..1fc0e9011a 100644 --- a/packages/terminal/terminal-bash/README.i18n.yaml +++ b/packages/terminal/terminal-bash/README.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 packages/terminal/terminal-bash/README.md -README.md: d0defd5f0395fc3bceca357f908d5fa185dc7fed -README.zh.md: 0b51e83bafb73205296509f8b592b590d0ff21a6 +README.md: eda8ba212cf182c62a1eebf176b2dc9a4795afc7 +README.zh.md: cc6141a2e4abf72ab796cb969845e249eca68a21 diff --git a/packages/terminal/terminal-bash/README.md b/packages/terminal/terminal-bash/README.md index d0defd5f03..eda8ba212c 100644 --- a/packages/terminal/terminal-bash/README.md +++ b/packages/terminal/terminal-bash/README.md @@ -85,7 +85,7 @@ This section explains the design behind the backend and points at the code that One backend serves both dialects: bash and pwsh share the same session machinery — sanitizer, bounded buffers, readiness polling, cancellation, and teardown — and differ only in argv, environment, and prompt installation. Bash receives a private marker through `PS1` plus `PROMPT_COMMAND`. Pwsh writes a prompt function, pins UTF-8 console encoding, and publishes startup only after the backend reports `stdin_read`; echoed setup text cannot publish the shell. A zero-scrollback `@xterm/headless` instance consumes raw PTY data and returns terminal-protocol replies through the same handle, while the line sanitizer remains the only output projection. -Scrollback and unread send output retain chunks with incremental byte and newline counts. Appending and evicting text takes amortized time proportional to incoming text; reads assemble the retained chunks. Retention preserves code-point boundaries and counts the empty line after a trailing newline. The [retention decision](../../../.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md) owns the complexity and measurement rationale. +Scrollback and unread send output retain independently owned strings with incremental byte and newline counts, so sanitized slices cannot retain discarded control sequences. Appending and evicting text takes amortized time proportional to incoming text; reads assemble the retained chunks. Retention preserves code-point boundaries and counts the empty line after a trailing newline. The [retention decision](../../../.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.md) owns the complexity and measurement rationale. ### Source map diff --git a/packages/terminal/terminal-bash/README.zh.md b/packages/terminal/terminal-bash/README.zh.md index 0b51e83baf..cc6141a2e4 100644 --- a/packages/terminal/terminal-bash/README.zh.md +++ b/packages/terminal/terminal-bash/README.zh.md @@ -85,7 +85,7 @@ shell 在整个生命周期内运行在有效的沙箱边界之下。当所有 一个后端服务两种方言:bash 与 pwsh 共享同一套会话机制——清理器、有界缓冲区、就绪轮询、取消与关闭——只在 argv、环境与提示符安装方式上不同。bash 通过 `PS1` 加 `PROMPT_COMMAND` 接收私有标记。pwsh 会写入提示符函数、固定 UTF-8 控制台编码,并只在后端报告 `stdin_read` 后发布启动;回显的设置文本不能发布 shell。一个不保留 scrollback 的 `@xterm/headless` 实例会消费原始 PTY 数据,并通过同一句柄返回终端协议响应;逐行 sanitizer 仍是唯一输出投影。 -Scrollback 和尚未读取的发送输出保留分片,并增量维护字节数与换行符数。追加与淘汰文本的摊还耗时与输入文本量成正比;读取时才拼接保留的分片。保留策略维持码点边界,并将末尾换行符之后的空行计入行数。[保留策略决策](../../../.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.zh.md)记录复杂度与测量依据。 +Scrollback 和尚未读取的发送输出保留独立拥有的字符串,并增量维护字节数与换行符数,因此清理后的切片不会保留已丢弃的控制序列。追加与淘汰文本的摊还耗时与输入文本量成正比;读取时才拼接保留的分片。保留策略维持码点边界,并将末尾换行符之后的空行计入行数。[保留策略决策](../../../.agents/notes/implemented/bug-fix/2026-09-11-incremental-terminal-retention.zh.md)记录复杂度与测量依据。 ### 源码地图 diff --git a/packages/terminal/terminal-bash/src/session.ts b/packages/terminal/terminal-bash/src/session.ts index 3d7a261455..7b43087855 100644 --- a/packages/terminal/terminal-bash/src/session.ts +++ b/packages/terminal/terminal-bash/src/session.ts @@ -65,8 +65,18 @@ class BoundedTextBuffer { private readonly maxLines?: number, ) {} + get truncated(): boolean { + return this.dropped + } + + get isEmpty(): boolean { + return this.head === undefined + } + append(text: string): void { if (text.length === 0) return + // Sanitized text can be a slice retaining discarded controls; copy UTF-16 without replacing lone surrogates. + text = Buffer.from(text, 'utf16le').toString('utf16le') this.bytes += Buffer.byteLength(text) const tail = this.tail if (tail !== undefined) { @@ -84,7 +94,7 @@ class BoundedTextBuffer { tail.text += text } else { if (tail !== undefined && tail.text.length <= COALESCED_CHUNK_UNITS) { - // Seal small fragments into owned storage before retaining another chunk. + // Copy coalesced fragments into one string; large tails already own their storage. tail.text = Buffer.from(tail.text, 'utf16le').toString('utf16le') } const chunk: TextChunk = { text, start: 0, next: undefined } @@ -562,7 +572,7 @@ export class LocalPtySession implements TerminalBackendSession { return } const elapsed = Date.now() - operation.startedAt - const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0 + const startupHasOutput = !this.initializing || !this.scrollback.isEmpty const acceptsStdinWait = startupHasOutput && foreground !== undefined && operation.acceptsStdinWait(foreground.processGroupId, foreground.inputWaiting) if (elapsed >= this.config.exactProbeAfterMs && acceptsStdinWait) { @@ -687,7 +697,7 @@ export class LocalPtySession implements TerminalBackendSession { private settleActive(waitReason: TerminalWaitReason, retainOwnership = false): void { const operation = this.active if (operation === undefined) return - const scrollbackTruncated = this.scrollback.snapshot().truncated + const scrollbackTruncated = this.scrollback.truncated if (retainOwnership) { this.stopPolling() this.activeAbort?.() diff --git a/packages/terminal/terminal-bash/tests/session-buffer.spec.ts b/packages/terminal/terminal-bash/tests/session-buffer.spec.ts index 293bf780c8..9477d19ee8 100644 --- a/packages/terminal/terminal-bash/tests/session-buffer.spec.ts +++ b/packages/terminal/terminal-bash/tests/session-buffer.spec.ts @@ -250,7 +250,7 @@ describe('LocalPtySession incremental output compatibility', () => { expect(await operation.done).toMatchObject({ viewport: 'fresh', truncated: true, waitReason: 'session_exit' }) }) - it('preserves split surrogate pairs when sealed coalesced text reaches the eviction head', () => { + it('preserves split surrogate pairs when copied coalesced text reaches the eviction head', () => { const limit = 4100 const { session } = fixture({ scrollbackMaxBytes: limit, maxReadBytes: limit }) // Lone UTF-16 halves cannot pass through the session's TextDecoder unchanged. diff --git a/packages/terminal/terminal-bash/tests/session.spec.ts b/packages/terminal/terminal-bash/tests/session.spec.ts index b28c579375..da4e4e681f 100644 --- a/packages/terminal/terminal-bash/tests/session.spec.ts +++ b/packages/terminal/terminal-bash/tests/session.spec.ts @@ -153,6 +153,27 @@ async function initialize(session: LocalPtySession, terminal: FakeTerminal): Pro } describe('LocalPtySession readiness and output', () => { + it('polls startup and settles sends without assembling scrollback for status checks', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const session = new LocalPtySession(terminal, config()) + const snapshot = vi.spyOn(session['scrollback'], 'snapshot') + try { + const pending = session.initialize() + await vi.advanceTimersByTimeAsync(20) + expect(snapshot).not.toHaveBeenCalled() + terminal.emitData('x'.repeat(200) + '\x1b]133;D;0\x07dsh> ') + await vi.advanceTimersByTimeAsync(20) + await pending + expect(snapshot).not.toHaveBeenCalled() + expect(session.read({})).toMatchObject({ text: 'x'.repeat(59) + 'dsh> ', truncated: true }) + expect(snapshot).toHaveBeenCalledTimes(1) + } finally { + snapshot.mockRestore() + await session.close('status check cleanup') + } + }) + it('answers split cursor-position queries before publishing prompt readiness', async () => { vi.useFakeTimers() const terminal = new FakeTerminal() From f2103056891212c9018b9fb2a4438939eeb5e60a Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 11 Sep 2026 23:42:20 +0800 Subject: [PATCH 03/60] fix(documentpreview): fit images to the pane and polish binary and loading states --- ...-sidebar-document-preview-polish.i18n.yaml | 6 + ...6-09-11-sidebar-document-preview-polish.md | 33 ++++++ ...9-11-sidebar-document-preview-polish.zh.md | 33 ++++++ apps/web/tests/document-preview.e2e.ts | 65 +++++++---- .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 10 +- .../ui-sidebar-documentpreview/README.zh.md | 10 +- .../src/client/LoadingIndicator.tsx | 16 ++- .../src/client/TextPreview.module.css | 15 ++- .../src/client/TextPreview.tsx | 68 +++++++---- .../src/client/document/registry.ts | 21 ++++ .../src/client/document/unviewable.ts | 37 ++++++ .../src/client/html/HtmlBody.module.css | 9 ++ .../src/client/html/HtmlBody.tsx | 2 +- .../src/client/html/locales.ts | 6 +- .../src/client/image/ImageBody.module.css | 13 ++- .../src/client/image/ImageBody.tsx | 12 +- .../src/client/image/index.ts | 4 + .../src/client/image/locales.ts | 8 +- .../src/client/locales.ts | 18 +-- .../src/client/pdf/PdfBody.module.css | 17 ++- .../src/client/pdf/PdfBody.tsx | 10 +- .../src/client/pdf/index.ts | 2 +- .../src/client/pdf/locales.ts | 10 +- .../tests/document-registry.client.spec.ts | 12 +- .../tests/document-toolbar.client.spec.tsx | 107 +++++++++++++++++- .../tests/document-unviewable.client.spec.ts | 25 ++++ .../tests/html-body.client.spec.tsx | 2 +- .../tests/image-body.client.spec.tsx | 4 +- .../tests/image-registration.client.spec.ts | 3 +- .../tests/pdf-body.client.spec.tsx | 2 +- .../tests/text-preview.client.spec.tsx | 2 +- .../web/document-preview/document.expected.md | 9 +- 33 files changed, 486 insertions(+), 109 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.md create mode 100644 .agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.zh.md create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/document/unviewable.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/document-unviewable.client.spec.ts diff --git a/.agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.i18n.yaml b/.agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.i18n.yaml new file mode 100644 index 0000000000..01f596999a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.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 .agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.md +2026-09-11-sidebar-document-preview-polish.md: 483a530c603e7721b5ef3e66be77be2d56498bd1 +2026-09-11-sidebar-document-preview-polish.zh.md: f189cb7fe96d22320bb84f873cd5c1b6f923f44f diff --git a/.agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.md b/.agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.md new file mode 100644 index 0000000000..483a530c60 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.md @@ -0,0 +1,33 @@ +# Agent Note: Sidebar document preview polish + +Status: implemented + +English | [中文](2026-09-11-sidebar-document-preview-polish.zh.md) + +## Problem + +The Sidebar document preview accumulated several experience defects ([#3974](https://github.com/deepseek-harness/deepseek-harness/issues/3974)). Images rendered at their intrinsic CSS-pixel size, so a wide image overflowed the pane and forced horizontal scrolling. The viewer dropdown always appended the plain-text fallback, so bitmap and PDF files offered a "Plain text" choice whose result is unreadable bytes, and files with one real renderer still showed a control with nothing meaningful to switch to. Binary containers with no renderer at all (video, archives, office documents) fell into the plain-text reader and surfaced a read error instead of a designed empty state. Each renderer carried its own loading copy and position, so opening a file flashed through several differently worded, differently placed indicators. PDF pages sat inside a double inset that shrank every page below the pane's width. + +## Decision + +**Image width fit.** The image frame follows the scroller's width with a 12px inset; the image itself carries `max-width: 100%` and an 8px corner radius, so a wider image scales down to the pane's width at its aspect ratio, a smaller image keeps its intrinsic size centred by auto margins, and a taller image scrolls vertically in the shared body. Height fitting was considered and dropped: it needs a fixed-height frame, and mixed portrait cases produced surprising layouts for no user request. + +**Binary viewer choices.** `DocumentPreviewDefinition` gains optional `binaryExtensions`, the suffixes among a renderer's `extensions` whose bytes are not readable text. The renderer owns this knowledge: the image body declares `png, jpg, jpeg, gif, webp, bmp, ico` and leaves `svg` out because SVG source is readable XML; the PDF body declares `pdf`. `binaryDocumentPath` in the registry module answers whether any registered definition declares a filename's suffix binary, and the preview owner skips the plain-text fallback for such files. The header renders the viewer menu only when at least two candidates exist; a single candidate shows no viewer control at all. + +**Unsupported empty state.** The owner-side list in `document/unviewable.ts` names binary container suffixes (video, audio, archives, office documents, executables, fonts, disk images, design formats) that no renderer claims. The owner consults it only when no implementation matches, so any renderer registration always wins; a matching file shows the path header, the file-type icon, and one `unsupportedFile` line, and never issues a read. An uncertain suffix stays out of the list and keeps the plain-text fallback; a text-claimed file whose bytes fail the reader's checks reports the same copy through `error.notText`. + +**Unified loading.** `LoadingIndicator` gains `iconOnly`, which moves the label to `aria-label` and renders no visible text; every renderer's loading copy is the shared "Reading…". Every wait before content exists — the owner's first read, PDF parsing, HTML packaging, image decoding — centres the spinner in the pane, so opening a file shows one spinner in one position until the body appears. An unrendered PDF page holds its place as a static 3:4 placeholder block on `--dsw-alias-bg-layer-2` with no spinner; specialising the document-open wait into the first page's placeholder was tried and reverted because the owner's centred read spinner precedes it, and the handoff visibly jumped positions. A shimmer animation on placeholders was considered and dropped for per-page animation cost. + +**PDF full-bleed and copy.** The PDF body and page insets are removed so pages fill the pane's width edge to edge; the image renderer keeps its own 12px inset. Chinese error and status lines across the preview dictionaries drop trailing full stops. + +## Alternatives considered + +**A boolean `binary` flag per definition.** The image renderer covers both bitmaps and SVG under one registration, so binariness is a property of the suffix, not the renderer. + +**Filtering in the preview owner by a hardcoded suffix list for registered renderers.** The owner would duplicate knowledge each renderer already holds, and external renderers could not extend the set; the owner-side list exists only for suffixes no renderer claims. + +**A one-item static viewer label.** An earlier iteration rendered the single remaining candidate as a static name; a control that offers no action is noise, so the header now renders nothing. + +## Consequences + +Images never scroll horizontally; the pane's width is the only layout input, so no zoom control was added. Bitmap and PDF tabs show no viewer control; SVG keeps the menu with the plain-text choice; unclaimed binary containers show the empty state without reading. From open to first content every preview shows one centred icon-only spinner, and PDF pages appear as quiet placeholder blocks. Registry unit tests pin `binaryDocumentPath` matching, toolbar tests pin the fallback/menu/empty-state branches, and the keyless Web document-preview scenario measures the width-fitted SVG against the pane and asserts the viewer control's absence per suffix. diff --git a/.agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.zh.md b/.agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.zh.md new file mode 100644 index 0000000000..f189cb7fe9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.zh.md @@ -0,0 +1,33 @@ +# Agent Note:侧边栏文档预览打磨 + +Status: implemented + +[English](2026-09-11-sidebar-document-preview-polish.md) | 中文 + +## 问题 + +侧边栏文档预览积累了多处体验缺陷([#3974](https://github.com/deepseek-harness/deepseek-harness/issues/3974))。图片按固有 CSS 像素尺寸渲染,宽图撑出面板、只能横向滚动。查看器下拉总是追加纯文本兜底,位图和 PDF 文件因此提供一个结果是不可读字节的「纯文本」选项,而只有一个真实渲染器的文件仍显示一个没有可切换项的控件。完全没有渲染器的二进制容器(视频、压缩包、office 文档)落入纯文本读取器,呈现的是读取报错而非设计过的空态。各渲染器自带不同的 loading 文案和位置,打开文件时会闪过多个措辞、位置都不同的指示器。PDF 页面被双层内边距包裹,每一页都窄于面板宽度。 + +## 决定 + +**图片宽度适配。** 图片外框跟随滚动容器的宽度并带 12px 内边距;图片本身使用 `max-width: 100%` 和 8px 圆角,宽图按纵横比缩小到面板宽度,小图保持固有尺寸并由 auto margin 居中,超高图在共享正文中纵向滚动。高度适配曾被考虑后放弃:它需要外框定高,且混合纵向场景会在没有用户诉求的情况下产生意外布局。 + +**二进制查看器选项。** `DocumentPreviewDefinition` 新增可选的 `binaryExtensions`,即渲染器 `extensions` 中字节不可按文本阅读的后缀。这份知识由渲染器持有:图片正文声明 `png, jpg, jpeg, gif, webp, bmp, ico`,不含 `svg`,因为 SVG 源码是可读的 XML;PDF 正文声明 `pdf`。注册表模块的 `binaryDocumentPath` 判断是否有已注册定义把文件名后缀声明为二进制,预览 owner 对这类文件跳过纯文本兜底。头部仅在候选不少于两个时渲染查看器菜单;只剩一个候选时完全不渲染查看器控件。 + +**不支持预览的空态。** owner 侧的 `document/unviewable.ts` 列出没有渲染器认领的二进制容器后缀(视频、音频、压缩包、office 文档、可执行文件、字体、磁盘镜像、设计格式)。owner 仅在没有实现匹配时查询该列表,因此任何渲染器注册始终优先;匹配的文件显示路径头部、文件类型图标和一行 `unsupportedFile` 说明,并且不会发起读取。不确定的后缀不进入列表、保留纯文本兜底;被文本认领但字节未通过读取器检查的文件通过 `error.notText` 报告同一句文案。 + +**统一 loading。** `LoadingIndicator` 新增 `iconOnly`,把标签移入 `aria-label`、不渲染可见文字;所有渲染器的 loading 文案统一为共享的「正在读取…」。内容出现前的每个等待——owner 首次读取、PDF 解析、HTML 打包、图片解码——都把 spinner 居中在面板中,打开文件到正文出现始终是同一位置的一个 spinner。未渲染的 PDF 页以 `--dsw-alias-bg-layer-2` 上的静态 3:4 占位块保持位置、不带 spinner;曾尝试把文档打开等待特化进第一页的占位块,但 owner 的居中读取 spinner 在它之前,交接会明显跳位,因此回退。占位块的扫光动画因逐页动画的性能成本被考虑后放弃。 + +**PDF 铺满与文案。** 移除 PDF 正文和页面的内边距,页面贴边占满面板宽度;图片渲染器保留自己的 12px 内边距。预览各字典的中文报错与状态文案去掉句尾句号。 + +## 曾考虑的替代方案 + +**按定义级布尔 `binary` 标志。** 图片渲染器在一个注册中同时覆盖位图和 SVG,二进制与否是后缀的属性,不是渲染器的属性。 + +**在预览 owner 中按硬编码后缀列表过滤已注册渲染器。** owner 会重复各渲染器已持有的知识,外部渲染器也无法扩展该集合;owner 侧列表只服务于没有渲染器认领的后缀。 + +**单项静态查看器标签。** 较早的迭代把唯一候选渲染为静态名称;一个不提供任何操作的控件只是噪音,因此头部现在什么都不渲染。 + +## 后果 + +图片不再产生横向滚动;面板宽度是唯一布局输入,因此未增加缩放控件。位图和 PDF tab 不显示查看器控件;SVG 保留含纯文本选项的菜单;未被认领的二进制容器显示空态且不读取。从打开到首个内容,每种预览都只显示一个居中的纯图标 spinner,PDF 页面以安静的占位块出现。注册表单元测试固定 `binaryDocumentPath` 的匹配行为,工具栏测试固定兜底/菜单/空态分支,keyless Web document-preview 场景按面板测量适配宽度后的 SVG 并按后缀断言查看器控件的有无。 diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 8715b98d3d..ca2638c8c0 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -80,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () } }) - it('opens text, isolated HTML, intrinsic images, and rendered PDF from the Session workspace', async () => { + it('opens text, isolated HTML, width-fitted images, and rendered PDF from the Session workspace', async () => { onTestFailed(async () => { await mkdir(SHOT_DIR, { recursive: true }) await saveFailureShot(page, `screenshots/0908-document-preview/smoke-${process.pid}`) @@ -130,6 +130,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () '', ].join('')), writeFile(join(cwd, 'smoke.pdf'), pdfFixture()), + writeFile(join(cwd, 'clip.mp4'), Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70])), ]) const column = page.locator('[data-rightbar-col]') @@ -174,6 +175,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () await column.locator('[data-files-entry="file"]').getByRole('button', { name, exact: true }).click() await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}`)).toBe(true) } + // Binary suffixes (bitmaps, PDF) drop the plain-text fallback; a single remaining viewer renders no control. const viewer = preview.locator('[data-document-viewer-menu]') const body = preview.locator('[data-textpreview-body]') const sections = ['# Document preview'] @@ -275,9 +277,9 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () ].join('\n')) await openFile('smoke.pdf') - await expect.poll(() => viewer.innerText()).toBe('PDF') const canvas = preview.getByRole('img', { name: 'PDF page 1', exact: true }) await canvas.waitFor({ state: 'visible', timeout: 30_000 }) + expect(await viewer.count()).toBe(0) expect(await preview.locator('[role="toolbar"]').count()).toBe(0) expect(await preview.locator('[data-pdf-page]').count()).toBe(2) await expect.poll(() => canvasColor(canvas), { timeout: 30_000 }).toBe('red') @@ -307,7 +309,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () await successShot(page, 'pdf') sections.push([ '## PDF', '', - `- Viewer: ${await viewer.innerText()}`, + `- Viewer menu hidden: ${String(await viewer.count() === 0)}`, `- Worker: ${workerNames.find(name => name === 'dsh-pdf')}`, `- Continuous pages: ${await preview.locator('[data-pdf-page]').count()}`, `- Horizontal overflow: ${String(await body.evaluate(node => node.scrollWidth > node.clientWidth))}`, @@ -316,9 +318,9 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () ].join('\n')) await openFile('tiny.png') - await expect.poll(() => viewer.innerText()).toBe('Image') const tinyImage = preview.getByRole('img', { name: 'Image preview: tiny.png', exact: true }) await tinyImage.waitFor({ state: 'visible', timeout: 15_000 }) + expect(await viewer.count()).toBe(0) expect(await tinyImage.evaluate(node => ({ width: (node as HTMLImageElement).naturalWidth, height: (node as HTMLImageElement).naturalHeight, @@ -338,25 +340,35 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () await openFile('large.svg') await expect.poll(() => viewer.innerText()).toBe('Image') + await viewer.click() + await page.getByRole('menuitem', { name: 'Plain text', exact: true }).waitFor({ timeout: 15_000 }) + await page.keyboard.press('Escape') const largeImage = preview.getByRole('img', { name: 'Image preview: large.svg', exact: true }) await largeImage.waitFor({ state: 'visible', timeout: 15_000 }) - expect(await largeImage.evaluate(node => ({ - naturalWidth: (node as HTMLImageElement).naturalWidth, - naturalHeight: (node as HTMLImageElement).naturalHeight, - width: getComputedStyle(node).width, - height: getComputedStyle(node).height, - }))).toEqual({ naturalWidth: 1200, naturalHeight: 1600, width: '1200px', height: '1600px' }) - expect(await body.evaluate(node => ({ - horizontal: node.scrollWidth > node.clientWidth, - vertical: node.scrollHeight > node.clientHeight, - }))).toEqual({ horizontal: true, vertical: true }) + const fitted = await largeImage.evaluate((node) => { + const image = node as HTMLImageElement + const scroller = image.closest('[data-textpreview-body]') + if (scroller === null) throw new Error('image document scroller is unavailable') + const rect = image.getBoundingClientRect() + return { + naturalWidth: image.naturalWidth, + naturalHeight: image.naturalHeight, + width: rect.width, + height: rect.height, + paneWidth: scroller.clientWidth, + paneHeight: scroller.clientHeight, + } + }) + expect(fitted).toMatchObject({ naturalWidth: 1200, naturalHeight: 1600 }) + expect(fitted.width).toBeLessThan(1200) + // Width fit: the image fills the frame's 12px-inset box while the aspect ratio holds. + expect(Math.abs((fitted.paneWidth - 24) - fitted.width)).toBeLessThanOrEqual(1) + expect(fitted.height / fitted.width).toBeCloseTo(1600 / 1200, 2) const scrolled = await body.evaluate((node) => { node.scrollLeft = node.scrollWidth - node.scrollTop = node.scrollHeight - return { left: node.scrollLeft, top: node.scrollTop } + return { left: node.scrollLeft, horizontalOverflow: node.scrollWidth > node.clientWidth } }) - expect(scrolled.left).toBeGreaterThan(0) - expect(scrolled.top).toBeGreaterThan(0) + expect(scrolled).toEqual({ left: 0, horizontalOverflow: false }) expect(await page.locator('html').getAttribute('data-image-preview-escape')).toBeNull() const releaseRead = Promise.withResolvers() @@ -449,12 +461,25 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () ].join('\n')) await openFile('notes.unknown') - await expect.poll(() => viewer.innerText()).toBe('Plain text') const plainLines = preview.locator('[data-textpreview-line]') await expect.poll(() => plainLines.count()).toBe(2) + // Plain text is the only candidate, so no viewer menu renders. + expect(await viewer.count()).toBe(0) const fallback = (await plainLines.allTextContents()).map(line => line.trim()) expect(fallback).toEqual(['UNKNOWN_SUFFIX', 'Plain fallback.']) - sections.push(['## Unknown suffix', '', `- Viewer: ${await viewer.innerText()}`, `- Text: ${fallback.join(' | ')}`].join('\n')) + sections.push(['## Unknown suffix', '', `- Viewer menu hidden: ${String(await viewer.count() === 0)}`, `- Text: ${fallback.join(' | ')}`].join('\n')) + + await filesTab.click() + await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'clip.mp4', exact: true }).click() + const unsupported = column.locator('[data-textpreview-state="unsupported"]') + await unsupported.waitFor({ timeout: 15_000 }) + const unsupportedLine = await unsupported.locator('[data-textpreview-unsupported]').innerText() + expect(unsupportedLine).toContain('Preview is not available for this file type yet.') + expect(await unsupported.locator('[data-textpreview-path]').innerText()).toContain('clip.mp4') + expect(await unsupported.locator('[data-document-viewer-menu]').count()).toBe(0) + expect(await unsupported.locator('[data-textpreview-tool="reload"]').count()).toBe(0) + await successShot(page, 'unsupported') + sections.push(['## Unviewable binary', '', '- State: unsupported', `- Line: ${unsupportedLine.trim()}`].join('\n')) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE) diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 9eab9c47f9..69a59f4317 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.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 packages/client/ui-sidebar-documentpreview/README.md -README.md: e3659ed08a7c82f7c5ef314806ea07262f421caa -README.zh.md: 29393ed6b8694ac8fd8f68f398c12e633e237351 +README.md: 3da629129f36d18a20eef74be8f96a1455d532f3 +README.zh.md: 6f1eb7eef1f690ebdfdf38cfdd1f62b1314d7d66 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index e3659ed08a..3da629129f 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -27,10 +27,10 @@ Preview readable files in the right Sidebar and choose among registered renderer ## What it registers - **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title. -- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown selects among matching renderers and plain text. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge. +- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown selects among matching renderers and plain text; a suffix declared binary (bitmap images, PDF) drops the plain-text fallback, and a single remaining candidate renders no viewer control at all. A known binary container suffix with no registered renderer (audio, video, archives, office documents) shows an unsupported empty state under the path header — the file-type icon and one explanatory line — and never issues a read. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge. - **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state. -Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, prepared `content`, `wrap`, `scrollportRef`, and the standard `useTabInfo`/`useResource` hooks; they do not receive a custom resource loader. A renderer that owns an inner scrolling element attaches `scrollportRef` to it, and the owner returns to the shared body when that element unmounts. Metadata declares `loading: 'text-pages'` or `'bytes-complete'`. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available; removing it selects the next candidate. Builtin bodies use these same registrations. +Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` names the suffixes among `extensions` whose bytes are not readable text; a file matching one loses the plain-text fallback among its viewer choices. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, prepared `content`, `wrap`, `scrollportRef`, and the standard `useTabInfo`/`useResource` hooks; they do not receive a custom resource loader. A renderer that owns an inner scrolling element attaches `scrollportRef` to it, and the owner returns to the shared body when that element unmounts. Metadata declares `loading: 'text-pages'` or `'bytes-complete'`. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available; removing it selects the next candidate. Builtin bodies use these same registrations. ## Addresses @@ -49,11 +49,11 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. ` HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its ordinary inject callback to `remote.workspaceFiles.readRelated`, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path; `rpc.ts` decodes the returned bytes. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL. -PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `` static-image context. The image keeps its intrinsic CSS-pixel dimensions; a smaller image centres in the shared scroller, and larger dimensions scroll on either axis. The renderer provides neither zoom nor drag-to-pan. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL. +PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `` static-image context, rounded inside a 12px inset. A wider image scales down to the pane's width at its intrinsic aspect ratio, a smaller image centres at its intrinsic CSS-pixel dimensions, and a taller image scrolls vertically. The renderer provides neither zoom nor drag-to-pan. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL. Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. -Initial reads, additional pages, and HTML/PDF/image preparation share a loading indicator that respects reduced-motion preferences. Loaded pages stay visible while another page loads. PDF pages form one vertical, width-fitted sequence and render lazily near the viewport. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control. +Initial reads, additional pages, and HTML/PDF/image preparation share an icon-only loading spinner that exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the spinner in the pane, so opening a file shows one spinner in one position until the body appears. Loaded pages stay visible while another page loads. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control. ## Navigation @@ -74,7 +74,7 @@ No direct effect; what the user reads here never enters a model request. - **Preview, not editing.** The viewers provide no file editing or shared search interface; a directory address fails with `not-regular-file`. Unknown extensions use the plain-text reader and remain subject to its UTF-8/NUL checks. - **Sequential text and bounded complete files.** Deep source lines require the preceding pages; PDF, HTML, and images require a complete result within the Host's `maxFileBytes` cap. -- **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; image horizontal position is never restored, and HTML iframe scrolling belongs to its opaque browsing context. +- **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; images fit the pane's width and never scroll horizontally, and HTML iframe scrolling belongs to its opaque browsing context. - **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe. - **Package-local wrap glyphs.** `IconWrapFill16` and `IconNowrapFill16` live in `src/client/icons.tsx` until the shared icon set carries them; their props already match the shared icon contract. - **Scroll writes are unthrottled.** Every scroll event records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 29393ed6b8..6f1eb7eef1 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -27,10 +27,10 @@ kind: "package-reference" ## 注册了什么 - **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统中的身份,也是其正文注册所用的键),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。 -- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。下拉菜单可在匹配的渲染器与纯文本间切换。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。 +- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。下拉菜单可在匹配的渲染器与纯文本间切换;声明为二进制的后缀(位图图片、PDF)不提供纯文本兜底,只剩一个候选时完全不渲染查看器控件。已知的二进制容器后缀(音频、视频、压缩包、office 文档)没有注册渲染器时,在路径头部下方显示不支持预览的空态——文件类型图标加一行说明——并且不会发起读取。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。 - **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。 -文档实现在 `ctx.documentPreviews.register({ id, extensions, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、准备好的 `content`、`wrap`、`scrollportRef` 和标准 `useTabInfo`/`useResource` 钩子,不接收自定义资源加载器。拥有内部滚动元素的渲染器把 `scrollportRef` 挂到该元素上;该元素卸载后,owner 恢复使用共享正文。元数据声明 `loading: 'text-pages'` 或 `'bytes-complete'`。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变;移除后选择下一个候选。内置正文也使用相同注册方式。 +文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中字节不可按文本阅读的后缀;匹配这类后缀的文件在其查看器选项中不再提供纯文本兜底。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、准备好的 `content`、`wrap`、`scrollportRef` 和标准 `useTabInfo`/`useResource` 钩子,不接收自定义资源加载器。拥有内部滚动元素的渲染器把 `scrollportRef` 挂到该元素上;该元素卸载后,owner 恢复使用共享正文。元数据声明 `loading: 'text-pages'` 或 `'bytes-complete'`。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变;移除后选择下一个候选。内置正文也使用相同注册方式。 ## 地址 @@ -49,11 +49,11 @@ tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路 HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过普通 inject 回调调用 `remote.workspaceFiles.readRelated`,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,`rpc.ts` 解码返回的字节。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。 -PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态图片上下文中渲染。图片保持固有 CSS 像素尺寸;小图在共享滚动区内居中,大图可沿任一轴滚动。渲染器既不提供缩放,也不提供拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。 +PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态图片上下文中渲染,带 12px 内边距和圆角。宽图按固有纵横比缩小到面板宽度,小图按固有 CSS 像素尺寸居中,超高图纵向滚动。渲染器既不提供缩放,也不提供拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。 共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。 -首次读取、追加页及 HTML/PDF/图片准备共用加载指示器,并遵循减少动态效果偏好。下一页加载期间保留已显示的内容。PDF 页面组成一个纵向、适配宽度的连续序列,并在接近视口时惰性渲染。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。 +首次读取、追加页及 HTML/PDF/图片准备共用仅图标的加载 spinner,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 spinner 居中在面板中,打开文件到正文出现始终是同一位置的一个 spinner。下一页加载期间保留已显示的内容。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。 ## 导航 @@ -74,7 +74,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态 - **预览而非编辑。** 查看器不提供文件编辑或共享搜索接口;目录地址以 `not-regular-file` 失败。未知扩展名使用纯文本读取,仍受其 UTF-8/NUL 检查限制。 - **文本顺序分页,完整文件受限。** 定位到较深处的源码行需要先加载此前各页;PDF、HTML 和图片必须取得 Host `maxFileBytes` 上限内的完整结果。 -- **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;图片的横向位置始终不恢复,HTML iframe 的滚动属于其不透明浏览上下文。 +- **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;图片适配面板宽度、不产生横向滚动,HTML iframe 的滚动属于其不透明浏览上下文。 - **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。 - **换行图标为包内自绘。** `IconWrapFill16` 与 `IconNowrapFill16` 住在 `src/client/icons.tsx`,直到共享图标集提供为止;它们的 props 已与共享图标契约一致。 - **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/LoadingIndicator.tsx b/packages/client/ui-sidebar-documentpreview/src/client/LoadingIndicator.tsx index fff0ba2e3a..d79ddce43f 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/LoadingIndicator.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/LoadingIndicator.tsx @@ -4,10 +4,18 @@ import clsx from 'clsx' import { IconLoadingOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import css from './LoadingIndicator.module.css' -/** @param props - localized status label and optional placement style. @returns an animated, accessible loading status. */ -export function LoadingIndicator({ label, className }: { label: string; className?: string | undefined }): ReactNode { - return +/** + * @param props - localized status label, optional placement style, and + * `iconOnly` to keep the label as the accessible name without visible text. + * @returns an animated, accessible loading status. + */ +export function LoadingIndicator({ label, className, iconOnly = false }: { + label: string + className?: string | undefined + iconOnly?: boolean +}): ReactNode { + return - {label} + {!iconOnly && {label}} } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css index 1e19b311ac..4c8fd719be 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css @@ -217,14 +217,27 @@ white-space: normal; } +/* The whole pane before any content exists, so the line centres like the + empty states below instead of sitting in the corner. */ .status { display: flex; flex-direction: column; gap: 8px; - align-items: flex-start; + align-items: center; + justify-content: center; + box-sizing: border-box; + height: 100%; padding: 12px 10px; } +/* The first read, centred in the still-empty body; loaded content replaces it. */ +.bodyLoading { + justify-content: center; + box-sizing: border-box; + width: 100%; + height: 100%; +} + .action { padding: 4px 10px; color: var(--dsw-alias-label-primary); diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx index 3aaa51e367..30ea335668 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx @@ -24,8 +24,9 @@ import { LoadingIndicator } from './LoadingIndicator.tsx' import { hostFileOf } from './rpc.ts' import type { TextStore } from './store.ts' import type { DocumentContent } from './document/contract.ts' -import { matchingDocumentPreviews } from './document/registry.ts' +import { binaryDocumentPath, matchingDocumentPreviews } from './document/registry.ts' import type { DocumentPreviewDefinition } from './document/registry.ts' +import { unviewableBinaryPath } from './document/unviewable.ts' import { PLAIN_BODY_ID } from './text/index.ts' import { loadedPages, lastLineLoaded, scrollToLine } from './text/lines.ts' import css from './TextPreview.module.css' @@ -85,11 +86,14 @@ export function TextPreview({ const file = useMemo(() => hostFileOf(tab.contentId), [tab.contentId]) const state = useStore(s => s.byTab[tab.id]) const definitions = useDocumentPreviews(value => value) + const unviewable = useMemo(() => unviewableBinaryPath(file.path), [file.path]) const candidates = useMemo(() => { const matched = matchingDocumentPreviews(definitions, file.path) + if (matched.length > 0 && binaryDocumentPath(definitions, file.path)) return matched + if (matched.length === 0 && unviewable) return matched const fallback = definitions.find(definition => definition.id === PLAIN_BODY_ID) return fallback === undefined ? matched : [...matched, fallback] - }, [definitions, file.path]) + }, [definitions, file.path, unviewable]) const selected = candidates.find(candidate => candidate.id === state?.rendererId) ?? candidates[0] const mode = selected?.loading const current = (state?.mode ?? 'text-pages') === mode ? state : undefined @@ -173,12 +177,35 @@ export function TextPreview({ return { kind: 'text', pages: loaded, text: loaded.filter(page => page.lines > 0).map(page => page.text).join('\n'), eof: current.eof } }, [mode, loaded, current?.complete, current?.eof]) + // A known binary suffix with no matching renderer never reads: no plain-text + // fallback, no viewer control, only the path and the unsupported line. + if (selected === undefined && unviewable) { + const parts = pathPartsOf(displayPath) + return ( +
+
+
+ + {parts.directory !== '' && {parts.directory}} + {parts.name} + +
+
+
+
+ +

{t('unsupportedFile')}

+
+
+
+ ) + } if (state === undefined || selected === undefined) { return (
{meta.status === 'none' ?

{t('resourceUnavailable')}

- : } + : }
) } @@ -236,21 +263,24 @@ export function TextPreview({ {name}
- { setMenuOpen(value => !value) }}> - {selected.title()} - + {candidates.length > 1 + && ( + { setMenuOpen(value => !value) }}> + {selected.title()} + + )} + items={candidates.map(candidate => ({ id: candidate.id, label: candidate.title() }))} + selectedId={selected.id} + onSelect={(id) => { actions.selected(tab.id, id); setMenuOpen(false) }} + onClose={() => { setMenuOpen(false) }} + align="end" + portal + dense + /> )} - items={candidates.map(candidate => ({ id: candidate.id, label: candidate.title() }))} - selectedId={selected.id} - onSelect={(id) => { actions.selected(tab.id, id); setMenuOpen(false) }} - onClose={() => { setMenuOpen(false) }} - align="end" - portal - dense - /> {selected.wrap === true && ( // The tooltip names the action while the stable aria name and // `aria-pressed` expose the control and its current state. @@ -295,7 +325,7 @@ export function TextPreview({ }} > {!hasContent && current?.failure === undefined && ( - + )} {content !== undefined && renderSlot('sidebar.right.tab.document', { resourceAddress: tab.contentId, content, wrap: state.wrap, scrollportRef: bindScrollport, @@ -342,7 +372,7 @@ export function TextPreview({ data-textpreview-more onClick={loadNext} > - {current.loading ? : t('loadMore')} + {current.loading ? : t('loadMore')} )} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts b/packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts index 29c583d17e..cac1b46fbd 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts @@ -10,6 +10,11 @@ export interface DocumentPreviewDefinition { readonly id: string /** File suffixes without a leading dot; compound suffixes such as tar.gz are accepted. */ readonly extensions: readonly string[] + /** + * Suffixes among `extensions` whose bytes are not readable text; a file + * matching one loses the plain-text fallback among its viewer choices. + */ + readonly binaryExtensions?: readonly string[] /** External implementations win over product implementations; defaults to extension. */ readonly priority?: 'builtin' | 'extension' /** Localized implementation label, evaluated when the toolbar renders. @returns the visible name. */ @@ -45,6 +50,22 @@ export function matchingDocumentPreviews( .map(candidate => candidate.definition) } +/** + * Whether any registered implementation declares the filename's suffix binary. + * @param definitions - registered implementations. + * @param path - decoded filename or file path. + * @returns true when a declared binary suffix matches the filename. + */ +export function binaryDocumentPath( + definitions: readonly DocumentPreviewDefinition[], + path: string, +): boolean { + const normalized = path.replaceAll('\\', '/').toLowerCase() + const name = normalized.slice(normalized.lastIndexOf('/') + 1) + return definitions.some(definition => (definition.binaryExtensions ?? []) + .some(extension => name.endsWith(`.${extension.toLowerCase().replace(/^\./u, '')}`))) +} + /** Observable registry of all live implementations, including lower-priority alternatives. */ export class DocumentPreviewRegistry { private readonly registered = new Map() diff --git a/packages/client/ui-sidebar-documentpreview/src/client/document/unviewable.ts b/packages/client/ui-sidebar-documentpreview/src/client/document/unviewable.ts new file mode 100644 index 0000000000..508c62869b --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/document/unviewable.ts @@ -0,0 +1,37 @@ +/** + * Known binary container suffixes with no registered renderer. The preview + * owner shows these the unsupported empty state instead of the plain-text + * fallback; any renderer registration for a suffix takes precedence because + * the owner consults this list only when no implementation matches. A suffix + * belongs here only when its bytes are never readable text — an uncertain + * suffix stays out and keeps the plain-text fallback. + */ +const UNVIEWABLE_BINARY_EXTENSIONS: readonly string[] = [ + // video + 'mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'wmv', 'm4v', + // audio + 'mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'wma', 'opus', + // archives + 'zip', 'gz', 'tgz', 'bz2', 'xz', 'zst', '7z', 'rar', 'tar', 'jar', + // office documents + 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'pages', 'key', 'numbers', + // executables and compiled objects + 'exe', 'dll', 'so', 'dylib', 'bin', 'o', 'class', 'pyc', 'wasm', + // fonts + 'ttf', 'otf', 'woff', 'woff2', 'eot', + // disk images and databases + 'dmg', 'iso', 'img', 'sqlite', 'db', + // design documents and image formats without a renderer + 'psd', 'ai', 'sketch', 'tiff', 'tif', 'heic', 'heif', 'avif', +] + +/** + * Whether a filename's suffix is a known binary container that no renderer claims. + * @param path - decoded filename or file path. + * @returns true when the suffix belongs to the unviewable binary list. + */ +export function unviewableBinaryPath(path: string): boolean { + const normalized = path.replaceAll('\\', '/').toLowerCase() + const name = normalized.slice(normalized.lastIndexOf('/') + 1) + return UNVIEWABLE_BINARY_EXTENSIONS.some(extension => name.endsWith(`.${extension}`)) +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.module.css index 02f352bb66..8147e1714c 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.module.css @@ -13,3 +13,12 @@ color: var(--dsw-alias-label-secondary); white-space: normal; } + +/* The packing wait fills the still-empty body and centres, like the image + renderer's status. */ +.opening { + justify-content: center; + box-sizing: border-box; + width: 100%; + min-height: 100%; +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx index 34c742786d..29221eba48 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx @@ -49,7 +49,7 @@ function HtmlFrame({ data, readRelative, t }: FrameInput & { t: HtmlBodyProps['t }, [data, readRelative]) if (frame?.data !== data || frame.readRelative !== readRelative) { - return + return } if (frame.url === undefined) return

{t('failed')}

return