mixdog 0.9.65 → 0.9.66

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 (31) hide show
  1. package/package.json +1 -1
  2. package/src/app.mjs +2 -2
  3. package/src/cli.mjs +1 -1
  4. package/src/repl.mjs +72 -6
  5. package/src/runtime/agent/orchestrator/session/context-utils.mjs +76 -21
  6. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +45 -4
  7. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +11 -1
  8. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +36 -13
  9. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +79 -18
  10. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +4 -0
  11. package/src/runtime/agent/orchestrator/session/store.mjs +37 -2
  12. package/src/session-runtime/context-status.mjs +2 -1
  13. package/src/session-runtime/provider-request-tools.mjs +6 -0
  14. package/src/session-runtime/tool-catalog-schema.mjs +5 -1
  15. package/src/tui/app/transcript-window.mjs +16 -0
  16. package/src/tui/app/use-transcript-window.mjs +36 -1
  17. package/src/tui/components/Markdown.jsx +15 -1
  18. package/src/tui/components/Message.jsx +1 -1
  19. package/src/tui/components/PromptInput.jsx +7 -0
  20. package/src/tui/dist/index.mjs +307 -79
  21. package/src/tui/engine/frame-batched-store.mjs +5 -3
  22. package/src/tui/engine/render-timing.mjs +28 -0
  23. package/src/tui/engine/session-api-ext.mjs +7 -0
  24. package/src/tui/engine/tui-steering-persist.mjs +25 -4
  25. package/src/tui/engine.mjs +9 -1
  26. package/src/tui/index.jsx +11 -3
  27. package/src/tui/markdown/measure-rendered-rows.mjs +28 -16
  28. package/src/tui/markdown/render-ansi.mjs +34 -1
  29. package/src/tui/markdown/stream-fence.mjs +44 -7
  30. package/src/tui/markdown/streaming-markdown.mjs +95 -27
  31. package/src/ui/stream-finalize.mjs +45 -0
