@deepseek-ai/dsh-client-ui-primitives 0.1.2-alpha.2 → 0.1.2-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
5
- README.md: 42c1110e8735dd2191c8b7a2e4dcc1f2b9b938bc
6
- README.zh.md: 9f3c06cacebb7a596204dbe1c8e00a549ce1fcae
5
+ README.md: 78cb6caec665687feb1c18c65e1afe47c6e01ea1
6
+ README.zh.md: 1450de83f88e4e33be4616ebd1f70588dd19e8d2
package/README.md CHANGED
@@ -33,7 +33,7 @@ Compose feature UI from these atoms whenever the web client needs a standard con
33
33
 
34
34
  ### Rendering agent output
35
35
 
36
- `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks and highlights a growing fence from saved Shiki grammar state; the final render uses the same span tree ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)). `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `MessageText` remains the literal-text primitive for user-authored content.
36
+ `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)). `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `MessageText` remains the literal-text primitive for user-authored content.
37
37
 
38
38
  ### Localizing copy
39
39
 
@@ -63,7 +63,7 @@ The package is one separation: presentational React atoms with zero Cordis and z
63
63
 
64
64
  ### Streaming markdown
65
65
 
66
- While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply. A growing fenced block tokenizes completed text from saved Shiki grammar state plus the unfinished last line; completed lines retain their DOM, and the settled render uses the same span tree. The settled full parse at finalize also resolves references that crossed the freeze boundary ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)).
66
+ While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply. A final unclosed top-level fence keeps its parsed code node and sends only the last completed line plus the current partial line through the same GFM grammar; a closing fence or ambiguous parse returns to the ordinary tail path. Highlighting likewise resumes from saved Shiki grammar state and publishes only newly completed lines plus the mutable tail. `CodeBlock` seals completed lines into fixed-size React groups, reuses earlier groups, and retains the whole highlighted tree across settlement when code and language are unchanged. The settled full parse still resolves references that crossed the freeze boundary ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)).
67
67
 
68
68
  ### Geometry and overflow
69
69
 
@@ -103,6 +103,7 @@ None; this package neither assembles nor sends a provider request.
103
103
  These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap.
104
104
 
105
105
  - **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it.
106
+ - **A long highlighted fence retains its complete token DOM** — streaming avoids re-parsing, re-tokenizing, and reconciling the completed prefix, but it does not discard old colors or virtualize token spans. Final DOM cardinality therefore still follows the fence's token count; nested/container fences and a pathological single long line remain on the general tail path.
106
107
  - **Glyph-level icons are redrawn approximations** — the fish logo and the sparkle mark come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
107
108
  - **`Pill` and `Input` have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
108
109
  - **No `Active` `StateDot` variant** — the supported states are done, warning, ongoing, and error.
@@ -118,3 +119,5 @@ These limits define how the atoms behave at the edges; they are current package
118
119
  None.
119
120
 
120
121
  </details>
122
+
123
+ **Runtime invariant:** No companion is published. Pure props-in React atoms with no Cordis API — no events, no services, no mutable cross-plugin state; rendering contracts are asserted directly by this package's component specs.
package/README.zh.md CHANGED
@@ -33,7 +33,7 @@ kind: "package-library"
33
33
 
34
34
  ### 渲染 agent 输出
35
35
 
36
- `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块,并从保存的 Shiki grammar state 为不断增长的 fence 增量高亮;最终渲染使用相同的 span 树([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`MessageText` 仍是用户创作内容的字面文本原语。
36
+ `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`MessageText` 仍是用户创作内容的字面文本原语。
37
37
 
38
38
  ### 本地化文案
39
39
 
@@ -63,7 +63,7 @@ kind: "package-library"
63
63
 
64
64
  ### 流式 markdown
65
65
 
