mixdog 0.9.16 → 0.9.18
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 +2 -1
- package/scripts/atomic-lock-tryonce-test.mjs +66 -0
- package/scripts/provider-toolcall-test.mjs +79 -2
- package/src/mixdog-session-runtime.mjs +12 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +33 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +49 -0
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +32 -18
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +142 -3
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +108 -30
- package/src/runtime/agent/orchestrator/session/store.mjs +5 -0
- package/src/runtime/agent/orchestrator/stall-policy.mjs +22 -12
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +15 -15
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +10 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +8 -2
- package/src/runtime/channels/backends/discord-attachments.mjs +2 -2
- package/src/runtime/channels/backends/discord-gateway.mjs +12 -2
- package/src/runtime/channels/backends/discord.mjs +97 -7
- package/src/runtime/channels/index.mjs +150 -23
- package/src/runtime/channels/lib/crash-log.mjs +21 -3
- package/src/runtime/channels/lib/output-forwarder.mjs +118 -7
- package/src/runtime/channels/lib/runtime-paths.mjs +21 -19
- package/src/runtime/channels/lib/voice-transcription.mjs +4 -2
- package/src/runtime/memory/index.mjs +37 -0
- package/src/runtime/memory/lib/embedding-warmup.mjs +3 -0
- package/src/runtime/memory/lib/ko-morph.mjs +1 -0
- package/src/runtime/shared/atomic-file.mjs +110 -0
- package/src/runtime/shared/transcript-writer.mjs +46 -4
- package/src/session-runtime/provider-models.mjs +47 -8
- package/src/standalone/channel-worker.mjs +14 -2
- package/src/tui/app/transcript-window.mjs +137 -6
- package/src/tui/app/use-transcript-window.mjs +67 -10
- package/src/tui/components/StatusLine.jsx +1 -1
- package/src/tui/dist/index.mjs +436 -93
- package/src/tui/engine/tui-steering-persist.mjs +66 -35
- package/src/tui/engine.mjs +66 -14
- package/src/tui/index.jsx +97 -6
- package/src/ui/statusline-segments.mjs +54 -36
- package/src/ui/statusline.mjs +141 -95
|
@@ -11,6 +11,10 @@ import { fastCapableFor, fastPreferenceFor } from './model-capabilities.mjs';
|
|
|
11
11
|
import { modelSettingsFor } from './config-helpers.mjs';
|
|
12
12
|
import { isSelectableLlmModel } from './model-recency.mjs';
|
|
13
13
|
|
|
14
|
+
const PROVIDER_MODELS_PROFILE_ENABLED = /^(1|true|yes|on)$/i.test(String(
|
|
15
|
+
process.env.MIXDOG_PROVIDER_MODELS_PROFILE || process.env.MIXDOG_BOOT_PROFILE || '',
|
|
16
|
+
));
|
|
17
|
+
|
|
14
18
|
export function createProviderModels({
|
|
15
19
|
caches,
|
|
16
20
|
modelMetaByRoute,
|
|
@@ -32,6 +36,10 @@ export function createProviderModels({
|
|
|
32
36
|
const config = () => getConfig();
|
|
33
37
|
const route = () => getRoute();
|
|
34
38
|
const reg = () => getReg();
|
|
39
|
+
function profile(event, fields = {}) {
|
|
40
|
+
if (!PROVIDER_MODELS_PROFILE_ENABLED) return;
|
|
41
|
+
bootProfile(`provider-models:${event}`, fields);
|
|
42
|
+
}
|
|
35
43
|
|
|
36
44
|
function modelMetaKey(providerId, modelId) {
|
|
37
45
|
return `${clean(providerId)}\n${clean(modelId)}`;
|
|
@@ -152,12 +160,17 @@ export function createProviderModels({
|
|
|
152
160
|
return results;
|
|
153
161
|
}
|
|
154
162
|
|
|
155
|
-
async function loadProviderModelsFresh({ forceRefresh = false } = {}) {
|
|
156
|
-
|
|
163
|
+
async function loadProviderModelsFresh({ forceRefresh = false, loadSecrets = true } = {}) {
|
|
164
|
+
const startedAt = performance.now();
|
|
165
|
+
profile('load:start', { forceRefresh, loadSecrets });
|
|
166
|
+
if (loadSecrets) ensureFullConfig();
|
|
167
|
+
const providersStartedAt = performance.now();
|
|
157
168
|
await ensureProvidersReady(config().providers || {});
|
|
169
|
+
profile('providers-ready', { ms: (performance.now() - providersStartedAt).toFixed(1) });
|
|
158
170
|
const allProviders = [...reg().getAllProviders()];
|
|
159
171
|
const providerResults = await Promise.all(allProviders.map(async ([name, provider]) => {
|
|
160
172
|
if (typeof provider?.listModels !== 'function') return [];
|
|
173
|
+
const providerStartedAt = performance.now();
|
|
161
174
|
try {
|
|
162
175
|
let models = null;
|
|
163
176
|
if (forceRefresh && typeof provider._refreshModelCache === 'function') {
|
|
@@ -173,8 +186,19 @@ export function createProviderModels({
|
|
|
173
186
|
if (!isSelectableLlmModel(m)) continue;
|
|
174
187
|
rows.push(providerModelCacheRow(name, m));
|
|
175
188
|
}
|
|
189
|
+
profile('provider:done', {
|
|
190
|
+
provider: name,
|
|
191
|
+
ms: (performance.now() - providerStartedAt).toFixed(1),
|
|
192
|
+
models: models.length,
|
|
193
|
+
rows: rows.length,
|
|
194
|
+
});
|
|
176
195
|
return rows;
|
|
177
|
-
} catch {
|
|
196
|
+
} catch (error) {
|
|
197
|
+
profile('provider:failed', {
|
|
198
|
+
provider: name,
|
|
199
|
+
ms: (performance.now() - providerStartedAt).toFixed(1),
|
|
200
|
+
error: error?.message || String(error),
|
|
201
|
+
});
|
|
178
202
|
// Ignore per-provider catalog failures so one bad credential or
|
|
179
203
|
// transient /models error does not hide other authenticated models.
|
|
180
204
|
return [];
|
|
@@ -189,9 +213,21 @@ export function createProviderModels({
|
|
|
189
213
|
results.push(row);
|
|
190
214
|
modelMetaByRoute.set(modelMetaKey(row.provider, row.id), row);
|
|
191
215
|
}
|
|
216
|
+
profile('load:done', { ms: (performance.now() - startedAt).toFixed(1), providers: allProviders.length, rows: results.length });
|
|
192
217
|
return results;
|
|
193
218
|
}
|
|
194
219
|
|
|
220
|
+
function shouldAdoptProviderModelCache(models, { loadSecrets = true } = {}) {
|
|
221
|
+
// Background warmup deliberately avoids ensureFullConfig() so it cannot
|
|
222
|
+
// block the TUI on keychain/config reload. That no-secrets path is only a
|
|
223
|
+
// best-effort provider-internal prefetch. Its result may be a partial
|
|
224
|
+
// catalog (for example local/env providers listed while keychain-backed
|
|
225
|
+
// providers failed), so never let it become the authoritative picker cache.
|
|
226
|
+
// Foreground/forced loads still adopt empty lists because they loaded the
|
|
227
|
+
// authoritative config.
|
|
228
|
+
return loadSecrets;
|
|
229
|
+
}
|
|
230
|
+
|
|
195
231
|
async function collectSearchProviderModels({ force = false } = {}) {
|
|
196
232
|
if (!force && Array.isArray(caches.searchProviderModelsCache.models)) {
|
|
197
233
|
return providerModelsFromCacheRows(quickHelpers.searchRowsWithDefault(caches.searchProviderModelsCache.models));
|
|
@@ -223,15 +259,15 @@ export function createProviderModels({
|
|
|
223
259
|
}
|
|
224
260
|
if (force) {
|
|
225
261
|
const seq = ++caches.providerModelsLoadSeq;
|
|
226
|
-
const models = await loadProviderModelsFresh({ forceRefresh: true });
|
|
262
|
+
const models = await loadProviderModelsFresh({ forceRefresh: true, loadSecrets: true });
|
|
227
263
|
if (seq === caches.providerModelsLoadSeq) caches.providerModelsCache = { models, at: Date.now() };
|
|
228
264
|
return providerModelsFromCacheRows(models);
|
|
229
265
|
}
|
|
230
266
|
if (!caches.providerModelsPromise) {
|
|
231
267
|
const seq = ++caches.providerModelsLoadSeq;
|
|
232
|
-
caches.providerModelsPromise = loadProviderModelsFresh()
|
|
268
|
+
caches.providerModelsPromise = loadProviderModelsFresh({ loadSecrets: true })
|
|
233
269
|
.then((models) => {
|
|
234
|
-
if (seq === caches.providerModelsLoadSeq) caches.providerModelsCache = { models, at: Date.now() };
|
|
270
|
+
if (seq === caches.providerModelsLoadSeq && shouldAdoptProviderModelCache(models, { loadSecrets: true })) caches.providerModelsCache = { models, at: Date.now() };
|
|
235
271
|
return models;
|
|
236
272
|
})
|
|
237
273
|
.finally(() => {
|
|
@@ -243,10 +279,13 @@ export function createProviderModels({
|
|
|
243
279
|
|
|
244
280
|
function warmProviderModelCache() {
|
|
245
281
|
if (Array.isArray(caches.providerModelsCache.models) || caches.providerModelsPromise) return caches.providerModelsPromise;
|
|
282
|
+
profile('warm:start');
|
|
246
283
|
const seq = ++caches.providerModelsLoadSeq;
|
|
247
|
-
caches.providerModelsPromise = loadProviderModelsFresh()
|
|
284
|
+
caches.providerModelsPromise = loadProviderModelsFresh({ loadSecrets: false })
|
|
248
285
|
.then((models) => {
|
|
249
|
-
if (seq === caches.providerModelsLoadSeq
|
|
286
|
+
if (seq === caches.providerModelsLoadSeq && shouldAdoptProviderModelCache(models, { loadSecrets: false })) {
|
|
287
|
+
caches.providerModelsCache = { models, at: Date.now() };
|
|
288
|
+
}
|
|
250
289
|
bootProfile('provider-models:warm-ready', { count: models.length });
|
|
251
290
|
return models;
|
|
252
291
|
})
|
|
@@ -75,6 +75,8 @@ export function createStandaloneChannelWorker({
|
|
|
75
75
|
let readyResolve = null;
|
|
76
76
|
let readyReject = null;
|
|
77
77
|
let stopPromise = null;
|
|
78
|
+
let stopRequested = false;
|
|
79
|
+
let bootGeneration = 0;
|
|
78
80
|
let inProcessMod = null;
|
|
79
81
|
let inProcessStartPromise = null;
|
|
80
82
|
let nextCallId = 1;
|
|
@@ -153,6 +155,7 @@ export function createStandaloneChannelWorker({
|
|
|
153
155
|
return stopPromise.then(() => start());
|
|
154
156
|
}
|
|
155
157
|
if (child && child.exitCode == null && !child.killed) return readyPromise || Promise.resolve(status());
|
|
158
|
+
stopRequested = false;
|
|
156
159
|
readyPromise = new Promise((resolve, reject) => {
|
|
157
160
|
readyResolve = resolve;
|
|
158
161
|
readyReject = reject;
|
|
@@ -181,6 +184,9 @@ export function createStandaloneChannelWorker({
|
|
|
181
184
|
windowsHide: true,
|
|
182
185
|
});
|
|
183
186
|
const spawnedPid = child.pid;
|
|
187
|
+
// Per-boot generation: a later start()/respawn bumps this, so a stale old
|
|
188
|
+
// child's late exit is ignored instead of respawning/rejecting the current one.
|
|
189
|
+
const myGeneration = ++bootGeneration;
|
|
184
190
|
startChildGuardian({ childPid: spawnedPid, label: 'channel-worker', orphanGraceMs: 8000, forceGraceMs: 3000 });
|
|
185
191
|
if (spawnedPid) ownedChildPids.add(spawnedPid);
|
|
186
192
|
installParentExitHook();
|
|
@@ -215,13 +221,17 @@ export function createStandaloneChannelWorker({
|
|
|
215
221
|
});
|
|
216
222
|
|
|
217
223
|
child.on('exit', (code, signal) => {
|
|
224
|
+
if (myGeneration !== bootGeneration) return;
|
|
218
225
|
if (spawnedPid) ownedChildPids.delete(spawnedPid);
|
|
219
226
|
const error = new Error(`channels runtime exited (${signal || (code ?? 'unknown')})`);
|
|
220
|
-
|
|
227
|
+
// Exit code 2 = terminal (non-transient) worker start failure: reject, never respawn.
|
|
228
|
+
// A requested stop (stopRequested) also suppresses respawn even if stopPromise's
|
|
229
|
+
// settle timer already cleared it before the child exited.
|
|
230
|
+
if (!becameReady && readyReject && !stopPromise && !stopRequested && code !== 2 && attempt < WORKER_BOOT_MAX_ATTEMPTS) {
|
|
221
231
|
logLine(logPath, `worker exited before ready (${signal || (code ?? 'unknown')}), attempt ${attempt}/${WORKER_BOOT_MAX_ATTEMPTS}; retrying in ${WORKER_BOOT_RETRY_DELAY_MS}ms`);
|
|
222
232
|
child = null;
|
|
223
233
|
setTimeout(() => {
|
|
224
|
-
if (stopPromise || !readyReject) return;
|
|
234
|
+
if (stopPromise || stopRequested || !readyReject) return;
|
|
225
235
|
spawnWorkerChild(attempt + 1);
|
|
226
236
|
}, WORKER_BOOT_RETRY_DELAY_MS);
|
|
227
237
|
return;
|
|
@@ -235,6 +245,7 @@ export function createStandaloneChannelWorker({
|
|
|
235
245
|
});
|
|
236
246
|
|
|
237
247
|
child.on('error', (error) => {
|
|
248
|
+
if (myGeneration !== bootGeneration) return;
|
|
238
249
|
logLine(logPath, `runtime error: ${error?.message || error}`);
|
|
239
250
|
if (readyReject) readyReject(error);
|
|
240
251
|
readyResolve = null;
|
|
@@ -347,6 +358,7 @@ export function createStandaloneChannelWorker({
|
|
|
347
358
|
|
|
348
359
|
function stop(reason = 'standalone shutdown', options = {}) {
|
|
349
360
|
const waitForExit = options?.waitForExit !== false;
|
|
361
|
+
stopRequested = true;
|
|
350
362
|
stopClientHeartbeat();
|
|
351
363
|
if (stopPromise) return stopPromise;
|
|
352
364
|
if (!useProcessWorker) {
|
|
@@ -562,6 +562,17 @@ export function measuredTranscriptRows(item, columns, toolOutputExpanded) {
|
|
|
562
562
|
|
|
563
563
|
const STREAMING_ROW_QUANTUM = 1;
|
|
564
564
|
|
|
565
|
+
// Bottom-pinned flag for the streaming-tail row estimate. Set once per hook
|
|
566
|
+
// render (use-transcript-window.mjs) BEFORE any tail resolution so every path
|
|
567
|
+
// that resolves the streaming tail height (tailSig, buildTranscriptRowIndex,
|
|
568
|
+
// incremental tail, transcriptStructureSignature) reads the same value and
|
|
569
|
+
// cannot diverge. Bottom-pinned → fold the live estimate in (geometry right on
|
|
570
|
+
// the first commit); away from bottom → d19cad1e defer-growth.
|
|
571
|
+
let streamingBottomPinned = false;
|
|
572
|
+
export function setStreamingBottomPinned(pinned) {
|
|
573
|
+
streamingBottomPinned = !!pinned;
|
|
574
|
+
}
|
|
575
|
+
|
|
565
576
|
function assistantTextForStreamingRowEstimate(text) {
|
|
566
577
|
return streamingLayoutText(text);
|
|
567
578
|
}
|
|
@@ -573,20 +584,42 @@ function streamingEstimateRows(item, columns, toolOutputExpanded) {
|
|
|
573
584
|
return Math.ceil(raw / STREAMING_ROW_QUANTUM) * STREAMING_ROW_QUANTUM;
|
|
574
585
|
}
|
|
575
586
|
|
|
576
|
-
function estimateTranscriptItemRowsCached(item, columns, toolOutputExpanded) {
|
|
587
|
+
export function estimateTranscriptItemRowsCached(item, columns, toolOutputExpanded) {
|
|
577
588
|
if (!item) return Math.max(1, Math.ceil(estimateTranscriptItemRows(item, columns, toolOutputExpanded)));
|
|
578
589
|
if (shouldSuppressFullyFailedToolItem(item)) return 0;
|
|
579
590
|
if (item.kind === 'assistant' && item.streaming) {
|
|
580
|
-
const estimate = streamingEstimateRows(item, columns, toolOutputExpanded);
|
|
581
591
|
const toolExpanded = toolOutputExpanded ? 1 : 0;
|
|
582
592
|
const idEntry = streamingMeasuredRowsById.get(item.id);
|
|
583
593
|
if (idEntry && idEntry.rows > 0 && idEntry.columns === columns && idEntry.toolExpanded === toolExpanded) {
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
594
|
+
if (streamingBottomPinned) {
|
|
595
|
+
// Bottom-pinned: the view follows the tail, so its geometry must be
|
|
596
|
+
// correct on the FIRST commit. Fold the freshly-grown live estimate in
|
|
597
|
+
// — max(measuredFloor, liveEstimate) — so totalRows/scrollOffset are
|
|
598
|
+
// right this frame instead of committing the stale measured floor and
|
|
599
|
+
// growing a frame later when the harvest bumps measuredRowsVersion
|
|
600
|
+
// (the judder). The measured floor still guards against an estimate
|
|
601
|
+
// that undercounts the real wrap. Away from bottom the defer-growth
|
|
602
|
+
// path below is kept (its anchor-stability / perf intent).
|
|
603
|
+
return Math.max(idEntry.rows, streamingEstimateRows(item, columns, toolOutputExpanded));
|
|
604
|
+
}
|
|
605
|
+
// Defer-growth: while a confirmed (post-commit Yoga) measurement exists
|
|
606
|
+
// for this streaming id, THIS render keeps that value instead of
|
|
607
|
+
// folding in the freshly-grown estimate. Combining
|
|
608
|
+
// max(measuredFloor, liveEstimate) let per-flush row growth reach
|
|
609
|
+
// totalRows/scrollOffset a full frame BEFORE Yoga confirmed the real
|
|
610
|
+
// wrap — the estimate could over/undercount vs the real layout, and
|
|
611
|
+
// the next commit's harvest then corrected it, bouncing an anchored or
|
|
612
|
+
// bottom-pinned offset by the mismatch. Freezing at the last confirmed
|
|
613
|
+
// height means growth only reaches the row index on the SAME frame the
|
|
614
|
+
// harvest (use-transcript-window.mjs) writes the new measured value and
|
|
615
|
+
// bumps measuredRowsVersion — the "measured frame" consumes it, not the
|
|
616
|
+
// estimate frame.
|
|
617
|
+
return idEntry.rows;
|
|
587
618
|
}
|
|
588
619
|
if (idEntry) streamingMeasuredRowsById.delete(item.id);
|
|
589
|
-
|
|
620
|
+
// No confirmed measurement yet for this id (item just started streaming):
|
|
621
|
+
// nothing to defer against, so the first frame falls back to the estimate.
|
|
622
|
+
return streamingEstimateRows(item, columns, toolOutputExpanded);
|
|
590
623
|
}
|
|
591
624
|
if (item.kind === 'assistant' && streamingMeasuredRowsById.has(item.id)) {
|
|
592
625
|
// Item settled (no longer streaming): the id-keyed floor is no longer
|
|
@@ -632,6 +665,104 @@ export function buildTranscriptRowIndex(items, {
|
|
|
632
665
|
return { rows, prefixRows, totalRows: prefixRows[allItems.length] || 0 };
|
|
633
666
|
}
|
|
634
667
|
|
|
668
|
+
// ── Incremental streaming-tail row-index cache ─────────────────────────────
|
|
669
|
+
// On a streaming flush the engine swaps `items` for a fresh array in which
|
|
670
|
+
// ONLY the trailing assistant item's text has grown; every settled item ahead
|
|
671
|
+
// of it is byte-identical geometry. buildTranscriptRowIndex re-walks all N
|
|
672
|
+
// items each flush (O(n) per ~16ms frame) even though only the last row's
|
|
673
|
+
// height can change. This cache holds the SETTLED-PREFIX row-index (all items
|
|
674
|
+
// except the trailing streaming assistant item) keyed on a stable signature of
|
|
675
|
+
// that prefix + columns + expanded + suppress + measuredRowsVersion. When the
|
|
676
|
+
// only difference since last flush is the trailing streaming item, we recompute
|
|
677
|
+
// just that one tail row and append it to the cached prefixRows. Any structural
|
|
678
|
+
// change (item count, non-tail change, columns, expanded, suppress, version)
|
|
679
|
+
// invalidates the cache and falls back to a full buildTranscriptRowIndex.
|
|
680
|
+
// The cache is per-hook-instance now (passed in as `cacheRef`); there is no
|
|
681
|
+
// module-level mutable state to leak across hook instances.
|
|
682
|
+
|
|
683
|
+
/** True when `items` ends with a streaming assistant item (the growing tail). */
|
|
684
|
+
function trailingStreamingItem(allItems) {
|
|
685
|
+
const last = allItems.length > 0 ? allItems[allItems.length - 1] : null;
|
|
686
|
+
return last && last.kind === 'assistant' && last.streaming ? last : null;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export function buildTranscriptRowIndexIncremental(items, {
|
|
690
|
+
columns = 80,
|
|
691
|
+
toolOutputExpanded = false,
|
|
692
|
+
suppressMeasuredRowHeights = false,
|
|
693
|
+
measuredRowsVersion = 0,
|
|
694
|
+
cacheRef = null,
|
|
695
|
+
prefixSig = null,
|
|
696
|
+
} = {}) {
|
|
697
|
+
const allItems = Array.isArray(items) ? items : [];
|
|
698
|
+
const tail = trailingStreamingItem(allItems);
|
|
699
|
+
// Per-hook-instance cache holder (useRef object). Fall back to a throwaway
|
|
700
|
+
// holder if none supplied so the builder still works in isolation.
|
|
701
|
+
const holder = cacheRef || { current: null };
|
|
702
|
+
// No streaming tail → nothing incremental to exploit; drop any stale cache and
|
|
703
|
+
// do the normal full build. (The memo layer already skips recompute when the
|
|
704
|
+
// structure signature is unchanged, so a settled transcript pays this once.)
|
|
705
|
+
if (!tail) {
|
|
706
|
+
holder.current = null;
|
|
707
|
+
return buildTranscriptRowIndex(allItems, {
|
|
708
|
+
columns, toolOutputExpanded, suppressMeasuredRowHeights,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
const prefixLen = allItems.length - 1;
|
|
712
|
+
const cache = holder.current;
|
|
713
|
+
// Prefix identity is carried by `prefixSig` (a hash of every prefix item's
|
|
714
|
+
// WeakMap sigPart, computed once in the hook). String equality catches a
|
|
715
|
+
// same-length MIDDLE-item replacement (tool-card patch swaps a middle object →
|
|
716
|
+
// fresh fragment → different sig), which a last-item ref check would miss.
|
|
717
|
+
// Fast path: same prefix length + tail id + prefixSig, and columns/expanded/
|
|
718
|
+
// suppress/version all match. Only the tail row can differ → recompute + append.
|
|
719
|
+
if (cache
|
|
720
|
+
&& cache.columns === columns
|
|
721
|
+
&& cache.toolExpanded === (toolOutputExpanded ? 1 : 0)
|
|
722
|
+
&& cache.suppress === suppressMeasuredRowHeights
|
|
723
|
+
&& cache.version === measuredRowsVersion
|
|
724
|
+
&& cache.prefixLen === prefixLen
|
|
725
|
+
&& prefixSig != null
|
|
726
|
+
&& cache.prefixSig === prefixSig
|
|
727
|
+
&& cache.tailId === tail.id) {
|
|
728
|
+
// prefixSig matches → prefix rows provably unchanged; NO prefix walk / no
|
|
729
|
+
// re-estimation. Only the tail row can differ, recompute it.
|
|
730
|
+
{
|
|
731
|
+
const tailMeasured = suppressMeasuredRowHeights
|
|
732
|
+
? null
|
|
733
|
+
: measuredTranscriptRows(tail, columns, toolOutputExpanded);
|
|
734
|
+
const tailRows = tailMeasured != null
|
|
735
|
+
? tailMeasured
|
|
736
|
+
: estimateTranscriptItemRowsCached(tail, columns, toolOutputExpanded);
|
|
737
|
+
const rows = cache.prefixRowsArr.slice();
|
|
738
|
+
rows.push(tailRows);
|
|
739
|
+
const prefixRows = cache.prefixPrefixRows.slice();
|
|
740
|
+
prefixRows.push(cache.prefixTotal + tailRows);
|
|
741
|
+
return { rows, prefixRows, totalRows: prefixRows[allItems.length] || 0 };
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
// Cache miss: full build, then repopulate the settled-prefix cache so the NEXT
|
|
745
|
+
// flush (only the tail grown) takes the fast path. The prefix arrays are the
|
|
746
|
+
// full-build outputs truncated before the tail row — byte-identical to a
|
|
747
|
+
// full rebuild's prefix by construction.
|
|
748
|
+
const full = buildTranscriptRowIndex(allItems, {
|
|
749
|
+
columns, toolOutputExpanded, suppressMeasuredRowHeights,
|
|
750
|
+
});
|
|
751
|
+
holder.current = {
|
|
752
|
+
columns,
|
|
753
|
+
toolExpanded: toolOutputExpanded ? 1 : 0,
|
|
754
|
+
suppress: suppressMeasuredRowHeights,
|
|
755
|
+
version: measuredRowsVersion,
|
|
756
|
+
prefixLen,
|
|
757
|
+
prefixSig,
|
|
758
|
+
tailId: tail.id,
|
|
759
|
+
prefixRowsArr: full.rows.slice(0, prefixLen),
|
|
760
|
+
prefixPrefixRows: full.prefixRows.slice(0, prefixLen + 1),
|
|
761
|
+
prefixTotal: full.prefixRows[prefixLen] || 0,
|
|
762
|
+
};
|
|
763
|
+
return full;
|
|
764
|
+
}
|
|
765
|
+
|
|
635
766
|
// Stable signature for the transcript row-index / window memos. Changes only
|
|
636
767
|
// when transcript STRUCTURE changes or the streaming item's estimated height
|
|
637
768
|
// changes — not on every character. Per-item sigParts are identity-memoized.
|
|
@@ -16,8 +16,10 @@ import {
|
|
|
16
16
|
streamingMeasuredRowsById,
|
|
17
17
|
pruneStreamingMeasuredRowsById,
|
|
18
18
|
transcriptItemVariantKey,
|
|
19
|
-
|
|
19
|
+
buildTranscriptRowIndexIncremental,
|
|
20
20
|
transcriptStructureSignature,
|
|
21
|
+
estimateTranscriptItemRowsCached,
|
|
22
|
+
setStreamingBottomPinned,
|
|
21
23
|
transcriptRenderWindow,
|
|
22
24
|
resolveAnchorScrollOffset,
|
|
23
25
|
upperBound,
|
|
@@ -55,6 +57,10 @@ export function useTranscriptWindow({
|
|
|
55
57
|
}) {
|
|
56
58
|
const transcriptTotalRowsRef = useRef(0);
|
|
57
59
|
const preservedScrollDeltaRef = useRef(0);
|
|
60
|
+
// Per-hook-instance settled-prefix row-index cache for the incremental
|
|
61
|
+
// builder. Was module-level (leaked across hook instances); now local so
|
|
62
|
+
// each transcript window owns its own tail-flush cache.
|
|
63
|
+
const incrementalRowIndexCacheRef = useRef(null);
|
|
58
64
|
// Previous frame's viewport-only geometry (content height + floating-panel
|
|
59
65
|
// reservation). A floating panel / view (picker/context/usage/text-entry)
|
|
60
66
|
// open-close changes transcriptContentHeight WITHOUT changing `items`, so the
|
|
@@ -120,16 +126,44 @@ export function useTranscriptWindow({
|
|
|
120
126
|
// input typing — skip the O(n) signature walk entirely. During streaming the
|
|
121
127
|
// engine hands us a fresh `items` array each flush, so this memo
|
|
122
128
|
// recomputes and still tracks the streaming item's height correctly.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
+
// Split the structure signature into prefix (all items except the trailing
|
|
130
|
+
// streaming item) + tail. On a streaming flush only the tail item object is
|
|
131
|
+
// replaced; every prefix item keeps identity, so the prefix sig is O(1)-
|
|
132
|
+
const streamingTailItem = useMemo(() => {
|
|
133
|
+
const last = (items || []).length > 0 ? items[items.length - 1] : null;
|
|
134
|
+
return last && last.kind === 'assistant' && last.streaming ? last : null;
|
|
135
|
+
}, [items]);
|
|
136
|
+
const prefixLen = streamingTailItem ? (items || []).length - 1 : (items || []).length;
|
|
137
|
+
// Key on `items` identity (engine swaps the array every flush — INCLUDING a
|
|
138
|
+
// tool-card patch that replaces a MIDDLE item object), so a same-length
|
|
139
|
+
// middle-item replacement is caught (new object → fresh WeakMap fragment →
|
|
140
|
+
// different prefixSig string). The walk itself is cheap: transcript-
|
|
141
|
+
// StructureSignature reads a WeakMap sigPart per item (no re-estimation for
|
|
142
|
+
// unchanged objects) and joins — O(n) map lookups, not O(n) measurement. The
|
|
143
|
+
// row-index memo stays keyed on the prefixSig STRING, so a tail-only flush
|
|
144
|
+
// (identical prefix objects → identical string) still skips the row rebuild.
|
|
145
|
+
const prefixSig = useMemo(
|
|
146
|
+
() => transcriptStructureSignature(
|
|
147
|
+
streamingTailItem ? (items || []).slice(0, prefixLen) : (items || []),
|
|
148
|
+
frameColumns, toolOutputExpanded,
|
|
149
|
+
),
|
|
150
|
+
[items, streamingTailItem, prefixLen, frameColumns, toolOutputExpanded],
|
|
129
151
|
);
|
|
130
152
|
const scrolledUpRowsForPin = Math.max(0, Number(scrollTargetRef.current) || 0);
|
|
131
153
|
const transcriptPinnedForStreaming = followingRef.current
|
|
132
154
|
|| scrolledUpRowsForPin <= transcriptBottomSlackRows;
|
|
155
|
+
// Publish the pinned state to the streaming-tail estimator BEFORE resolving
|
|
156
|
+
// tailSig / the row-index memo this render, so max(measuredFloor, liveEstimate)
|
|
157
|
+
// applies while bottom-pinned (right geometry on first commit, no judder) and
|
|
158
|
+
// every tail-resolving path reads the same height (no sig/geometry divergence).
|
|
159
|
+
setStreamingBottomPinned(transcriptPinnedForStreaming);
|
|
160
|
+
const tailSig = streamingTailItem
|
|
161
|
+
? `a${streamingTailItem.id}:${estimateTranscriptItemRowsCached(streamingTailItem, frameColumns, toolOutputExpanded)}`
|
|
162
|
+
: '_';
|
|
163
|
+
const transcriptStructureSig = `${prefixSig}#${tailSig}`;
|
|
164
|
+
const transcriptStreamingActive = (items || []).some(
|
|
165
|
+
(item) => item?.kind === 'assistant' && item?.streaming,
|
|
166
|
+
);
|
|
133
167
|
const scrolledUpForStreamingMeasure = scrolledUpRowsForPin > transcriptBottomSlackRows;
|
|
134
168
|
const prevEstimateGeometry = transcriptGeomRef.current?.suppressMeasuredRowHeights === true;
|
|
135
169
|
const hasStreamingReadingAnchor = !!transcriptAnchorRef.current
|
|
@@ -140,12 +174,24 @@ export function useTranscriptWindow({
|
|
|
140
174
|
(scrolledUpForStreamingMeasure && hasStreamingReadingAnchor)
|
|
141
175
|
|| (scrolledUpForStreamingMeasure && prevEstimateGeometry && !transcriptPinnedForStreaming)
|
|
142
176
|
));
|
|
143
|
-
|
|
177
|
+
// Incremental builder: on a streaming flush where only the trailing assistant
|
|
178
|
+
// item's text grew, it recomputes just the tail row and appends to a cached
|
|
179
|
+
// settled-prefix row-index (O(1) prefix) instead of re-walking all N items.
|
|
180
|
+
// Any structural change (item count, non-tail change, columns, expanded,
|
|
181
|
+
// suppress, measuredRowsVersion) misses the cache and falls back to a full
|
|
182
|
+
// buildTranscriptRowIndex — so the prefix table is byte-identical to a full
|
|
183
|
+
// rebuild for the settled prefix. All those invalidators are folded into the
|
|
184
|
+
// memo deps below (sig captures item/column/expanded structure; the rest are
|
|
185
|
+
// listed explicitly), so the memo only recomputes when one of them changes.
|
|
186
|
+
const transcriptRowIndex = useMemo(() => buildTranscriptRowIndexIncremental(items, {
|
|
144
187
|
columns: frameColumns,
|
|
145
188
|
toolOutputExpanded,
|
|
146
189
|
suppressMeasuredRowHeights,
|
|
147
|
-
|
|
148
|
-
|
|
190
|
+
measuredRowsVersion,
|
|
191
|
+
cacheRef: incrementalRowIndexCacheRef,
|
|
192
|
+
prefixSig,
|
|
193
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: prefixSig/tailSig capture the relevant item changes (raw `items` dropped so a tail-only flush never re-enters the builder via array identity); measuredRowsVersion folds in app-level measured height corrections
|
|
194
|
+
}), [prefixSig, tailSig, measuredRowsVersion, suppressMeasuredRowHeights]);
|
|
149
195
|
// ── Same-frame anchor lock ───────────────────────────────────────────────
|
|
150
196
|
// While the user reads older transcript (anchor captured, not dirty), resolve
|
|
151
197
|
// the scroll offset that keeps the anchored viewport-top row fixed for THIS
|
|
@@ -412,6 +458,17 @@ export function useTranscriptWindow({
|
|
|
412
458
|
const idPrev = streamingMeasuredRowsById.get(item.id);
|
|
413
459
|
if (!idPrev || idPrev.rows !== measured || idPrev.columns !== frameColumns || idPrev.toolExpanded !== toolExpandedFlag) {
|
|
414
460
|
streamingMeasuredRowsById.set(item.id, { rows: measured, columns: frameColumns, toolExpanded: toolExpandedFlag });
|
|
461
|
+
// ALWAYS bump on a real id-store change, bottom-pinned or not.
|
|
462
|
+
// estimateTranscriptItemRowsCached (transcript-window.mjs) now
|
|
463
|
+
// returns ONLY this id-store floor for a streaming item (defer-
|
|
464
|
+
// growth: no live-estimate blend), so it is the SOLE path that ever
|
|
465
|
+
// feeds this item's grown height into transcriptStructureSig /
|
|
466
|
+
// buildTranscriptRowIndex. Skipping the bump while bottom-pinned
|
|
467
|
+
// used to be safe because the blended estimate kept the signature
|
|
468
|
+
// moving on its own each flush; with the blend gone, skipping it
|
|
469
|
+
// here would freeze totalRows/prefixRows for this item until the
|
|
470
|
+
// stream settles — invisible growth for anything reading rowIndex
|
|
471
|
+
// (windowing, maxScrollRows, an anchor captured mid-stream).
|
|
415
472
|
changed = true;
|
|
416
473
|
}
|
|
417
474
|
}
|
|
@@ -84,7 +84,7 @@ function hasActiveStatuslineWork(line, agentWorkers = [], agentJobs = [], active
|
|
|
84
84
|
return hasRunningStatuslineWorkers(agentWorkers, agentJobs)
|
|
85
85
|
|| hasActiveStatuslineTools(activeTools)
|
|
86
86
|
|| /\bRunning \d+ (?:Agents?|Shells?)\b/.test(stripAnsi(line))
|
|
87
|
-
|| /\b(?:Exploring|Searching)\b/.test(stripAnsi(line));
|
|
87
|
+
|| /\b(?:Exploring|Searching|Memory)\b/.test(stripAnsi(line));
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
function bootFullRenderEligible(mountAtMs, line, agentWorkers = [], agentJobs = [], activeTools = null) {
|