mixdog 0.9.64 → 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/runtime/shared/turn-snapshot.mjs +184 -0
- package/src/session-runtime/context-status.mjs +2 -1
- package/src/session-runtime/provider-request-tools.mjs +6 -0
- package/src/session-runtime/runtime-core.mjs +4 -0
- package/src/session-runtime/session-turn-api.mjs +7 -0
- package/src/session-runtime/tool-catalog-schema.mjs +5 -1
- package/src/tui/App.jsx +1 -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 +431 -81
- package/src/tui/engine/frame-batched-store.mjs +5 -3
- package/src/tui/engine/live-share.mjs +100 -0
- package/src/tui/engine/render-timing.mjs +28 -0
- package/src/tui/engine/session-api-ext.mjs +10 -0
- package/src/tui/engine/tui-steering-persist.mjs +25 -4
- package/src/tui/engine.mjs +36 -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;
|
|
@@ -62,6 +62,7 @@ export function createLiveShare({
|
|
|
62
62
|
getPublishedState,
|
|
63
63
|
listeners,
|
|
64
64
|
onRemoteSubmit,
|
|
65
|
+
onRemoteAbort,
|
|
65
66
|
onOwnerClosed,
|
|
66
67
|
viewerApply,
|
|
67
68
|
}) {
|
|
@@ -73,6 +74,7 @@ export function createLiveShare({
|
|
|
73
74
|
let lastItems = null;
|
|
74
75
|
let lastTail = null;
|
|
75
76
|
let lastSpinner = null;
|
|
77
|
+
let lastLiveSig = '';
|
|
76
78
|
|
|
77
79
|
const broadcast = (frame) => {
|
|
78
80
|
if (sockets.size === 0) return;
|
|
@@ -82,12 +84,49 @@ export function createLiveShare({
|
|
|
82
84
|
}
|
|
83
85
|
};
|
|
84
86
|
|
|
87
|
+
// Live-state mirror (attach parity): the transcript alone left an attached
|
|
88
|
+
// viewer blind to the owner's activity — busy/stop state, the queued
|
|
89
|
+
// follow-up list, the Explore/Search summary, agent workers/jobs, and the
|
|
90
|
+
// context gauge stats all live in owner process state. Mirror a compact
|
|
91
|
+
// subset so viewer surfaces render them natively. queued entries are
|
|
92
|
+
// projected to display fields only (content parts may carry images).
|
|
93
|
+
const LIVE_STATS_KEYS = [
|
|
94
|
+
'currentContextSource', 'currentContextTokens', 'currentEstimatedContextTokens',
|
|
95
|
+
'currentContextUpdatedAt', 'costUsd', 'turns', 'inputTokens', 'outputTokens',
|
|
96
|
+
'latestInputTokens', 'latestPromptTokens', 'contextTokens',
|
|
97
|
+
];
|
|
98
|
+
const liveStateOf = (st) => {
|
|
99
|
+
const stats = st.stats && typeof st.stats === 'object' ? st.stats : {};
|
|
100
|
+
const statsSubset = {};
|
|
101
|
+
for (const key of LIVE_STATS_KEYS) {
|
|
102
|
+
if (stats[key] !== undefined) statsSubset[key] = stats[key];
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
busy: st.busy === true,
|
|
106
|
+
commandBusy: st.commandBusy === true,
|
|
107
|
+
queued: (Array.isArray(st.queued) ? st.queued : []).map((entry) => ({
|
|
108
|
+
id: entry?.id,
|
|
109
|
+
text: String(entry?.displayText ?? entry?.text ?? entry?.message ?? '').slice(0, 2000),
|
|
110
|
+
...(entry?.enqueuedAt ? { enqueuedAt: entry.enqueuedAt } : {}),
|
|
111
|
+
})),
|
|
112
|
+
activeToolSummary: st.activeToolSummary || null,
|
|
113
|
+
agentWorkers: Array.isArray(st.agentWorkers) ? st.agentWorkers : [],
|
|
114
|
+
agentJobs: Array.isArray(st.agentJobs) ? st.agentJobs : [],
|
|
115
|
+
ownerClientHostPid: Number(st.clientHostPid) || process.pid,
|
|
116
|
+
displayContextWindow: Number(st.displayContextWindow) || 0,
|
|
117
|
+
compactBoundaryTokens: Number(st.compactBoundaryTokens) || 0,
|
|
118
|
+
autoCompactTokenLimit: Number(st.autoCompactTokenLimit) || 0,
|
|
119
|
+
stats: statsSubset,
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
|
|
85
123
|
const fullFrame = (st) => ({
|
|
86
124
|
t: 'full',
|
|
87
125
|
sessionId: serverId,
|
|
88
126
|
items: Array.isArray(st.items) ? st.items : [],
|
|
89
127
|
tail: st.streamingTail || null,
|
|
90
128
|
spinner: st.spinner || null,
|
|
129
|
+
live: liveStateOf(st),
|
|
91
130
|
});
|
|
92
131
|
|
|
93
132
|
// Runs on every frame-batched publish. With no viewers it only re-baselines
|
|
@@ -98,6 +137,7 @@ export function createLiveShare({
|
|
|
98
137
|
lastItems = st.items;
|
|
99
138
|
lastTail = st.streamingTail;
|
|
100
139
|
lastSpinner = st.spinner;
|
|
140
|
+
lastLiveSig = '';
|
|
101
141
|
return;
|
|
102
142
|
}
|
|
103
143
|
const frame = { t: 'delta' };
|
|
@@ -155,6 +195,13 @@ export function createLiveShare({
|
|
|
155
195
|
lastSpinner = st.spinner;
|
|
156
196
|
dirty = true;
|
|
157
197
|
}
|
|
198
|
+
const live = liveStateOf(st);
|
|
199
|
+
const liveSig = JSON.stringify(live);
|
|
200
|
+
if (liveSig !== lastLiveSig) {
|
|
201
|
+
frame.live = live;
|
|
202
|
+
lastLiveSig = liveSig;
|
|
203
|
+
dirty = true;
|
|
204
|
+
}
|
|
158
205
|
if (dirty) broadcast(frame);
|
|
159
206
|
};
|
|
160
207
|
listeners.add(onPublish);
|
|
@@ -190,6 +237,10 @@ export function createLiveShare({
|
|
|
190
237
|
attachLineReader(socket, (frame) => {
|
|
191
238
|
if (frame.t === 'submit' && typeof frame.text === 'string' && frame.text.trim()) {
|
|
192
239
|
onRemoteSubmit(frame.text);
|
|
240
|
+
} else if (frame.t === 'abort') {
|
|
241
|
+
// Viewer stop button: interrupt the owner's active turn here — the
|
|
242
|
+
// viewer process has no turn of its own to cancel.
|
|
243
|
+
onRemoteAbort?.();
|
|
193
244
|
} else if (frame.t === 'sync') {
|
|
194
245
|
try { socket.write(frameLine(fullFrame(getPublishedState()))); } catch { /* close handles */ }
|
|
195
246
|
}
|
|
@@ -199,6 +250,7 @@ export function createLiveShare({
|
|
|
199
250
|
lastItems = st.items;
|
|
200
251
|
lastTail = st.streamingTail;
|
|
201
252
|
lastSpinner = st.spinner;
|
|
253
|
+
lastLiveSig = JSON.stringify(liveStateOf(st));
|
|
202
254
|
socket.write(frameLine(fullFrame(st)));
|
|
203
255
|
} catch {
|
|
204
256
|
try { socket.destroy(); } catch { /* already gone */ }
|
|
@@ -275,11 +327,48 @@ export function createLiveShare({
|
|
|
275
327
|
else viewerApply.appendItems([item]);
|
|
276
328
|
};
|
|
277
329
|
|
|
330
|
+
// Mirror the owner's live-state subset into the viewer store. Context stats
|
|
331
|
+
// merge over the local stats object so unmirrored accumulator fields keep
|
|
332
|
+
// their last local values instead of vanishing.
|
|
333
|
+
const applyLiveState = (live) => {
|
|
334
|
+
if (!live || typeof live !== 'object') return;
|
|
335
|
+
const patch = {
|
|
336
|
+
busy: live.busy === true,
|
|
337
|
+
commandBusy: live.commandBusy === true,
|
|
338
|
+
queued: Array.isArray(live.queued) ? live.queued : [],
|
|
339
|
+
activeToolSummary: live.activeToolSummary || null,
|
|
340
|
+
agentWorkers: Array.isArray(live.agentWorkers) ? live.agentWorkers : [],
|
|
341
|
+
agentJobs: Array.isArray(live.agentJobs) ? live.agentJobs : [],
|
|
342
|
+
ownerClientHostPid: Number(live.ownerClientHostPid) || 0,
|
|
343
|
+
};
|
|
344
|
+
for (const key of ['displayContextWindow', 'compactBoundaryTokens', 'autoCompactTokenLimit']) {
|
|
345
|
+
if (Number(live[key]) > 0) patch[key] = Number(live[key]);
|
|
346
|
+
}
|
|
347
|
+
if (live.stats && typeof live.stats === 'object' && Object.keys(live.stats).length > 0) {
|
|
348
|
+
const current = viewerApply.getState().stats;
|
|
349
|
+
patch.stats = { ...(current && typeof current === 'object' ? current : {}), ...live.stats };
|
|
350
|
+
}
|
|
351
|
+
viewerApply.set(patch);
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// Owner gone (clean close, crash, or pipe drop): the mirrored activity is
|
|
355
|
+
// no longer authoritative — clear it so the viewer never shows a frozen
|
|
356
|
+
// spinner/queue while the promotion path takes over.
|
|
357
|
+
const clearMirroredLiveState = () => {
|
|
358
|
+
try {
|
|
359
|
+
viewerApply?.set?.({
|
|
360
|
+
busy: false, commandBusy: false, spinner: null, queued: [],
|
|
361
|
+
activeToolSummary: null, agentWorkers: [], agentJobs: [], ownerClientHostPid: 0,
|
|
362
|
+
});
|
|
363
|
+
} catch { /* viewer store already disposed */ }
|
|
364
|
+
};
|
|
365
|
+
|
|
278
366
|
const applyViewerFrame = (frame, socket) => {
|
|
279
367
|
if (frame.t === 'full') {
|
|
280
368
|
viewerApply.replaceItems(Array.isArray(frame.items) ? frame.items : []);
|
|
281
369
|
if (frame.tail) viewerApply.updateStreamingTail(frame.tail.id, frame.tail);
|
|
282
370
|
viewerApply.set({ spinner: frame.spinner || null });
|
|
371
|
+
applyLiveState(frame.live);
|
|
283
372
|
return;
|
|
284
373
|
}
|
|
285
374
|
if (frame.t !== 'delta') return;
|
|
@@ -307,6 +396,7 @@ export function createLiveShare({
|
|
|
307
396
|
else viewerApply.clearStreamingTail();
|
|
308
397
|
}
|
|
309
398
|
if ('spinner' in frame) viewerApply.set({ spinner: frame.spinner || null });
|
|
399
|
+
if ('live' in frame) applyLiveState(frame.live);
|
|
310
400
|
};
|
|
311
401
|
|
|
312
402
|
const stopClient = () => {
|
|
@@ -335,6 +425,7 @@ export function createLiveShare({
|
|
|
335
425
|
const wasUp = clientUp && client === socket;
|
|
336
426
|
if (client === socket) { client = null; clientId = ''; clientUp = false; }
|
|
337
427
|
try { socket.destroy(); } catch { /* already gone */ }
|
|
428
|
+
if (wasUp) clearMirroredLiveState();
|
|
338
429
|
// A live link that dropped means the owner ended or crashed: nudge the
|
|
339
430
|
// promotion path instead of waiting for the next store-mtime change.
|
|
340
431
|
if (wasUp) onOwnerClosed?.(id, ownerClosed);
|
|
@@ -381,6 +472,15 @@ export function createLiveShare({
|
|
|
381
472
|
return false;
|
|
382
473
|
}
|
|
383
474
|
},
|
|
475
|
+
sendAbort() {
|
|
476
|
+
if (!clientUp || !client) return false;
|
|
477
|
+
try {
|
|
478
|
+
client.write(frameLine({ t: 'abort' }));
|
|
479
|
+
return true;
|
|
480
|
+
} catch {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
},
|
|
384
484
|
dispose() {
|
|
385
485
|
listeners.delete(onPublish);
|
|
386
486
|
stopServer();
|
|
@@ -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 = [];
|
|
@@ -515,6 +515,9 @@ export function createEngineApiB(bag) {
|
|
|
515
515
|
getUsageDashboard: async (options = {}) => {
|
|
516
516
|
return await runtime.getUsageDashboard?.(options);
|
|
517
517
|
},
|
|
518
|
+
getTurnReviewDiff: async () => {
|
|
519
|
+
return (await runtime.getTurnReviewDiff?.()) ?? { supported: false, files: [], patch: '' };
|
|
520
|
+
},
|
|
518
521
|
getOnboardingStatus: () => {
|
|
519
522
|
return runtime.getOnboardingStatus?.() || { completed: true, workflowRoutes: {} };
|
|
520
523
|
},
|
|
@@ -742,6 +745,13 @@ export function createEngineApiB(bag) {
|
|
|
742
745
|
listSessions: (options) => {
|
|
743
746
|
return runtime.listSessions(options);
|
|
744
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
|
+
},
|
|
745
755
|
deleteSession: async (id) => {
|
|
746
756
|
if (getState().commandBusy) return false;
|
|
747
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;
|
|
@@ -695,6 +703,13 @@ export async function createEngineSession({
|
|
|
695
703
|
lifecycle.runtimePulseTimer = setInterval(() => {
|
|
696
704
|
if (flags.disposed) return;
|
|
697
705
|
if (flags.pendingSessionReset) return;
|
|
706
|
+
// Attached viewer with a live pipe: the owner's frames are authoritative
|
|
707
|
+
// for stats/agent/tool state. Recomputing them locally here would stomp
|
|
708
|
+
// the mirror with this process's empty registries every 2s.
|
|
709
|
+
if (bag.liveShareMirroring?.()) {
|
|
710
|
+
set({ ...routeState() });
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
698
713
|
syncContextStats({ allowEstimated: true });
|
|
699
714
|
set({
|
|
700
715
|
...routeState(),
|
|
@@ -822,6 +837,11 @@ export async function createEngineSession({
|
|
|
822
837
|
bag.enqueue(text);
|
|
823
838
|
void bag.drain();
|
|
824
839
|
},
|
|
840
|
+
onRemoteAbort: () => {
|
|
841
|
+
// Forwarded viewer stop: interrupt OUR active turn (we are the owner).
|
|
842
|
+
if (flags.disposed || state.sessionRemoteAttached) return;
|
|
843
|
+
try { api.abort?.(); } catch { /* abort is best-effort */ }
|
|
844
|
+
},
|
|
825
845
|
onOwnerClosed: (id) => {
|
|
826
846
|
// Owner left (clean close or crash): promote via the normal quiet
|
|
827
847
|
// re-resume once its final save/presence-clear has landed.
|
|
@@ -849,6 +869,9 @@ export async function createEngineSession({
|
|
|
849
869
|
// resume() calls this right after installing the restored items so the
|
|
850
870
|
// owner's full frame lands at the entry boundary instead of seconds later.
|
|
851
871
|
bag.ensureLiveShare = () => { try { liveShare.ensure(); } catch { /* share tick retries */ } };
|
|
872
|
+
// Pulse guard: while this surface is an attached viewer with a live pipe,
|
|
873
|
+
// owner frames own stats/agent/tool state (see runtimePulseTimer above).
|
|
874
|
+
bag.liveShareMirroring = () => state.sessionRemoteAttached && liveShare.viewerConnected();
|
|
852
875
|
// Live viewer submits ride the owner's pipe (instant user bubble + shared
|
|
853
876
|
// streaming); the durable spool path below remains the fallback.
|
|
854
877
|
if (typeof api.submit === 'function') {
|
|
@@ -861,6 +884,18 @@ export async function createEngineSession({
|
|
|
861
884
|
return baseSubmit(prompt, options);
|
|
862
885
|
};
|
|
863
886
|
}
|
|
887
|
+
// Viewer stop button: the local engine has no in-flight turn to cancel —
|
|
888
|
+
// forward the interrupt to the owner over the pipe. Falls back to the local
|
|
889
|
+
// abort (no-op safe) when the pipe is down.
|
|
890
|
+
if (typeof api.abort === 'function') {
|
|
891
|
+
const baseAbort = api.abort;
|
|
892
|
+
api.abort = (...args) => {
|
|
893
|
+
if (state.sessionRemoteAttached && liveShare.viewerConnected() && liveShare.sendAbort()) {
|
|
894
|
+
return true;
|
|
895
|
+
}
|
|
896
|
+
return baseAbort(...args);
|
|
897
|
+
};
|
|
898
|
+
}
|
|
864
899
|
// Attach-time pipe fast-path: session entry (resume) reconciles the live
|
|
865
900
|
// pipe IMMEDIATELY instead of waiting for the 3s share tick. The attach
|
|
866
901
|
// render comes from the last disk save WITHOUT the in-flight turn, so that
|
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
|
}
|