@itookit/dsht 0.3.0 → 0.3.3

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.
Files changed (134) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +39 -23
  3. package/README.zh.md +40 -24
  4. package/dist/catalog/controller.d.ts +32 -0
  5. package/dist/catalog/controller.js +88 -0
  6. package/dist/catalog/index.d.ts +2 -0
  7. package/dist/catalog/index.js +2 -0
  8. package/dist/{cli.js → cli/index.js} +42 -29
  9. package/dist/controller/connection.d.ts +80 -0
  10. package/dist/controller/connection.js +190 -0
  11. package/dist/controller/controller.d.ts +269 -0
  12. package/dist/controller/controller.js +372 -0
  13. package/dist/controller/index.d.ts +5 -0
  14. package/dist/controller/index.js +3 -0
  15. package/dist/controller/memory-log.d.ts +35 -0
  16. package/dist/controller/memory-log.js +95 -0
  17. package/dist/cost/config.d.ts +17 -0
  18. package/dist/cost/config.js +68 -0
  19. package/dist/cost/controller.d.ts +41 -0
  20. package/dist/cost/controller.js +115 -0
  21. package/dist/cost/index.d.ts +10 -0
  22. package/dist/cost/index.js +8 -0
  23. package/dist/cost/ledger-files.d.ts +16 -0
  24. package/dist/cost/ledger-files.js +103 -0
  25. package/dist/cost/ledger.d.ts +78 -0
  26. package/dist/cost/ledger.js +146 -0
  27. package/dist/cost/pricing.d.ts +86 -0
  28. package/dist/cost/pricing.js +223 -0
  29. package/dist/cost/records.d.ts +17 -0
  30. package/dist/cost/records.js +79 -0
  31. package/dist/cost/scanner.d.ts +22 -0
  32. package/dist/cost/scanner.js +95 -0
  33. package/dist/cost/types.d.ts +85 -0
  34. package/dist/cost/types.js +7 -0
  35. package/dist/index.d.ts +3 -0
  36. package/dist/index.js +2 -0
  37. package/dist/session/connection-view.d.ts +18 -0
  38. package/dist/session/connection-view.js +1 -0
  39. package/dist/{controller.d.ts → session/controller.d.ts} +102 -136
  40. package/dist/session/controller.js +616 -0
  41. package/dist/session/export-html.d.ts +9 -0
  42. package/dist/session/export-html.js +39 -0
  43. package/dist/{export.d.ts → session/export.d.ts} +1 -1
  44. package/dist/{export.js → session/export.js} +6 -20
  45. package/dist/{history.d.ts → session/history.d.ts} +17 -0
  46. package/dist/{history.js → session/history.js} +163 -2
  47. package/dist/session/index.d.ts +17 -0
  48. package/dist/session/index.js +10 -0
  49. package/dist/session/markdown.d.ts +39 -0
  50. package/dist/session/markdown.js +255 -0
  51. package/dist/session/math.d.ts +11 -0
  52. package/dist/session/math.js +82 -0
  53. package/dist/session/navigation.d.ts +37 -0
  54. package/dist/session/navigation.js +84 -0
  55. package/dist/{references.js → session/references.js} +1 -1
  56. package/dist/{telemetry.d.ts → session/telemetry.d.ts} +1 -1
  57. package/dist/{telemetry.js → session/telemetry.js} +1 -1
  58. package/dist/{transcript.d.ts → session/transcript.d.ts} +65 -1
  59. package/dist/{transcript.js → session/transcript.js} +141 -20
  60. package/dist/session/types.d.ts +18 -0
  61. package/dist/session/types.js +2 -0
  62. package/dist/state.d.ts +41 -0
  63. package/dist/state.js +9 -0
  64. package/dist/storage/directories.d.ts +14 -0
  65. package/dist/storage/directories.js +24 -0
  66. package/dist/storage/files.d.ts +51 -0
  67. package/dist/storage/files.js +152 -0
  68. package/dist/storage/heap-snapshot.d.ts +19 -0
  69. package/dist/storage/heap-snapshot.js +29 -0
  70. package/dist/storage/index.d.ts +4 -0
  71. package/dist/storage/index.js +4 -0
  72. package/dist/transport/auth.js +65 -0
  73. package/dist/transport/host.d.ts +13 -0
  74. package/dist/transport/host.js +1 -0
  75. package/dist/ui/app.d.ts +11 -0
  76. package/dist/ui/app.js +790 -0
  77. package/dist/ui/chat/header.d.ts +11 -0
  78. package/dist/ui/chat/header.js +14 -0
  79. package/dist/ui/chat/history-view.d.ts +12 -0
  80. package/dist/ui/chat/history-view.js +17 -0
  81. package/dist/ui/chat/status.d.ts +91 -0
  82. package/dist/ui/chat/status.js +386 -0
  83. package/dist/ui/chat/viewport.d.ts +17 -0
  84. package/dist/ui/chat/viewport.js +14 -0
  85. package/dist/ui/commands/parse.d.ts +99 -0
  86. package/dist/ui/commands/parse.js +126 -0
  87. package/dist/ui/commands/registry.d.ts +33 -0
  88. package/dist/ui/commands/registry.js +73 -0
  89. package/dist/ui/copy-mode.d.ts +4 -0
  90. package/dist/ui/copy-mode.js +6 -0
  91. package/dist/{cost-view.d.ts → ui/dialogs/cost.d.ts} +1 -1
  92. package/dist/{cost-view.js → ui/dialogs/cost.js} +6 -6
  93. package/dist/ui/dialogs/index.d.ts +120 -0
  94. package/dist/ui/dialogs/index.js +113 -0
  95. package/dist/ui/dialogs/picker.d.ts +18 -0
  96. package/dist/ui/dialogs/picker.js +38 -0
  97. package/dist/ui/frozen.d.ts +8 -0
  98. package/dist/ui/frozen.js +7 -0
  99. package/dist/ui/input/references.d.ts +12 -0
  100. package/dist/ui/input/references.js +15 -0
  101. package/dist/ui/mount.d.ts +6 -0
  102. package/dist/ui/mount.js +11 -0
  103. package/dist/{theme.d.ts → ui/theme/index.d.ts} +1 -1
  104. package/package.json +19 -13
  105. package/dist/app.d.ts +0 -21
  106. package/dist/app.js +0 -805
  107. package/dist/auth.js +0 -108
  108. package/dist/controller.js +0 -961
  109. package/dist/cost.d.ts +0 -119
  110. package/dist/cost.js +0 -313
  111. package/dist/history-view.d.ts +0 -8
  112. package/dist/history-view.js +0 -12
  113. package/dist/navigation.d.ts +0 -11
  114. package/dist/navigation.js +0 -36
  115. package/dist/status.d.ts +0 -28
  116. package/dist/status.js +0 -157
  117. /package/dist/{cli.d.ts → cli/index.d.ts} +0 -0
  118. /package/dist/{memory.d.ts → session/memory.d.ts} +0 -0
  119. /package/dist/{memory.js → session/memory.js} +0 -0
  120. /package/dist/{references.d.ts → session/references.d.ts} +0 -0
  121. /package/dist/{auth.d.ts → transport/auth.d.ts} +0 -0
  122. /package/dist/{client.d.ts → transport/client.d.ts} +0 -0
  123. /package/dist/{client.js → transport/client.js} +0 -0
  124. /package/dist/{endpoint.d.ts → transport/endpoint.d.ts} +0 -0
  125. /package/dist/{endpoint.js → transport/endpoint.js} +0 -0
  126. /package/dist/{wire.d.ts → transport/wire.d.ts} +0 -0
  127. /package/dist/{wire.js → transport/wire.js} +0 -0
  128. /package/dist/{input-history.d.ts → ui/input/history.d.ts} +0 -0
  129. /package/dist/{input-history.js → ui/input/history.js} +0 -0
  130. /package/dist/{input.d.ts → ui/input/input.d.ts} +0 -0
  131. /package/dist/{input.js → ui/input/input.js} +0 -0
  132. /package/dist/{mouse.d.ts → ui/input/mouse.d.ts} +0 -0
  133. /package/dist/{mouse.js → ui/input/mouse.js} +0 -0
  134. /package/dist/{theme.js → ui/theme/index.js} +0 -0
