mixdog 1.2.4 → 1.2.5
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/scripts/mobile-release.mjs +5 -4
- package/src/defaults/shipped-history.json +1 -0
- package/src/output-styles/common.md +2 -1
- package/src/rules/lead/LEAD.md +6 -4
- package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +25 -17
- package/src/standalone/desktop-service-registry.mjs +32 -5
- package/src/tui/dist/index.mjs +51 -19
- package/src/ui/streaming-markdown-heal.mjs +70 -18
package/package.json
CHANGED
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
// CLI: MOBILE_MODE=auto|force|skip MOBILE_BUMP=patch|minor node scripts/mobile-release.mjs plan
|
|
10
10
|
// appends mobile_release / mobile_version / mobile_tag to $GITHUB_OUTPUT.
|
|
11
11
|
//
|
|
12
|
-
// link: the Android
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// link: the Android APK the relay's /app page links — the direct download URL
|
|
13
|
+
// (.../releases/download/mobile-vX.Y.Z/<asset>.apk) of the newest published
|
|
14
|
+
// (non-draft) mobile-v* release whose APK actually downloads, or the
|
|
15
|
+
// releases list when there is none. `--require=<tag>` instead fails
|
|
15
16
|
// unless that exact release is the one that resolves (post-publish check).
|
|
16
17
|
// CLI: GITHUB_REPOSITORY=o/r GH_TOKEN=… node scripts/mobile-release.mjs link [--require=mobile-vX.Y.Z]
|
|
17
18
|
// appends url / tag to $GITHUB_OUTPUT.
|
|
@@ -148,7 +149,7 @@ export async function resolveAndroidLink({
|
|
|
148
149
|
const candidates = androidReleaseCandidates(await listReleases(repository, { token, fetchImpl }));
|
|
149
150
|
for (const candidate of requiredTag ? candidates.filter((item) => item.tag === requiredTag) : candidates) {
|
|
150
151
|
if (await downloadable(candidate.apkUrl, { fetchImpl, attempts, delayMs })) {
|
|
151
|
-
return { url: candidate.
|
|
152
|
+
return { url: candidate.apkUrl, tag: candidate.tag };
|
|
152
153
|
}
|
|
153
154
|
if (!requiredTag) console.warn(`::warning::${candidate.tag} APK is not downloadable; trying an older release.`);
|
|
154
155
|
}
|
|
@@ -15,6 +15,7 @@ partial: true
|
|
|
15
15
|
- Distinguish facts, inference, and uncertainty; keep exact literals and
|
|
16
16
|
material risks. Caveats only when they change the reader's next step.
|
|
17
17
|
- Never pad: no tangents, unsolicited offers, raw dumps, or process recaps.
|
|
18
|
-
Explicit depth requests win; depth applies to substantive answers
|
|
18
|
+
Explicit depth requests win; depth applies to substantive answers, not the
|
|
19
|
+
one-line pre-tool briefing.
|
|
19
20
|
- At every depth keep decision-relevant failures, security warnings, and
|
|
20
21
|
destructive-action confirmations; quote needed error text exactly.
|
package/src/rules/lead/LEAD.md
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
- The user's latest explicit request overrides any internal rule; drive it to
|
|
5
5
|
completion.
|
|
6
6
|
- The user sees your text, and tool calls only as activity cards, never your
|
|
7
|
-
thinking. Before
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
thinking. Before your first tool call, briefly state what you are about to
|
|
8
|
+
do. While working, give short updates at key moments: when you find
|
|
9
|
+
something, change direction, or make progress without an update for a
|
|
10
|
+
while. Otherwise bring the user only material findings, direction or scope
|
|
11
|
+
changes, unapproved destructive actions, blockers or meaningful delays — never per-call
|
|
12
|
+
narration or plan repeats.
|
|
11
13
|
- Mid-task: a replacement supersedes, an addition folds in, a status question
|
|
12
14
|
gets a brief answer while work continues.
|
|
13
15
|
- Context is managed automatically: never stop or propose stopping because of
|
|
@@ -212,8 +212,13 @@ export function listStoredAgentWorkerLinks() {
|
|
|
212
212
|
// The agent pool only reads top-level session fields, yet each refresh used to
|
|
213
213
|
// JSON.parse every active worker's whole transcript (megabytes per worker,
|
|
214
214
|
// every catalog publish). Cache the header per file fingerprint instead.
|
|
215
|
-
|
|
215
|
+
// The cache holds exactly the headers the latest pool assembly read: a fixed
|
|
216
|
+
// LRU cap below the index size (263 headers against 256 slots) evicted every
|
|
217
|
+
// entry just before its reuse, so each refresh re-parsed every worker
|
|
218
|
+
// transcript synchronously (~130 MB, ~1 s of blocked daemon event loop).
|
|
216
219
|
const workerSessionHeaderCache = new Map();
|
|
220
|
+
/** Assembly the cache entries were last read by; older entries are dropped. */
|
|
221
|
+
let headerPass = 0;
|
|
217
222
|
// Pool rows need identity, routing, and lifecycle metadata only. Keeping the
|
|
218
223
|
// rest of a session here also retained its tool catalogs and provider state.
|
|
219
224
|
const WORKER_HEADER_FIELDS = [
|
|
@@ -251,9 +256,7 @@ function readWorkerSessionHeader(sessionId) {
|
|
|
251
256
|
if (probe.state !== PROBE_PRESENT) return null;
|
|
252
257
|
const cached = workerSessionHeaderCache.get(path);
|
|
253
258
|
if (cached && cached.mtimeMs === probe.mtimeMs && cached.size === probe.size) {
|
|
254
|
-
|
|
255
|
-
workerSessionHeaderCache.delete(path);
|
|
256
|
-
workerSessionHeaderCache.set(path, cached);
|
|
259
|
+
cached.pass = headerPass;
|
|
257
260
|
return cached.header;
|
|
258
261
|
}
|
|
259
262
|
let session = null;
|
|
@@ -267,10 +270,7 @@ function readWorkerSessionHeader(sessionId) {
|
|
|
267
270
|
for (const field of WORKER_HEADER_FIELDS) {
|
|
268
271
|
if (Object.hasOwn(session, field)) header[field] = session[field];
|
|
269
272
|
}
|
|
270
|
-
workerSessionHeaderCache.set(path, { mtimeMs: probe.mtimeMs, size: probe.size, header });
|
|
271
|
-
if (workerSessionHeaderCache.size > WORKER_HEADER_CACHE_MAX) {
|
|
272
|
-
workerSessionHeaderCache.delete(workerSessionHeaderCache.keys().next().value);
|
|
273
|
-
}
|
|
273
|
+
workerSessionHeaderCache.set(path, { mtimeMs: probe.mtimeMs, size: probe.size, header, pass: headerPass });
|
|
274
274
|
return header;
|
|
275
275
|
}
|
|
276
276
|
|
|
@@ -487,13 +487,21 @@ export function listStoredAgentWorkers({ runningSessionIds = null } = {}) {
|
|
|
487
487
|
};
|
|
488
488
|
return tombstoneBlocksWork(work, findTagTombstone(work, tombstones));
|
|
489
489
|
};
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
490
|
+
headerPass += 1;
|
|
491
|
+
try {
|
|
492
|
+
return assembleStoredAgentWorkers({
|
|
493
|
+
now,
|
|
494
|
+
runningSessionIds,
|
|
495
|
+
storedAgentWorkerIndexRows: () => rows.filter((row) => !isReaped(cleanValue(row.sessionId))),
|
|
496
|
+
projectChildWorkerRow,
|
|
497
|
+
storedLeadWorkerRows,
|
|
498
|
+
projectLeadWorkerRow,
|
|
499
|
+
pruneOrphanChildRows,
|
|
500
|
+
});
|
|
501
|
+
} finally {
|
|
502
|
+
// Keep what this assembly read, whatever the index size; forget the rest.
|
|
503
|
+
for (const [path, entry] of workerSessionHeaderCache) {
|
|
504
|
+
if (entry.pass !== headerPass) workerSessionHeaderCache.delete(path);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
499
507
|
}
|
|
@@ -165,6 +165,8 @@ export class DesktopServiceRegistry {
|
|
|
165
165
|
moduleUrl: requestedModule,
|
|
166
166
|
instance,
|
|
167
167
|
subscribers: new Set(),
|
|
168
|
+
visibilityEpochs: new Map(),
|
|
169
|
+
nextVisibilityEpoch: 0,
|
|
168
170
|
};
|
|
169
171
|
this.#servicesById.set(requestedId, record);
|
|
170
172
|
this.#servicesByModule.set(requestedModule, record);
|
|
@@ -187,7 +189,14 @@ export class DesktopServiceRegistry {
|
|
|
187
189
|
const service = await this.#initialize(params);
|
|
188
190
|
if (this.#closed) throw new Error('session service is closed');
|
|
189
191
|
const token = subscriberToken(ctx);
|
|
190
|
-
if (token)
|
|
192
|
+
if (token) {
|
|
193
|
+
service.subscribers.add(token);
|
|
194
|
+
// The adapter outlives desktop processes, whose request counters restart.
|
|
195
|
+
// Allocate the ordering namespace here, not from a client's wall clock.
|
|
196
|
+
if (!service.visibilityEpochs.has(token)) {
|
|
197
|
+
service.visibilityEpochs.set(token, ++service.nextVisibilityEpoch);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
191
200
|
this.#syncModelProxyPresence();
|
|
192
201
|
return { desktopId: service.desktopId };
|
|
193
202
|
}
|
|
@@ -215,14 +224,23 @@ export class DesktopServiceRegistry {
|
|
|
215
224
|
}
|
|
216
225
|
|
|
217
226
|
async invoke({ desktopId, method, args = [] } = {}, ctx = null) {
|
|
218
|
-
const { service } = this.#requireSubscribed(desktopId, ctx);
|
|
227
|
+
const { service, token } = this.#requireSubscribed(desktopId, ctx);
|
|
219
228
|
const name = String(method || '');
|
|
220
229
|
if (!name) throw new TypeError('desktop service method is required');
|
|
230
|
+
let callArgs = Array.isArray(args) ? args : [];
|
|
231
|
+
if (name === 'setVisibleSessions' && token) {
|
|
232
|
+
const epoch = service.visibilityEpochs.get(token);
|
|
233
|
+
if (epoch === undefined) {
|
|
234
|
+
service.subscribers.delete(token);
|
|
235
|
+
throw new Error('desktop service client is not initialized');
|
|
236
|
+
}
|
|
237
|
+
callArgs = [callArgs[0], callArgs[1], epoch];
|
|
238
|
+
}
|
|
221
239
|
// `[slow-op] desktop.invoke` names the call it timed: a desktop operation
|
|
222
240
|
// (Git reads) or a session capability, not only the generic route.
|
|
223
241
|
const target = name === 'invokeDesktopOperation' || name === 'invokeCapability' ? String(args?.[0] || '') : '';
|
|
224
242
|
annotateSpan(target ? `${name}:${target}` : name);
|
|
225
|
-
return sanitizeForWire(await service.instance.invoke(name,
|
|
243
|
+
return sanitizeForWire(await service.instance.invoke(name, callArgs)) ?? null;
|
|
226
244
|
}
|
|
227
245
|
|
|
228
246
|
async control({ desktopId, message } = {}, ctx = null) {
|
|
@@ -241,7 +259,7 @@ export class DesktopServiceRegistry {
|
|
|
241
259
|
async unsubscribe({ desktopId } = {}, ctx = null) {
|
|
242
260
|
const service = this.#require(desktopId);
|
|
243
261
|
const token = subscriberToken(ctx);
|
|
244
|
-
if (token) service
|
|
262
|
+
if (token) await this.#releaseSubscriber(service, token);
|
|
245
263
|
this.#syncModelProxyPresence();
|
|
246
264
|
return { ok: true, unsubscribed: true };
|
|
247
265
|
}
|
|
@@ -250,11 +268,20 @@ export class DesktopServiceRegistry {
|
|
|
250
268
|
const token = String(clientToken || '');
|
|
251
269
|
if (!token) return;
|
|
252
270
|
for (const service of this.#servicesById.values()) {
|
|
253
|
-
service.
|
|
271
|
+
void this.#releaseSubscriber(service, token).catch((error) => {
|
|
272
|
+
this.#log(`desktop visibility release failed: ${error?.message || error}`);
|
|
273
|
+
});
|
|
254
274
|
}
|
|
255
275
|
this.#syncModelProxyPresence();
|
|
256
276
|
}
|
|
257
277
|
|
|
278
|
+
async #releaseSubscriber(service, token) {
|
|
279
|
+
service.subscribers.delete(token);
|
|
280
|
+
const epoch = service.visibilityEpochs.get(token);
|
|
281
|
+
service.visibilityEpochs.delete(token);
|
|
282
|
+
if (epoch !== undefined) await service.instance.releaseClient?.(epoch);
|
|
283
|
+
}
|
|
284
|
+
|
|
258
285
|
notifySessionRuntimeReleased(sessionId, reason) {
|
|
259
286
|
// An evicted session has NO session subscribers. Its desktop resources
|
|
260
287
|
// still belong to the desktop service and must be notified on that lane.
|
package/src/tui/dist/index.mjs
CHANGED
|
@@ -38488,19 +38488,34 @@ var HEALABLE_MARKDOWN_SYNTAX = /[`*_~[]/;
|
|
|
38488
38488
|
function openFence(text) {
|
|
38489
38489
|
let marker = "";
|
|
38490
38490
|
let length = 0;
|
|
38491
|
+
let start = 0;
|
|
38492
|
+
let lineStart2 = 0;
|
|
38491
38493
|
for (const rawLine of String(text ?? "").split("\n")) {
|
|
38492
38494
|
const fence = FENCE_LINE.exec(rawLine.replace(/\r$/, ""));
|
|
38493
|
-
if (
|
|
38494
|
-
|
|
38495
|
-
|
|
38496
|
-
|
|
38497
|
-
|
|
38498
|
-
|
|
38499
|
-
marker
|
|
38500
|
-
|
|
38495
|
+
if (fence) {
|
|
38496
|
+
const marks = fence[1];
|
|
38497
|
+
if (!marker) {
|
|
38498
|
+
marker = marks[0];
|
|
38499
|
+
length = marks.length;
|
|
38500
|
+
start = lineStart2;
|
|
38501
|
+
} else if (marks[0] === marker && marks.length >= length && !fence[2].trim()) {
|
|
38502
|
+
marker = "";
|
|
38503
|
+
length = 0;
|
|
38504
|
+
}
|
|
38501
38505
|
}
|
|
38506
|
+
lineStart2 += rawLine.length + 1;
|
|
38502
38507
|
}
|
|
38503
|
-
return marker ? { marker, length } : null;
|
|
38508
|
+
return marker ? { marker, length, start } : null;
|
|
38509
|
+
}
|
|
38510
|
+
function trimPartialOpeningFence(text, fence) {
|
|
38511
|
+
const lineEnd2 = text.indexOf("\n", fence.start);
|
|
38512
|
+
return lineEnd2 >= 0 && /\S/.test(text.slice(lineEnd2 + 1)) ? text : text.slice(0, fence.start);
|
|
38513
|
+
}
|
|
38514
|
+
var PENDING_BLOCK_MARKER_LINE = /(?:^|\n)[ \t]*(?:[-+*=_#>~`]+|\d{1,9}[.)]?)[ \t]*$/;
|
|
38515
|
+
function trimPendingBlockMarker(text) {
|
|
38516
|
+
const pending = PENDING_BLOCK_MARKER_LINE.exec(text);
|
|
38517
|
+
if (!pending) return text;
|
|
38518
|
+
return text.slice(0, pending.index + (pending[0].startsWith("\n") ? 1 : 0));
|
|
38504
38519
|
}
|
|
38505
38520
|
function trimPartialClosingFence(text, fence) {
|
|
38506
38521
|
const partial = /(?:^|\r?\n) {0,3}([`~]+)$/.exec(text);
|
|
@@ -38531,7 +38546,7 @@ function closeInlineCode(text, isPendingLocalPath) {
|
|
|
38531
38546
|
} else if (run === open) open = 0;
|
|
38532
38547
|
index = end;
|
|
38533
38548
|
}
|
|
38534
|
-
if (open && isPendingLocalPath?.(text.slice(openIndex + open))) {
|
|
38549
|
+
if (open && (!text.slice(openIndex + open) || isPendingLocalPath?.(text.slice(openIndex + open)))) {
|
|
38535
38550
|
return text.slice(0, openIndex);
|
|
38536
38551
|
}
|
|
38537
38552
|
return open > 0 ? `${text}${"`".repeat(open)}` : text;
|
|
@@ -38561,6 +38576,7 @@ function scanEmphasisRuns(masked) {
|
|
|
38561
38576
|
let end = index + 1;
|
|
38562
38577
|
while (masked[end] === marker) end += 1;
|
|
38563
38578
|
const length = end - index;
|
|
38579
|
+
const at = index;
|
|
38564
38580
|
const before = index > 0 ? masked[index - 1] : "";
|
|
38565
38581
|
const after = end < masked.length ? masked[end] : "";
|
|
38566
38582
|
const beforeSpace = isMarkdownSpace(before);
|
|
@@ -38570,16 +38586,25 @@ function scanEmphasisRuns(masked) {
|
|
|
38570
38586
|
const left = !afterSpace && (!afterPunctuation || beforeSpace || beforePunctuation);
|
|
38571
38587
|
const right = !beforeSpace && (!beforePunctuation || afterSpace || afterPunctuation);
|
|
38572
38588
|
if (marker === "~") {
|
|
38573
|
-
|
|
38589
|
+
runs.push({
|
|
38590
|
+
marker,
|
|
38591
|
+
length: Math.min(length, 2),
|
|
38592
|
+
canOpen: left && length >= 2,
|
|
38593
|
+
canClose: right && length >= 2,
|
|
38594
|
+
at,
|
|
38595
|
+
end
|
|
38596
|
+
});
|
|
38574
38597
|
} else if (marker === "_") {
|
|
38575
38598
|
runs.push({
|
|
38576
38599
|
marker,
|
|
38577
38600
|
length,
|
|
38578
38601
|
canOpen: left && (!right || beforePunctuation),
|
|
38579
|
-
canClose: right && (!left || afterPunctuation)
|
|
38602
|
+
canClose: right && (!left || afterPunctuation),
|
|
38603
|
+
at,
|
|
38604
|
+
end
|
|
38580
38605
|
});
|
|
38581
38606
|
} else {
|
|
38582
|
-
runs.push({ marker, length, canOpen: left, canClose: right });
|
|
38607
|
+
runs.push({ marker, length, canOpen: left, canClose: right, at, end });
|
|
38583
38608
|
}
|
|
38584
38609
|
index = end;
|
|
38585
38610
|
}
|
|
@@ -38587,7 +38612,9 @@ function scanEmphasisRuns(masked) {
|
|
|
38587
38612
|
}
|
|
38588
38613
|
function closeEmphasis(text) {
|
|
38589
38614
|
const open = [];
|
|
38590
|
-
|
|
38615
|
+
const runs = scanEmphasisRuns(maskCode(text));
|
|
38616
|
+
const last = runs[runs.length - 1];
|
|
38617
|
+
for (const run of runs) {
|
|
38591
38618
|
let length = run.length;
|
|
38592
38619
|
while (run.canClose && length > 0) {
|
|
38593
38620
|
let match = -1;
|
|
@@ -38604,13 +38631,17 @@ function closeEmphasis(text) {
|
|
|
38604
38631
|
length -= used;
|
|
38605
38632
|
open.length = opener.length > 0 ? match + 1 : match;
|
|
38606
38633
|
}
|
|
38634
|
+
if (run === last && run.end === text.length && length === run.length) {
|
|
38635
|
+
return closeEmphasis(text.slice(0, run.at));
|
|
38636
|
+
}
|
|
38607
38637
|
if (length > 0 && run.canOpen) open.push({ marker: run.marker, length });
|
|
38608
38638
|
}
|
|
38609
|
-
|
|
38639
|
+
const body = text.trimEnd();
|
|
38640
|
+
let closers = "";
|
|
38610
38641
|
for (let index = open.length - 1; index >= 0; index -= 1) {
|
|
38611
|
-
|
|
38642
|
+
closers += open[index].marker.repeat(Math.min(open[index].length, 3));
|
|
38612
38643
|
}
|
|
38613
|
-
return
|
|
38644
|
+
return closers ? `${body}${closers}${text.slice(body.length)}` : text;
|
|
38614
38645
|
}
|
|
38615
38646
|
function linkDestinationEnd(text, start) {
|
|
38616
38647
|
let depth = 1;
|
|
@@ -38694,8 +38725,9 @@ function trimPendingTableHead(text) {
|
|
|
38694
38725
|
function healStreamingMarkdownTail(text, isPendingLocalPath) {
|
|
38695
38726
|
const source = String(text ?? "");
|
|
38696
38727
|
const fence = source.includes("`") || source.includes("~") ? openFence(source) : null;
|
|
38697
|
-
if (fence) return trimPartialClosingFence(source, fence);
|
|
38698
|
-
const
|
|
38728
|
+
if (fence) return trimPartialOpeningFence(trimPartialClosingFence(source, fence), fence);
|
|
38729
|
+
const unmarked = trimPendingBlockMarker(source);
|
|
38730
|
+
const value = unmarked.includes("|") ? trimPendingTableHead(unmarked) : unmarked;
|
|
38699
38731
|
const hasSyntax = HEALABLE_MARKDOWN_SYNTAX.test(value);
|
|
38700
38732
|
if (!value || !hasSyntax && !isPendingLocalPath) return value;
|
|
38701
38733
|
const healed = hasSyntax ? closeEmphasis(healIncompleteLink(closeInlineCode(value, isPendingLocalPath), isPendingLocalPath)) : value;
|
|
@@ -8,19 +8,47 @@ const HEALABLE_MARKDOWN_SYNTAX = /[`*_~[]/;
|
|
|
8
8
|
function openFence(text) {
|
|
9
9
|
let marker = '';
|
|
10
10
|
let length = 0;
|
|
11
|
+
let start = 0;
|
|
12
|
+
let lineStart = 0;
|
|
11
13
|
for (const rawLine of String(text ?? '').split('\n')) {
|
|
12
14
|
const fence = FENCE_LINE.exec(rawLine.replace(/\r$/, ''));
|
|
13
|
-
if (
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
marker
|
|
20
|
-
|
|
15
|
+
if (fence) {
|
|
16
|
+
const marks = fence[1];
|
|
17
|
+
if (!marker) {
|
|
18
|
+
marker = marks[0];
|
|
19
|
+
length = marks.length;
|
|
20
|
+
start = lineStart;
|
|
21
|
+
} else if (marks[0] === marker && marks.length >= length && !fence[2].trim()) {
|
|
22
|
+
marker = '';
|
|
23
|
+
length = 0;
|
|
24
|
+
}
|
|
21
25
|
}
|
|
26
|
+
lineStart += rawLine.length + 1;
|
|
22
27
|
}
|
|
23
|
-
return marker ? { marker, length } : null;
|
|
28
|
+
return marker ? { marker, length, start } : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The opening fence line arrives a token at a time: ``` → ```py → ```python.
|
|
32
|
+
// Rendered at once, the card opened unlabelled and then relabelled (and
|
|
33
|
+
// re-highlighted) per token. The card appears with its first code character:
|
|
34
|
+
// an opening line alone would paint an empty box before any code.
|
|
35
|
+
function trimPartialOpeningFence(text, fence) {
|
|
36
|
+
const lineEnd = text.indexOf('\n', fence.start);
|
|
37
|
+
return lineEnd >= 0 && /\S/.test(text.slice(lineEnd + 1)) ? text : text.slice(0, fence.start);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A line holding only a block marker so far has no content to show, and the
|
|
41
|
+
// marker alone parses as something else: `-` or `=` under a paragraph is a
|
|
42
|
+
// setext underline (the paragraph flashed into a heading until the item text
|
|
43
|
+
// arrived), `2` is a lazy paragraph continuation until its `.` arrives, `#`
|
|
44
|
+
// grows into `##`, and a lone backtick run is literal text until it becomes a
|
|
45
|
+
// fence. Hold the line back until its first content character.
|
|
46
|
+
const PENDING_BLOCK_MARKER_LINE = /(?:^|\n)[ \t]*(?:[-+*=_#>~`]+|\d{1,9}[.)]?)[ \t]*$/;
|
|
47
|
+
|
|
48
|
+
function trimPendingBlockMarker(text) {
|
|
49
|
+
const pending = PENDING_BLOCK_MARKER_LINE.exec(text);
|
|
50
|
+
if (!pending) return text;
|
|
51
|
+
return text.slice(0, pending.index + (pending[0].startsWith('\n') ? 1 : 0));
|
|
24
52
|
}
|
|
25
53
|
|
|
26
54
|
// A closing fence arrives a token at a time. Until its run is long enough to
|
|
@@ -57,7 +85,9 @@ function closeInlineCode(text, isPendingLocalPath) {
|
|
|
57
85
|
} else if (run === open) open = 0;
|
|
58
86
|
index = end;
|
|
59
87
|
}
|
|
60
|
-
|
|
88
|
+
// An opener with nothing after it yet closes into an empty span, which
|
|
89
|
+
// parses as a literal backtick run that then vanishes into code.
|
|
90
|
+
if (open && (!text.slice(openIndex + open) || isPendingLocalPath?.(text.slice(openIndex + open)))) {
|
|
61
91
|
return text.slice(0, openIndex);
|
|
62
92
|
}
|
|
63
93
|
return open > 0 ? `${text}${'`'.repeat(open)}` : text;
|
|
@@ -93,6 +123,7 @@ function scanEmphasisRuns(masked) {
|
|
|
93
123
|
let end = index + 1;
|
|
94
124
|
while (masked[end] === marker) end += 1;
|
|
95
125
|
const length = end - index;
|
|
126
|
+
const at = index;
|
|
96
127
|
const before = index > 0 ? masked[index - 1] : '';
|
|
97
128
|
const after = end < masked.length ? masked[end] : '';
|
|
98
129
|
const beforeSpace = isMarkdownSpace(before);
|
|
@@ -102,16 +133,26 @@ function scanEmphasisRuns(masked) {
|
|
|
102
133
|
const left = !afterSpace && (!afterPunctuation || beforeSpace || beforePunctuation);
|
|
103
134
|
const right = !beforeSpace && (!beforePunctuation || afterSpace || afterPunctuation);
|
|
104
135
|
if (marker === '~') {
|
|
105
|
-
|
|
136
|
+
// A single tilde is inert, but at the end it may be half of `~~`.
|
|
137
|
+
runs.push({
|
|
138
|
+
marker,
|
|
139
|
+
length: Math.min(length, 2),
|
|
140
|
+
canOpen: left && length >= 2,
|
|
141
|
+
canClose: right && length >= 2,
|
|
142
|
+
at,
|
|
143
|
+
end,
|
|
144
|
+
});
|
|
106
145
|
} else if (marker === '_') {
|
|
107
146
|
runs.push({
|
|
108
147
|
marker,
|
|
109
148
|
length,
|
|
110
149
|
canOpen: left && (!right || beforePunctuation),
|
|
111
150
|
canClose: right && (!left || afterPunctuation),
|
|
151
|
+
at,
|
|
152
|
+
end,
|
|
112
153
|
});
|
|
113
154
|
} else {
|
|
114
|
-
runs.push({ marker, length, canOpen: left, canClose: right });
|
|
155
|
+
runs.push({ marker, length, canOpen: left, canClose: right, at, end });
|
|
115
156
|
}
|
|
116
157
|
index = end;
|
|
117
158
|
}
|
|
@@ -120,7 +161,9 @@ function scanEmphasisRuns(masked) {
|
|
|
120
161
|
|
|
121
162
|
function closeEmphasis(text) {
|
|
122
163
|
const open = [];
|
|
123
|
-
|
|
164
|
+
const runs = scanEmphasisRuns(maskCode(text));
|
|
165
|
+
const last = runs[runs.length - 1];
|
|
166
|
+
for (const run of runs) {
|
|
124
167
|
let length = run.length;
|
|
125
168
|
while (run.canClose && length > 0) {
|
|
126
169
|
let match = -1;
|
|
@@ -137,13 +180,21 @@ function closeEmphasis(text) {
|
|
|
137
180
|
length -= used;
|
|
138
181
|
open.length = opener.length > 0 ? match + 1 : match;
|
|
139
182
|
}
|
|
183
|
+
// A run ending the text that closed nothing has no content after it yet:
|
|
184
|
+
// it painted as literal `**` until the next token turned it into style.
|
|
185
|
+
if (run === last && run.end === text.length && length === run.length) {
|
|
186
|
+
return closeEmphasis(text.slice(0, run.at));
|
|
187
|
+
}
|
|
140
188
|
if (length > 0 && run.canOpen) open.push({ marker: run.marker, length });
|
|
141
189
|
}
|
|
142
|
-
|
|
190
|
+
// Closers go before trailing whitespace: `**word ` closed after the space
|
|
191
|
+
// could not close (a closing run must follow non-space) and stayed literal.
|
|
192
|
+
const body = text.trimEnd();
|
|
193
|
+
let closers = '';
|
|
143
194
|
for (let index = open.length - 1; index >= 0; index -= 1) {
|
|
144
|
-
|
|
195
|
+
closers += open[index].marker.repeat(Math.min(open[index].length, 3));
|
|
145
196
|
}
|
|
146
|
-
return
|
|
197
|
+
return closers ? `${body}${closers}${text.slice(body.length)}` : text;
|
|
147
198
|
}
|
|
148
199
|
|
|
149
200
|
function linkDestinationEnd(text, start) {
|
|
@@ -246,8 +297,9 @@ function trimPendingTableHead(text) {
|
|
|
246
297
|
export function healStreamingMarkdownTail(text, isPendingLocalPath) {
|
|
247
298
|
const source = String(text ?? '');
|
|
248
299
|
const fence = source.includes('`') || source.includes('~') ? openFence(source) : null;
|
|
249
|
-
if (fence) return trimPartialClosingFence(source, fence);
|
|
250
|
-
const
|
|
300
|
+
if (fence) return trimPartialOpeningFence(trimPartialClosingFence(source, fence), fence);
|
|
301
|
+
const unmarked = trimPendingBlockMarker(source);
|
|
302
|
+
const value = unmarked.includes('|') ? trimPendingTableHead(unmarked) : unmarked;
|
|
251
303
|
const hasSyntax = HEALABLE_MARKDOWN_SYNTAX.test(value);
|
|
252
304
|
if (!value || (!hasSyntax && !isPendingLocalPath)) return value;
|
|
253
305
|
const healed = hasSyntax
|