66
- 回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复。不断增长的 fenced block 会从已保存的 Shiki grammar state 加上尚未完成的最后一行继续分词;已完成行保留其 DOM,定稿渲染则使用相同的 span 树。定稿时的全量解析还会解析跨过冻结边界的引用([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。
66
+ 回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复。末尾的顶层未闭合 fence 会保留已解析的 code node,只把最后一个已完成行与当前未完成行交给同一套 GFM grammar;闭合 fence 或有歧义的解析会回到普通尾部路径。高亮同样从保存的 Shiki grammar state 续接,并只发布新完成行与可变尾部。`CodeBlock` 把已完成行封入固定大小的 React 分组、复用更早的分组,并在代码与语言未变化时跨定稿保留整棵高亮树。定稿时的全量解析仍会解析跨过冻结边界的引用([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。
67
67
 
68
68
  ### 几何与溢出
69
69
 
@@ -103,6 +103,7 @@ kind: "package-library"
103
103
  这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。
104
104
 
105
105
  - **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。
106
+ - **长高亮 fence 会保留完整 token DOM**:流式路径避免重新解析、重新 tokenize 和 reconcile 已完成前缀,但不会丢弃旧颜色或虚拟化 token span。因此最终 DOM 数量仍随 fence 的 token 数增长;嵌套/容器内 fence 与病态的单个超长行仍走通用尾部路径。
106
107
  - **字形级图标是重新绘制的近似版本**:鱼形标志与闪光标记来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
107
108
  - **`Pill` 与 `Input` 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
108
109
  - **`StateDot` 没有 `Active` 变体**:支持的状态为 done、warning、ongoing 和 error。
@@ -118,3 +119,5 @@ kind: "package-library"
118
119
  无。
119
120
 
120
121
  </details>
122
+
123
+ **运行时不变式:** 不发布伴生入口。这些是纯 props-in React atom,没有 Cordis API、事件、service 或跨插件可变状态;渲染约定由组件测试覆盖。
@@ -54,7 +54,7 @@
54
54
 
55
55
  /* Dialog Cancel (figma 451:18655): bordered capsule on transparent fill. */
56
56
  .outline {
57
- border: 1px solid var(--dsw-alias-border-l2);
57
+ border: 0.5px solid var(--dsw-alias-border-l3);
58
58
  background: transparent;
59
59
  }
60
60
 
@@ -4,7 +4,7 @@
4
4
  gap: 6px;
5
5
  height: 32px;
6
6
  padding: 0 8px;
7
- border: 1px solid var(--dsw-alias-border-l2);
7
+ border: 0.5px solid var(--dsw-alias-border-l4);
8
8
  border-radius: 8px;
9
9
  background: var(--dsw-alias-bg-layer-1);
10
10
  }
@@ -4,7 +4,7 @@
4
4
  }
5
5
 
6
6
  /* Dropdown card (figma MenuDropdown 122:9481 / 419:16920): menu surface,
7
- * r12, inverted hairline border, shadow-lv3, 4px inset padding. */
7
+ * r12, elevation-prominent (hairline stroke in shadow), 4px inset padding. */
8
8
  .list,
9
9
  .submenu {
10
10
  /* min-widths below are the design's outer card widths — include the pad. */
@@ -13,10 +13,11 @@
13
13
  display: flex;
14
14
  flex-direction: column;
15
15
  gap: 0;
16
- border: 1px solid var(--dsw-alias-border-inverted);
17
- border-radius: 12px;
16
+ border: 0;
17
+ border-radius: 20px;
18
18
  background: var(--dsw-specific-menu);
19
- box-shadow: var(--dsw-shadow-lv3);
19
+ --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
20
+ box-shadow: var(--dsw-elevation-prominent);
20
21
  /* Elevated surface: the scrollbar thumb takes the l2 elevation tokens. The
21
22
  declaration sits on the card rather than on `.scrollable .viewport`
22
23
  because the elevation is a property of this surface, and the custom
@@ -85,7 +86,7 @@
85
86
  flex-direction: column;
86
87
  margin-top: 4px;
87
88
  padding-top: 4px;
88
- border-top: 1px solid var(--dsw-alias-border-l2);
89
+ border-top: 0.5px solid var(--dsw-alias-border-l2);
89
90
  }
90
91
 
91
92
  .itemWrap {
@@ -212,7 +213,7 @@
212
213
 
213
214
  /* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. */
214
215
  .separator {
215
- height: 1px;
216
+ height: 0.5px;
216
217
  margin: 4px 2px;
217
218
  background: var(--dsw-alias-border-l1);
218
219
  }
@@ -18,7 +18,7 @@
18
18
  backdrop-filter: var(--dsw-mask-blur);
19
19
  }
20
20
 
21
- /* Dialog card: r24, shadow-lv3, layer-2 fill, inverted border, pb 24. */
21
+ /* Dialog card: r24, elevation-prominent, layer-2 fill, pb 24. */
22
22
  .dialog {
23
23
  position: relative;
24
24
  z-index: 1;
@@ -28,10 +28,10 @@
28
28
  width: min(380px, 100%);
29
29
  padding: 0 0 24px;
30
30
  overflow: hidden;
31
- border: 1px solid var(--dsw-alias-border-inverted);
31
+ border: 0;
32
32
  border-radius: 24px;
33
33
  background: var(--dsw-alias-bg-layer-2);
34
- box-shadow: var(--dsw-shadow-lv3);
34
+ box-shadow: var(--dsw-elevation-prominent);
35
35
  }
36
36
 
37
37
  .content {
@@ -18,6 +18,7 @@
18
18
  position: absolute;
19
19
  inset: 0;
20
20
  border-radius: 50%;
21
+ corner-shape: round;
21
22
  background: currentColor;
22
23
  opacity: 0.1;
23
24
  }
@@ -27,6 +28,7 @@
27
28
  position: absolute;
28
29
  inset: 20%;
29
30
  border-radius: 50%;
31
+ corner-shape: round;
30
32
  background: currentColor;
31
33
  }
32
34
 
@@ -62,7 +62,7 @@
62
62
  divider the IN/OUT card draws between its sections. A running card is
63
63
  banner-only, so it draws none. */
64
64
  .block:not([data-running]) .header {
65
- border-bottom: 1px solid var(--dsw-alias-border-l2);
65
+ border-bottom: 0.5px solid var(--dsw-alias-border-l2);
66
66
  }
67
67
 
68
68
  /* One row per command line. The prompt column is the only element allowed to
package/lib/index.js CHANGED
@@ -4014,7 +4014,7 @@ function runState(running, exitCode, signal, labels) {
4014
4014
  * @param line - the line's styled runs.
4015
4015
  * @returns the line's children.
4016
4016
  */
4017
- function renderLine(line) {
4017
+ function renderLine$1(line) {
4018
4018
  return line.map((span, index) => span.style === void 0 ? span.text : jsx("span", {
4019
4019
  style: span.style,
4020
4020
  children: span.text
@@ -4094,7 +4094,7 @@ function TerminalBlock({ command, cwd, home, output, exitCode, signal, running =
4094
4094
  children: [
4095
4095
  (capped ? lines.slice(0, headLines) : lines).map((line, index) => jsx("div", {
4096
4096
  className: css$15.line,
4097
- children: renderLine(line)
4097
+ children: renderLine$1(line)
4098
4098
  }, index)),
4099
4099
  hidden > 0 && jsx("button", {
4100
4100
  type: "button",
@@ -4106,7 +4106,7 @@ function TerminalBlock({ command, cwd, home, output, exitCode, signal, running =
4106
4106
  }),
4107
4107
  capped && lines.slice(lines.length - tailLines).map((line, index) => jsx("div", {
4108
4108
  className: css$15.line,
4109
- children: renderLine(line)
4109
+ children: renderLine$1(line)
4110
4110
  }, index))
4111
4111
  ]
4112
4112
  }))]
@@ -4253,6 +4253,14 @@ const LANG_ALIASES = new Map([
4253
4253
  ["xml", "xml"],
4254
4254
  ["lua", "lua"]
4255
4255
  ]);
4256
+ /**
4257
+ * Whether a language hint can use the shared syntax highlighter.
4258
+ * @param lang - Language hint from a code surface.
4259
+ * @returns Whether the hint resolves to a supported grammar.
4260
+ */
4261
+ function supportsHighlighting(lang) {
4262
+ return lang !== void 0 && LANG_ALIASES.has(lang.toLowerCase());
4263
+ }
4256
4264
  /** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */
4257
4265
  const cssVariablesTheme = createCssVariablesTheme({
4258
4266
  name: "css-variables",
@@ -4435,10 +4443,11 @@ function lineSpans(line) {
4435
4443
  * tokenization is line-based and forward-only — a line's tokens depend only on
4436
4444
  * its own text and the grammar state entering it — so appended text never
4437
4445
  * changes a completed line's tokens. The session caches the spans of every
4438
- * completed line together with the grammar state after them; each
4439
- * {@link update} tokenizes newly completed text from that state, plus the
4440
- * still-growing last line. Per-call cost therefore excludes the completed
4441
- * prefix, and the result equals a from-scratch tokenization of the same code.
4446
+ * completed line together with the grammar state after them;
4447
+ * {@link updateFrame} reports only newly completed lines plus the still-growing
4448
+ * last line, while {@link update} materializes the complete compatibility
4449
+ * result. Per-call tokenization cost therefore excludes the completed prefix,
4450
+ * and the result equals a from-scratch tokenization of the same code.
4442
4451
  * Non-append input and a change of resolved grammar reset the cache and
4443
4452
  * re-tokenize fully, so any input stays correct.
4444
4453
  */
@@ -4454,11 +4463,15 @@ var StreamingHighlightSession = class {
4454
4463
  lastCode;
4455
4464
  lastLang;
4456
4465
  lastResult;
4466
+ generation = 0;
4467
+ lastFrame;
4457
4468
  reset(resolved) {
4458
4469
  this.resolved = resolved;
4459
4470
  this.prefix = "";
4460
4471
  this.spans = [];
4461
4472
  this.state = void 0;
4473
+ this.generation += 1;
4474
+ this.lastFrame = void 0;
4462
4475
  }
4463
4476
  /** Tokenize `text` with `resolved`, resuming from the cached grammar state when one exists. */
4464
4477
  tokenize(resolved, text) {
@@ -4469,28 +4482,23 @@ var StreamingHighlightSession = class {
4469
4482
  });
4470
4483
  }
4471
4484
  /**
4472
- * Tokenize the fence's current text into per-line highlighted runs;
4473
- * `undefined` means the caller renders its plain fallback. Idempotent per
4474
- * (`code`, `lang`) input — repeated calls return the identical result array —
4475
- * and a retained line keeps its span-array identity across growing calls, so
4476
- * a React caller can reuse cached line elements. A lazy grammar not yet
4477
- * loaded returns `undefined` and loads in the background exactly as
4478
- * {@link highlightToHtml} does; the next call after it registers highlights.
4479
- * @param code - the fence text accumulated so far (display-trimmed, no synthetic trailing newline).
4480
- * @param lang - the language hint (a markdown fence info string).
4481
- * @returns one entry per line of `code` (each an array of runs), or `undefined` for unknown or not-yet-loaded languages.
4485
+ * Tokenize one update as a delta for a retained renderer.
4486
+ * @param code - the fence text accumulated so far.
4487
+ * @param lang - the language hint.
4488
+ * @returns Newly completed lines plus the current tail, or `undefined` for the plain arm.
4482
4489
  */
4483
- update(code, lang) {
4484
- if (code === this.lastCode && lang === this.lastLang && this.lastResult !== void 0) return this.lastResult;
4490
+ updateFrame(code, lang) {
4491
+ if (code === this.lastCode && lang === this.lastLang && this.lastFrame !== void 0) return this.lastFrame;
4485
4492
  this.lastCode = code;
4486
4493
  this.lastLang = lang;
4494
+ this.lastResult = void 0;
4487
4495
  const resolved = lang === void 0 ? void 0 : LANG_ALIASES.get(lang.toLowerCase());
4488
4496
  if (resolved === void 0 || !ensureGrammar(resolved)) {
4489
4497
  this.reset(void 0);
4490
- this.lastResult = void 0;
4491
4498
  return;
4492
4499
  }
4493
4500
  if (resolved !== this.resolved || !code.startsWith(this.prefix)) this.reset(resolved);
4501
+ const firstNewLine = this.spans.length;
4494
4502
  const rest = code.slice(this.prefix.length);
4495
4503
  const lastNewline = rest.lastIndexOf("\n");
4496
4504
  if (lastNewline >= 0) {
@@ -4500,7 +4508,30 @@ var StreamingHighlightSession = class {
4500
4508
  this.state = highlighter().getLastGrammarState(tokens);
4501
4509
  this.prefix = code.slice(0, this.prefix.length + lastNewline + 1);
4502
4510
  }
4503
- this.lastResult = [...this.spans, ...this.tokenize(resolved, rest.slice(lastNewline + 1)).map(lineSpans)];
4511
+ this.lastFrame = {
4512
+ generation: this.generation,
4513
+ appended: this.spans.slice(firstNewLine),
4514
+ tail: this.tokenize(resolved, rest.slice(lastNewline + 1)).map(lineSpans)
4515
+ };
4516
+ return this.lastFrame;
4517
+ }
4518
+ /**
4519
+ * Tokenize the fence's current text into per-line highlighted runs;
4520
+ * `undefined` means the caller renders its plain fallback. Idempotent per
4521
+ * (`code`, `lang`) input — repeated calls return the identical result array —
4522
+ * and a retained line keeps its span-array identity across growing calls, so
4523
+ * a React caller can reuse cached line elements. A lazy grammar not yet
4524
+ * loaded returns `undefined` and loads in the background exactly as
4525
+ * {@link highlightToHtml} does; the next call after it registers highlights.
4526
+ * @param code - the fence text accumulated so far (display-trimmed, no synthetic trailing newline).
4527
+ * @param lang - the language hint (a markdown fence info string).
4528
+ * @returns one entry per line of `code` (each an array of runs), or `undefined` for unknown or not-yet-loaded languages.
4529
+ */
4530
+ update(code, lang) {
4531
+ if (code === this.lastCode && lang === this.lastLang && this.lastResult !== void 0) return this.lastResult;
4532
+ const frame = this.updateFrame(code, lang);
4533
+ if (frame === void 0) return void 0;
4534
+ this.lastResult = [...this.spans, ...frame.tail];
4504
4535
  return this.lastResult;
4505
4536
  }
4506
4537
  };
@@ -4535,6 +4566,73 @@ function highlightLines(code, lang) {
4535
4566
  })));
4536
4567
  }
4537
4568
  //#endregion
4569
+ //#region lib/types/markdown/useViewportHighlighting.js
4570
+ const noop = () => {};
4571
+ /** One document-wide observer; activated elements leave it permanently. */
4572
+ var HighlightViewport = class {
4573
+ observer;
4574
+ activators = /* @__PURE__ */ new Map();
4575
+ observe(element, activate) {
4576
+ if (typeof IntersectionObserver === "undefined") {
4577
+ activate();
4578
+ return noop;
4579
+ }
4580
+ this.observer ??= new IntersectionObserver((entries) => {
4581
+ for (const entry of entries) {
4582
+ if (!entry.isIntersecting) continue;
4583
+ const current = this.activators.get(entry.target);
4584
+ /* v8 ignore next -- the observer reports only elements still registered with it. */
4585
+ if (current === void 0) continue;
4586
+ this.activators.delete(entry.target);
4587
+ this.observer?.unobserve(entry.target);
4588
+ current();
4589
+ }
4590
+ this.releaseEmptyObserver();
4591
+ });
4592
+ this.activators.set(element, activate);
4593
+ this.observer.observe(element);
4594
+ return () => {
4595
+ this.activators.delete(element);
4596
+ this.observer?.unobserve(element);
4597
+ this.releaseEmptyObserver();
4598
+ };
4599
+ }
4600
+ releaseEmptyObserver() {
4601
+ if (this.activators.size > 0) return;
4602
+ this.observer?.disconnect();
4603
+ this.observer = void 0;
4604
+ }
4605
+ };
4606
+ const highlightViewport = new HighlightViewport();
4607
+ /**
4608
+ * Activate one supported code surface when it first intersects the viewport.
4609
+ * Activation lasts for the component lifetime; browsers without
4610
+ * IntersectionObserver activate immediately.
4611
+ * @param target - Code surface whose plain rendering reserves its geometry.
4612
+ * @param lang - Optional language hint.
4613
+ * @returns Whether this component may build highlighted output.
4614
+ */
4615
+ function useViewportHighlighting(target, lang) {
4616
+ const supported = supportsHighlighting(lang);
4617
+ const [activated, setActivated] = useState(false);
4618
+ const activate = useCallback(() => {
4619
+ setActivated(true);
4620
+ }, []);
4621
+ useEffect(() => {
4622
+ if (activated || !supported) return;
4623
+ const element = target.current;
4624
+ /* v8 ignore next -- React attaches the host ref before running effects. */
4625
+ if (element === null) return;
4626
+ return highlightViewport.observe(element, activate);
4627
+ }, [
4628
+ activate,
4629
+ activated,
4630
+ supported,
4631
+ target
4632
+ ]);
4633
+ return activated && supported;
4634
+ }
4635
+ //#endregion
4538
4636
  //#region lib/types/ReadBlock.js
4539
4637
  /**
4540
4638
  * Content lines shown before the height cap collapses the middle. Matches
@@ -4555,8 +4653,11 @@ function renderSpans(spans) {
4555
4653
  * @returns the read block element.
4556
4654
  */
4557
4655
  function ReadBlock({ label, labels, lines, totalLines, lang, maxLines = 16, className }) {
4656
+ const rootRef = useRef(null);
4657
+ const highlighting = useViewportHighlighting(rootRef, lang);
4558
4658
  const raw = useMemo(() => lines.map((line) => line.text).join("\n"), [lines]);
4559
- const highlighted = useMemo(() => highlightLines(raw, lang), [
4659
+ const highlighted = useMemo(() => highlighting ? highlightLines(raw, lang) : void 0, [
4660
+ highlighting,
4560
4661
  raw,
4561
4662
  lang,
4562
4663
  useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
@@ -4594,6 +4695,7 @@ function ReadBlock({ label, labels, lines, totalLines, lang, maxLines = 16, clas
4594
4695
  }, line.number));
4595
4696
  const paired = lines.map((line, index) => [line, highlighted?.[index]]);
4596
4697
  return jsxs("div", {
4698
+ ref: rootRef,
4597
4699
  className: clsx(css$16.block, className),
4598
4700
  "data-read": "",
4599
4701
  children: [jsxs("div", {
@@ -5009,19 +5111,23 @@ function SearchBlock(props) {
5009
5111
  * Re-parsing the whole accumulated document on every streaming chunk is
5010
5112
  * quadratic in the final reply length. CommonMark block parsing is line-based
5011
5113
  * and appended text can only reshape the parse frontier — the last top-level
5012
- * block (a paragraph becoming a setext heading or a table, a list continuing
5013
- * after a blank line, an unclosed fence swallowing lines) — so earlier blocks
5014
- * are final. This parser therefore freezes all but the trailing
5015
- * {@link UNSTABLE_TAIL_BLOCKS} blocks and re-parses only the source tail
5016
- * behind them: each source region is parsed O(1) times over the stream
5017
- * instead of once per chunk.
5114
+ * block (a paragraph becoming a setext heading or a table, or a list
5115
+ * continuing after a blank line) — so earlier blocks are final. This parser
5116
+ * therefore freezes all but the trailing {@link UNSTABLE_TAIL_BLOCKS} blocks
5117
+ * and re-parses only the source tail behind them. A final unclosed top-level
5118
+ * fence cannot freeze as a block, so its completed content lines use a second
5119
+ * frontier: only the last completed line and current partial line return
5120
+ * through the caller's grammar. Each source region is therefore parsed a
5121
+ * bounded number of times over the stream instead of once per chunk.
5018
5122
  *
5019
- * The freeze boundary comes from the parser's own `position` offsets, never
5020
- * from custom source scanning. The cut sits at the *end offset* of the last
5021
- * frozen block (not the next block's start): a following block's start offset
5022
- * excludes up to three spaces of insignificant leading indentation, which is
5023
- * harmless to drop, but cutting at the previous end also keeps the
5024
- * inter-block blank lines in the tail so the sliced source stays verbatim.
5123
+ * The block freeze boundary comes from the parser's own `position` offsets.
5124
+ * The cut sits at the *end offset* of the last frozen block (not the next
5125
+ * block's start): a following block's start offset excludes up to three spaces
5126
+ * of insignificant leading indentation, which is harmless to drop, but
5127
+ * cutting at the previous end also keeps the inter-block blank lines in the
5128
+ * tail so the sliced source stays verbatim. Fence scanning only recognizes a
5129
+ * parser-confirmed code node and closing delimiter; ambiguous input returns to
5130
+ * the normal tail parse.
5025
5131
  *
5026
5132
  * Known deviation, shared with any prefix-freeze scheme: micromark resolves
5027
5133
  * reference-style links and footnotes document-wide at parse time, so a
@@ -5046,6 +5152,84 @@ function blockKey(node, base, index) {
5046
5152
  const offset = node.position?.start.offset;
5047
5153
  return offset === void 0 ? -(index + 1) : base + offset;
5048
5154
  }
5155
+ /** Return the first line terminator at or after `start`, including a CRLF pair. */
5156
+ function lineTerminatorEnd(text, start) {
5157
+ for (let index = start; index < text.length; index += 1) {
5158
+ const char = text[index];
5159
+ if (char === "\n") return index + 1;
5160
+ if (char === "\r") return text[index + 1] === "\n" ? index + 2 : index + 1;
5161
+ }
5162
+ }
5163
+ /**
5164
+ * Source prefix before the last completed line. Keeping that line beside the
5165
+ * current partial line lets the grammar retain its trailing-newline semantics.
5166
+ */
5167
+ function committableLinePrefixLength(text) {
5168
+ let previousEnd = 0;
5169
+ let end = 0;
5170
+ for (let index = 0; index < text.length; index += 1) {
5171
+ const char = text[index];
5172
+ if (char === "\n") {
5173
+ previousEnd = end;
5174
+ end = index + 1;
5175
+ continue;
5176
+ }
5177
+ if (char !== "\r" || index + 1 >= text.length) continue;
5178
+ if (text[index + 1] === "\n") index += 1;
5179
+ previousEnd = end;
5180
+ end = index + 1;
5181
+ }
5182
+ return previousEnd;
5183
+ }
5184
+ /** Exact source terminator ending a non-empty committable prefix. */
5185
+ function trailingLineTerminator(text) {
5186
+ return text.endsWith("\r\n") ? "\r\n" : text.endsWith("\r") ? "\r" : "\n";
5187
+ }
5188
+ /** Whether `text` contains a CommonMark closing fence on one of its logical lines. */
5189
+ function containsClosingFence(text, marker, markerLength) {
5190
+ let start = 0;
5191
+ while (start <= text.length) {
5192
+ let end = start;
5193
+ while (end < text.length && text[end] !== "\n" && text[end] !== "\r") end += 1;
5194
+ const line = text.slice(start, end);
5195
+ let indent = 0;
5196
+ while (indent < 3 && line[indent] === " ") indent += 1;
5197
+ let run = indent;
5198
+ while (line[run] === marker) run += 1;
5199
+ if (run - indent >= markerLength && /^[ \t]*$/.test(line.slice(run))) return true;
5200
+ if (end === text.length) return false;
5201
+ start = text[end] === "\r" && text[end + 1] === "\n" ? end + 2 : end + 1;
5202
+ }
5203
+ /* v8 ignore next -- each loop iteration returns at EOF or advances past a line terminator. */
5204
+ return false;
5205
+ }
5206
+ /** Advance an mdast point across one append while treating a split CRLF as one line ending. */
5207
+ function advancePoint(point, appended, precededByCarriageReturn) {
5208
+ let line = point.line;
5209
+ let column = point.column;
5210
+ let afterCarriageReturn = precededByCarriageReturn;
5211
+ for (const char of appended) {
5212
+ if (char === "\n") {
5213
+ if (!afterCarriageReturn) line += 1;
5214
+ column = 1;
5215
+ afterCarriageReturn = false;
5216
+ continue;
5217
+ }
5218
+ if (char === "\r") {
5219
+ line += 1;
5220
+ column = 1;
5221
+ afterCarriageReturn = true;
5222
+ continue;
5223
+ }
5224
+ column += 1;
5225
+ afterCarriageReturn = false;
5226
+ }
5227
+ return {
5228
+ line,
5229
+ column,
5230
+ offset: point.offset + appended.length
5231
+ };
5232
+ }
5049
5233
  /**
5050
5234
  * Append-only incremental parser over a caller-supplied grammar. One instance
5051
5235
  * accumulates one streaming document; non-append input resets it.
@@ -5057,10 +5241,112 @@ var IncrementalMarkdownParser = class {
5057
5241
  frozen = [];
5058
5242
  generation = 0;
5059
5243
  cached = null;
5244
+ openFence = null;
5060
5245
  /** @param parse - Grammar shared with whatever renders the blocks, so boundaries agree. */
5061
5246
  constructor(parse) {
5062
5247
  this.parse = parse;
5063
5248
  }
5249
+ /** Parse one unclosed-fence content slice through the caller's grammar. */
5250
+ fenceValue(state, text) {
5251
+ const root = this.parse(`${state.syntheticPrefix}${text}`);
5252
+ if (root.children.length !== 1) return void 0;
5253
+ const node = root.children[0];
5254
+ return node.type === "code" ? node.value : void 0;
5255
+ }
5256
+ /** Recognize the parsed tail's final unclosed fence and prepare its incremental content frontier. */
5257
+ openFenceState(text, base, tail, frozen) {
5258
+ const codeIndex = tail.length - 1;
5259
+ const block = tail[codeIndex];
5260
+ if (block?.node.type !== "code") return null;
5261
+ const node = block.node;
5262
+ const startOffset = node.position?.start.offset;
5263
+ const end = node.position?.end;
5264
+ if (startOffset === void 0 || end?.offset === void 0) return null;
5265
+ /* v8 ignore next -- the caller's parse slice ends at text.length, so its final node ends there. */
5266
+ if (base + end.offset !== text.length) return null;
5267
+ const source = text.slice(base);
5268
+ const previousLf = source.lastIndexOf("\n", startOffset - 1);
5269
+ const previousCr = source.lastIndexOf("\r", startOffset - 1);
5270
+ const lineStart = Math.max(previousLf, previousCr) + 1;
5271
+ const terminatorEnd = lineTerminatorEnd(source, startOffset);
5272
+ /* v8 ignore next -- a parser-confirmed fenced code node requires its opening line terminator. */
5273
+ if (terminatorEnd === void 0) return null;
5274
+ if (terminatorEnd === source.length && source.endsWith("\r")) return null;
5275
+ const openingLine = source.slice(lineStart, terminatorEnd).replace(/[\r\n]+$/, "");
5276
+ const opening = /^( {0,3})(`{3,}|~{3,})/.exec(openingLine);
5277
+ if (opening === null) return null;
5278
+ const indent = opening[1];
5279
+ const run = opening[2];
5280
+ /* v8 ignore next -- mdast positions a fenced code node at the matched delimiter after indentation. */
5281
+ if (lineStart + indent.length !== startOffset) return null;
5282
+ const marker = run[0];
5283
+ const contentStart = base + terminatorEnd;
5284
+ const content = text.slice(contentStart);
5285
+ if (containsClosingFence(content, marker, run.length)) return null;
5286
+ const syntheticPrefix = `${indent}${run}\n`;
5287
+ const stableLength = committableLinePrefixLength(content);
5288
+ const stableValue = stableLength === 0 ? "" : this.fenceValue({ syntheticPrefix }, content.slice(0, stableLength));
5289
+ if (stableValue === void 0) return null;
5290
+ const pendingStart = contentStart + stableLength;
5291
+ const stableSource = content.slice(0, stableLength);
5292
+ const valuePrefix = stableLength === 0 ? "" : `${stableValue}${trailingLineTerminator(stableSource)}`;
5293
+ const pendingValue = this.fenceValue({ syntheticPrefix }, text.slice(pendingStart));
5294
+ if (pendingValue === void 0 || `${valuePrefix}${pendingValue}` !== node.value) return null;
5295
+ return {
5296
+ marker,
5297
+ markerLength: run.length,
5298
+ syntheticPrefix,
5299
+ codeIndex,
5300
+ frozen,
5301
+ tail,
5302
+ pendingStart,
5303
+ valuePrefix,
5304
+ end: {
5305
+ line: end.line,
5306
+ column: end.column,
5307
+ offset: end.offset
5308
+ },
5309
+ endedWithCarriageReturn: text.endsWith("\r")
5310
+ };
5311
+ }
5312
+ /** Extend a recognized unclosed fence without parsing its completed content prefix again. */
5313
+ updateOpenFence(state, text, previousText) {
5314
+ const pending = text.slice(state.pendingStart);
5315
+ if (containsClosingFence(pending, state.marker, state.markerLength)) return void 0;
5316
+ const pendingValue = this.fenceValue(state, pending);
5317
+ if (pendingValue === void 0) return void 0;
5318
+ const stableLength = committableLinePrefixLength(pending);
5319
+ const stableValue = stableLength === 0 ? "" : this.fenceValue(state, pending.slice(0, stableLength));
5320
+ if (stableValue === void 0) return void 0;
5321
+ const previousNode = state.tail[state.codeIndex].node;
5322
+ const end = advancePoint(state.end, text.slice(previousText.length), state.endedWithCarriageReturn);
5323
+ const node = {
5324
+ ...previousNode,
5325
+ value: `${state.valuePrefix}${pendingValue}`,
5326
+ position: {
5327
+ start: previousNode.position.start,
5328
+ end
5329
+ }
5330
+ };
5331
+ const tail = state.tail.map((entry, index) => index === state.codeIndex ? {
5332
+ ...entry,
5333
+ node
5334
+ } : entry);
5335
+ const cached = {
5336
+ frozen: state.frozen,
5337
+ tail,
5338
+ generation: this.generation
5339
+ };
5340
+ this.openFence = {
5341
+ ...state,
5342
+ tail,
5343
+ pendingStart: state.pendingStart + stableLength,
5344
+ valuePrefix: stableLength === 0 ? state.valuePrefix : `${state.valuePrefix}${stableValue}${trailingLineTerminator(pending.slice(0, stableLength))}`,
5345
+ end,
5346
+ endedWithCarriageReturn: text.endsWith("\r")
5347
+ };
5348
+ return cached;
5349
+ }
5064
5350
  /**
5065
5351
  * Fold the current accumulated text and return the frozen/tail split.
5066
5352
  * Idempotent for identical input (the previous result is returned as-is),
@@ -5074,8 +5360,19 @@ var IncrementalMarkdownParser = class {
5074
5360
  this.prevText = "";
5075
5361
  this.tailStart = 0;
5076
5362
  this.frozen = [];
5363
+ this.openFence = null;
5077
5364
  this.generation += 1;
5078
5365
  }
5366
+ const previousText = this.prevText;
5367
+ if (previousText !== "" && this.openFence !== null) {
5368
+ const incremental = this.updateOpenFence(this.openFence, text, previousText);
5369
+ if (incremental !== void 0) {
5370
+ this.prevText = text;
5371
+ this.cached = incremental;
5372
+ return incremental;
5373
+ }
5374
+ this.openFence = null;
5375
+ }
5079
5376
  this.prevText = text;
5080
5377
  const base = this.tailStart;
5081
5378
  const blocks = this.parse(text.slice(base)).children;
@@ -5100,6 +5397,7 @@ var IncrementalMarkdownParser = class {
5100
5397
  tail,
5101
5398
  generation: this.generation
5102
5399
  };
5400
+ this.openFence = this.openFenceState(text, base, tail, this.cached.frozen);
5103
5401
  return this.cached;
5104
5402
  }
5105
5403
  };
@@ -5488,52 +5786,100 @@ const SHIKI_PRE_PROPS = {
5488
5786
  },
5489
5787
  tabIndex: 0
5490
5788
  };
5789
+ /** Completed-line group size; React reconciles groups while the DOM remains line-for-line identical. */
5790
+ const STREAMING_LINE_GROUP_SIZE = 32;
5791
+ function renderLine(line, index) {
5792
+ return jsxs(Fragment$1, { children: [index > 0 && "\n", jsx("span", {
5793
+ className: "line",
5794
+ children: line.map((span, spanIndex) => jsx("span", {
5795
+ style: span.style,
5796
+ children: span.text
5797
+ }, spanIndex))
5798
+ })] }, index);
5799
+ }
5491
5800
  function CodeBlock({ code, lang, streaming, className, copyLabel, copiedLabel }) {
5492
5801
  const trimmed = code.endsWith("\n") ? code.slice(0, -1) : code;
5802
+ const rootRef = useRef(null);
5803
+ const highlighting = useViewportHighlighting(rootRef, lang);
5493
5804
  const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount);
5494
- const html = useMemo(() => streaming === true ? void 0 : highlightToHtml(trimmed, lang), [
5495
- streaming,
5496
- trimmed,
5497
- lang,
5498
- loaded
5499
- ]);
5500
5805
  const sessionRef = useRef(null);
5501
5806
  const lineCacheRef = useRef(null);
5807
+ const settledRef = useRef(false);
5502
5808
  const streamedBody = useMemo(() => {
5809
+ if (!highlighting) {
5810
+ sessionRef.current = null;
5811
+ lineCacheRef.current = null;
5812
+ settledRef.current = false;
5813
+ return;
5814
+ }
5503
5815
  if (streaming !== true) {
5816
+ const previous = lineCacheRef.current;
5817
+ if (previous !== null && previous.code === trimmed && previous.lang === lang) {
5818
+ settledRef.current = true;
5819
+ return previous.body;
5820
+ }
5504
5821
  sessionRef.current = null;
5505
5822
  lineCacheRef.current = null;
5823
+ settledRef.current = true;
5506
5824
  return;
5507
5825
  }
5826
+ if (settledRef.current) {
5827
+ sessionRef.current = null;
5828
+ lineCacheRef.current = null;
5829
+ settledRef.current = false;
5830
+ }
5508
5831
  sessionRef.current ??= new StreamingHighlightSession();
5509
- const lines = sessionRef.current.update(trimmed, lang);
5510
- if (lines === void 0) {
5832
+ const frame = sessionRef.current.updateFrame(trimmed, lang);
5833
+ if (frame === void 0) {
5511
5834
  lineCacheRef.current = null;
5512
5835
  return;
5513
5836
  }
5514
5837
  const previous = lineCacheRef.current;
5515
- const elements = lines.map((line, index) => previous !== null && previous.lines[index] === line ? previous.elements[index] : jsxs(Fragment$1, { children: [index > 0 && "\n", jsx("span", {
5516
- className: "line",
5517
- children: line.map((span, spanIndex) => jsx("span", {
5518
- style: span.style,
5519
- children: span.text
5520
- }, spanIndex))
5521
- })] }, index));
5522
- lineCacheRef.current = {
5523
- lines,
5524
- elements
5525
- };
5526
- return jsx("pre", {
5838
+ if (previous?.frame === frame && previous.code === trimmed && previous.lang === lang) return previous.body;
5839
+ const sameGeneration = previous?.generation === frame.generation;
5840
+ const groups = sameGeneration ? [...previous.groups] : [];
5841
+ let pending = sameGeneration ? [...previous.pending] : [];
5842
+ let nextLine = sameGeneration ? previous.nextLine : 0;
5843
+ for (const line of frame.appended) {
5844
+ pending.push(renderLine(line, nextLine));
5845
+ nextLine += 1;
5846
+ if (pending.length !== STREAMING_LINE_GROUP_SIZE) continue;
5847
+ const start = nextLine - pending.length;
5848
+ groups.push(jsx(Fragment$1, { children: pending }, start));
5849
+ pending = [];
5850
+ }
5851
+ const tail = frame.tail.map((line, index) => renderLine(line, nextLine + index));
5852
+ const tailGroup = jsx(Fragment$1, { children: [...pending, ...tail] }, nextLine - pending.length);
5853
+ const body = jsx("pre", {
5527
5854
  ...SHIKI_PRE_PROPS,
5528
- children: jsx("code", { children: elements })
5855
+ children: jsxs("code", { children: [groups, tailGroup] })
5529
5856
  });
5857
+ lineCacheRef.current = {
5858
+ code: trimmed,
5859
+ lang,
5860
+ generation: frame.generation,
5861
+ frame,
5862
+ groups,
5863
+ pending,
5864
+ nextLine,
5865
+ body
5866
+ };
5867
+ return body;
5530
5868
  }, [
5531
5869
  streaming,
5870
+ highlighting,
5871
+ trimmed,
5872
+ lang,
5873
+ loaded
5874
+ ]);
5875
+ const html = useMemo(() => highlighting && streaming !== true && streamedBody === void 0 ? highlightToHtml(trimmed, lang) : void 0, [
5876
+ streaming,
5877
+ highlighting,
5878
+ streamedBody,
5532
5879
  trimmed,
5533
5880
  lang,
5534
5881
  loaded
5535
5882
  ]);
5536
- const rootRef = useRef(null);
5537
5883
  const [copied, setCopied] = useState(false);
5538
5884
  const onCopy = useCallback(() => {
5539
5885
  if (copied) return;
@@ -6039,7 +6385,8 @@ function renderSettled(text, labels, fileMentions) {
6039
6385
  };
6040
6386
  const blocks = wrapBlockChildren(renderBlocks(root.children.map((node, index) => ({
6041
6387
  node,
6042
- key: index
6388
+ /* v8 ignore next -- parseFull uses parseGfm, which stamps every top-level node. */
6389
+ key: node.position?.start.offset ?? -(index + 1)
6043
6390
  })), context), false);
6044
6391
  const section = renderFootnoteSection(context);
6045
6392
  return section === null ? blocks : [
@@ -23,7 +23,7 @@
23
23
  max-height: 200px;
24
24
  overflow: auto;
25
25
  background: var(--dsw-alias-markdown-code-block);
26
- border: 1px solid var(--dsw-alias-border-l1);
26
+ border: 0.5px solid var(--dsw-alias-border-l1);
27
27
  border-radius: 6px;
28
28
  font-family: var(--ds-font-family-code);
29
29
  font-size: 11px;
@@ -126,7 +126,7 @@
126
126
  .markdown hr {
127
127
  display: block;
128
128
  border: none;
129
- height: 1px;
129
+ height: 0.5px;
130
130
  margin: 32px 0;
131
131
  background: var(--dsw-alias-border-l2);
132
132
  }
@@ -225,7 +225,7 @@
225
225
  .tableScroll th {
226
226
  text-align: start;
227
227
  padding: 10px 16px;
228
- border-bottom: 1px solid var(--dsw-alias-border-l3);
228
+ border-bottom: 0.5px solid var(--dsw-alias-border-l3);
229
229
  border-top: none;
230
230
  font: var(--dsw-font-markdown-table-head);
231
231
  max-width: 320px;
@@ -235,7 +235,7 @@
235
235
 
236
236
  .tableScroll td {
237
237
  padding: 10px 16px;
238
- border-bottom: 1px solid var(--dsw-alias-border-l2);
238
+ border-bottom: 0.5px solid var(--dsw-alias-border-l2);
239
239
  font: var(--dsw-font-markdown-table);
240
240
  max-width: 320px;
241
241
  max-width: min(30vw, 320px);
@@ -6,9 +6,10 @@ export interface CodeBlockProps {
6
6
  /**
7
7
  * The code is still growing (a streaming markdown fence): highlight through
8
8
  * a per-instance {@link StreamingHighlightSession}, which re-tokenizes only
9
- * appended text and keeps completed lines' elements (and DOM) untouched.
10
- * The caller must keep the component instance stable across growth (a
11
- * stream-stable React key); settled callers omit this and get shiki's HTML.
9
+ * appended text and keeps completed line groups (and DOM) untouched. The
10
+ * caller must keep the component instance stable across growth (a
11
+ * stream-stable React key); an unchanged streamed fence also retains that
12
+ * tree when it settles. Cold settled callers get shiki's HTML.
12
13
  */
13
14
  streaming?: boolean | undefined;
14
15
  /** Extra class merged onto the wrapper (callers position; this component draws). */
@@ -18,6 +18,12 @@
18
18
  * highlighting, still monospace) — never an error.
19
19
  */
20
20
  import type { CSSProperties } from 'react';
21
+ /**
22
+ * Whether a language hint can use the shared syntax highlighter.
23
+ * @param lang - Language hint from a code surface.
24
+ * @returns Whether the hint resolves to a supported grammar.
25
+ */
26
+ export declare function supportsHighlighting(lang: string | undefined): boolean;
21
27
  /**
22
28
  * Subscribe to lazy-grammar load completions; `listener` fires after a
23
29
  * {@link LAZY_GRAMMARS} grammar finishes registering on the singleton, so a
@@ -61,10 +67,11 @@ export interface HighlightSpan {
61
67
  * tokenization is line-based and forward-only — a line's tokens depend only on
62
68
  * its own text and the grammar state entering it — so appended text never
63
69
  * changes a completed line's tokens. The session caches the spans of every
64
- * completed line together with the grammar state after them; each
65
- * {@link update} tokenizes newly completed text from that state, plus the
66
- * still-growing last line. Per-call cost therefore excludes the completed
67
- * prefix, and the result equals a from-scratch tokenization of the same code.
70
+ * completed line together with the grammar state after them;
71
+ * {@link updateFrame} reports only newly completed lines plus the still-growing
72
+ * last line, while {@link update} materializes the complete compatibility
73
+ * result. Per-call tokenization cost therefore excludes the completed prefix,
74
+ * and the result equals a from-scratch tokenization of the same code.
68
75
  * Non-append input and a change of resolved grammar reset the cache and
69
76
  * re-tokenize fully, so any input stays correct.
70
77
  */
@@ -80,9 +87,18 @@ export declare class StreamingHighlightSession {
80
87
  private lastCode;
81
88
  private lastLang;
82
89
  private lastResult;
90
+ private generation;
91
+ private lastFrame;
83
92
  private reset;
84
93
  /** Tokenize `text` with `resolved`, resuming from the cached grammar state when one exists. */
85
94
  private tokenize;
95
+ /**
96
+ * Tokenize one update as a delta for a retained renderer.
97
+ * @param code - the fence text accumulated so far.
98
+ * @param lang - the language hint.
99
+ * @returns Newly completed lines plus the current tail, or `undefined` for the plain arm.
100
+ */
101
+ updateFrame(code: string, lang: string | undefined): StreamingHighlightFrame | undefined;
86
102
  /**
87
103
  * Tokenize the fence's current text into per-line highlighted runs;
88
104
  * `undefined` means the caller renders its plain fallback. Idempotent per
@@ -97,6 +113,15 @@ export declare class StreamingHighlightSession {
97
113
  */
98
114
  update(code: string, lang: string | undefined): readonly HighlightSpan[][] | undefined;
99
115
  }
116
+ /** One retained-renderer update from {@link StreamingHighlightSession.updateFrame}. */
117
+ export interface StreamingHighlightFrame {
118
+ /** Changes whenever prior completed lines must be discarded. */
119
+ readonly generation: number;
120
+ /** Completed lines added since the preceding frame in this generation. */
121
+ readonly appended: readonly HighlightSpan[][];
122
+ /** The still-growing final line or lines, replaced by the next frame. */
123
+ readonly tail: readonly HighlightSpan[][];
124
+ }
100
125
  /**
101
126
  * Tokenize `code` into per-line highlighted runs when `lang` maps to a
102
127
  * registered grammar; `undefined` means the caller renders its plain fallback.
@@ -4,19 +4,23 @@
4
4
  * Re-parsing the whole accumulated document on every streaming chunk is
5
5
  * quadratic in the final reply length. CommonMark block parsing is line-based
6
6
  * and appended text can only reshape the parse frontier — the last top-level
7
- * block (a paragraph becoming a setext heading or a table, a list continuing
8
- * after a blank line, an unclosed fence swallowing lines) — so earlier blocks
9
- * are final. This parser therefore freezes all but the trailing
10
- * {@link UNSTABLE_TAIL_BLOCKS} blocks and re-parses only the source tail
11
- * behind them: each source region is parsed O(1) times over the stream
12
- * instead of once per chunk.
7
+ * block (a paragraph becoming a setext heading or a table, or a list
8
+ * continuing after a blank line) — so earlier blocks are final. This parser
9
+ * therefore freezes all but the trailing {@link UNSTABLE_TAIL_BLOCKS} blocks
10
+ * and re-parses only the source tail behind them. A final unclosed top-level
11
+ * fence cannot freeze as a block, so its completed content lines use a second
12
+ * frontier: only the last completed line and current partial line return
13
+ * through the caller's grammar. Each source region is therefore parsed a
14
+ * bounded number of times over the stream instead of once per chunk.
13
15
  *
14
- * The freeze boundary comes from the parser's own `position` offsets, never
15
- * from custom source scanning. The cut sits at the *end offset* of the last
16
- * frozen block (not the next block's start): a following block's start offset
17
- * excludes up to three spaces of insignificant leading indentation, which is
18
- * harmless to drop, but cutting at the previous end also keeps the
19
- * inter-block blank lines in the tail so the sliced source stays verbatim.
16
+ * The block freeze boundary comes from the parser's own `position` offsets.
17
+ * The cut sits at the *end offset* of the last frozen block (not the next
18
+ * block's start): a following block's start offset excludes up to three spaces
19
+ * of insignificant leading indentation, which is harmless to drop, but
20
+ * cutting at the previous end also keeps the inter-block blank lines in the
21
+ * tail so the sliced source stays verbatim. Fence scanning only recognizes a
22
+ * parser-confirmed code node and closing delimiter; ambiguous input returns to
23
+ * the normal tail parse.
20
24
  *
21
25
  * Known deviation, shared with any prefix-freeze scheme: micromark resolves
22
26
  * reference-style links and footnotes document-wide at parse time, so a
@@ -55,8 +59,15 @@ export declare class IncrementalMarkdownParser {
55
59
  private frozen;
56
60
  private generation;
57
61
  private cached;
62
+ private openFence;
58
63
  /** @param parse - Grammar shared with whatever renders the blocks, so boundaries agree. */
59
64
  constructor(parse: (text: string) => Root);
65
+ /** Parse one unclosed-fence content slice through the caller's grammar. */
66
+ private fenceValue;
67
+ /** Recognize the parsed tail's final unclosed fence and prepare its incremental content frontier. */
68
+ private openFenceState;
69
+ /** Extend a recognized unclosed fence without parsing its completed content prefix again. */
70
+ private updateOpenFence;
60
71
  /**
61
72
  * Fold the current accumulated text and return the frozen/tail split.
62
73
  * Idempotent for identical input (the previous result is returned as-is),
@@ -0,0 +1,11 @@
1
+ import type { RefObject } from 'react';
2
+ /**
3
+ * Activate one supported code surface when it first intersects the viewport.
4
+ * Activation lasts for the component lifetime; browsers without
5
+ * IntersectionObserver activate immediately.
6
+ * @param target - Code surface whose plain rendering reserves its geometry.
7
+ * @param lang - Optional language hint.
8
+ * @returns Whether this component may build highlighted output.
9
+ */
10
+ export declare function useViewportHighlighting(target: RefObject<Element>, lang: string | undefined): boolean;
11
+ //# sourceMappingURL=useViewportHighlighting.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-client-ui-primitives",
3
3
  "description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)",
4
- "version": "0.1.2-alpha.2",
4
+ "version": "0.1.2-alpha.4",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -18,10 +18,6 @@
18
18
  "types": "./lib/types/index.d.ts",
19
19
  "default": "./lib/index.js"
20
20
  },
21
- "./invariant": {
22
- "types": "./lib/types/invariant.d.ts",
23
- "default": "./lib/invariant.js"
24
- },
25
21
  "./src/*": "./src/*",
26
22
  "./package.json": "./package.json"
27
23
  },
@@ -51,12 +47,10 @@
51
47
  "devDependencies": {
52
48
  "@types/react": "~18.3.1",
53
49
  "@types/react-dom": "~18.3.0",
54
- "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
55
50
  "@deepseek-ai/cordis": "^4.0.2"
56
51
  },
57
52
  "files": [
58
53
  "lib/index.js",
59
- "lib/invariant.js",
60
54
  "lib/**/*.css",
61
55
  "lib/types/**/*.d.ts"
62
56
  ],
package/lib/invariant.js DELETED
@@ -1,26 +0,0 @@
1
- //#region lib/types/invariant.js
2
- /**
3
- * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-primitives`.
4
- * @module @deepseek-ai/dsh-client-ui-primitives/invariant
5
- */
6
- const PACKAGE_NAME = "@deepseek-ai/dsh-client-ui-primitives";
7
- /** Cordis companion plugin name. */
8
- const name = "client-ui-primitives-invariant";
9
- /** Service required before the companion can reserve package ownership. */
10
- const inject = ["invariants"];
11
- /**
12
- * No runtime invariant: pure props-in React atoms with no Cordis API —
13
- * no events, no services, no mutable cross-plugin state; rendering contracts
14
- * are asserted directly by this package's component specs.
15
- */
16
- const install = () => {};
17
- /**
18
- * Register this package's invariant companion.
19
- * @param ctx - Cordis context carrying the invariant service.
20
- * @returns the installed registration's disposer after setup succeeds.
21
- */
22
- const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
23
- //#endregion
24
- export { apply, inject, name };
25
-
26
- //# sourceMappingURL=invariant.js.map
@@ -1,16 +0,0 @@
1
- /**
2
- * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-primitives`.
3
- * @module @deepseek-ai/dsh-client-ui-primitives/invariant
4
- */
5
- import type { Context } from '@deepseek-ai/cordis';
6
- /** Cordis companion plugin name. */
7
- export declare const name = "client-ui-primitives-invariant";
8
- /** Service required before the companion can reserve package ownership. */
9
- export declare const inject: string[];
10
- /**
11
- * Register this package's invariant companion.
12
- * @param ctx - Cordis context carrying the invariant service.
13
- * @returns the installed registration's disposer after setup succeeds.
14
- */
15
- export declare const apply: (ctx: Context) => Promise<() => void>;
16
- //# sourceMappingURL=invariant.d.ts.map