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
|
@@ -36,25 +36,67 @@ function _isPreviewNoise(text) {
|
|
|
36
36
|
return isSessionPreviewNoise(text);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (text) return text;
|
|
47
|
-
}
|
|
48
|
-
return '';
|
|
39
|
+
const sessionMessageProjectionMemo = new WeakMap();
|
|
40
|
+
|
|
41
|
+
function _previewFromMessage(message) {
|
|
42
|
+
if (message?.role !== 'user') return '';
|
|
43
|
+
const raw = _messageText(message.content);
|
|
44
|
+
if (_isPreviewNoise(raw)) return '';
|
|
45
|
+
return _cleanPreview(raw);
|
|
49
46
|
}
|
|
50
47
|
|
|
51
|
-
function
|
|
48
|
+
function _sessionMessageProjection(session) {
|
|
52
49
|
const messages = Array.isArray(session?.messages) ? session.messages : [];
|
|
50
|
+
const cached = session && typeof session === 'object'
|
|
51
|
+
? sessionMessageProjectionMemo.get(session)
|
|
52
|
+
: null;
|
|
53
|
+
let start = 0;
|
|
53
54
|
let count = 0;
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
let preview = '';
|
|
56
|
+
let previewMessage = null;
|
|
57
|
+
const canAppend = cached
|
|
58
|
+
&& messages.length >= cached.length
|
|
59
|
+
&& (cached.length === 0 || messages[cached.length - 1] === cached.lastMessage);
|
|
60
|
+
if (canAppend) {
|
|
61
|
+
start = cached.length;
|
|
62
|
+
count = cached.count;
|
|
63
|
+
preview = cached.preview;
|
|
64
|
+
previewMessage = cached.previewMessage;
|
|
65
|
+
// The first real user message is stable in normal append-only session
|
|
66
|
+
// flow, but refresh it cheaply so an in-place content scrub still
|
|
67
|
+
// invalidates the cached title source without rescanning the transcript.
|
|
68
|
+
if (previewMessage) {
|
|
69
|
+
const refreshed = _previewFromMessage(previewMessage);
|
|
70
|
+
if (refreshed) preview = refreshed;
|
|
71
|
+
else {
|
|
72
|
+
start = 0;
|
|
73
|
+
count = 0;
|
|
74
|
+
preview = '';
|
|
75
|
+
previewMessage = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
56
78
|
}
|
|
57
|
-
|
|
79
|
+
for (let index = start; index < messages.length; index += 1) {
|
|
80
|
+
const message = messages[index];
|
|
81
|
+
if (message && (message.role === 'user' || message.role === 'assistant')) count += 1;
|
|
82
|
+
if (!preview) {
|
|
83
|
+
const candidate = _previewFromMessage(message);
|
|
84
|
+
if (candidate) {
|
|
85
|
+
preview = candidate;
|
|
86
|
+
previewMessage = message;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (session && typeof session === 'object') {
|
|
91
|
+
sessionMessageProjectionMemo.set(session, {
|
|
92
|
+
length: messages.length,
|
|
93
|
+
lastMessage: messages[messages.length - 1] || null,
|
|
94
|
+
count,
|
|
95
|
+
preview,
|
|
96
|
+
previewMessage,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return { count, preview };
|
|
58
100
|
}
|
|
59
101
|
|
|
60
102
|
function _positiveNumber(value, fallback = 0) {
|
|
@@ -79,9 +121,14 @@ function _desktopSessionSummary(value, cwd = null) {
|
|
|
79
121
|
|
|
80
122
|
export function _sessionSummary(session) {
|
|
81
123
|
if (!session?.id) return null;
|
|
124
|
+
const messageProjection = _sessionMessageProjection(session);
|
|
82
125
|
return {
|
|
83
126
|
id: String(session.id),
|
|
84
127
|
updatedAt: _positiveNumber(session.updatedAt, Date.now()),
|
|
128
|
+
// Conversation activity is intentionally separate from lifecycle
|
|
129
|
+
// bookkeeping. Resume/detach saves can advance updatedAt without a
|
|
130
|
+
// user-visible turn and must not reshuffle Recent session lists.
|
|
131
|
+
lastUsedAt: _positiveNumber(session.lastUsedAt, 0),
|
|
85
132
|
createdAt: _positiveNumber(session.createdAt, 0),
|
|
86
133
|
lastHeartbeatAt: _positiveNumber(session.lastHeartbeatAt, 0),
|
|
87
134
|
closed: session.closed === true,
|
|
@@ -101,8 +148,8 @@ export function _sessionSummary(session) {
|
|
|
101
148
|
task_id: session.task_id || session.taskId || null,
|
|
102
149
|
permission: session.permission || null,
|
|
103
150
|
toolPermission: session.toolPermission || null,
|
|
104
|
-
messageCount:
|
|
105
|
-
preview:
|
|
151
|
+
messageCount: messageProjection.count,
|
|
152
|
+
preview: messageProjection.preview,
|
|
106
153
|
generation: typeof session.generation === 'number' ? session.generation : 0,
|
|
107
154
|
implicitBashSessionId: session.implicitBashSessionId || null,
|
|
108
155
|
};
|
|
@@ -113,6 +160,7 @@ function _normalizeSummaryRow(row) {
|
|
|
113
160
|
return {
|
|
114
161
|
id: row.id,
|
|
115
162
|
updatedAt: _positiveNumber(row.updatedAt, 0),
|
|
163
|
+
lastUsedAt: _positiveNumber(row.lastUsedAt, 0),
|
|
116
164
|
createdAt: _positiveNumber(row.createdAt, 0),
|
|
117
165
|
lastHeartbeatAt: _positiveNumber(row.lastHeartbeatAt, 0),
|
|
118
166
|
closed: row.closed === true,
|
|
@@ -173,6 +221,12 @@ const _pendingUpserts = new Map(); // id → summary row (latest wins)
|
|
|
173
221
|
const _pendingRemovals = new Set(); // ids to drop (upsert/removal are mutually exclusive per id)
|
|
174
222
|
let _summaryRetryTimer = null;
|
|
175
223
|
let _summaryFlushScheduled = false;
|
|
224
|
+
let _summaryFlushInflight = 0;
|
|
225
|
+
|
|
226
|
+
export function _hasUnsettledSummaryOps() {
|
|
227
|
+
return _pendingUpserts.size > 0 || _pendingRemovals.size > 0
|
|
228
|
+
|| _summaryFlushScheduled || _summaryFlushInflight > 0;
|
|
229
|
+
}
|
|
176
230
|
|
|
177
231
|
function _scheduleSummaryRetry() {
|
|
178
232
|
if (_summaryRetryTimer) return;
|
|
@@ -206,6 +260,7 @@ export function _flushPendingSummaryOps({ sync = false } = {}) {
|
|
|
206
260
|
_pendingRemovals.clear();
|
|
207
261
|
const mutate = (cur) => {
|
|
208
262
|
const index = _normalizeSummaryIndex(cur);
|
|
263
|
+
const existingById = new Map(index.rows.map((row) => [row.id, row]));
|
|
209
264
|
let changed = false;
|
|
210
265
|
const rows = index.rows.filter((r) => {
|
|
211
266
|
if (removals.has(r.id) || upserts.has(r.id)) { changed = true; return false; }
|
|
@@ -214,7 +269,7 @@ export function _flushPendingSummaryOps({ sync = false } = {}) {
|
|
|
214
269
|
for (const row of upserts.values()) {
|
|
215
270
|
const cleanRow = _normalizeSummaryRow(row);
|
|
216
271
|
if (!cleanRow) continue;
|
|
217
|
-
const existing =
|
|
272
|
+
const existing = existingById.get(cleanRow.id) || null;
|
|
218
273
|
if (existing && JSON.stringify(existing) === JSON.stringify(cleanRow)) {
|
|
219
274
|
rows.push(existing);
|
|
220
275
|
continue;
|
|
@@ -248,12 +303,18 @@ export function _flushPendingSummaryOps({ sync = false } = {}) {
|
|
|
248
303
|
} catch { requeue(); }
|
|
249
304
|
return;
|
|
250
305
|
}
|
|
306
|
+
_summaryFlushInflight++;
|
|
251
307
|
updateJsonAtomic(summaryIndexPath(), mutate, { compact: true, lock: true, timeoutMs: SUMMARY_LOCK_TIMEOUT_MS })
|
|
252
|
-
.catch(() => { requeue(); })
|
|
308
|
+
.catch(() => { requeue(); })
|
|
309
|
+
.finally(() => { _summaryFlushInflight--; });
|
|
253
310
|
}
|
|
254
311
|
|
|
255
312
|
export function _upsertSessionSummary(session) {
|
|
256
313
|
const row = _sessionSummary(session);
|
|
314
|
+
_upsertSessionSummaryRow(row);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function _upsertSessionSummaryRow(row) {
|
|
257
318
|
if (!row) return;
|
|
258
319
|
_pendingRemovals.delete(row.id);
|
|
259
320
|
_pendingUpserts.set(row.id, row);
|
|
@@ -20,6 +20,10 @@ const LEAD_OWNERS = new Set(['cli', 'user', 'mixdog', 'legacy']);
|
|
|
20
20
|
function isLeadVisibleRow(row) {
|
|
21
21
|
const owner = String(row.owner || 'user').trim().toLowerCase();
|
|
22
22
|
if (owner && !LEAD_OWNERS.has(owner)) return false;
|
|
23
|
+
// Mirror listLeadSessions: a previewless zero-message row is an unusable
|
|
24
|
+
// scratch (desktop boot leftovers, crashed first turns) — resuming it
|
|
25
|
+
// shows an empty conversation, so the catalog hides it.
|
|
26
|
+
if (!row.preview && row.messageCount === 0) return false;
|
|
23
27
|
const sourceType = String(row.sourceType || '').trim().toLowerCase();
|
|
24
28
|
const sourceName = String(row.sourceName || '').trim().toLowerCase();
|
|
25
29
|
const agent = String(row.agent || '').trim().toLowerCase();
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
_removeSessionSummary,
|
|
35
35
|
_pruneSummaryIndexIds,
|
|
36
36
|
_flushPendingSummaryOps,
|
|
37
|
+
_hasUnsettledSummaryOps,
|
|
37
38
|
} from './store-summary-index.mjs';
|
|
38
39
|
// Facade re-export: summary-index API moved to store-summary-index.mjs; keep
|
|
39
40
|
// prior importers of store.mjs unchanged.
|
|
@@ -68,6 +69,9 @@ export { saveSessionAsync, saveSessionAsyncDeferred } from './store/save-worker.
|
|
|
68
69
|
|
|
69
70
|
/** Module-level map tracking in-flight saves per session ID to prevent concurrent write corruption. */
|
|
70
71
|
const _savePending = new Map();
|
|
72
|
+
// Disk mtime of the summary index at the last time it was read into (or
|
|
73
|
+
// refreshed as) the in-memory cache base — cross-process staleness detector.
|
|
74
|
+
let _summaryIndexMtimeSeen = 0;
|
|
71
75
|
|
|
72
76
|
|
|
73
77
|
/**
|
|
@@ -836,7 +840,12 @@ function _overlayUnpersistedSummaryRows(rows, invalidStorageIds = new Set()) {
|
|
|
836
840
|
function rebuildSessionSummaryIndex() {
|
|
837
841
|
_ensureSummaryCacheDataDir();
|
|
838
842
|
const { rows } = _scanStoredSessionSummaryRows();
|
|
839
|
-
|
|
843
|
+
const indexedRows = _writeSummaryIndex(rows);
|
|
844
|
+
// This process just authored the new cache base. Remember its mtime so the
|
|
845
|
+
// next local save cannot mistake the rebuild for a cross-process update
|
|
846
|
+
// and replace a newer in-memory row with the just-obsoleted sidecar.
|
|
847
|
+
try { _summaryIndexMtimeSeen = statSync(summaryIndexPath()).mtimeMs || 0; } catch { /* stat only */ }
|
|
848
|
+
return _setSummaryRowsCache(indexedRows);
|
|
840
849
|
}
|
|
841
850
|
|
|
842
851
|
export function listStoredSessionSummaries(options = {}) {
|
|
@@ -867,7 +876,30 @@ export function listStoredSessionSummaries(options = {}) {
|
|
|
867
876
|
return [];
|
|
868
877
|
}
|
|
869
878
|
}
|
|
870
|
-
if (_summaryRowsCache !== null)
|
|
879
|
+
if (_summaryRowsCache !== null) {
|
|
880
|
+
// A local session save has already updated the in-memory cache but its
|
|
881
|
+
// non-blocking sidecar merge may still be queued/in flight. Re-reading
|
|
882
|
+
// the older sidecar in that window would temporarily erase the new row.
|
|
883
|
+
if (_hasUnsettledSummaryOps()) return _cachedSummaryRows().slice();
|
|
884
|
+
// Cross-process freshness: another live process (terminal CLI owning a
|
|
885
|
+
// session this surface only views) advances messageCount/updatedAt by
|
|
886
|
+
// rewriting the summary index FILE — an in-memory cache that never
|
|
887
|
+
// looks back at disk serves frozen rows forever (user: the unread dot
|
|
888
|
+
// never fired for terminal-owned growth). One stat per call; when the
|
|
889
|
+
// index advanced, re-read the cheap index JSON as the new cache base
|
|
890
|
+
// (local optimistic overlays stay applied on top).
|
|
891
|
+
let diskMtime = 0;
|
|
892
|
+
try { diskMtime = statSync(summaryIndexPath()).mtimeMs || 0; } catch { /* no index yet */ }
|
|
893
|
+
if (diskMtime <= _summaryIndexMtimeSeen) return _cachedSummaryRows().slice();
|
|
894
|
+
try {
|
|
895
|
+
const raw = JSON.parse(readFileSync(summaryIndexPath(), 'utf-8'));
|
|
896
|
+
if (Number(raw?.version) === SESSION_SUMMARY_INDEX_VERSION) {
|
|
897
|
+
_summaryIndexMtimeSeen = diskMtime;
|
|
898
|
+
return _setSummaryRowsCache(_normalizeSummaryIndex(raw).rows).slice();
|
|
899
|
+
}
|
|
900
|
+
} catch { /* torn concurrent write — keep serving the cache; retry next call */ }
|
|
901
|
+
return _cachedSummaryRows().slice();
|
|
902
|
+
}
|
|
871
903
|
|
|
872
904
|
let indexedRows = [];
|
|
873
905
|
let p;
|
|
@@ -879,6 +911,9 @@ export function listStoredSessionSummaries(options = {}) {
|
|
|
879
911
|
const raw = JSON.parse(readFileSync(p, 'utf-8'));
|
|
880
912
|
hasIndex = Number(raw?.version) === SESSION_SUMMARY_INDEX_VERSION;
|
|
881
913
|
if (hasIndex) indexedRows = _normalizeSummaryIndex(raw).rows;
|
|
914
|
+
if (hasIndex) {
|
|
915
|
+
try { _summaryIndexMtimeSeen = statSync(p).mtimeMs || 0; } catch { /* stat only */ }
|
|
916
|
+
}
|
|
882
917
|
}
|
|
883
918
|
} catch { /* unreadable/malformed sidecar falls through to rebuild */ }
|
|
884
919
|
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
providerTokenCalibration,
|
|
6
6
|
resolveSessionCompactPolicy,
|
|
7
7
|
summarizeContextMessages,
|
|
8
|
+
summarizeContextMessagesAtRevision,
|
|
8
9
|
toolSchemaSignature,
|
|
9
10
|
} from '../runtime/agent/orchestrator/session/context-utils.mjs';
|
|
10
11
|
import { SUMMARY_PREFIX } from '../runtime/agent/orchestrator/session/compact.mjs';
|
|
@@ -234,7 +235,7 @@ export function createContextStatus({
|
|
|
234
235
|
return contextStatusCacheValue;
|
|
235
236
|
}
|
|
236
237
|
|
|
237
|
-
const messageSummary =
|
|
238
|
+
const messageSummary = summarizeContextMessagesAtRevision(messages, messagesRevision);
|
|
238
239
|
const toolSchemaTokens = estimateToolSchemaTokens(requestTools);
|
|
239
240
|
const toolSchemaBreakdown = estimateToolSchemaBreakdown(requestTools);
|
|
240
241
|
const requestReserveTokens = estimateRequestReserveTokens(requestTools);
|
|
@@ -2,6 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
|
|
2
2
|
|
|
3
3
|
const requestToolScope = new AsyncLocalStorage();
|
|
4
4
|
const NATIVE_PREFIX_COUNT = Symbol('mixdog.providerNativeToolPrefixCount');
|
|
5
|
+
const finalizedProviderRequestTools = new WeakSet();
|
|
5
6
|
let requestToolScopeGeneration = 0;
|
|
6
7
|
|
|
7
8
|
function normalizedProvider(provider) {
|
|
@@ -15,6 +16,10 @@ export function providerNativeToolPrefixCount(requestTools, fallback = 0) {
|
|
|
15
16
|
return Math.max(0, Math.min(Array.isArray(requestTools) ? requestTools.length : 0, Number(raw) || 0));
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
export function isFinalizedProviderRequestTools(requestTools) {
|
|
20
|
+
return Array.isArray(requestTools) && finalizedProviderRequestTools.has(requestTools);
|
|
21
|
+
}
|
|
22
|
+
|
|
18
23
|
export function finalizeProviderRequestTools(requestTools, nativePrefixCount = 0) {
|
|
19
24
|
Object.defineProperty(requestTools, NATIVE_PREFIX_COUNT, {
|
|
20
25
|
value: Math.max(0, Math.min(requestTools.length, Number(nativePrefixCount) || 0)),
|
|
@@ -22,6 +27,7 @@ export function finalizeProviderRequestTools(requestTools, nativePrefixCount = 0
|
|
|
22
27
|
configurable: false,
|
|
23
28
|
writable: false,
|
|
24
29
|
});
|
|
30
|
+
finalizedProviderRequestTools.add(requestTools);
|
|
25
31
|
return Object.freeze(requestTools);
|
|
26
32
|
}
|
|
27
33
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from '../runtime/agent/orchestrator/providers/custom-tool-wire.mjs';
|
|
14
14
|
import {
|
|
15
15
|
finalizeProviderRequestTools,
|
|
16
|
+
isFinalizedProviderRequestTools,
|
|
16
17
|
providerNativeToolPrefixCount,
|
|
17
18
|
} from './provider-request-tools.mjs';
|
|
18
19
|
import {
|
|
@@ -101,7 +102,10 @@ export function toolSchemaBucket(tool) {
|
|
|
101
102
|
export function estimateToolSchemaBreakdown(tools) {
|
|
102
103
|
if (Array.isArray(tools)) {
|
|
103
104
|
const cached = toolSchemaBreakdownMemo.get(tools);
|
|
104
|
-
if (
|
|
105
|
+
if (cached && (
|
|
106
|
+
isFinalizedProviderRequestTools(tools)
|
|
107
|
+
|| sameToolSchemaEntries(cached, tools)
|
|
108
|
+
)) return cached.value;
|
|
105
109
|
}
|
|
106
110
|
const out = {};
|
|
107
111
|
const list = Array.isArray(tools) ? tools : [];
|
|
@@ -303,6 +303,22 @@ export function hasStreamingRowStateToPrune() {
|
|
|
303
303
|
|| streamingTailEstimateById.size > 0;
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
export function transcriptHarvestInputsEqual(left, right) {
|
|
307
|
+
return !!left && !!right
|
|
308
|
+
&& left.revision === right.revision
|
|
309
|
+
&& left.settledItems === right.settledItems
|
|
310
|
+
&& left.streamingTailItem === right.streamingTailItem
|
|
311
|
+
&& left.startIndex === right.startIndex
|
|
312
|
+
&& left.endIndex === right.endIndex
|
|
313
|
+
&& left.frameColumns === right.frameColumns
|
|
314
|
+
&& left.toolOutputExpanded === right.toolOutputExpanded
|
|
315
|
+
&& left.transcriptContentHeight === right.transcriptContentHeight
|
|
316
|
+
&& left.floatingPanelRows === right.floatingPanelRows
|
|
317
|
+
&& left.overlayHintRequested === right.overlayHintRequested
|
|
318
|
+
&& left.transcriptGuardRows === right.transcriptGuardRows
|
|
319
|
+
&& left.themeEpoch === right.themeEpoch;
|
|
320
|
+
}
|
|
321
|
+
|
|
306
322
|
/** Drop streamingMeasuredRowsById entries for ids no longer mounted, so the
|
|
307
323
|
* store does not grow unbounded over a long session (mirrors the id→item /
|
|
308
324
|
* id→callback map pruning already done for the mounted set). */
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
TRANSCRIPT_WINDOW_OVERSCAN_ROWS,
|
|
30
30
|
upperBound,
|
|
31
31
|
shiftSelectionRectY,
|
|
32
|
+
transcriptHarvestInputsEqual,
|
|
32
33
|
} from './transcript-window.mjs';
|
|
33
34
|
import { shouldSuppressFullyFailedToolItem } from '../transcript-tool-failures.mjs';
|
|
34
35
|
|
|
@@ -125,6 +126,11 @@ export function useTranscriptWindow({
|
|
|
125
126
|
// overscan band).
|
|
126
127
|
const transcriptItemElsRef = useRef(new Map());
|
|
127
128
|
const transcriptMeasureRefCache = useRef(new Map());
|
|
129
|
+
const harvestGateRef = useRef({
|
|
130
|
+
inputs: null,
|
|
131
|
+
skippedForDrag: false,
|
|
132
|
+
forceNext: false,
|
|
133
|
+
});
|
|
128
134
|
// id → latest item object for this render. The callback ref reads from here so
|
|
129
135
|
// a reused (stable) callback never captures a stale item across patches.
|
|
130
136
|
const transcriptMeasureItemsRef = useRef(new Map());
|
|
@@ -516,6 +522,20 @@ export function useTranscriptWindow({
|
|
|
516
522
|
&& floatingPanelRows <= 0
|
|
517
523
|
&& transcriptGuardRows > 0
|
|
518
524
|
&& !overlayHintOnLastItem;
|
|
525
|
+
const harvestInputs = {
|
|
526
|
+
revision,
|
|
527
|
+
settledItems,
|
|
528
|
+
streamingTailItem,
|
|
529
|
+
startIndex: transcriptWindow.startIndex,
|
|
530
|
+
endIndex: transcriptWindow.endIndex,
|
|
531
|
+
frameColumns,
|
|
532
|
+
toolOutputExpanded,
|
|
533
|
+
transcriptContentHeight,
|
|
534
|
+
floatingPanelRows,
|
|
535
|
+
overlayHintRequested,
|
|
536
|
+
transcriptGuardRows,
|
|
537
|
+
themeEpoch,
|
|
538
|
+
};
|
|
519
539
|
// ── App-level measured height harvest ───────────────────────────────────
|
|
520
540
|
// Runs after EVERY commit (no deps): Yoga has just laid out the mounted rows,
|
|
521
541
|
// so each tracked item Box's getComputedHeight() is its REAL terminal height.
|
|
@@ -528,6 +548,7 @@ export function useTranscriptWindow({
|
|
|
528
548
|
// follow path already keeps them visually stable.
|
|
529
549
|
useLayoutEffect(() => {
|
|
530
550
|
if (!TRANSCRIPT_MEASURED_ROWS) return;
|
|
551
|
+
const gate = harvestGateRef.current;
|
|
531
552
|
// Skip the per-row Yoga harvest while a drag is in progress. Edge auto-
|
|
532
553
|
// scroll commits setScrollOffset on every pointer motion, but the mounted
|
|
533
554
|
// rows' real heights do not change during a drag — only their scroll
|
|
@@ -535,9 +556,18 @@ export function useTranscriptWindow({
|
|
|
535
556
|
// variantKey check per row) for no height change, which is pure drag
|
|
536
557
|
// overhead on a tall transcript. The cached measurements stay authoritative
|
|
537
558
|
// for the row-index math; a single re-measure is forced on release below.
|
|
538
|
-
if (dragRef.current.active)
|
|
559
|
+
if (dragRef.current.active) {
|
|
560
|
+
gate.skippedForDrag = true;
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (!gate.skippedForDrag
|
|
564
|
+
&& !gate.forceNext
|
|
565
|
+
&& transcriptHarvestInputsEqual(gate.inputs, harvestInputs)) return;
|
|
539
566
|
const els = transcriptItemElsRef.current;
|
|
540
567
|
if (!els || els.size === 0) return;
|
|
568
|
+
gate.inputs = harvestInputs;
|
|
569
|
+
gate.skippedForDrag = false;
|
|
570
|
+
gate.forceNext = false;
|
|
541
571
|
const liveItems = transcriptMeasureItemsRef.current;
|
|
542
572
|
const toolExpandedFlag = toolOutputExpanded ? 1 : 0;
|
|
543
573
|
let changed = false;
|
|
@@ -655,6 +685,11 @@ export function useTranscriptWindow({
|
|
|
655
685
|
}, 0);
|
|
656
686
|
if (typeof streak.timer?.unref === 'function') streak.timer.unref();
|
|
657
687
|
}
|
|
688
|
+
// Preserve the original convergence contract: a measurement-driven
|
|
689
|
+
// render gets one confirmation harvest even when no external layout
|
|
690
|
+
// input changed. Stable rows stop there; a real oscillation continues
|
|
691
|
+
// until the existing circuit breaker trips.
|
|
692
|
+
gate.forceNext = true;
|
|
658
693
|
setMeasuredRowsVersion((v) => (v + 1) % 1000000);
|
|
659
694
|
}
|
|
660
695
|
}
|
|
@@ -80,6 +80,14 @@ export function Markdown({ children, themeEpoch = 0, trimPartialFences = false,
|
|
|
80
80
|
);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
const StableMarkdownChunk = React.memo(function StableMarkdownChunk({
|
|
84
|
+
text,
|
|
85
|
+
themeEpoch,
|
|
86
|
+
columns,
|
|
87
|
+
}) {
|
|
88
|
+
return <Markdown themeEpoch={themeEpoch} columns={columns}>{text}</Markdown>;
|
|
89
|
+
});
|
|
90
|
+
|
|
83
91
|
export function StreamingMarkdown({ children, themeEpoch = 0, columns, streamKey }) {
|
|
84
92
|
const parts = resolveStreamingMarkdownParts(children, streamKey);
|
|
85
93
|
if (parts.plain) {
|
|
@@ -89,9 +97,15 @@ export function StreamingMarkdown({ children, themeEpoch = 0, columns, streamKey
|
|
|
89
97
|
// identical visible text directly.
|
|
90
98
|
return <Text color={theme.text} wrap="wrap">{parts.unstableForRender}</Text>;
|
|
91
99
|
}
|
|
100
|
+
const stableChunks = parts.stableChunks?.length
|
|
101
|
+
? parts.stableChunks
|
|
102
|
+
: parts.stablePrefix ? [parts.stablePrefix] : [];
|
|
92
103
|
return (
|
|
93
104
|
<Box flexDirection="column" gap={1}>
|
|
94
|
-
{
|
|
105
|
+
{stableChunks.map((text, index) => (
|
|
106
|
+
<StableMarkdownChunk key={`stable-${index}`} text={text}
|
|
107
|
+
themeEpoch={themeEpoch} columns={columns} />
|
|
108
|
+
))}
|
|
95
109
|
{parts.unstableSuffix
|
|
96
110
|
? <Markdown themeEpoch={themeEpoch} columns={columns} trimPartialFences>{parts.unstableForRender}</Markdown>
|
|
97
111
|
: null}
|
|
@@ -50,7 +50,7 @@ export const AssistantMessage = React.memo(function AssistantMessage({
|
|
|
50
50
|
|
|
51
51
|
const bodyWidth = assistantBodyWidth(columns);
|
|
52
52
|
const renderText = streaming && streamingWindowRows > 0
|
|
53
|
-
? windowPlainStreamingText(text, bodyWidth, streamingWindowRows)
|
|
53
|
+
? windowPlainStreamingText(text, bodyWidth, streamingWindowRows, assistantId)
|
|
54
54
|
: text;
|
|
55
55
|
return (
|
|
56
56
|
<Box flexDirection="row" marginTop={1}>
|
|
@@ -126,6 +126,7 @@ export function PromptInput({
|
|
|
126
126
|
// that node's REAL laid-out position + our caret col/row — no external
|
|
127
127
|
// absolute-coordinate guessing, so it never drifts).
|
|
128
128
|
const boxRef = useRef(null);
|
|
129
|
+
const inkRootRef = useRef(null);
|
|
129
130
|
const cursorEnabledRef = useRef(false); // latest enabled state, read by the anchor fn at render time
|
|
130
131
|
const contentWidthRef = useRef(80);
|
|
131
132
|
const preferredColumnRef = useRef(null);
|
|
@@ -154,9 +155,15 @@ export function PromptInput({
|
|
|
154
155
|
// (slower) rendering, never a crash.
|
|
155
156
|
const flushThrottleRef = useRef({ lastAt: 0, timer: null });
|
|
156
157
|
const flushImmediate = () => {
|
|
158
|
+
const cachedRoot = inkRootRef.current;
|
|
159
|
+
if (typeof cachedRoot?.onImmediateRender === 'function') {
|
|
160
|
+
cachedRoot.onImmediateRender();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
157
163
|
let node = boxRef.current;
|
|
158
164
|
for (let i = 0; node && i < 64; i++) {
|
|
159
165
|
if (node.nodeName === 'ink-root') {
|
|
166
|
+
inkRootRef.current = node;
|
|
160
167
|
if (typeof node.onImmediateRender === 'function') node.onImmediateRender();
|
|
161
168
|
return;
|
|
162
169
|
}
|