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.
- package/package.json +1 -1
- package/src/app.mjs +2 -2
- package/src/cli.mjs +1 -1
- package/src/repl.mjs +72 -6
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +76 -21
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +45 -4
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +36 -13
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +79 -18
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +37 -2
- package/src/session-runtime/context-status.mjs +2 -1
- package/src/session-runtime/provider-request-tools.mjs +6 -0
- package/src/session-runtime/tool-catalog-schema.mjs +5 -1
- package/src/tui/app/transcript-window.mjs +16 -0
- package/src/tui/app/use-transcript-window.mjs +36 -1
- package/src/tui/components/Markdown.jsx +15 -1
- package/src/tui/components/Message.jsx +1 -1
- package/src/tui/components/PromptInput.jsx +7 -0
- package/src/tui/dist/index.mjs +307 -79
- package/src/tui/engine/frame-batched-store.mjs +5 -3
- package/src/tui/engine/render-timing.mjs +28 -0
- package/src/tui/engine/session-api-ext.mjs +7 -0
- package/src/tui/engine/tui-steering-persist.mjs +25 -4
- package/src/tui/engine.mjs +9 -1
- package/src/tui/index.jsx +11 -3
- package/src/tui/markdown/measure-rendered-rows.mjs +28 -16
- package/src/tui/markdown/render-ansi.mjs +34 -1
- package/src/tui/markdown/stream-fence.mjs +44 -7
- package/src/tui/markdown/streaming-markdown.mjs +95 -27
- 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
|
-
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
}
|
package/src/tui/engine.mjs
CHANGED
|
@@ -77,7 +77,12 @@ import {
|
|
|
77
77
|
import {
|
|
78
78
|
resolveTuiRuntimeNotificationDelivery,
|
|
79
79
|
} from './engine/notification-plan.mjs';
|
|
80
|
-
import {
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
let
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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 {
|
|
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
|
-
|
|
24
|
-
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
34
|
-
if (rows >= rowBudget) break;
|
|
47
|
+
start = lineStart;
|
|
48
|
+
if (rows >= rowBudget || newline < 0) break;
|
|
49
|
+
end = newline;
|
|
35
50
|
}
|
|
36
|
-
return start > 0 ?
|
|
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
|
|
58
|
+
function touchLruKey(cache, key, value) {
|
|
44
59
|
if (!key) return;
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
while (
|
|
48
|
-
const oldest =
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
174
|
-
if (!t.startsWith(
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
});
|