@@ -12,6 +12,8 @@ export function createFrameBatchedStorePublisher({
12
12
  setTimer = setTimeout,
13
13
  clearTimer = clearTimeout,
14
14
  enqueueMicrotask = queueMicrotask,
15
+ scheduleFrame = (callback, delay) => setTimer(callback, delay),
16
+ cancelFrame = (handle) => clearTimer(handle),
15
17
  }) {
16
18
  let timer = null;
17
19
  let emitPending = false;
@@ -20,7 +22,7 @@ export function createFrameBatchedStorePublisher({
20
22
 
21
23
  const flush = () => {
22
24
  if (timer !== null) {
23
- clearTimer(timer);
25
+ cancelFrame(timer);
24
26
  timer = null;
25
27
  }
26
28
  immediatePending = false;
@@ -43,7 +45,7 @@ export function createFrameBatchedStorePublisher({
43
45
  const emit = () => {
44
46
  emitPending = true;
45
47
  if (timer !== null || isDisposed()) return;
46
- timer = setTimer(flush, frameMs);
48
+ timer = scheduleFrame(flush, frameMs);
47
49
  timer?.unref?.();
48
50
  };
49
51
 
@@ -64,7 +66,7 @@ export function createFrameBatchedStorePublisher({
64
66
  // Disposal is itself an immediate boundary: publish the final pending
65
67
  // snapshot once, while subscribers are still present, then disarm.
66
68
  if (emitPending && !isDisposed()) flush();
67
- else if (timer !== null) clearTimer(timer);
69
+ else if (timer !== null) cancelFrame(timer);
68
70
  timer = null;
69
71
  emitPending = false;
70
72
  structureChangePending = false;
@@ -20,11 +20,38 @@
20
20
  const RENDER_ACK_FALLBACK_MS = 32;
21
21
  const RENDER_ACK_HANG_GUARD_MS = 250;
22
22
  const RENDER_SETTLE_IDLE_MS = 64;
23
+ export const TUI_RENDER_FPS = 60;
24
+ // Ink uses Math.ceil(1000 / maxFps); share that exact cadence with the store.
25
+ export const TUI_FRAME_MS = Math.ceil(1000 / TUI_RENDER_FPS);
23
26
 
24
27
  // Back-compat alias: previously the fixed wait duration, now the fallback only.
25
28
 
26
29
  let pendingRenderAcks = [];
27
30
  let renderAckSeq = 0;
31
+ let lastRenderFrameAt = 0;
32
+
33
+ export const renderFrameDelay = (
34
+ lastFrameAt,
35
+ currentTime,
36
+ frameMs = TUI_FRAME_MS,
37
+ ) => {
38
+ if (!(lastFrameAt > 0)) return frameMs;
39
+ return Math.max(0, frameMs - Math.max(0, currentTime - lastFrameAt));
40
+ };
41
+
42
+ export const scheduleRenderAlignedStoreFlush = (
43
+ callback,
44
+ frameMs = TUI_FRAME_MS,
45
+ ) => {
46
+ const timer = setTimeout(
47
+ callback,
48
+ renderFrameDelay(lastRenderFrameAt, performance.now(), frameMs),
49
+ );
50
+ timer.unref?.();
51
+ return timer;
52
+ };
53
+
54
+ export const cancelRenderAlignedStoreFlush = (timer) => clearTimeout(timer);
28
55
 
29
56
  /**
30
57
  * Called by the Ink onRender hook once per painted frame. The sequence is
@@ -38,6 +65,7 @@ export const scheduleRenderFrameAck = () => {
38
65
  };
39
66
 
40
67
  const notifyRenderFrame = (seq = ++renderAckSeq) => {
68
+ lastRenderFrameAt = performance.now();
41
69
  if (pendingRenderAcks.length === 0) return;
42
70
  const acks = pendingRenderAcks;
43
71
  pendingRenderAcks = [];
@@ -745,6 +745,13 @@ export function createEngineApiB(bag) {
745
745
  listSessions: (options) => {
746
746
  return runtime.listSessions(options);
747
747
  },
748
+ // Desktop sidebar watcher hook: EngineHost fs.watches this directory so
749
+ // heartbeat sidecar create/delete pushes instant working/dot updates.
750
+ // Without it the watcher silently no-ops and the sidebar falls back to
751
+ // the 60s safety poll (user: spinner kept spinning after the turn ended).
752
+ sessionStoreDir: () => {
753
+ try { return runtime.sessionStoreDir?.() || null; } catch { return null; }
754
+ },
748
755
  deleteSession: async (id) => {
749
756
  if (getState().commandBusy) return false;
750
757
  const deletingCurrent = String(runtime.session?.id || getState().sessionId || '') === String(id || '');
@@ -8,6 +8,11 @@ import { promptContentText } from './queue-helpers.mjs';
8
8
 
9
9
  const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
10
10
  const PENDING_MESSAGES_MODE = 0o600;
11
+ // Restore window for persisted busy-input steering rows. Rows older than this
12
+ // are leftovers of a session that ended long ago — restoring them into a fresh
13
+ // TUI boot reads as a surprise self-injection (user report), so they are
14
+ // discarded at drain time instead.
15
+ const STALE_STEERING_RESTORE_TTL_MS = 30 * 60 * 1000;
11
16
 
12
17
  // Serialize this UI process's own steering writes so append→drop→drain keep
13
18
  // their issue order even though each now waits on the lock asynchronously.
@@ -44,7 +49,9 @@ function normalizeTuiSteeringQueueEntry(entry) {
44
49
  if (typeof entry.text === 'string' && entry.text.trim()) {
45
50
  const text = entry.text.trim();
46
51
  const id = typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : null;
47
- return id ? { id, text } : text;
52
+ if (!id) return text;
53
+ const at = Number(entry.at);
54
+ return Number.isFinite(at) && at > 0 ? { id, text, at } : { id, text };
48
55
  }
49
56
  return null;
50
57
  }
@@ -116,7 +123,7 @@ export function appendTuiSteeringPersist(leadSessionId, entry) {
116
123
  const key = tuiSteeringSessionKey(leadSessionId);
117
124
  if (!key) return Promise.resolve();
118
125
  if (!entry.steeringPersistId) entry.steeringPersistId = newSteeringPersistId();
119
- const record = { id: entry.steeringPersistId, text };
126
+ const record = { id: entry.steeringPersistId, text, at: Date.now() };
120
127
  return _serialize(async () => {
121
128
  try {
122
129
  await updateJsonAtomic(pendingMessagesPath(), (raw) => {
@@ -187,12 +194,23 @@ export function drainTuiSteeringPersist(leadSessionId) {
187
194
  if (!key) return Promise.resolve([]);
188
195
  return _serialize(async () => {
189
196
  let drained = [];
197
+ let droppedStale = 0;
190
198
  try {
191
199
  await updateJsonAtomic(pendingMessagesPath(), (raw) => {
192
200
  const next = normalizePendingStore(raw);
193
201
  const q = Array.isArray(next.sessions[key]) ? next.sessions[key] : [];
194
- drained = q.map(drainedRowToRestore).filter(Boolean);
195
- if (drained.length === 0) return undefined;
202
+ // Per-row timestamps age precisely; legacy rows without one age from
203
+ // the session key's last touch time.
204
+ const now = Date.now();
205
+ const touchedAt = Number(next.sessionTouchedAt?.[key]) || 0;
206
+ const fresh = q.filter((row) => {
207
+ const at = Number(row?.at) || touchedAt;
208
+ const stale = at > 0 && (now - at) > STALE_STEERING_RESTORE_TTL_MS;
209
+ if (stale) droppedStale += 1;
210
+ return !stale;
211
+ });
212
+ drained = fresh.map(drainedRowToRestore).filter(Boolean);
213
+ if (drained.length === 0 && droppedStale === 0) return undefined;
196
214
  delete next.sessions[key];
197
215
  if (next.sessionTouchedAt) delete next.sessionTouchedAt[key];
198
216
  next.updatedAt = Date.now();
@@ -201,6 +219,9 @@ export function drainTuiSteeringPersist(leadSessionId) {
201
219
  } catch (err) {
202
220
  try { process.stderr.write(`[tui] steering-queue drain failed sessionId=${leadSessionId}: ${err?.message || err}\n`); } catch {}
203
221
  }
222
+ if (droppedStale > 0) {
223
+ try { process.stderr.write(`[tui] dropped ${droppedStale} stale steering row(s) sessionId=${leadSessionId}\n`); } catch {}
224
+ }
204
225
  return drained;
205
226
  });
206
227
  }
@@ -77,7 +77,12 @@ import {
77
77
  import {
78
78
  resolveTuiRuntimeNotificationDelivery,
79
79
  } from './engine/notification-plan.mjs';
80
- import { yieldToRenderer } from './engine/render-timing.mjs';
80
+ import {
81
+ TUI_FRAME_MS,
82
+ cancelRenderAlignedStoreFlush,
83
+ scheduleRenderAlignedStoreFlush,
84
+ yieldToRenderer,
85
+ } from './engine/render-timing.mjs';
81
86
  import {
82
87
  aggregateRawResult,
83
88
  aggregateBucketForCategory,
@@ -273,6 +278,9 @@ export async function createEngineSession({
273
278
  },
274
279
  listeners,
275
280
  isDisposed: () => flags.disposed,
281
+ frameMs: TUI_FRAME_MS,
282
+ scheduleFrame: scheduleRenderAlignedStoreFlush,
283
+ cancelFrame: cancelRenderAlignedStoreFlush,
276
284
  });
277
285
  const emit = publisher.emit;
278
286
  const flushEmit = publisher.flush;
package/src/tui/index.jsx CHANGED
@@ -14,7 +14,7 @@ import { format } from 'node:util';
14
14
  import { App } from './App.jsx';
15
15
  import { cancelPendingMouseTrackingRestores } from './app/use-mouse-input.mjs';
16
16
  import { createEngineSession } from './engine.mjs';
17
- import { scheduleRenderFrameAck } from './engine/render-timing.mjs';
17
+ import { scheduleRenderFrameAck, TUI_RENDER_FPS } from './engine/render-timing.mjs';
18
18
  import { installProcessSignalCleanup } from '../runtime/shared/process-shutdown.mjs';
19
19
  import { finishProcessLifecycle } from '../runtime/shared/process-lifecycle.mjs';
20
20
  import { rgbSgr } from '../ui/ansi.mjs';
@@ -481,6 +481,12 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
481
481
 
482
482
  process.on('exit', restoreTerminal);
483
483
 
484
+ // Engine startup is independent from palette selection. Start it now and
485
+ // overlap its runtime import/config work with the theme read, while still
486
+ // awaiting the theme before the first React frame.
487
+ const storeOutcomePromise = createEngineSession({ provider, model, toolMode, remote })
488
+ .then((store) => ({ store, error: null }), (error) => ({ store: null, error }));
489
+
484
490
  // Apply the persisted UI theme (ui.theme in mixdog-config.json) before the
485
491
  // first React frame so the whole tree paints in the chosen palette. Unknown
486
492
  // or missing values leave the default Mixdog dark palette in place; a failed
@@ -496,7 +502,9 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
496
502
 
497
503
  let store;
498
504
  try {
499
- store = await createEngineSession({ provider, model, toolMode, remote });
505
+ const outcome = await storeOutcomePromise;
506
+ if (outcome.error) throw outcome.error;
507
+ store = outcome.store;
500
508
  bootProfile('store:ready', { ms: (performance.now() - startedAt).toFixed(1) });
501
509
  } catch (error) {
502
510
  splash.stop();
@@ -590,7 +598,7 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
590
598
  // its clear-frame → write → relative re-render dance, which scrolls the
591
599
  // alt screen one line per stray console line (the streaming newline
592
600
  // bounce). See installTuiConsoleGuard.
593
- const instance = render(<App store={store} forceOnboarding={forceOnboarding === true} />, { exitOnCtrlC: false, maxFps: 60, incrementalRendering: true, patchConsole: false, onRender: makeRenderProfiler() });
601
+ const instance = render(<App store={store} forceOnboarding={forceOnboarding === true} />, { exitOnCtrlC: false, maxFps: TUI_RENDER_FPS, incrementalRendering: true, patchConsole: false, onRender: makeRenderProfiler() });
594
602
  bootProfile('render:mounted', { ms: (performance.now() - startedAt).toFixed(1) });
595
603
  const { waitUntilExit } = instance;
596
604
  // [mixdog fork] Hand the ink renderer's drag-selection setter to the store so
@@ -85,15 +85,19 @@ function measureStreamingPartsUncached(parts, columns) {
85
85
  }
86
86
  let rows = 0;
87
87
  let childCount = 0;
88
- if (parts.stablePrefix) {
89
- rows += measureMarkdownRenderedRows(parts.stablePrefix, columns, { trimPartialFences: false });
88
+ const stableChunks = parts.stableChunks?.length
89
+ ? parts.stableChunks
90
+ : parts.stablePrefix ? [parts.stablePrefix] : [];
91
+ for (const chunk of stableChunks) {
92
+ if (childCount > 0) rows += 1;
93
+ rows += measureMarkdownRenderedRows(chunk, columns, { trimPartialFences: false });
90
94
  childCount += 1;
91
95
  }
92
96
  if (parts.unstableSuffix) {
97
+ if (childCount > 0) rows += 1;
93
98
  rows += measureMarkdownRenderedRows(parts.unstableForRender, columns, { trimPartialFences: true });
94
99
  childCount += 1;
95
100
  }
96
- if (childCount === 2) rows += 1;
97
101
  return childCount === 0 ? 1 : Math.max(1, rows);
98
102
  }
99
103
 
@@ -166,30 +170,38 @@ export function measureStreamingMarkdownRenderedRows(text, columns, streamKey) {
166
170
  let rows = 0;
167
171
  let childCount = 0;
168
172
  let stableRows = 0;
169
- if (parts.stablePrefix) {
170
- // The renderer mounts stablePrefix and unstableSuffix as separate Markdown
171
- // children. An unchanged stable child is therefore safe to reuse exactly;
172
- // only the independently-rendered live suffix needs measuring on append.
173
- stableRows = cached
174
- && cached.mode === 'markdown'
175
- && cached.columns === columns
176
- && cached.stablePrefix === parts.stablePrefix
177
- ? cached.stableRows
178
- : measureMarkdownRenderedRows(parts.stablePrefix, columns, { trimPartialFences: false });
179
- rows += stableRows;
180
- childCount += 1;
173
+ const stableChunks = parts.stableChunks?.length
174
+ ? parts.stableChunks
175
+ : parts.stablePrefix ? [parts.stablePrefix] : [];
176
+ const reusableChunks = cached
177
+ && cached.mode === 'markdown'
178
+ && cached.columns === columns
179
+ && Array.isArray(cached.stableChunks)
180
+ && cached.stableChunks.length <= stableChunks.length
181
+ && cached.stableChunks.every((chunk, index) => chunk === stableChunks[index]);
182
+ let measuredStableChunks = 0;
183
+ if (reusableChunks) {
184
+ stableRows = cached.stableRows;
185
+ measuredStableChunks = cached.stableChunks.length;
186
+ }
187
+ for (let index = measuredStableChunks; index < stableChunks.length; index += 1) {
188
+ if (index > 0) stableRows += 1;
189
+ stableRows += measureMarkdownRenderedRows(stableChunks[index], columns, { trimPartialFences: false });
181
190
  }
191
+ rows += stableRows;
192
+ childCount = stableChunks.length;
182
193
  if (parts.unstableSuffix) {
194
+ if (childCount > 0) rows += 1;
183
195
  rows += measureMarkdownRenderedRows(parts.unstableForRender, columns, { trimPartialFences: true });
184
196
  childCount += 1;
185
197
  }
186
- if (childCount === 2) rows += 1;
187
198
  const measuredRows = childCount === 0 ? 1 : Math.max(1, rows);
188
199
  cacheStreamingRows(key, {
189
200
  text: value,
190
201
  columns,
191
202
  mode: 'markdown',
192
203
  stablePrefix: parts.stablePrefix,
204
+ stableChunks: stableChunks.slice(),
193
205
  stableRows,
194
206
  rows: measuredRows,
195
207
  });
@@ -16,9 +16,14 @@
16
16
  import { marked } from 'marked';
17
17
  import { formatToken } from './format-token.mjs';
18
18
  import { trimPartialClosingFences, findOpenFenceStart } from './stream-fence.mjs';
19
+ import { getThemeVersion } from '../theme.mjs';
19
20
 
20
21
  const TOKEN_CACHE_MAX = 500;
21
22
  const tokenCache = new Map();
23
+ const renderedSegmentCache = [];
24
+ const RENDERED_SEGMENT_CACHE_MAX = 12;
25
+ const RENDERED_SEGMENT_CACHE_MAX_CHARS = 256 * 1024;
26
+ let renderedSegmentCacheChars = 0;
22
27
  const MD_SYNTAX_RE = /[#*`|[>\-_~]|\n\n|^\d+\. |\n\d+\. /;
23
28
 
24
29
  let _configured = false;
@@ -99,8 +104,24 @@ function lexMarkdown(content, { trimPartialFences = false } = {}) {
99
104
  * Blank-edge-only ansi segments are dropped (mirrors the component's pushAnsi).
100
105
  */
101
106
  export function renderTokenAnsiSegments(content, opts = {}) {
102
- const tokens = lexMarkdown(content, opts);
107
+ const text = String(content ?? '');
103
108
  const width = Number(opts.width) || 0;
109
+ const trimPartialFences = opts.trimPartialFences === true;
110
+ const themeVersion = getThemeVersion();
111
+ for (let index = renderedSegmentCache.length - 1; index >= 0; index -= 1) {
112
+ const entry = renderedSegmentCache[index];
113
+ if (
114
+ entry.text === text
115
+ && entry.width === width
116
+ && entry.trimPartialFences === trimPartialFences
117
+ && entry.themeVersion === themeVersion
118
+ ) {
119
+ renderedSegmentCache.splice(index, 1);
120
+ renderedSegmentCache.push(entry);
121
+ return entry.segments;
122
+ }
123
+ }
124
+ const tokens = lexMarkdown(text, opts);
104
125
  const segments = [];
105
126
  for (const token of tokens) {
106
127
  if (token.type === 'table') {
@@ -113,5 +134,17 @@ export function renderTokenAnsiSegments(content, opts = {}) {
113
134
  segments.push({ type: 'ansi', ansi, token });
114
135
  }
115
136
  }
137
+ if (text.length <= RENDERED_SEGMENT_CACHE_MAX_CHARS) {
138
+ const entry = { text, width, trimPartialFences, themeVersion, segments };
139
+ renderedSegmentCache.push(entry);
140
+ renderedSegmentCacheChars += text.length;
141
+ while (
142
+ renderedSegmentCache.length > RENDERED_SEGMENT_CACHE_MAX
143
+ || renderedSegmentCacheChars > RENDERED_SEGMENT_CACHE_MAX_CHARS
144
+ ) {
145
+ const removed = renderedSegmentCache.shift();
146
+ renderedSegmentCacheChars -= removed?.text.length || 0;
147
+ }
148
+ }
116
149
  return segments;
117
150
  }
@@ -55,6 +55,8 @@ export function trimPartialClosingFences(tokens) {
55
55
  */
56
56
  const OPEN_FENCE_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
57
57
  const CLOSE_FENCE_RE = /^ {0,3}([`~]+)[ \t]*$/;
58
+ const OPEN_FENCE_SCAN_LRU_MAX = 32;
59
+ const openFenceScanByStreamKey = new Map();
58
60
 
59
61
  function isClosingFence(line, char, openLen) {
60
62
  const m = CLOSE_FENCE_RE.exec(line);
@@ -66,12 +68,14 @@ function isClosingFence(line, char, openLen) {
66
68
  return run.startsWith(char.repeat(openLen));
67
69
  }
68
70
 
69
- export function findOpenFenceStart(text) {
70
- const value = String(text ?? '');
71
- let open = null;
72
- let start = 0;
73
- for (let i = 0; i <= value.length; i++) {
71
+ function scanOpenFence(value, startAt = 0, initialOpen = null) {
72
+ let open = initialOpen;
73
+ let start = startAt;
74
+ const checkpointIndex = value.lastIndexOf('\n') + 1;
75
+ let openBeforeCheckpoint = start === checkpointIndex ? open : null;
76
+ for (let i = startAt; i <= value.length; i++) {
74
77
  if (i !== value.length && value[i] !== '\n') continue;
78
+ if (start === checkpointIndex) openBeforeCheckpoint = open;
75
79
  const line = value.slice(start, i);
76
80
  if (!open) {
77
81
  const m = OPEN_FENCE_RE.exec(line);
@@ -88,7 +92,40 @@ export function findOpenFenceStart(text) {
88
92
  }
89
93
  start = i + 1;
90
94
  }
95
+ return { open, checkpointIndex, openBeforeCheckpoint };
96
+ }
97
+
98
+ function touchOpenFenceScan(key, entry) {
99
+ if (openFenceScanByStreamKey.has(key)) openFenceScanByStreamKey.delete(key);
100
+ openFenceScanByStreamKey.set(key, entry);
101
+ while (openFenceScanByStreamKey.size > OPEN_FENCE_SCAN_LRU_MAX) {
102
+ const oldest = openFenceScanByStreamKey.keys().next().value;
103
+ if (oldest === undefined) break;
104
+ openFenceScanByStreamKey.delete(oldest);
105
+ }
106
+ }
107
+
108
+ export function resetOpenFenceScan(streamKey) {
109
+ if (streamKey == null || streamKey === '') return;
110
+ openFenceScanByStreamKey.delete(String(streamKey));
111
+ }
112
+
113
+ export function resetAllOpenFenceScans() {
114
+ openFenceScanByStreamKey.clear();
115
+ }
116
+
117
+ export function findOpenFenceStart(text, streamKey = null) {
118
+ const value = String(text ?? '');
119
+ const key = streamKey == null || streamKey === '' ? null : String(streamKey);
120
+ const cached = key ? openFenceScanByStreamKey.get(key) : null;
121
+ if (cached?.text === value) return cached.result;
122
+ const scanned = cached && value.startsWith(cached.text)
123
+ ? scanOpenFence(value, cached.checkpointIndex, cached.openBeforeCheckpoint)
124
+ : scanOpenFence(value);
91
125
  // Only fast-path unambiguously top-level (column-0) fences.
92
- if (!open || open.indent !== 0) return null;
93
- return { index: open.index, lang: open.lang };
126
+ const result = !scanned.open || scanned.open.indent !== 0
127
+ ? null
128
+ : { index: scanned.open.index, lang: scanned.open.lang };
129
+ if (key) touchOpenFenceScan(key, { text: value, ...scanned, result });
130
+ return result;
94
131
  }
@@ -4,10 +4,17 @@
4
4
  */
5
5
  import { marked } from 'marked';
6
6
  import { configureMarked, hasMarkdownSyntax } from './render-ansi.mjs';
7
- import { trimPartialClosingFences, findOpenFenceStart } from './stream-fence.mjs';
7
+ import {
8
+ trimPartialClosingFences,
9
+ findOpenFenceStart,
10
+ resetAllOpenFenceScans,
11
+ resetOpenFenceScan,
12
+ } from './stream-fence.mjs';
8
13
  import { displayWidth } from '../display-width.mjs';
9
14
 
10
15
  const stablePrefixByStreamKey = new Map();
16
+ const markdownSyntaxByStreamKey = new Map();
17
+ const plainWindowSyntaxByStreamKey = new Map();
11
18
  // Reuse the current normalized-text split across measure → render → harvest.
12
19
  const resolvedPartsByStreamKey = new Map();
13
20
  const STABLE_PREFIX_LRU_MAX = 32;
@@ -17,48 +24,92 @@ export function streamingLayoutText(text) {
17
24
  return String(text ?? '').replace(/^\n+|\n+$/g, '');
18
25
  }
19
26
 
20
- export function windowPlainStreamingText(text, columns, maxRows) {
27
+ export function windowPlainStreamingText(text, columns, maxRows, streamKey = null) {
21
28
  const value = streamingLayoutText(text);
22
29
  const rowBudget = Math.max(0, Math.floor(Number(maxRows) || 0));
23
- if (!value || rowBudget <= 0 || hasMarkdownSyntax(value)) return value;
24
- const lines = value.split('\n');
30
+ const key = streamKey == null || streamKey === '' ? null : String(streamKey);
31
+ if (!value || rowBudget <= 0 || cachedStreamingHasMarkdownSyntax(
32
+ plainWindowSyntaxByStreamKey,
33
+ value,
34
+ key,
35
+ )) return value;
25
36
  const width = Math.max(1, Math.floor(Number(columns) || 80));
26
37
  let rows = 0;
27
- let start = lines.length;
28
- while (start > 0) {
29
- const line = lines[start - 1];
38
+ let end = value.length;
39
+ let start = end;
40
+ while (end >= 0) {
41
+ const newline = value.lastIndexOf('\n', end - 1);
42
+ const lineStart = newline + 1;
43
+ const line = value.slice(lineStart, end);
30
44
  const lineRows = Math.max(1, Math.ceil(displayWidth(line) / width));
31
45
  if (rows > 0 && rows + lineRows > rowBudget) break;
32
46
  rows += lineRows;
33
- start -= 1;
34
- if (rows >= rowBudget) break;
47
+ start = lineStart;
48
+ if (rows >= rowBudget || newline < 0) break;
49
+ end = newline;
35
50
  }
36
- return start > 0 ? lines.slice(start).join('\n') : value;
51
+ return start > 0 ? value.slice(start) : value;
37
52
  }
38
53
 
39
54
  function isWhitespaceOnlyText(text) {
40
55
  return !String(text ?? '').trim();
41
56
  }
42
57
 
43
- function touchStablePrefixKey(key, value) {
58
+ function touchLruKey(cache, key, value) {
44
59
  if (!key) return;
45
- if (stablePrefixByStreamKey.has(key)) stablePrefixByStreamKey.delete(key);
46
- stablePrefixByStreamKey.set(key, value);
47
- while (stablePrefixByStreamKey.size > STABLE_PREFIX_LRU_MAX) {
48
- const oldest = stablePrefixByStreamKey.keys().next().value;
60
+ if (cache.has(key)) cache.delete(key);
61
+ cache.set(key, value);
62
+ while (cache.size > STABLE_PREFIX_LRU_MAX) {
63
+ const oldest = cache.keys().next().value;
49
64
  if (oldest === undefined) break;
50
- stablePrefixByStreamKey.delete(oldest);
65
+ cache.delete(oldest);
51
66
  }
52
67
  }
53
68
 
69
+ function touchStablePrefixKey(key, value) {
70
+ touchLruKey(stablePrefixByStreamKey, key, value);
71
+ }
72
+
54
73
  function getStablePrefixKey(key) {
55
- if (!key || !stablePrefixByStreamKey.has(key)) return '';
74
+ if (!key || !stablePrefixByStreamKey.has(key)) return { text: '', chunks: [] };
56
75
  const value = stablePrefixByStreamKey.get(key);
57
- stablePrefixByStreamKey.delete(key);
58
- stablePrefixByStreamKey.set(key, value);
76
+ touchStablePrefixKey(key, value);
59
77
  return value;
60
78
  }
61
79
 
80
+ function stableStateForText(text, previous) {
81
+ if (!text) return { text: '', chunks: [] };
82
+ if (text.startsWith(previous.text)) {
83
+ const appended = text.substring(previous.text.length);
84
+ return appended
85
+ ? { text, chunks: [...previous.chunks, appended] }
86
+ : previous;
87
+ }
88
+ return { text, chunks: [text] };
89
+ }
90
+
91
+ function cachedStreamingHasMarkdownSyntax(cache, text, key) {
92
+ if (!key) return hasMarkdownSyntax(text);
93
+ const previous = cache.get(key);
94
+ let value;
95
+ if (previous && text.startsWith(previous.text)) {
96
+ if (previous.value) {
97
+ value = true;
98
+ } else {
99
+ const lineStart = previous.text.lastIndexOf('\n') + 1;
100
+ value = hasMarkdownSyntax(text.substring(Math.max(0, lineStart - 1)));
101
+ }
102
+ } else {
103
+ value = hasMarkdownSyntax(text);
104
+ }
105
+ touchLruKey(cache, key, { text, value });
106
+ return value;
107
+ }
108
+
109
+ function streamingHasMarkdownSyntax(text, key) {
110
+ return cachedStreamingHasMarkdownSyntax(markdownSyntaxByStreamKey, text, key);
111
+ }
112
+
62
113
  function getResolvedPartsKey(key, text) {
63
114
  if (!key) return null;
64
115
  const entry = resolvedPartsByStreamKey.get(key);
@@ -136,12 +187,18 @@ export function resetStreamingMarkdownStablePrefix(streamKey) {
136
187
  if (streamKey == null || streamKey === '') return;
137
188
  const key = String(streamKey);
138
189
  stablePrefixByStreamKey.delete(key);
190
+ markdownSyntaxByStreamKey.delete(key);
191
+ plainWindowSyntaxByStreamKey.delete(key);
139
192
  resolvedPartsByStreamKey.delete(key);
193
+ resetOpenFenceScan(key);
140
194
  }
141
195
 
142
196
  export function resetAllStreamingMarkdownStablePrefixes() {
143
197
  stablePrefixByStreamKey.clear();
198
+ markdownSyntaxByStreamKey.clear();
199
+ plainWindowSyntaxByStreamKey.clear();
144
200
  resolvedPartsByStreamKey.clear();
201
+ resetAllOpenFenceScans();
145
202
  }
146
203
 
147
204
  export function resolveStreamingMarkdownParts(text, streamKey) {
@@ -155,42 +212,47 @@ export function resolveStreamingMarkdownParts(text, streamKey) {
155
212
  return cacheResolvedPartsKey(key, t, {
156
213
  plain: true,
157
214
  stablePrefix: '',
215
+ stableChunks: [],
158
216
  unstableSuffix: '',
159
217
  unstableForRender: '',
160
218
  });
161
219
  }
162
220
 
163
- if (!hasMarkdownSyntax(t)) {
221
+ if (!streamingHasMarkdownSyntax(t, key)) {
164
222
  if (key) stablePrefixByStreamKey.delete(key);
165
223
  return cacheResolvedPartsKey(key, t, {
166
224
  plain: true,
167
225
  stablePrefix: '',
226
+ stableChunks: [],
168
227
  unstableSuffix: t,
169
228
  unstableForRender: t,
170
229
  });
171
230
  }
172
231
 
173
- let stablePrefix = key ? getStablePrefixKey(key) : '';
174
- if (!t.startsWith(stablePrefix)) {
175
- stablePrefix = '';
232
+ let stableState = key ? getStablePrefixKey(key) : { text: '', chunks: [] };
233
+ if (!t.startsWith(stableState.text)) {
234
+ stableState = { text: '', chunks: [] };
176
235
  }
236
+ let stablePrefix = stableState.text;
177
237
 
178
238
  // Open-fence fast path: never run marked.lexer on a growing unclosed code
179
239
  // block (its closing-fence regex never matches and backtracks over the whole
180
240
  // body every delta — the ~56ms/frame cost). Split cheaply at the fence line:
181
241
  // everything before it is settled markdown (lexed + cached once by the stable
182
242
  // <Markdown>), the open block is rendered flat until the closing fence lands.
183
- const open = findOpenFenceStart(t);
243
+ const open = findOpenFenceStart(t, key);
184
244
  if (open) {
185
245
  let openPrefix = t.substring(0, open.index);
186
246
  if (isWhitespaceOnlyText(openPrefix)) openPrefix = '';
187
- if (key && openPrefix) touchStablePrefixKey(key, openPrefix);
247
+ stableState = stableStateForText(openPrefix, stableState);
248
+ if (key && openPrefix) touchStablePrefixKey(key, stableState);
188
249
  else if (key) stablePrefixByStreamKey.delete(key);
189
250
  const unstableSuffix = t.substring(openPrefix.length);
190
251
  return cacheResolvedPartsKey(key, t, {
191
252
  plain: false,
192
253
  openFence: true,
193
254
  stablePrefix: openPrefix,
255
+ stableChunks: stableState.chunks,
194
256
  unstableSuffix,
195
257
  unstableForRender: unstableSuffix,
196
258
  });
@@ -214,20 +276,26 @@ export function resolveStreamingMarkdownParts(text, streamKey) {
214
276
  if (advance > 0) {
215
277
  stablePrefix = t.substring(0, boundary + advance);
216
278
  if (isWhitespaceOnlyText(stablePrefix)) stablePrefix = '';
217
- if (key && stablePrefix) touchStablePrefixKey(key, stablePrefix);
279
+ stableState = stableStateForText(stablePrefix, stableState);
280
+ if (key && stablePrefix) touchStablePrefixKey(key, stableState);
218
281
  else if (key && !stablePrefix) stablePrefixByStreamKey.delete(key);
219
282
  }
220
283
  } catch {
221
284
  stablePrefix = '';
285
+ stableState = { text: '', chunks: [] };
222
286
  if (key) stablePrefixByStreamKey.delete(key);
223
287
  }
224
288
 
225
- if (isWhitespaceOnlyText(stablePrefix)) stablePrefix = '';
289
+ if (isWhitespaceOnlyText(stablePrefix)) {
290
+ stablePrefix = '';
291
+ stableState = { text: '', chunks: [] };
292
+ }
226
293
 
227
294
  const unstableSuffix = t.substring(stablePrefix.length);
228
295
  return cacheResolvedPartsKey(key, t, {
229
296
  plain: false,
230
297
  stablePrefix,
298
+ stableChunks: stableState.chunks,
231
299
  unstableSuffix,
232
300
  unstableForRender: balanceStreamingMarkdown(unstableSuffix),
233
301
  });