mixdog 0.9.55 → 0.9.59
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 +3 -1
- package/scripts/_smoke_wd.mjs +7 -0
- package/scripts/agent-terminal-reap-test.mjs +127 -2
- package/scripts/compact-file-reattach-test.mjs +88 -0
- package/scripts/compact-pressure-test.mjs +45 -21
- package/scripts/compact-recall-digest-test.mjs +57 -0
- package/scripts/compact-smoke.mjs +35 -39
- package/scripts/desktop-session-bridge-test.mjs +271 -9
- package/scripts/generate-oc-icons.mjs +11 -0
- package/scripts/live-share-test.mjs +148 -0
- package/scripts/live-worker-smoke.mjs +1 -1
- package/scripts/max-output-recovery-test.mjs +12 -1
- package/scripts/mouse-tracking-restore-smoke.mjs +107 -0
- package/scripts/provider-admission-scheduler-test.mjs +100 -0
- package/scripts/provider-contract-test.mjs +49 -0
- package/scripts/resource-admission-test.mjs +5 -2
- package/scripts/session-ingest-compaction-smoke.mjs +38 -0
- package/scripts/steering-drain-buckets-test.mjs +15 -0
- package/scripts/submit-commandbusy-race-test.mjs +54 -3
- package/scripts/tool-smoke.mjs +2 -3
- package/scripts/tui-ambiguous-width-test.mjs +113 -0
- package/scripts/tui-runtime-load-bench.mjs +5 -0
- package/scripts/tui-transcript-perf-test.mjs +167 -0
- package/src/app.mjs +23 -0
- package/src/cli.mjs +6 -0
- package/src/lib/keychain-cjs.cjs +70 -20
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +4 -2
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +18 -17
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +111 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +61 -2
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -4
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +43 -2
- package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +112 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +203 -49
- package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +6 -2
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -15
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +109 -2
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -7
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +39 -39
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +108 -4
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +19 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -11
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +132 -24
- package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager.mjs +16 -1
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +39 -21
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +18 -0
- package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +91 -1
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +14 -33
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +169 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +474 -42
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +17 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +36 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +249 -11
- package/src/runtime/channels/data/voice-runtime-manifest.json +21 -5
- package/src/runtime/channels/lib/scheduler.mjs +8 -6
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +71 -8
- package/src/runtime/channels/lib/voice-transcription.mjs +2 -2
- package/src/runtime/channels/lib/whisper-language.mjs +4 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +7 -0
- package/src/runtime/memory/lib/embedding-worker.mjs +20 -0
- package/src/runtime/memory/lib/query-handlers.mjs +10 -4
- package/src/runtime/memory/lib/recall-format.mjs +43 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +90 -64
- package/src/runtime/shared/err-text.mjs +4 -1
- package/src/runtime/shared/resource-admission.mjs +11 -0
- package/src/runtime/shared/schedule-model-ref.mjs +28 -0
- package/src/runtime/shared/schedule-session-run.mjs +63 -0
- package/src/runtime/shared/schedules-db.mjs +8 -3
- package/src/runtime/shared/tool-result-summary.mjs +4 -1
- package/src/runtime/shared/tool-surface.mjs +7 -7
- package/src/runtime/shared/transcript-writer.mjs +17 -0
- package/src/session-runtime/channel-config-api.mjs +11 -0
- package/src/session-runtime/config-helpers.mjs +9 -6
- package/src/session-runtime/context-status.mjs +80 -3
- package/src/session-runtime/lifecycle-api.mjs +154 -40
- package/src/session-runtime/provider-auth-api.mjs +21 -2
- package/src/session-runtime/provider-usage.mjs +4 -1
- package/src/session-runtime/resource-api.mjs +12 -11
- package/src/session-runtime/runtime-core.mjs +45 -11
- package/src/session-runtime/session-text.mjs +56 -6
- package/src/session-runtime/session-turn-api.mjs +70 -2
- package/src/session-runtime/tool-catalog.mjs +2 -1
- package/src/session-runtime/workflow-agents-api.mjs +2 -2
- package/src/standalone/agent-tool/render.mjs +5 -1
- package/src/standalone/agent-tool.mjs +63 -3
- package/src/standalone/channel-admin.mjs +19 -3
- package/src/standalone/channel-daemon.mjs +40 -0
- package/src/tui/app/resume-picker.mjs +5 -1
- package/src/tui/app/settings-picker.mjs +19 -0
- package/src/tui/app/transcript-window.mjs +45 -8
- package/src/tui/app/use-mouse-input.mjs +101 -31
- package/src/tui/app/use-transcript-window.mjs +53 -9
- package/src/tui/components/ToolExecution.jsx +3 -4
- package/src/tui/components/TranscriptItem.jsx +3 -0
- package/src/tui/dist/index.mjs +17391 -14920
- package/src/tui/engine/context-state.mjs +10 -1
- package/src/tui/engine/live-share.mjs +390 -0
- package/src/tui/engine/queue-helpers.mjs +23 -0
- package/src/tui/engine/session-api-ext.mjs +439 -40
- package/src/tui/engine/session-api.mjs +7 -1
- package/src/tui/engine/session-flow.mjs +21 -7
- package/src/tui/engine/tool-card-results.mjs +3 -1
- package/src/tui/engine/turn.mjs +57 -4
- package/src/tui/engine.mjs +211 -7
- package/src/tui/index.jsx +2 -0
- package/src/tui/lib/voice-setup.mjs +10 -4
- package/src/tui/paste-attachments.mjs +4 -1
- package/src/vendor/statusline/src/gateway/session-routes.mjs +24 -1
- package/src/workflows/default/WORKFLOW.md +3 -0
- package/vendor/ink/build/display-width.js +14 -0
- package/vendor/ink/build/output.js +24 -3
- package/vendor/ink/build/wrap-text.d.ts +2 -0
- package/vendor/ink/build/wrap-text.js +45 -4
package/src/tui/engine/turn.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, ass
|
|
|
12
12
|
|
|
13
13
|
export function createRunTurn(bag) {
|
|
14
14
|
const {
|
|
15
|
-
runtime, nextId, tuiDebug, LEAD_TURN_TIMEOUT_MS, flags, pending, itemIndexById, getState, set, flushEmit, flushEmitImmediate, pushItem, appendItems, patchItem, replaceItems, updateStreamingTail: updateStreamingTailFromStore, settleStreamingTail: settleStreamingTailFromStore, clearStreamingTail: clearStreamingTailFromStore, pushNotice, pushUserOrSyntheticItem, markToolCallActive, markToolCallDone, clearActiveToolSummary, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, requestToolApproval, patchToolCardResult, flushToolResults, flushDeferredExecutionPendingResumeKick, drain, drainPendingSteering,
|
|
15
|
+
runtime, nextId, tuiDebug, LEAD_TURN_TIMEOUT_MS, flags, pending, itemIndexById, getState, set, flushEmit, flushEmitImmediate, pushItem, appendItems, patchItem, replaceItems, updateStreamingTail: updateStreamingTailFromStore, settleStreamingTail: settleStreamingTailFromStore, clearStreamingTail: clearStreamingTailFromStore, pushNotice, pushUserOrSyntheticItem, markToolCallActive, markToolCallDone, clearActiveToolSummary, agentStatusState, routeState, transcriptRouteMetadata, syncContextStats, denyAllToolApprovals, requestToolApproval, patchToolCardResult, flushToolResults, flushDeferredExecutionPendingResumeKick, drain, drainPendingSteering,
|
|
16
16
|
} = bag;
|
|
17
17
|
// Small fallbacks keep isolated createRunTurn harnesses source-compatible;
|
|
18
18
|
// the real engine supplies atomic implementations that also maintain revision.
|
|
@@ -35,6 +35,13 @@ export function createRunTurn(bag) {
|
|
|
35
35
|
async function runTurn(userText, options = {}) {
|
|
36
36
|
const turnIndex = getState().stats.turns || 0;
|
|
37
37
|
const startedAt = Date.now();
|
|
38
|
+
const completionVerb = pickDoneVerb(turnIndex);
|
|
39
|
+
const baseTranscriptMeta = flags.pendingTranscriptMeta
|
|
40
|
+
|| transcriptRouteMetadata?.(startedAt)
|
|
41
|
+
|| { at: startedAt };
|
|
42
|
+
const turnTranscriptMeta = { ...baseTranscriptMeta, completionVerb };
|
|
43
|
+
flags.pendingTranscriptMeta = null;
|
|
44
|
+
const { at: _userItemAt, ...turnRouteMeta } = turnTranscriptMeta;
|
|
38
45
|
// Per-turn epoch. Force-release (watchdog grace) bumps the shared counter so
|
|
39
46
|
// this turn's own eventual `finally` — which may run LONG after force-release
|
|
40
47
|
// already started a new turn that reuses the per-session mutex — can detect
|
|
@@ -223,6 +230,34 @@ export function createRunTurn(bag) {
|
|
|
223
230
|
const toolCards = [];
|
|
224
231
|
const toolGroups = new Map();
|
|
225
232
|
const resultsDone = new Set();
|
|
233
|
+
// ── Live shell-output tail → running tool card ─────────────────────────
|
|
234
|
+
// 1 s poll of orchestrator liveness while this turn runs. When exactly one
|
|
235
|
+
// standalone (non-aggregate) card is unresolved and the runtime reports a
|
|
236
|
+
// fresh toolOutputTail for the running tool, patch it onto the card as
|
|
237
|
+
// `liveOutput` so transcript consumers (desktop) can render live command
|
|
238
|
+
// output. The result patch clears the field; timer dies with the turn.
|
|
239
|
+
let liveTailTimer = null;
|
|
240
|
+
let lastLiveTailPatched = '';
|
|
241
|
+
const clearLiveTailTimer = () => {
|
|
242
|
+
if (liveTailTimer) { clearInterval(liveTailTimer); liveTailTimer = null; }
|
|
243
|
+
};
|
|
244
|
+
liveTailTimer = setInterval(() => {
|
|
245
|
+
if (!isCurrentTurn()) { clearLiveTailTimer(); return; }
|
|
246
|
+
let liveness = null;
|
|
247
|
+
try { liveness = runtime.getTurnLiveness?.(); } catch { return; }
|
|
248
|
+
if (!liveness || liveness.stage !== 'tool_running') return;
|
|
249
|
+
const tail = typeof liveness.toolOutputTail === 'string' ? liveness.toolOutputTail : '';
|
|
250
|
+
if (!tail || tail === lastLiveTailPatched) return;
|
|
251
|
+
const running = toolCards.filter((c) => !c.done && !c.aggregate);
|
|
252
|
+
if (running.length !== 1) return;
|
|
253
|
+
const card = running[0];
|
|
254
|
+
// Real output proves the tool is genuinely running — surface the card
|
|
255
|
+
// even if its deferred-display timer has not fired yet.
|
|
256
|
+
card.ensureVisible?.();
|
|
257
|
+
lastLiveTailPatched = tail;
|
|
258
|
+
patchItem(card.itemId, { liveOutput: tail });
|
|
259
|
+
}, 1000);
|
|
260
|
+
liveTailTimer.unref?.();
|
|
226
261
|
// Streaming providers can deliver eager onToolResult before onToolCall registers
|
|
227
262
|
// cards (send() still in flight). Hold those by callId until the batch lands.
|
|
228
263
|
const earlyResultBuffer = new Map();
|
|
@@ -518,6 +553,11 @@ export function createRunTurn(bag) {
|
|
|
518
553
|
const ensureAssistant = (initialText = '') => {
|
|
519
554
|
if (!currentAssistantId) {
|
|
520
555
|
currentAssistantId = nextId();
|
|
556
|
+
const assistantAt = Number.isFinite(Number(turnTranscriptMeta.assistantAt))
|
|
557
|
+
&& Number(turnTranscriptMeta.assistantAt) > 0
|
|
558
|
+
? Number(turnTranscriptMeta.assistantAt)
|
|
559
|
+
: Date.now();
|
|
560
|
+
turnTranscriptMeta.assistantAt = assistantAt;
|
|
521
561
|
// Do NOT reset currentAssistantText here. The first onTextDelta has
|
|
522
562
|
// already accumulated the opening chunk before this batched flush runs;
|
|
523
563
|
// wiping it dropped the leading characters and forced a later set() to
|
|
@@ -525,7 +565,7 @@ export function createRunTurn(bag) {
|
|
|
525
565
|
// Seed the new row with the already-visible text so the ● gutter and the
|
|
526
566
|
// first body line appear in the SAME set()/emit() — no empty "●-only"
|
|
527
567
|
// row that scrolls once on its own and again when the body lands.
|
|
528
|
-
updateStreamingTail(currentAssistantId, { text: String(initialText || '') });
|
|
568
|
+
updateStreamingTail(currentAssistantId, { text: String(initialText || ''), at: assistantAt, ...turnRouteMeta });
|
|
529
569
|
}
|
|
530
570
|
return currentAssistantId;
|
|
531
571
|
};
|
|
@@ -661,7 +701,15 @@ export function createRunTurn(bag) {
|
|
|
661
701
|
const id = ensureAssistant(streamingVisibleText);
|
|
662
702
|
const current = getState().streamingTail;
|
|
663
703
|
if (!current || current.id !== id || !Object.is(current.text, streamingVisibleText)) {
|
|
664
|
-
patch.streamingTail = {
|
|
704
|
+
patch.streamingTail = {
|
|
705
|
+
...(current || {}),
|
|
706
|
+
kind: 'assistant',
|
|
707
|
+
id,
|
|
708
|
+
text: streamingVisibleText,
|
|
709
|
+
streaming: true,
|
|
710
|
+
at: current?.at || Date.now(),
|
|
711
|
+
...turnRouteMeta,
|
|
712
|
+
};
|
|
665
713
|
}
|
|
666
714
|
}
|
|
667
715
|
// Only touch the spinner when there is a real reason: a visible-line
|
|
@@ -799,6 +847,7 @@ export function createRunTurn(bag) {
|
|
|
799
847
|
|
|
800
848
|
try {
|
|
801
849
|
const { result, session } = await runtime.ask(userText, {
|
|
850
|
+
transcriptMeta: turnTranscriptMeta,
|
|
802
851
|
drainSteering: (_sessionId, drainOptions) => (isCurrentTurn() ? drainPendingSteering(drainOptions) : []),
|
|
803
852
|
onStreamDelta: () => {
|
|
804
853
|
markTurnProgress('stream-delta');
|
|
@@ -1096,10 +1145,13 @@ export function createRunTurn(bag) {
|
|
|
1096
1145
|
if (currentAssistantText) {
|
|
1097
1146
|
set({
|
|
1098
1147
|
streamingTail: {
|
|
1148
|
+
...(getState().streamingTail || {}),
|
|
1099
1149
|
kind: 'assistant',
|
|
1100
1150
|
id: currentAssistantId,
|
|
1101
1151
|
text: currentAssistantText,
|
|
1102
1152
|
streaming: true,
|
|
1153
|
+
at: getState().streamingTail?.at || Date.now(),
|
|
1154
|
+
...turnRouteMeta,
|
|
1103
1155
|
},
|
|
1104
1156
|
});
|
|
1105
1157
|
} else {
|
|
@@ -1249,6 +1301,7 @@ export function createRunTurn(bag) {
|
|
|
1249
1301
|
// Turn is unwinding normally (or via abort) — cancel the idle watchdog and
|
|
1250
1302
|
// its force-release grace so they never fire on a live turn.
|
|
1251
1303
|
clearWatchdog();
|
|
1304
|
+
clearLiveTailTimer();
|
|
1252
1305
|
// If the watchdog force-release already fired, a NEWER turn now owns the
|
|
1253
1306
|
// shared store (busy, flags.activePromptRestore, turndone, drain). This stale
|
|
1254
1307
|
// unwind must NOT write shared getState() or it corrupts that turn.
|
|
@@ -1314,7 +1367,7 @@ export function createRunTurn(bag) {
|
|
|
1314
1367
|
// in scrollback. (Previously TurnDone rendered only in the
|
|
1315
1368
|
// bottom-fixed live-status slot and vanished on the next turn.)
|
|
1316
1369
|
if (!reclaimed && !isNoOpTurn) {
|
|
1317
|
-
closingItems.push({ kind: 'turndone', id: nextId(), elapsedMs, status: turnStatus, outputTokens: finalOutputTokens, thinkingElapsedMs, verb:
|
|
1370
|
+
closingItems.push({ kind: 'turndone', id: nextId(), elapsedMs, status: turnStatus, outputTokens: finalOutputTokens, thinkingElapsedMs, verb: completionVerb, at: Date.now(), ...turnRouteMeta });
|
|
1318
1371
|
}
|
|
1319
1372
|
// Deferred cards + turndone + status all land in ONE set() (one commit).
|
|
1320
1373
|
appendItemsBatch(closingItems, {
|
package/src/tui/engine.mjs
CHANGED
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
* This file keeps the stateful createEngineSession store + notification plan.
|
|
12
12
|
*/
|
|
13
13
|
import { performance } from 'node:perf_hooks';
|
|
14
|
-
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from 'node:fs';
|
|
15
15
|
import { randomUUID } from 'node:crypto';
|
|
16
16
|
import { tmpdir } from 'node:os';
|
|
17
|
-
import { join } from 'node:path';
|
|
17
|
+
import { basename, dirname, join } from 'node:path';
|
|
18
18
|
import { Worker } from 'node:worker_threads';
|
|
19
19
|
import {
|
|
20
20
|
aggregateToolCategoryEntry,
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from '../runtime/shared/tool-execution-contract.mjs';
|
|
29
29
|
import { isLateToolAnnouncement } from '../session-runtime/session-text.mjs';
|
|
30
30
|
import { presentErrorText } from '../runtime/shared/err-text.mjs';
|
|
31
|
+
import { sessionPath } from '../runtime/agent/orchestrator/session/store/paths-heartbeat.mjs';
|
|
31
32
|
import { listThemes, getThemeSetting, setThemeSetting } from './theme.mjs';
|
|
32
33
|
import { resetAllStreamingMarkdownStablePrefixes } from './markdown/streaming-markdown.mjs';
|
|
33
34
|
import { bootProfile } from './engine/boot-profile.mjs';
|
|
@@ -104,6 +105,8 @@ import { createSessionFlow } from './engine/session-flow.mjs';
|
|
|
104
105
|
import { createRunTurn } from './engine/turn.mjs';
|
|
105
106
|
import { createEngineApi } from './engine/session-api.mjs';
|
|
106
107
|
import { createFrameBatchedStorePublisher } from './engine/frame-batched-store.mjs';
|
|
108
|
+
import { createLiveShare, liveSharePipePath } from './engine/live-share.mjs';
|
|
109
|
+
import { displayModelName } from '../ui/model-display.mjs';
|
|
107
110
|
|
|
108
111
|
// Source tests resolve from src/tui/engine.mjs; the built bundle resolves from
|
|
109
112
|
// src/tui/dist/index.mjs.
|
|
@@ -189,7 +192,9 @@ export function cleanupStaleTranscriptSpillDirs({
|
|
|
189
192
|
export function createTranscriptSpillBuffer({
|
|
190
193
|
cap = TRANSCRIPT_LIVE_ITEM_CAP,
|
|
191
194
|
chunkSize = TRANSCRIPT_SPILL_CHUNK_ITEMS,
|
|
192
|
-
|
|
195
|
+
// stdout/stderr: worker threads otherwise copy straight into the REAL fds,
|
|
196
|
+
// bypassing the TUI stderr guard and printing over the terminal frame.
|
|
197
|
+
workerFactory = (source) => new Worker(source, { eval: true, stdout: true, stderr: true }),
|
|
193
198
|
onWarning = (message) => tuiDebug(message),
|
|
194
199
|
writeTimeoutMs = 5000,
|
|
195
200
|
} = {}) {
|
|
@@ -269,6 +274,8 @@ export function createTranscriptSpillBuffer({
|
|
|
269
274
|
const worker = workerFactory(workerSource);
|
|
270
275
|
spillWorker = worker;
|
|
271
276
|
workerSpawnCount += 1;
|
|
277
|
+
worker.stdout?.on?.('data', (chunk) => { try { process.stderr.write(chunk); } catch { /* best-effort */ } });
|
|
278
|
+
worker.stderr?.on?.('data', (chunk) => { try { process.stderr.write(chunk); } catch { /* best-effort */ } });
|
|
272
279
|
worker.on('message', (result) => {
|
|
273
280
|
if (spillWorker !== worker || result?.id !== activeWrite?.id) return;
|
|
274
281
|
finishWrite(result?.ok === true, result?.error);
|
|
@@ -644,6 +651,7 @@ export async function createEngineSession({
|
|
|
644
651
|
activePromptRestore: null,
|
|
645
652
|
pushingFromDeferredEntry: false,
|
|
646
653
|
flushDeferredBeforeImmediatePush: null,
|
|
654
|
+
pendingTranscriptMeta: null,
|
|
647
655
|
};
|
|
648
656
|
const lifecycle = { runtimePulseTimer: null, unsubscribeRuntimeNotifications: null, unsubscribeRemoteState: null };
|
|
649
657
|
const pending = [];
|
|
@@ -912,6 +920,17 @@ export async function createEngineSession({
|
|
|
912
920
|
activeToolCalls.clear();
|
|
913
921
|
if (state.activeToolSummary) set({ activeToolSummary: null });
|
|
914
922
|
};
|
|
923
|
+
const transcriptRouteMetadata = (at = Date.now()) => {
|
|
924
|
+
const route = routeState();
|
|
925
|
+
const modelName = displayModelName(route.model, route.provider);
|
|
926
|
+
const workflowLabel = String(route.workflow?.name || route.workflow?.id || '').trim();
|
|
927
|
+
return {
|
|
928
|
+
at,
|
|
929
|
+
...(modelName ? { model: modelName } : {}),
|
|
930
|
+
...(route.provider ? { provider: String(route.provider) } : {}),
|
|
931
|
+
...(workflowLabel ? { agent: workflowLabel } : {}),
|
|
932
|
+
};
|
|
933
|
+
};
|
|
915
934
|
const pushItem = (item) => {
|
|
916
935
|
if (!flags.pushingFromDeferredEntry && flags.flushDeferredBeforeImmediatePush) {
|
|
917
936
|
flags.flushDeferredBeforeImmediatePush();
|
|
@@ -1000,7 +1019,7 @@ export async function createEngineSession({
|
|
|
1000
1019
|
});
|
|
1001
1020
|
return true;
|
|
1002
1021
|
};
|
|
1003
|
-
const pushUserOrSyntheticItem = (text, id = nextId(), origin = 'user') => {
|
|
1022
|
+
const pushUserOrSyntheticItem = (text, id = nextId(), origin = 'user', extra = null) => {
|
|
1004
1023
|
// The lenient shape-only wrapper check is display-suppression only and
|
|
1005
1024
|
// must never hide a real, directly-typed/pasted user prompt just because
|
|
1006
1025
|
// it happens to resemble "instruction + Result: + quoted body". Only
|
|
@@ -1021,7 +1040,15 @@ export async function createEngineSession({
|
|
|
1021
1040
|
// up-arrow history survives across sessions. Runs before pushItem so the
|
|
1022
1041
|
// merge in pushItem's user branch (loadPromptHistory) already sees it.
|
|
1023
1042
|
if (origin === 'user') appendPromptHistory(state.cwd, text);
|
|
1024
|
-
|
|
1043
|
+
const transcriptMeta = transcriptRouteMetadata();
|
|
1044
|
+
if (origin === 'user') flags.pendingTranscriptMeta = transcriptMeta;
|
|
1045
|
+
pushItem({
|
|
1046
|
+
kind: 'user', id, text, ...transcriptMeta,
|
|
1047
|
+
// Byte-free attachment metadata (name/mime/size) from the queue entry —
|
|
1048
|
+
// lets the desktop transcript render image chips without ever carrying
|
|
1049
|
+
// base64 payloads through snapshots.
|
|
1050
|
+
...(extra && Array.isArray(extra.images) && extra.images.length ? { images: extra.images } : {}),
|
|
1051
|
+
});
|
|
1025
1052
|
};
|
|
1026
1053
|
const pushAsyncAgentResponse = (text, id = nextId(), origin = 'injected', metadata = {}) => {
|
|
1027
1054
|
const synthetic = parseSyntheticAgentMessage(text);
|
|
@@ -1219,7 +1246,7 @@ export async function createEngineSession({
|
|
|
1219
1246
|
pushToast, pushNotice, removeNotice, setProgressHint,
|
|
1220
1247
|
pushUserOrSyntheticItem, pushAsyncAgentResponse, upsertSyntheticToolItem,
|
|
1221
1248
|
markToolCallActive, markToolCallDone, clearActiveToolSummary, clearToastTimers,
|
|
1222
|
-
autoClearState, agentStatusState, baseRouteState, routeState, syncContextStats,
|
|
1249
|
+
autoClearState, agentStatusState, baseRouteState, routeState, transcriptRouteMetadata, syncContextStats,
|
|
1223
1250
|
disposeTranscriptSpill: () => transcriptSpill.dispose(),
|
|
1224
1251
|
snapshotTranscriptSpill: () => transcriptSpill.snapshot(),
|
|
1225
1252
|
restoreTranscriptSpill: (snapshot) => transcriptSpill.restoreSnapshot(snapshot),
|
|
@@ -1230,6 +1257,183 @@ export async function createEngineSession({
|
|
|
1230
1257
|
});
|
|
1231
1258
|
Object.assign(bag, createSessionFlow(bag));
|
|
1232
1259
|
bag.runTurn = createRunTurn(bag);
|
|
1260
|
+
const api = createEngineApi(bag);
|
|
1261
|
+
// Cross-surface share: presence + the durable pending spool remain the
|
|
1262
|
+
// ownership/base layer; a local pipe layer (live-share) streams frame
|
|
1263
|
+
// deltas so co-open surfaces mirror each other in real time.
|
|
1264
|
+
// - presence: mark OUR current session as held open (idle included) so a
|
|
1265
|
+
// cross-open elsewhere attaches as a viewer instead of splitting
|
|
1266
|
+
// ownership; cleared on session switch here and on dispose
|
|
1267
|
+
// (session-api-ext), with sidecar staleness covering crashes.
|
|
1268
|
+
// - owner leg: host the live pipe, push transcript/tail/spinner deltas,
|
|
1269
|
+
// and run foreign submits through the normal queue — full user bubble +
|
|
1270
|
+
// streaming on every surface. The spool drain stays as the fallback
|
|
1271
|
+
// intake (instant via fs.watch, 3s tick as safety net).
|
|
1272
|
+
// - viewer leg: connect to the owner's pipe and mirror its live state.
|
|
1273
|
+
// While the pipe is up the disk-mtime re-resume is skipped (no turn-end
|
|
1274
|
+
// flicker); when the owner disappears the quiet re-resume promotes this
|
|
1275
|
+
// surface to real ownership.
|
|
1276
|
+
let heldPresenceId = '';
|
|
1277
|
+
let viewerStoreMtime = 0;
|
|
1278
|
+
const drainRemoteInjections = () => {
|
|
1279
|
+
const injected = runtime.takeRemoteInjections?.() || [];
|
|
1280
|
+
if (injected.length === 0) return;
|
|
1281
|
+
for (const text of injected) bag.enqueue(text);
|
|
1282
|
+
void bag.drain();
|
|
1283
|
+
};
|
|
1284
|
+
const liveShare = createLiveShare({
|
|
1285
|
+
ownerSessionId: () => (flags.disposed || flags.pendingSessionReset || state.sessionRemoteAttached
|
|
1286
|
+
? '' : String(state.sessionId || '')),
|
|
1287
|
+
viewerSessionId: () => (flags.disposed || !state.sessionRemoteAttached
|
|
1288
|
+
? '' : String(state.sessionId || '')),
|
|
1289
|
+
socketPathFor: (id) => liveSharePipePath(id, sessionPath(id)),
|
|
1290
|
+
getPublishedState: () => publishedState,
|
|
1291
|
+
listeners,
|
|
1292
|
+
onRemoteSubmit: (text) => {
|
|
1293
|
+
if (flags.disposed || state.sessionRemoteAttached) return;
|
|
1294
|
+
bag.enqueue(text);
|
|
1295
|
+
void bag.drain();
|
|
1296
|
+
},
|
|
1297
|
+
onOwnerClosed: (id) => {
|
|
1298
|
+
// Owner left (clean close or crash): promote via the normal quiet
|
|
1299
|
+
// re-resume once its final save/presence-clear has landed.
|
|
1300
|
+
const timer = setTimeout(() => {
|
|
1301
|
+
if (flags.disposed || !state.sessionRemoteAttached) return;
|
|
1302
|
+
if (String(state.sessionId || '') !== id) return;
|
|
1303
|
+
void Promise.resolve(api.resume(id, { quiet: true })).catch(() => { /* tick retries */ });
|
|
1304
|
+
}, 1500);
|
|
1305
|
+
timer.unref?.();
|
|
1306
|
+
},
|
|
1307
|
+
viewerApply: {
|
|
1308
|
+
getState: () => state,
|
|
1309
|
+
set,
|
|
1310
|
+
replaceItems: (...args) => bag.replaceItems(...args),
|
|
1311
|
+
patchItem: (...args) => bag.patchItem(...args),
|
|
1312
|
+
appendItems: (...args) => bag.appendItems(...args),
|
|
1313
|
+
updateStreamingTail: (...args) => bag.updateStreamingTail(...args),
|
|
1314
|
+
clearStreamingTail: (...args) => bag.clearStreamingTail(...args),
|
|
1315
|
+
},
|
|
1316
|
+
});
|
|
1317
|
+
// Immediate live-share reconcile for session entry/promotion. Waiting for
|
|
1318
|
+
// the 3s share tick left a just-resumed live-owned session showing the
|
|
1319
|
+
// stale disk snapshot, then full-swapped the transcript mid-view once the
|
|
1320
|
+
// pipe finally connected (visible up/down lurch until heights resettled).
|
|
1321
|
+
// resume() calls this right after installing the restored items so the
|
|
1322
|
+
// owner's full frame lands at the entry boundary instead of seconds later.
|
|
1323
|
+
bag.ensureLiveShare = () => { try { liveShare.ensure(); } catch { /* share tick retries */ } };
|
|
1324
|
+
// Live viewer submits ride the owner's pipe (instant user bubble + shared
|
|
1325
|
+
// streaming); the durable spool path below remains the fallback.
|
|
1326
|
+
if (typeof api.submit === 'function') {
|
|
1327
|
+
const baseSubmit = api.submit;
|
|
1328
|
+
api.submit = (prompt, options = {}) => {
|
|
1329
|
+
if (state.sessionRemoteAttached && liveShare.viewerConnected()) {
|
|
1330
|
+
const text = String(promptDisplayText(prompt, options) || '').trim();
|
|
1331
|
+
if (text && liveShare.sendSubmit(text)) return true;
|
|
1332
|
+
}
|
|
1333
|
+
return baseSubmit(prompt, options);
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
// Attach-time pipe fast-path: session entry (resume) reconciles the live
|
|
1337
|
+
// pipe IMMEDIATELY instead of waiting for the 3s share tick. The attach
|
|
1338
|
+
// render comes from the last disk save WITHOUT the in-flight turn, so that
|
|
1339
|
+
// tick-wide window is exactly when a running tool call / mid-turn
|
|
1340
|
+
// conversation looks missing and then pops in late (user report). The
|
|
1341
|
+
// owner leg benefits equally: its pipe server starts the moment the
|
|
1342
|
+
// session opens, so cross-surface viewers can connect at once.
|
|
1343
|
+
const reconcileLiveShareNow = () => { try { liveShare.ensure(); } catch { /* tick retries */ } };
|
|
1344
|
+
for (const method of ['resume', 'newSession', 'switchContext']) {
|
|
1345
|
+
if (typeof api[method] !== 'function') continue;
|
|
1346
|
+
const base = api[method].bind(api);
|
|
1347
|
+
api[method] = async (...args) => {
|
|
1348
|
+
const result = await base(...args);
|
|
1349
|
+
reconcileLiveShareNow();
|
|
1350
|
+
if (method === 'resume' && result === true && state.sessionRemoteAttached) {
|
|
1351
|
+
const id = String(state.sessionId || '');
|
|
1352
|
+
// EngineHost holds renderer publications across resume. Keep that hold
|
|
1353
|
+
// until the owner's first FULL frame replaces the persisted transcript,
|
|
1354
|
+
// then synchronously publish the complete draft before getState().
|
|
1355
|
+
// Old owners without live-share fall back to the disk restore after the
|
|
1356
|
+
// short bounded wait rather than blocking session entry indefinitely.
|
|
1357
|
+
if (id && await liveShare.waitForViewerSync(id)) bag.flushEmit();
|
|
1358
|
+
}
|
|
1359
|
+
return result;
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
// Instant input pickup: watch the shared pending spool so an attached
|
|
1363
|
+
// surface's fallback submit reaches this owner immediately instead of on
|
|
1364
|
+
// the 3s tick. Best-effort — the tick below remains the safety net.
|
|
1365
|
+
let spoolWatcher = null;
|
|
1366
|
+
let spoolDebounce = null;
|
|
1367
|
+
try {
|
|
1368
|
+
const spoolPath = String(runtime.pendingSpoolPath?.() || '');
|
|
1369
|
+
if (spoolPath) {
|
|
1370
|
+
const spoolFile = basename(spoolPath);
|
|
1371
|
+
spoolWatcher = watch(dirname(spoolPath), { persistent: false }, (_event, filename) => {
|
|
1372
|
+
if (filename && String(filename) !== spoolFile) return;
|
|
1373
|
+
if (flags.disposed || spoolDebounce) return;
|
|
1374
|
+
spoolDebounce = setTimeout(() => {
|
|
1375
|
+
spoolDebounce = null;
|
|
1376
|
+
if (flags.disposed || flags.pendingSessionReset) return;
|
|
1377
|
+
if (state.busy || state.commandBusy || state.sessionRemoteAttached) return;
|
|
1378
|
+
try { drainRemoteInjections(); } catch { /* tick fallback */ }
|
|
1379
|
+
}, 120);
|
|
1380
|
+
spoolDebounce.unref?.();
|
|
1381
|
+
});
|
|
1382
|
+
spoolWatcher.on?.('error', () => {
|
|
1383
|
+
try { spoolWatcher.close(); } catch { /* already closed */ }
|
|
1384
|
+
spoolWatcher = null;
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
} catch { /* spool watch is an optimization; the 3s tick remains */ }
|
|
1388
|
+
const remoteAttachTimer = setInterval(() => {
|
|
1389
|
+
if (flags.disposed) {
|
|
1390
|
+
clearInterval(remoteAttachTimer);
|
|
1391
|
+
try { liveShare.dispose(); } catch { /* best-effort */ }
|
|
1392
|
+
try { spoolWatcher?.close(); } catch { /* already closed */ }
|
|
1393
|
+
if (spoolDebounce) { clearTimeout(spoolDebounce); spoolDebounce = null; }
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
if (flags.pendingSessionReset) return;
|
|
1397
|
+
try {
|
|
1398
|
+
const heldId = runtime.publishSessionPresence?.() || '';
|
|
1399
|
+
if (heldPresenceId && heldPresenceId !== heldId) runtime.clearSessionPresence?.(heldPresenceId);
|
|
1400
|
+
heldPresenceId = heldId;
|
|
1401
|
+
} catch { /* best-effort */ }
|
|
1402
|
+
try { liveShare.ensure(); } catch { /* next tick retries */ }
|
|
1403
|
+
try {
|
|
1404
|
+
if (state.busy || state.commandBusy) return;
|
|
1405
|
+
if (state.sessionRemoteAttached) {
|
|
1406
|
+
const id = String(state.sessionId || '');
|
|
1407
|
+
if (!id) return;
|
|
1408
|
+
// Pipe-connected viewers follow the owner live; the disk-mtime
|
|
1409
|
+
// re-resume would only reload mid-stream state and flicker.
|
|
1410
|
+
if (liveShare.viewerConnected()) { viewerStoreMtime = 0; return; }
|
|
1411
|
+
// Self-heal: a force-killed owner never announces onOwnerClosed and
|
|
1412
|
+
// its final save never bumps the store mtime, so without this probe
|
|
1413
|
+
// the surface stays a viewer forever, spooling messages to nobody.
|
|
1414
|
+
// When the resume guard says the owner is gone, promote via the same
|
|
1415
|
+
// quiet re-resume (it drains the pending spool on the next tick).
|
|
1416
|
+
if (runtime.sessionOwnerGone?.(id) === true) {
|
|
1417
|
+
viewerStoreMtime = 0;
|
|
1418
|
+
void Promise.resolve(api.resume(id, { quiet: true })).catch(() => { /* next tick retries */ });
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
let mtime = 0;
|
|
1422
|
+
try { mtime = statSync(sessionPath(id)).mtimeMs || 0; } catch { return; }
|
|
1423
|
+
// First attached tick only baselines: the resume that attached this
|
|
1424
|
+
// surface already loaded the current on-disk transcript.
|
|
1425
|
+
if (!viewerStoreMtime) { viewerStoreMtime = mtime; return; }
|
|
1426
|
+
if (mtime > viewerStoreMtime) {
|
|
1427
|
+
viewerStoreMtime = mtime;
|
|
1428
|
+
void Promise.resolve(api.resume(id, { quiet: true })).catch(() => { /* next tick retries */ });
|
|
1429
|
+
}
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
viewerStoreMtime = 0;
|
|
1433
|
+
drainRemoteInjections();
|
|
1434
|
+
} catch { /* best-effort */ }
|
|
1435
|
+
}, 3000);
|
|
1436
|
+
remoteAttachTimer.unref?.();
|
|
1233
1437
|
void bag.restoreLeadSteeringFromDisk();
|
|
1234
|
-
return
|
|
1438
|
+
return api;
|
|
1235
1439
|
}
|
package/src/tui/index.jsx
CHANGED
|
@@ -12,6 +12,7 @@ import { dirname, join } from 'node:path';
|
|
|
12
12
|
import { performance } from 'node:perf_hooks';
|
|
13
13
|
import { format } from 'node:util';
|
|
14
14
|
import { App } from './App.jsx';
|
|
15
|
+
import { cancelPendingMouseTrackingRestores } from './app/use-mouse-input.mjs';
|
|
15
16
|
import { createEngineSession } from './engine.mjs';
|
|
16
17
|
import { scheduleRenderFrameAck } from './engine/render-timing.mjs';
|
|
17
18
|
import { installProcessSignalCleanup } from '../runtime/shared/process-shutdown.mjs';
|
|
@@ -451,6 +452,7 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
|
|
|
451
452
|
const restoreTerminal = () => {
|
|
452
453
|
if (restored) return;
|
|
453
454
|
restored = true;
|
|
455
|
+
cancelPendingMouseTrackingRestores();
|
|
454
456
|
restorePrimedInput();
|
|
455
457
|
try {
|
|
456
458
|
process.stdout.write(
|
|
@@ -50,11 +50,14 @@ export function isVoiceEnabled() {
|
|
|
50
50
|
/** Read the managed runtime state without installing or mutating anything. */
|
|
51
51
|
export async function getVoiceStatus({ dataDir = resolvePluginData() } = {}) {
|
|
52
52
|
const fetcher = await loadVoiceRuntimeFetcher();
|
|
53
|
-
const runtime = fetcher.resolveVoiceRuntime(dataDir
|
|
53
|
+
const runtime = fetcher.resolveVoiceRuntime(dataDir, {
|
|
54
|
+
modelId: fetcher.selectVoiceModelId(readSection('voice')),
|
|
55
|
+
});
|
|
54
56
|
return {
|
|
55
57
|
enabled: isVoiceEnabled(),
|
|
56
58
|
busy: isVoiceInstallBusy(),
|
|
57
59
|
installed: runtime.installed === true,
|
|
60
|
+
modelId: runtime.modelId,
|
|
58
61
|
components: {
|
|
59
62
|
whisper: Boolean(runtime.binary && runtime.serverCmd),
|
|
60
63
|
model: Boolean(runtime.model),
|
|
@@ -112,7 +115,10 @@ function makeThrottledProgressNotice({ pushNotice, setProgressHint } = {}, inter
|
|
|
112
115
|
*/
|
|
113
116
|
export async function ensureVoiceRuntimeReady({ dataDir = resolvePluginData(), pushNotice, setProgressHint } = {}) {
|
|
114
117
|
const fetcher = await loadVoiceRuntimeFetcher();
|
|
115
|
-
|
|
118
|
+
// System-language default: Korean devices install the Korean fine-tune,
|
|
119
|
+
// everything else the standard multilingual Q8. voice.model overrides.
|
|
120
|
+
const modelId = fetcher.selectVoiceModelId(readSection('voice'));
|
|
121
|
+
let runtime = fetcher.resolveVoiceRuntime(dataDir, { modelId });
|
|
116
122
|
if (runtime.installed) return runtime;
|
|
117
123
|
|
|
118
124
|
const onProgress = makeThrottledProgressNotice({ pushNotice, setProgressHint });
|
|
@@ -120,13 +126,13 @@ export async function ensureVoiceRuntimeReady({ dataDir = resolvePluginData(), p
|
|
|
120
126
|
await fetcher.ensureWhisperRuntime(dataDir, onProgress);
|
|
121
127
|
}
|
|
122
128
|
if (!runtime.model) {
|
|
123
|
-
await fetcher.ensureWhisperModel(dataDir, onProgress);
|
|
129
|
+
await fetcher.ensureWhisperModel(dataDir, onProgress, modelId);
|
|
124
130
|
}
|
|
125
131
|
if (!runtime.ffmpeg) {
|
|
126
132
|
await fetcher.ensureFfmpegRuntime(dataDir, onProgress);
|
|
127
133
|
}
|
|
128
134
|
|
|
129
|
-
runtime = fetcher.resolveVoiceRuntime(dataDir);
|
|
135
|
+
runtime = fetcher.resolveVoiceRuntime(dataDir, { modelId });
|
|
130
136
|
if (!runtime.installed) {
|
|
131
137
|
throw new Error('voice runtime install did not complete (still missing a required component)');
|
|
132
138
|
}
|
|
@@ -145,7 +145,10 @@ export function splitPastedImagePathCandidates(text) {
|
|
|
145
145
|
return out;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
// Exported for the desktop engine capability surface (resizeImage): the GUI
|
|
149
|
+
// composer routes its attachments through this SAME pipeline so desktop and
|
|
150
|
+
// TUI submit byte-identical image payloads.
|
|
151
|
+
export async function imageAttachmentFromBuffer(buffer, mimeType, { filename = 'Pasted image', sourcePath = '' } = {}) {
|
|
149
152
|
if (!Buffer.isBuffer(buffer) || buffer.length === 0) throw new Error('image is empty');
|
|
150
153
|
const ext = (mimeType || 'image/png').split('/')[1] || 'png';
|
|
151
154
|
const resized = await resizeImageBuffer(buffer, ext);
|
|
@@ -6,6 +6,24 @@ import { CLAUDE_CURRENT_MODE } from './claude-current.mjs';
|
|
|
6
6
|
|
|
7
7
|
const STORE_FILE = 'gateway-session-routes.json';
|
|
8
8
|
const UUIDISH_SESSION_RE = /^[0-9a-z][0-9a-z._-]{7,}$/i;
|
|
9
|
+
// Route entries were written forever and never pruned — a two-week desktop
|
|
10
|
+
// installation reached a 1.1MB store that readStore() parsed synchronously on
|
|
11
|
+
// every route lookup. Bound the store by age and count; a route older than the
|
|
12
|
+
// TTL belongs to a session whose provider pin is long since irrelevant.
|
|
13
|
+
const ROUTE_TTL_MS = 14 * 24 * 60 * 60 * 1000;
|
|
14
|
+
const MAX_SESSION_ROUTES = 300;
|
|
15
|
+
const MAX_HOST_ROUTES = 100;
|
|
16
|
+
|
|
17
|
+
function pruneRouteMap(map, now, cap) {
|
|
18
|
+
const entries = Object.entries(map || {})
|
|
19
|
+
.filter(([, route]) => {
|
|
20
|
+
const at = Number(route?.updatedAt) || 0;
|
|
21
|
+
return at > 0 && (now - at) <= ROUTE_TTL_MS;
|
|
22
|
+
})
|
|
23
|
+
.sort((a, b) => (Number(b[1]?.updatedAt) || 0) - (Number(a[1]?.updatedAt) || 0))
|
|
24
|
+
.slice(0, cap);
|
|
25
|
+
return Object.fromEntries(entries);
|
|
26
|
+
}
|
|
9
27
|
|
|
10
28
|
function cleanString(value) {
|
|
11
29
|
const s = typeof value === 'string' ? value.trim() : '';
|
|
@@ -163,7 +181,12 @@ export function writeGatewaySessionRoutes(entries = []) {
|
|
|
163
181
|
sessions[sid] = { ...route, sessionId: sid, updatedAt };
|
|
164
182
|
}
|
|
165
183
|
}
|
|
166
|
-
return {
|
|
184
|
+
return {
|
|
185
|
+
version: 2,
|
|
186
|
+
updatedAt,
|
|
187
|
+
sessions: pruneRouteMap(sessions, updatedAt, MAX_SESSION_ROUTES),
|
|
188
|
+
sessionHosts: pruneRouteMap(sessionHosts, updatedAt, MAX_HOST_ROUTES),
|
|
189
|
+
};
|
|
167
190
|
}, { compact: true, fsync: false, fsyncDir: false });
|
|
168
191
|
return true;
|
|
169
192
|
} catch {
|
|
@@ -15,6 +15,9 @@ approval.
|
|
|
15
15
|
|
|
16
16
|
On approval, fan out at maximum width: one agent per independent scope, all
|
|
17
17
|
spawned in one turn; only a scope that depends on another's output waits.
|
|
18
|
+
Split the plan into as many scopes as possible: disjoint file/module sets
|
|
19
|
+
are independent; merge only on a true output dependency. Prefer parallel
|
|
20
|
+
scopes over sequential slices in one agent.
|
|
18
21
|
|
|
19
22
|
Route by complexity: simple, well-understood implementation goes to Worker;
|
|
20
23
|
complex or investigative implementation goes to Heavy Worker; Lead itself
|
|
@@ -72,6 +72,20 @@ export function displayStringWidth(str) {
|
|
|
72
72
|
return displayWidthWith(str, AMBIGUOUS_WIDE);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Extra terminal cells that must be emitted as literal spaces after a glyph.
|
|
77
|
+
* Windows Terminal paints the policy glyphs across two cells but advances its
|
|
78
|
+
* text cursor by the plain string-width value (one), so Ink's zero-byte wide
|
|
79
|
+
* placeholder alone does not separate the following glyph. Native wide glyphs
|
|
80
|
+
* (CJK/emoji) return zero because the terminal already advances two cells.
|
|
81
|
+
*/
|
|
82
|
+
export function syntheticWideCellPadding(str) {
|
|
83
|
+
const s = String(str ?? '');
|
|
84
|
+
if (!AMBIGUOUS_WIDE || !PROBLEM_RE.test(s))
|
|
85
|
+
return 0;
|
|
86
|
+
return Math.max(0, displayStringWidth(s) - stringWidth(s));
|
|
87
|
+
}
|
|
88
|
+
|
|
75
89
|
/** widest-line equivalent under the policy. */
|
|
76
90
|
export function displayWidestLine(text) {
|
|
77
91
|
let lineWidth = 0;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import sliceAnsi from 'slice-ansi';
|
|
2
1
|
import { styledCharsFromTokens, styledCharsToString, tokenize, } from '@alcalzone/ansi-tokenize';
|
|
2
|
+
import { sliceTextByDisplayWidth } from './wrap-text.js';
|
|
3
3
|
// [mixdog fork] use the shared display-width policy so ink's per-character
|
|
4
4
|
// advance + width cache treat circled digits / arrows as 2 cells under Windows
|
|
5
5
|
// Terminal, matching OUR wrap/row math. See display-width.js (kept in sync with
|
|
6
6
|
// src/tui/display-width.mjs).
|
|
7
|
-
import { displayStringWidth as stringWidth } from './display-width.js';
|
|
7
|
+
import { displayStringWidth as stringWidth, syntheticWideCellPadding } from './display-width.js';
|
|
8
8
|
const RGB_COLOR_RE = /^rgb\(\s*(\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\s*\)$/;
|
|
9
9
|
const rgbStyle = (value, type, fallback) => {
|
|
10
10
|
const match = RGB_COLOR_RE.exec(String(value || ''));
|
|
@@ -191,7 +191,12 @@ export default class Output {
|
|
|
191
191
|
const from = x < clip.x1 ? clip.x1 - x : 0;
|
|
192
192
|
const width = this.caches.getStringWidth(line);
|
|
193
193
|
const to = x + width > clip.x2 ? clip.x2 - x : width;
|
|
194
|
-
|
|
194
|
+
// [mixdog fork] `from`/`to` are display-cell offsets.
|
|
195
|
+
// Plain slice-ansi treats enclosed alphanumerics as
|
|
196
|
+
// one cell and lets clipped transcript body text
|
|
197
|
+
// overwrite cells to its right. Slice with the same
|
|
198
|
+
// wide-glyph policy used by wrap/measure/output.
|
|
199
|
+
return sliceTextByDisplayWidth(line, from, to);
|
|
195
200
|
});
|
|
196
201
|
if (x < clip.x1) {
|
|
197
202
|
x = clip.x1;
|
|
@@ -253,6 +258,14 @@ export default class Output {
|
|
|
253
258
|
currentLine[offsetX] = character;
|
|
254
259
|
// Determine printed width using string-width to align with measurement
|
|
255
260
|
const characterWidth = Math.max(1, this.caches.getStringWidth(character.value));
|
|
261
|
+
// Policy-widened ambiguous glyphs need a REAL spacer in the
|
|
262
|
+
// terminal byte stream: unlike native CJK/emoji, WT paints
|
|
263
|
+
// their ink wide but advances only one cell. Keep the grid
|
|
264
|
+
// placeholder empty for layout/selection; serialization
|
|
265
|
+
// materializes only the synthetic tail cells as spaces.
|
|
266
|
+
const syntheticPadding = characterWidth > 1
|
|
267
|
+
? syntheticWideCellPadding(character.value)
|
|
268
|
+
: 0;
|
|
256
269
|
// For multi-column characters, clear following cells to avoid stray spaces/artifacts
|
|
257
270
|
if (characterWidth > 1) {
|
|
258
271
|
for (let index = 1; index < characterWidth; index++) {
|
|
@@ -261,6 +274,7 @@ export default class Output {
|
|
|
261
274
|
value: '',
|
|
262
275
|
fullWidth: false,
|
|
263
276
|
styles: character.styles,
|
|
277
|
+
syntheticPad: syntheticPadding > 0 && index >= characterWidth - syntheticPadding,
|
|
264
278
|
};
|
|
265
279
|
}
|
|
266
280
|
}
|
|
@@ -508,6 +522,13 @@ export default class Output {
|
|
|
508
522
|
if (item === undefined) {
|
|
509
523
|
continue;
|
|
510
524
|
}
|
|
525
|
+
// A synthetic wide-cell placeholder is already included in
|
|
526
|
+
// the leading glyph's modeled width. Emit a literal blank so WT's
|
|
527
|
+
// cursor advances, but do not count that blank a second time.
|
|
528
|
+
if (item.syntheticPad) {
|
|
529
|
+
lineWithinWidth.push({ ...item, value: ' ' });
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
511
532
|
const value = item.value ?? '';
|
|
512
533
|
if (value === '') {
|
|
513
534
|
lineWithinWidth.push(item);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import { type Styles } from './styles.js';
|
|
2
|
+
export declare const sliceTextByDisplayWidthWithPolicy: (line: string, from: number, to: number, wide: boolean) => string;
|
|
3
|
+
export declare const sliceTextByDisplayWidth: (line: string, from: number, to: number) => string;
|
|
2
4
|
declare const wrapText: (text: string, maxWidth: number, wrapType: Styles["textWrap"]) => string;
|
|
3
5
|
export default wrapText;
|