@@ -1,4 +1,4 @@
1
- import type { Client } from './client.ts';
1
+ import type { Client } from '../transport/client.ts';
2
2
  /** Save an archive without overwriting an existing file or retaining a partial download.
3
3
  * @param client - Authenticated host transport.
4
4
  * @param sessionId - Selected session to export.
@@ -1,6 +1,6 @@
1
1
  /** Stream authenticated session archives to exclusive local files. */
2
- import { open, unlink } from 'node:fs/promises';
3
2
  import { resolve } from 'node:path';
3
+ import { writeExclusiveStream } from "../storage/index.js";
4
4
  /** Save an archive without overwriting an existing file or retaining a partial download.
5
5
  * @param client - Authenticated host transport.
6
6
  * @param sessionId - Selected session to export.
@@ -10,14 +10,12 @@ import { resolve } from 'node:path';
10
10
  */
11
11
  export async function saveSessionLog(client, sessionId, destination, signal) {
12
12
  const path = resolve(destination ?? `session-${sessionId.replace(/[^a-zA-Z0-9_-]/g, '_')}-${Date.now()}.zip`);
13
- const file = await open(path, 'wx', 0o600);
14
- let complete = false;
15
- try {
13
+ await writeExclusiveStream(path, async () => {
16
14
  const response = await client.sessionLog(sessionId, signal);
17
15
  if (!response.body)
18
16
  throw new Error('Session log export has no body');
19
17
  const reader = response.body.getReader();
20
- async function* chunks() {
18
+ return (async function* chunks() {
21
19
  try {
22
20
  while (true) {
23
21
  const chunk = await reader.read();
@@ -34,19 +32,7 @@ export async function saveSessionLog(client, sessionId, destination, signal) {
34
32
  reader.releaseLock();
35
33
  }
36
34
  }
37
- }
38
- await file.writeFile(chunks(), { signal });
39
- signal.throwIfAborted();
40
- complete = true;
41
- return path;
42
- }
43
- finally {
44
- try {
45
- await file.close();
46
- }
47
- finally {
48
- if (!complete)
49
- await unlink(path);
50
- }
51
- }
35
+ })();
36
+ }, signal);
37
+ return path;
52
38
  }
@@ -1,4 +1,5 @@
1
1
  import { type Message, type MessagePart, type Transcript } from './transcript.ts';
2
+ import { type MarkdownSpan } from './markdown.ts';
2
3
  /** Default fold mode; individual sequence overrides are view state, never stored content. */
3
4
  export type Reasoning = 'row' | 'full';
4
5
  /** Color semantics are independent of the selected terminal palette. */
@@ -9,11 +10,27 @@ export interface HistoryRow {
9
10
  kind: RowKind;
10
11
  bold?: boolean;
11
12
  seq?: number;
13
+ spans?: MarkdownSpan[];
12
14
  }
13
15
  /** Drop all terminal rows and layout metadata for an evicted or inactive transcript.
14
16
  * @param transcript - Transcript whose previously returned layout is no longer used.
15
17
  */
16
18
  export declare function releaseHistoryLayout(transcript: Transcript): void;
19
+ /** Report what one transcript's layout still holds, for memory samples and diagnostics.
20
+ *
21
+ * The row cache is the only structure that grows with expanded reasoning and Markdown rows, and
22
+ * its byte bound counts neither the span objects nor the incremental live-tail state.
23
+ * @param transcript - Transcript whose layout should be measured.
24
+ * @returns Cache and live-tail counters, or undefined when no layout was built yet.
25
+ */
26
+ export declare function layoutStats(transcript: Transcript): {
27
+ rows: number;
28
+ cacheBytes: number;
29
+ spans: number;
30
+ spanChars: number;
31
+ liveWraps: number;
32
+ liveMarkdown: number;
33
+ } | undefined;
17
34
  /** Lay out an indexed conversation without concatenating its historical rows on every stream frame.
18
35
  * @param transcript - Selected session's semantic content and unfinished assistant output.
19
36
  * @param width - Available terminal columns.
@@ -1,6 +1,7 @@
1
1
  /** Indexed semantic history with bounded terminal-row caching and viewport-only materialization. */
2
2
  import wrapAnsi from 'wrap-ansi';
3
3
  import { toolLine } from "./transcript.js";
4
+ import { hasMarkdown, markdownRows } from "./markdown.js";
4
5
  /** A session owns one layout cache; dropped sessions release their entire cache. */
5
6
  class LayoutIndex {
6
7
  width;
@@ -15,6 +16,9 @@ class LayoutIndex {
15
16
  // Bound both row objects and long rows. Full semantic content remains available for search.
16
17
  maxRows = 2048;
17
18
  heights = new WeakMap();
19
+ /** Incremental wrapping state for the growing live tail, keyed by each live part's identity. */
20
+ liveWraps = new Map();
21
+ liveMarkdown = new Map();
18
22
  constructor(width, reasoning, overrides) {
19
23
  this.width = width;
20
24
  this.reasoning = reasoning;
@@ -51,8 +55,26 @@ class LayoutIndex {
51
55
  this.cacheSize = 0;
52
56
  this.length = 0;
53
57
  this.heights = new WeakMap();
58
+ this.liveWraps.clear();
59
+ this.liveMarkdown.clear();
54
60
  }
55
61
  get cachedRowCount() { return this.cacheSize; }
62
+ /** Measure what the row cache actually holds, including the style spans the byte bound ignores.
63
+ * @returns Cached rows, their accounted characters, and the span objects inside them.
64
+ */
65
+ stats() {
66
+ let cacheBytes = 0, spans = 0, spanChars = 0;
67
+ for (const { rows } of this.cache.values()) {
68
+ for (const row of rows) {
69
+ cacheBytes += row.text.length;
70
+ for (const span of row.spans ?? []) {
71
+ spans++;
72
+ spanChars += span.text.length;
73
+ }
74
+ }
75
+ }
76
+ return { rows: this.cacheSize, cacheBytes, spans, spanChars };
77
+ }
56
78
  get assistantSeen() { return this.segments.at(-1)?.assistantSeen ?? false; }
57
79
  update(messages) {
58
80
  if (messages === this.messages)
@@ -111,6 +133,108 @@ class LayoutIndex {
111
133
  }
112
134
  }
113
135
  const liveRows = new WeakMap();
136
+ /** Wrap text into rows with the per-kind trimming rule.
137
+ * @param text - Text to wrap.
138
+ * @param width - Available terminal columns.
139
+ * @param kind - Part kind, which decides whether wrapped lines are trimmed.
140
+ * @param seq - Durable sequence, for committed parts.
141
+ * @returns The wrapped rows.
142
+ */
143
+ function wrapRows(text, width, kind, seq) {
144
+ return wrapAnsi(text, width, { hard: true, trim: !['tool', 'success', 'error'].includes(kind) })
145
+ .split('\n').map(text => ({ text, kind, seq }));
146
+ }
147
+ /** Bound the source inspected for a folded row, which renders only the first `width` columns.
148
+ * @param text - Complete live text.
149
+ * @param width - Available terminal columns.
150
+ * @returns A prefix that cannot change the folded row.
151
+ */
152
+ function foldedSource(text, width) {
153
+ let limit = width * 4 + 64;
154
+ while (limit < text.length && toolLine(text.slice(0, limit), width).length < width)
155
+ limit *= 2;
156
+ return limit >= text.length ? text : text.slice(0, limit);
157
+ }
158
+ /** Locate the start of one rendered row inside the source it was wrapped from.
159
+ *
160
+ * Trim removes the whitespace runs at both ends of a row, so a rendered row is not a contiguous
161
+ * slice; matching it backwards while skipping source whitespace recovers where it began. The scan
162
+ * gives up once it would have to skip more than `limit` characters, which sends the caller back to
163
+ * a whole-text wrap instead of scanning an unbounded whitespace run.
164
+ * @param pending - Source text the row was wrapped from.
165
+ * @param row - One rendered row from that wrap.
166
+ * @param limit - Maximum characters the backward scan may skip.
167
+ * @returns The row's start offset, or undefined when it cannot be located.
168
+ */
169
+ function rawStart(pending, row, limit) {
170
+ let source = pending.length - 1;
171
+ let target = row.length - 1;
172
+ const floor = pending.length - limit;
173
+ while (target >= 0 && source >= floor) {
174
+ if (pending[source] === row[target]) {
175
+ source--;
176
+ target--;
177
+ continue;
178
+ }
179
+ if (/\s/u.test(pending[source])) {
180
+ source--;
181
+ continue;
182
+ }
183
+ return undefined;
184
+ }
185
+ return target < 0 ? source + 1 : undefined;
186
+ }
187
+ /** Wrap one live part incrementally, re-wrapping only rows that can still change plus the new delta.
188
+ *
189
+ * A streaming part only grows, so every row before its last non-empty one is final. The state keeps
190
+ * those rows plus the raw source of the unfinished remainder, and re-anchors from the whole text
191
+ * only when that remainder cannot be located or outgrows a few widths.
192
+ * @param part - One live part carrying a stable `key`.
193
+ * @param state - Per-layout state keyed by that identity.
194
+ * @param width - Available terminal columns.
195
+ * @param reasoning - Fold mode for completed live reasoning.
196
+ * @returns The part's rows.
197
+ */
198
+ function livePartRows(part, state, width, reasoning) {
199
+ const key = part.key;
200
+ if (key === undefined)
201
+ return partRows([part], width, reasoning);
202
+ if (part.kind === 'reasoning' && reasoning === 'row' && (part.closed || width < 60)) {
203
+ state.delete(key);
204
+ return wrapRows(toolLine(`◇ /think live · ${foldedSource(part.text, width).slice(2)}`, width), width, 'reasoning');
205
+ }
206
+ const previous = state.get(key);
207
+ const extend = previous !== undefined && previous.width === width && previous.reasoning === reasoning
208
+ && previous.kind === part.kind && part.text.length >= previous.length;
209
+ const trim = !['tool', 'success', 'error'].includes(part.kind);
210
+ const limit = width * 8 + 512;
211
+ const attempt = (source, prior) => {
212
+ const wrapped = wrapAnsi(source, width, { hard: true, trim }).split('\n');
213
+ const filled = wrapped.reduce((found, text, index) => text === '' ? found : index, -1);
214
+ if (filled < 0)
215
+ return { rows: [], tail: wrapped, carry: source };
216
+ const start = rawStart(source, wrapped[filled], limit);
217
+ if (start === undefined)
218
+ return undefined;
219
+ return {
220
+ rows: [...prior, ...wrapped.slice(0, filled).map(text => ({ text, kind: part.kind }))],
221
+ tail: wrapped.slice(filled),
222
+ // The last non-empty row and everything after it, including trailing empty rows, stay unfinished.
223
+ carry: source.slice(start),
224
+ };
225
+ };
226
+ let result = attempt(extend ? previous.carry + part.text.slice(previous.length) : part.text, extend ? previous.rows : []);
227
+ if (result === undefined || result.carry.length > width * 4 + 64)
228
+ result = attempt(part.text, []);
229
+ // Recovery can fail on text whose last row cannot be located; the plain one-shot wrap is the fallback.
230
+ if (result === undefined) {
231
+ state.delete(key);
232
+ return partRows([part], width, reasoning);
233
+ }
234
+ const rows = [...result.rows, ...result.tail.map(text => ({ text, kind: part.kind }))];
235
+ state.set(key, { width, reasoning, kind: part.kind, length: part.text.length, rows: result.rows, carry: result.carry });
236
+ return rows;
237
+ }
114
238
  function partRows(parts, width, reasoning, seq) {
115
239
  return parts.flatMap(part => {
116
240
  const cached = seq === undefined ? liveRows.get(part) : undefined;
@@ -118,7 +242,8 @@ function partRows(parts, width, reasoning, seq) {
118
242
  return cached.rows;
119
243
  const fold = part.kind === 'reasoning' && reasoning === 'row' && (seq !== undefined || part.closed || width < 60);
120
244
  const text = fold ? toolLine(`◇ /think${seq === undefined ? ' live' : ` ${seq}`} · ${part.text.slice(2)}`, width) : part.text;
121
- const rows = wrapAnsi(text, width, { hard: true, trim: !['tool', 'success', 'error'].includes(part.kind) }).split('\n').map(text => ({ text, kind: part.kind, seq }));
245
+ const rows = part.kind === 'text' ? markdownRows(text, width).map(row => ({ ...row, kind: part.kind, seq }))
246
+ : wrapRows(text, width, part.kind, seq);
122
247
  if (seq === undefined)
123
248
  liveRows.set(part, { width, reasoning, rows });
124
249
  return rows;
@@ -142,6 +267,19 @@ export function releaseHistoryLayout(transcript) {
142
267
  indexes.get(transcript)?.dispose();
143
268
  indexes.delete(transcript);
144
269
  }
270
+ /** Report what one transcript's layout still holds, for memory samples and diagnostics.
271
+ *
272
+ * The row cache is the only structure that grows with expanded reasoning and Markdown rows, and
273
+ * its byte bound counts neither the span objects nor the incremental live-tail state.
274
+ * @param transcript - Transcript whose layout should be measured.
275
+ * @returns Cache and live-tail counters, or undefined when no layout was built yet.
276
+ */
277
+ export function layoutStats(transcript) {
278
+ const index = indexes.get(transcript);
279
+ if (!index)
280
+ return undefined;
281
+ return { ...index.stats(), liveWraps: index.liveWraps.size, liveMarkdown: index.liveMarkdown.size };
282
+ }
145
283
  /** Lay out an indexed conversation without concatenating its historical rows on every stream frame.
146
284
  * @param transcript - Selected session's semantic content and unfinished assistant output.
147
285
  * @param width - Available terminal columns.
@@ -158,9 +296,32 @@ export function historyLayout(transcript, width, reasoning = 'row', overrides =
158
296
  }
159
297
  index.update(transcript.messagesForWidth(width));
160
298
  const live = transcript.liveParts(width);
299
+ const keys = new Set(live.map(part => part.key));
300
+ for (const key of index.liveWraps.keys())
301
+ if (!keys.has(key))
302
+ index.liveWraps.delete(key);
303
+ for (const key of index.liveMarkdown.keys())
304
+ if (!keys.has(key))
305
+ index.liveMarkdown.delete(key);
161
306
  const streamed = live.length ? [
162
307
  ...(transcript.liveToolOnly || index.assistantSeen ? [] : [{ text: '✦ Assistant · streaming', kind: 'assistant', bold: true }]),
163
- ...partRows(live, width, liveReasoning),
308
+ ...live.flatMap(part => {
309
+ if (part.kind === 'text') {
310
+ const previous = part.key === undefined ? undefined : index.liveMarkdown.get(part.key);
311
+ const extendsPrevious = previous && part.text.length >= previous.length;
312
+ // Include the preceding line so a split list marker or blank line can change Markdown parsing.
313
+ const start = extendsPrevious ? part.text.lastIndexOf('\n', Math.max(0, previous.length - 2)) + 1 : 0;
314
+ const rich = !!(extendsPrevious && previous.rich) || hasMarkdown(part.text.slice(start));
315
+ if (part.key !== undefined)
316
+ index.liveMarkdown.set(part.key, { length: part.text.length, rich });
317
+ if (rich) {
318
+ if (part.key !== undefined)
319
+ index.liveWraps.delete(part.key);
320
+ return partRows([part], width, liveReasoning);
321
+ }
322
+ }
323
+ return livePartRows(part, index.liveWraps, width, liveReasoning);
324
+ }),
164
325
  ] : [];
165
326
  const committed = index;
166
327
  const length = committed.length + streamed.length;
@@ -0,0 +1,17 @@
1
+ /** Session domain: the selected session, its transcript, layout, telemetry and navigation. */
2
+ export { SessionController } from './controller.ts';
3
+ export { contentText, toolLine, Transcript } from './transcript.ts';
4
+ export type { LivePhase, Message, MessagePart, ThoughtEntry } from './transcript.ts';
5
+ export { historyLayout, layoutStats, releaseHistoryLayout } from './history.ts';
6
+ export { markdownCacheStats } from './markdown.ts';
7
+ export type { HistoryRow, Reasoning, RowKind } from './history.ts';
8
+ export { Telemetry } from './telemetry.ts';
9
+ export type { QueuedInput } from './telemetry.ts';
10
+ export { DEFAULT_HISTORY_LIMITS, historyLimits } from './memory.ts';
11
+ export type { HistoryLimits } from './memory.ts';
12
+ export { navigationCommand, resolveTarget, sessionLabel } from './navigation.ts';
13
+ export { activeReference, fileMention, fileReferences } from './references.ts';
14
+ export type { FileReference } from './references.ts';
15
+ export { saveSessionLog } from './export.ts';
16
+ export type { HistorySearch, RemovalTarget } from './types.ts';
17
+ export type { ConnectionView } from './connection-view.ts';
@@ -0,0 +1,10 @@
1
+ /** Session domain: the selected session, its transcript, layout, telemetry and navigation. */
2
+ export { SessionController } from "./controller.js";
3
+ export { contentText, toolLine, Transcript } from "./transcript.js";
4
+ export { historyLayout, layoutStats, releaseHistoryLayout } from "./history.js";
5
+ export { markdownCacheStats } from "./markdown.js";
6
+ export { Telemetry } from "./telemetry.js";
7
+ export { DEFAULT_HISTORY_LIMITS, historyLimits } from "./memory.js";
8
+ export { navigationCommand, resolveTarget, sessionLabel } from "./navigation.js";
9
+ export { activeReference, fileMention, fileReferences } from "./references.js";
10
+ export { saveSessionLog } from "./export.js";
@@ -0,0 +1,39 @@
1
+ /** Local styles only; remote control sequences never become terminal instructions. */
2
+ export interface MarkdownSpan {
3
+ text: string;
4
+ bold?: boolean;
5
+ italic?: boolean;
6
+ underline?: boolean;
7
+ strikethrough?: boolean;
8
+ inverse?: boolean;
9
+ }
10
+ /** A measured row retains plain text for searching, copying and row geometry. */
11
+ export interface MarkdownRow {
12
+ text: string;
13
+ spans?: MarkdownSpan[];
14
+ }
15
+ /** Report the bounded math and diagram cache, so a memory sample can tell churn from retention.
16
+ * @returns Cached entries, their accounted characters, and lookup counters.
17
+ */
18
+ export declare function markdownCacheStats(): {
19
+ entries: number;
20
+ chars: number;
21
+ hits: number;
22
+ misses: number;
23
+ };
24
+ /** Whether a source can contain Markdown constructs; false preserves incremental plain-text wrapping.
25
+ * @param source - Complete text or a delta with its preceding line.
26
+ * @returns Whether Markdown lexing is required.
27
+ */
28
+ export declare function hasMarkdown(source: string): boolean;
29
+ /** Parse GFM and TeX into styled rows whose widths match the terminal viewport.
30
+ * @param source - Model or user message; remote terminal controls are removed.
31
+ * @param width - Available terminal columns.
32
+ * @returns Terminal rows, with literal source for invalid or unsupported rich blocks.
33
+ */
34
+ export declare function markdownRows(source: string, width: number): MarkdownRow[];
35
+ /** Render safe, offline HTML with Mermaid SVG images and MathJax-generated MathML.
36
+ * @param source - Markdown source, never trusted as executable HTML.
37
+ * @returns An HTML fragment that requires no scripts or remote assets.
38
+ */
39
+ export declare function markdownHtml(source: string): string;
@@ -0,0 +1,255 @@
1
+ /** Markdown becomes terminal rows before viewport indexing; source text remains in the transcript. */
2
+ import { Marked } from 'marked';
3
+ import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
4
+ import stringWidth from 'string-width';
5
+ import wrapAnsi from 'wrap-ansi';
6
+ import { stripVTControlCharacters } from 'node:util';
7
+ import { decodeHTML } from 'entities';
8
+ import { safeText } from "../transport/wire.js";
9
+ import { renderMath } from "./math.js";
10
+ const parser = new Marked({ gfm: true });
11
+ // Math is tokenized before Markdown escapes, but after fenced and inline code have claimed their source.
12
+ function mathToken(source, block) {
13
+ const match = block
14
+ ? /^(?:\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\])(?:[ \t]*\n|$)/.exec(source)
15
+ : /^(?:\$\$([^\n]+?)\$\$|\$(?![\s$])((?:\\.|[^$\n\\])+?)(?<!\s)\$(?!\d)|\\\(([^\n]+?)\\\)|\\\[([^\n]+?)\\\])/.exec(source);
16
+ if (!match)
17
+ return undefined;
18
+ return { type: 'math', raw: match[0], text: match.slice(1).find(value => value !== undefined),
19
+ display: block || source.startsWith('$$') || source.startsWith('\\[') };
20
+ }
21
+ for (const level of ['block', 'inline'])
22
+ parser.use({ extensions: [{
23
+ name: 'math', level,
24
+ start: source => { const index = source.search(level === 'block' ? /\$\$|\\\[/ : /\$|\\[([]/); return index < 0 ? undefined : index; },
25
+ tokenizer: source => mathToken(source, level === 'block'),
26
+ renderer: token => mathHtml(token),
27
+ }] });
28
+ const rendered = new Map();
29
+ let cachedChars = 0;
30
+ let cacheHits = 0;
31
+ let cacheMisses = 0;
32
+ function cached(key, render) {
33
+ const existing = rendered.get(key);
34
+ if (existing !== undefined) {
35
+ cacheHits++;
36
+ return existing;
37
+ }
38
+ cacheMisses++;
39
+ const result = render();
40
+ const size = key.length + result.length;
41
+ if (size <= 64 * 1024) {
42
+ while (rendered.size >= 128 || cachedChars + size > 256 * 1024) {
43
+ const oldest = rendered.keys().next().value;
44
+ cachedChars -= oldest.length + rendered.get(oldest).length;
45
+ rendered.delete(oldest);
46
+ }
47
+ rendered.set(key, result);
48
+ cachedChars += size;
49
+ }
50
+ return result;
51
+ }
52
+ /** Report the bounded math and diagram cache, so a memory sample can tell churn from retention.
53
+ * @returns Cached entries, their accounted characters, and lookup counters.
54
+ */
55
+ export function markdownCacheStats() {
56
+ return { entries: rendered.size, chars: cachedChars, hits: cacheHits, misses: cacheMisses };
57
+ }
58
+ function mathText(token) {
59
+ return cached(`math:${token.display}:${token.text}`, () => renderMath(token.text, token.display)?.text ?? token.raw.trimEnd());
60
+ }
61
+ function mathHtml(token) {
62
+ return renderMath(token.text, token.display)?.html ?? `<code>${escapeHtml(token.raw)}</code>`;
63
+ }
64
+ function style(text, open, close) {
65
+ return `\x1b[${open}m${text.replaceAll(`\x1b[${close}m`, `\x1b[${close}m\x1b[${open}m`)}\x1b[${close}m`;
66
+ }
67
+ function plain(text) { return safeText(text).replace(/\t/g, ' '); }
68
+ function inline(tokens) {
69
+ return tokens.map(token => {
70
+ switch (token.type) {
71
+ case 'strong': return style(inline(token.tokens), 1, 22);
72
+ case 'em': return style(inline(token.tokens), 3, 23);
73
+ case 'del': return style(inline(token.tokens), 9, 29);
74
+ case 'codespan': return style(plain(token.text), 7, 27);
75
+ case 'link': {
76
+ const label = inline(token.tokens);
77
+ const href = plain(token.href);
78
+ return style(label, 4, 24) + (stripVTControlCharacters(label) === href ? '' : ` (${href})`);
79
+ }
80
+ case 'image': return `${inline(token.tokens)} (${plain(token.href)})`;
81
+ case 'br': return '\n';
82
+ case 'math': return mathText(token);
83
+ case 'html': return /^<br\s*\/?\s*>$/i.test(token.raw) ? '\n' : plain(token.raw);
84
+ case 'escape': return plain(token.text);
85
+ default: return 'tokens' in token && token.tokens ? inline(token.tokens)
86
+ : plain(decodeHTML('text' in token && typeof token.text === 'string' ? token.text : token.raw));
87
+ }
88
+ }).join('');
89
+ }
90
+ function wrap(text, width, trim = true) {
91
+ return wrapAnsi(text, Math.max(1, width), { hard: true, trim }).split('\n');
92
+ }
93
+ function table(token, width) {
94
+ const cells = [token.header, ...token.rows].map(row => row.map(cell => inline(cell.tokens)));
95
+ const count = token.header.length;
96
+ const available = width - 3 * count - 1;
97
+ // Narrow screens show each record vertically instead of squeezing columns into unreadable fragments.
98
+ if (available < 12 * count) {
99
+ if (cells.length === 1)
100
+ return cells[0].flatMap(cell => wrap(style(cell, 1, 22), width));
101
+ return cells.slice(1).flatMap((row, index) => [
102
+ ...(index ? ['─'.repeat(Math.min(width, 20))] : []),
103
+ ...row.flatMap((cell, column) => wrap(`${cells[0][column] ? style(cells[0][column], 1, 22) + ': ' : ''}${cell}`, width)),
104
+ ]);
105
+ }
106
+ const widths = Array.from({ length: count }, (_, column) => Math.max(3, ...cells.map(row => Math.max(...row[column].split('\n').map(line => stringWidth(line))))));
107
+ while (widths.reduce((sum, value) => sum + value, 0) > available) {
108
+ const column = widths.indexOf(Math.max(...widths));
109
+ widths[column]--;
110
+ }
111
+ const border = (left, middle, right) => left + widths.map(size => '─'.repeat(size + 2)).join(middle) + right;
112
+ const output = [border('┌', '┬', '┐')];
113
+ cells.forEach((row, index) => {
114
+ const lines = row.map((cell, column) => wrap(index === 0 ? style(cell, 1, 22) : cell, widths[column]));
115
+ for (let line = 0; line < Math.max(...lines.map(cell => cell.length)); line++) {
116
+ output.push('│ ' + lines.map((cell, column) => {
117
+ const value = cell[line] ?? '';
118
+ const padding = widths[column] - stringWidth(value);
119
+ const align = token.align[column];
120
+ const before = align === 'right' ? padding : align === 'center' ? Math.floor(padding / 2) : 0;
121
+ return ' '.repeat(Math.max(0, before)) + value + ' '.repeat(Math.max(0, padding - before));
122
+ }).join(' │ ') + ' │');
123
+ }
124
+ if (index === 0)
125
+ output.push(border('├', '┼', '┤'));
126
+ });
127
+ output.push(border('└', '┴', '┘'));
128
+ return output;
129
+ }
130
+ function diagram(source, width) {
131
+ if (source.length > 16 * 1024)
132
+ return undefined;
133
+ try {
134
+ const text = cached(`mermaid:${source}`, () => {
135
+ // The ASCII renderer measures code units. Reserve one extra cell for each wide BMP character.
136
+ const padding = Array.from({ length: 256 }, (_, i) => String.fromCharCode(0xe000 + i)).find(c => !source.includes(c));
137
+ if (!padding)
138
+ throw new Error('No available diagram padding character');
139
+ const measured = [...source].map(c => c + padding.repeat(Math.max(0, stringWidth(c) - c.length))).join('');
140
+ return plain(renderMermaidASCII(measured, { colorMode: 'none' }).replaceAll(padding, ''));
141
+ });
142
+ const lines = text.trimEnd().split('\n').map(line => line.trimEnd());
143
+ return lines.every(line => stringWidth(line) <= width) ? lines : undefined;
144
+ }
145
+ catch {
146
+ return undefined; /* Unsupported or unfinished diagrams retain their source. */
147
+ }
148
+ }
149
+ function blocks(tokens, width) {
150
+ return tokens.flatMap(token => {
151
+ switch (token.type) {
152
+ case 'space': return [''];
153
+ case 'def': return [];
154
+ case 'heading': return wrap(style(inline(token.tokens), 1, 22), width);
155
+ case 'paragraph':
156
+ case 'text': return wrap(token.tokens ? inline(token.tokens) : plain(token.text), width);
157
+ case 'hr': return ['─'.repeat(width)];
158
+ case 'table': return table(token, width);
159
+ case 'blockquote': return blocks(token.tokens, Math.max(1, width - 2)).map(line => width > 2 ? `│ ${line}` : line);
160
+ case 'list': return token.items.flatMap((item, index) => {
161
+ const marker = item.task ? item.checked ? '[x] ' : '[ ] ' : token.ordered ? `${Number(token.start) + index}. ` : '• ';
162
+ const prefix = width > marker.length + 1 ? marker : '';
163
+ return blocks(item.tokens.filter(child => child.type !== 'checkbox'), Math.max(1, width - prefix.length))
164
+ .map((line, row) => `${row === 0 ? prefix : ' '.repeat(prefix.length)}${line}`);
165
+ });
166
+ case 'code': {
167
+ const language = plain(token.lang ?? '').split(/\s/)[0];
168
+ // An open fence is source, even when its current contents form a parseable partial diagram.
169
+ const closed = /(?:^|\n) {0,3}(`{3,}|~{3,})[ \t]*(?:\n)?$/.test(token.raw);
170
+ const graph = language === 'mermaid' && closed ? diagram(token.text, width) : undefined;
171
+ if (graph)
172
+ return graph;
173
+ if (['math', 'latex', 'tex', 'mathjax'].includes(language) && closed) {
174
+ return wrap(mathText({ type: 'math', text: token.text, raw: token.text, display: true }), width, false);
175
+ }
176
+ return [...wrap(`┌ ${language || 'code'}`, width, false), ...wrap(plain(token.text), width, false), '└'];
177
+ }
178
+ case 'math': return wrap(mathText(token), width, false);
179
+ default: return wrap(plain(token.raw), width);
180
+ }
181
+ });
182
+ }
183
+ // Decode only locally generated style codes after wrap-ansi has balanced styles across line breaks.
184
+ function row(text) {
185
+ const spans = [];
186
+ let current = {};
187
+ let start = 0;
188
+ const properties = { 1: 'bold', 3: 'italic', 4: 'underline', 7: 'inverse', 9: 'strikethrough',
189
+ 22: 'bold', 23: 'italic', 24: 'underline', 27: 'inverse', 29: 'strikethrough' };
190
+ for (const match of text.matchAll(/\x1b\[(\d+)m/g)) {
191
+ if (match.index > start)
192
+ spans.push({ text: text.slice(start, match.index), ...current });
193
+ const code = Number(match[1]);
194
+ if (code === 0)
195
+ current = {};
196
+ else if (code in properties)
197
+ current[properties[code]] = code < 20;
198
+ start = match.index + match[0].length;
199
+ }
200
+ if (start < text.length)
201
+ spans.push({ text: text.slice(start), ...current });
202
+ return { text: spans.map(span => span.text).join(''), ...(text.includes('\x1b') ? { spans } : {}) };
203
+ }
204
+ /** Whether a source can contain Markdown constructs; false preserves incremental plain-text wrapping.
205
+ * @param source - Complete text or a delta with its preceding line.
206
+ * @returns Whether Markdown lexing is required.
207
+ */
208
+ export function hasMarkdown(source) {
209
+ return /[*_`~\[\]<>|$\\#&\t]|(?:^|\n)(?: {4}| {0,3}(?:[-+=]{1,}|\d+[.)])(?:\s|$))|\n\s*\n/.test(source);
210
+ }
211
+ /** Parse GFM and TeX into styled rows whose widths match the terminal viewport.
212
+ * @param source - Model or user message; remote terminal controls are removed.
213
+ * @param width - Available terminal columns.
214
+ * @returns Terminal rows, with literal source for invalid or unsupported rich blocks.
215
+ */
216
+ export function markdownRows(source, width) {
217
+ width = Math.max(1, width);
218
+ if (!hasMarkdown(source))
219
+ return wrap(plain(source), width).map(row);
220
+ return blocks(parser.lexer(plain(source)), width).map(row);
221
+ }
222
+ function escapeHtml(text) {
223
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
224
+ }
225
+ function safeLink(href) {
226
+ return /^(?:https?:|mailto:)/i.test(href) && !/[\u0000-\u0020]/.test(href) ? escapeHtml(href) : undefined;
227
+ }
228
+ parser.use({ renderer: {
229
+ html: token => escapeHtml(token.text),
230
+ link(token) {
231
+ const label = this.parser.parseInline(token.tokens);
232
+ const href = safeLink(token.href);
233
+ return href ? `<a href="${href}" rel="noreferrer">${label}</a>` : `${label} (${escapeHtml(token.href)})`;
234
+ },
235
+ image: token => `${escapeHtml(token.text)} (${escapeHtml(token.href)})`,
236
+ code(token) {
237
+ const language = (token.lang ?? '').split(/\s/)[0];
238
+ if (language === 'mermaid' && token.text.length <= 16 * 1024) {
239
+ try {
240
+ const svg = renderMermaidSVG(token.text);
241
+ // SVG image documents cannot run script or load external resources in the exported page.
242
+ return `<img alt="Mermaid diagram" src="data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}">`;
243
+ }
244
+ catch { /* Unsupported Mermaid syntax remains readable in a code block. */ }
245
+ }
246
+ if (['math', 'latex', 'tex', 'mathjax'].includes(language ?? ''))
247
+ return mathHtml({ type: 'math', text: token.text, raw: token.text, display: true });
248
+ return `<pre><code>${escapeHtml(token.text)}</code></pre>`;
249
+ },
250
+ } });
251
+ /** Render safe, offline HTML with Mermaid SVG images and MathJax-generated MathML.
252
+ * @param source - Markdown source, never trusted as executable HTML.
253
+ * @returns An HTML fragment that requires no scripts or remote assets.
254
+ */
255
+ export function markdownHtml(source) { return parser.parse(safeText(source), { async: false }); }
@@ -0,0 +1,11 @@
1
+ import '@mathjax/src/js/input/tex/base/BaseConfiguration.js';
2
+ import '@mathjax/src/js/input/tex/ams/AmsConfiguration.js';
3
+ /** Convert one TeX expression, without extension autoloads, network access or shared macro state.
4
+ * @param source - TeX without delimiters.
5
+ * @param display - Block rather than inline mathematics.
6
+ * @returns Unicode approximation and MathJax-generated MathML, or undefined for invalid TeX.
7
+ */
8
+ export declare function renderMath(source: string, display: boolean): {
9
+ text: string;
10
+ html: string;
11
+ } | undefined;