switchroom 0.19.26 → 0.19.27
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/bin/git-agent-attribution-hook.sh +144 -0
- package/dist/agent-scheduler/index.js +55 -0
- package/dist/auth-broker/index.js +116 -6
- package/dist/cli/notion-write-pretool.mjs +55 -0
- package/dist/cli/switchroom.js +2055 -1193
- package/dist/host-control/main.js +117 -7
- package/dist/vault/approvals/kernel-server.js +114 -6
- package/dist/vault/broker/server.js +114 -6
- package/package.json +6 -2
- package/profiles/_base/cron-session.sh.hbs +8 -0
- package/profiles/_base/start.sh.hbs +105 -0
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +93 -1
- package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
- package/telegram-plugin/dist/server.js +96 -1
- package/telegram-plugin/edit-flood-fuse.ts +637 -56
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +58 -68
- package/telegram-plugin/gateway/narrative-lane.ts +14 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
- package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
- package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
- package/telegram-plugin/gateway/status-pin-store.ts +58 -9
- package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +72 -5
- package/telegram-plugin/outbound-class.ts +81 -0
- package/telegram-plugin/provider-credit.ts +237 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
- package/telegram-plugin/send-gate.ts +24 -2
- package/telegram-plugin/status-no-truncate.ts +10 -48
- package/telegram-plugin/status-pin-driver.ts +33 -45
- package/telegram-plugin/status-pin.ts +18 -1
- package/telegram-plugin/tests/card-golden.test.ts +69 -0
- package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
- package/telegram-plugin/tests/card-variants.golden.txt +211 -0
- package/telegram-plugin/tests/card-variants.ts +366 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
- package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
- package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
- package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
- package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
- package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
- package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
- package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
- package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
- package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
- package/telegram-plugin/tests/status-pin.test.ts +56 -5
- package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
- package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tool-activity-summary.ts +239 -365
- package/telegram-plugin/uat/assertions.ts +22 -11
- package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
- package/telegram-plugin/worker-activity-feed.ts +105 -47
- package/vendor/hindsight-memory/CLAUDE.md +45 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- package/vendor/hindsight-memory/scripts/recall.py +176 -7
- package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -90,12 +90,46 @@ import {
|
|
|
90
90
|
STATUS_LINE_MAX,
|
|
91
91
|
NESTED_PREFIX,
|
|
92
92
|
WORKER_STEP_INDENT,
|
|
93
|
-
SUBORDINATE_LINE_INDENT,
|
|
94
|
-
nestSubordinateCardLines,
|
|
95
93
|
} from './status-no-truncate.js'
|
|
96
|
-
import { escapeMarkdown,
|
|
94
|
+
import { escapeMarkdown, truncate } from './card-format.js'
|
|
97
95
|
import { isTelegramSurfaceTool } from './tool-names.js'
|
|
98
|
-
|
|
96
|
+
// The card layout core. Header composition (`metricsRun` /
|
|
97
|
+
// `renderActivityHeader`), the per-line escape pipeline (`escapeStepLine`), the
|
|
98
|
+
// bullet emitter (`emitSection`) and the char-budget backstop
|
|
99
|
+
// (`fitCardToBudget`) each live there exactly once; the renderers below are
|
|
100
|
+
// CONFIGURATIONS of it, never parallel implementations (#3844).
|
|
101
|
+
import {
|
|
102
|
+
cardSpecLines,
|
|
103
|
+
cleanStepLine,
|
|
104
|
+
escapeStepLine,
|
|
105
|
+
emitSection,
|
|
106
|
+
fitCardToBudget,
|
|
107
|
+
formatFeedElapsed,
|
|
108
|
+
formatTokenCount,
|
|
109
|
+
metricsRun,
|
|
110
|
+
renderActivityHeader,
|
|
111
|
+
renderCardSpec,
|
|
112
|
+
renderCardTitleLine,
|
|
113
|
+
renderStepFeed,
|
|
114
|
+
tokenSegment,
|
|
115
|
+
toolWord,
|
|
116
|
+
type CardCandidate,
|
|
117
|
+
type CardSection,
|
|
118
|
+
type CardSpec,
|
|
119
|
+
type CardState,
|
|
120
|
+
} from './card-layout.js'
|
|
121
|
+
|
|
122
|
+
// Re-exported so every existing importer keeps its `tool-activity-summary.js`
|
|
123
|
+
// entrypoint while the implementations live in the one layout core.
|
|
124
|
+
export {
|
|
125
|
+
escapeStepLine,
|
|
126
|
+
formatFeedElapsed,
|
|
127
|
+
formatTokenCount,
|
|
128
|
+
renderActivityHeader,
|
|
129
|
+
renderCardTitleLine,
|
|
130
|
+
renderStepFeed,
|
|
131
|
+
}
|
|
132
|
+
export type { CardSection, CardSpec, CardState }
|
|
99
133
|
|
|
100
134
|
/**
|
|
101
135
|
* Optional header for the main-session activity card, matching the worker
|
|
@@ -167,92 +201,6 @@ export function clipNarrative(s: string): string {
|
|
|
167
201
|
return s.split('\n')[0].trim().slice(0, STATUS_LINE_MAX);
|
|
168
202
|
}
|
|
169
203
|
|
|
170
|
-
/**
|
|
171
|
-
* Render a two-line header for the activity card, matching the worker card's
|
|
172
|
-
* style. Used by both the main-session card and the worker card.
|
|
173
|
-
*
|
|
174
|
-
* Line 1: `<emoji> <b>Label</b> · <i>description</i>` (description optional)
|
|
175
|
-
* Line 2: status + elapsed + tool count
|
|
176
|
-
*
|
|
177
|
-
* `emoji` — leading emoji (e.g. "🤖", "🛠")
|
|
178
|
-
* `label` — bold name (e.g. "Agent", "Worker")
|
|
179
|
-
* `description` — italicised task description (optional)
|
|
180
|
-
* `elapsedMs` — wall-clock elapsed, rendered via `formatFeedElapsed`
|
|
181
|
-
* `toolCount` — labeled tool calls this turn
|
|
182
|
-
* `state` — 'running' | 'done' | 'failed' | 'incomplete' (controls the
|
|
183
|
-
* status line wording; 'failed'/'incomplete' render
|
|
184
|
-
* `failed · …` / `incomplete · …` so a failed or reaped worker
|
|
185
|
-
* never reads as done)
|
|
186
|
-
*
|
|
187
|
-
* Returns a two-element array of ready Telegram HTML lines (no trailing newline).
|
|
188
|
-
*/
|
|
189
|
-
export function renderActivityHeader(
|
|
190
|
-
emoji: string,
|
|
191
|
-
label: string,
|
|
192
|
-
description: string,
|
|
193
|
-
elapsedMs: number,
|
|
194
|
-
toolCount: number,
|
|
195
|
-
state: 'running' | 'done' | 'failed' | 'incomplete',
|
|
196
|
-
model?: string,
|
|
197
|
-
totalTokens?: number,
|
|
198
|
-
): [string, string] {
|
|
199
|
-
const toolWord = toolCount === 1 ? 'tool' : 'tools'
|
|
200
|
-
const elapsed = formatFeedElapsed(elapsedMs)
|
|
201
|
-
const descPart = description.length > 0 ? ` · _${escapeMarkdown(description)}_` : ''
|
|
202
|
-
const line1 = `${emoji} **${escapeMarkdown(label)}**${descPart}`
|
|
203
|
-
// Running total tokens: joins the dot-separated metrics between the tool
|
|
204
|
-
// count and the model tag. Omitted (empty) when the total is 0/unknown.
|
|
205
|
-
const tokPart = tokenSegment(totalTokens)
|
|
206
|
-
// Subtle live-model tag: joins the existing dot-separated metrics (never a new
|
|
207
|
-
// line). formatModelLabel returns null for absent/sentinel values → no suffix.
|
|
208
|
-
const modelLabel = formatModelLabel(model)
|
|
209
|
-
const modelPart = modelLabel != null ? ` · ${escapeMarkdown(modelLabel)}` : ''
|
|
210
|
-
const line2 = state === 'running'
|
|
211
|
-
? `_${elapsed} · ${toolCount} ${toolWord}${tokPart}${modelPart}_`
|
|
212
|
-
: `_${state} · ${toolCount} ${toolWord}${tokPart} · ${elapsed}${modelPart}_`
|
|
213
|
-
return [line1, line2]
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Compact token-count formatter for the activity card's metrics line:
|
|
218
|
-
* <1000 → raw ("940")
|
|
219
|
-
* ≥1000, <1e6 → one-decimal k ("12.4k", "1.0k")
|
|
220
|
-
* ≥1e6 → one-decimal M ("1.2M")
|
|
221
|
-
* Negative / non-finite inputs clamp to "0". The caller appends " tok".
|
|
222
|
-
*/
|
|
223
|
-
export function formatTokenCount(n: number): string {
|
|
224
|
-
if (!Number.isFinite(n) || n <= 0) return '0'
|
|
225
|
-
if (n < 1000) return String(Math.floor(n))
|
|
226
|
-
if (n < 1_000_000) {
|
|
227
|
-
// Round to the displayed 1-decimal k FIRST: inputs in [999_950, 999_999]
|
|
228
|
-
// round to "1000.0k", which must promote into the M branch rather than
|
|
229
|
-
// render a nonsense "1000.0k". Fall through when the rounded k reaches 1000.
|
|
230
|
-
const k = Number((n / 1000).toFixed(1))
|
|
231
|
-
if (k < 1000) return `${k.toFixed(1)}k`
|
|
232
|
-
}
|
|
233
|
-
return `${(n / 1_000_000).toFixed(1)}M`
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* The ` · {N} tok` metrics segment, or '' when there are no tokens to show.
|
|
238
|
-
* A 0 / undefined total (a worker that emitted no usage — e.g. a non-Claude
|
|
239
|
-
* transcript) OMITS the segment entirely so the line stays clean; the same
|
|
240
|
-
* predicate is used by BOTH render variants (single-worker header + combined
|
|
241
|
-
* row) so they never diverge.
|
|
242
|
-
*/
|
|
243
|
-
function tokenSegment(totalTokens: number | undefined): string {
|
|
244
|
-
if (totalTokens == null || totalTokens <= 0) return ''
|
|
245
|
-
return ` · ${formatTokenCount(totalTokens)} tok`
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/** Format elapsed milliseconds for display in the activity header (e.g. "12s", "2m05s"). */
|
|
249
|
-
export function formatFeedElapsed(ms: number): string {
|
|
250
|
-
const s = Math.floor(ms / 1000)
|
|
251
|
-
if (s < 60) return `${s}s`
|
|
252
|
-
const m = Math.floor(s / 60)
|
|
253
|
-
return `${m}m${(s % 60).toString().padStart(2, '0')}s`
|
|
254
|
-
}
|
|
255
|
-
|
|
256
204
|
/**
|
|
257
205
|
* Minimum time the CURRENT step must have been running before its own
|
|
258
206
|
* `· <elapsed>` suffix appears on the `→` line. Under this, no suffix — a
|
|
@@ -272,62 +220,6 @@ export function formatStepSuffix(stepElapsedMs: number): string {
|
|
|
272
220
|
return ` · ${formatFeedElapsed(stepElapsedMs)}`
|
|
273
221
|
}
|
|
274
222
|
|
|
275
|
-
// ─── Truncation pipeline (the single correctness-critical primitive) ────────
|
|
276
|
-
//
|
|
277
|
-
// Per RAW line, in this EXACT order:
|
|
278
|
-
// 1. stripMarkdown(raw)
|
|
279
|
-
// 2. .replace(/\s+/g, ' ').trim()
|
|
280
|
-
// 3. truncate(_, STATUS_LINE_MAX)
|
|
281
|
-
// 4. escapeMarkdown(_) ← escape is ALWAYS the last per-line op.
|
|
282
|
-
// Escaping last is load-bearing: clipping an already-escaped string can split
|
|
283
|
-
// a markdown escape (\* → \), which renders wrong.
|
|
284
|
-
|
|
285
|
-
/** Clean + clip + escape a single raw step line. Returns ready-to-wrap markdown. */
|
|
286
|
-
function escapeStepLine(raw: string): string {
|
|
287
|
-
const cleaned = stripMarkdown(raw).replace(/\s+/g, ' ').trim()
|
|
288
|
-
return escapeMarkdown(truncate(cleaned, STATUS_LINE_MAX))
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Shared step-feed emitter. Appends `✓`/`→` bullet lines to `out` for the
|
|
293
|
-
* given ALREADY-ESCAPED step strings, windowing to `window` (default
|
|
294
|
-
* STATUS_ROLLING_LINES; the worker surfaces pass a deeper window) and
|
|
295
|
-
* prepending a `+N earlier…` header when the feed overflows the window (on
|
|
296
|
-
* BOTH surfaces now). The worker feed imports this directly.
|
|
297
|
-
*
|
|
298
|
-
* `out` — accumulator mutated in place
|
|
299
|
-
* `steps` — pre-cleaned + pre-escaped HTML step strings
|
|
300
|
-
* `allDone` — when true ALL steps render done (✓ struck italic); when false the
|
|
301
|
-
* newest renders in-progress (→ bold)
|
|
302
|
-
* `liveSuffix` — appended INSIDE the newest in-progress line (heartbeat tick)
|
|
303
|
-
* `indent` — literal prefix put on EVERY emitted line (incl. the
|
|
304
|
-
* `+N earlier…` header), OUTSIDE the markdown spans so it never
|
|
305
|
-
* lands inside an emphasis run. Default `''` — byte-identical
|
|
306
|
-
* output for callers that don't indent. The combined worker card
|
|
307
|
-
* passes `WORKER_STEP_INDENT` to nest steps under their worker.
|
|
308
|
-
*/
|
|
309
|
-
export function renderStepFeed(
|
|
310
|
-
out: string[],
|
|
311
|
-
steps: string[],
|
|
312
|
-
allDone: boolean,
|
|
313
|
-
liveSuffix = '',
|
|
314
|
-
window: number = STATUS_ROLLING_LINES,
|
|
315
|
-
indent = '',
|
|
316
|
-
): void {
|
|
317
|
-
if (steps.length === 0) return
|
|
318
|
-
const shown = steps.slice(-Math.max(1, window))
|
|
319
|
-
const hidden = steps.length - shown.length
|
|
320
|
-
if (hidden > 0) out.push(`${indent}_✓ +${hidden} earlier…_`)
|
|
321
|
-
const lastIdx = shown.length - 1
|
|
322
|
-
shown.forEach((s, i) => {
|
|
323
|
-
out.push(
|
|
324
|
-
!allDone && i === lastIdx
|
|
325
|
-
? `${indent}**→ ${s}${liveSuffix}**`
|
|
326
|
-
: `${indent}~~_✓ ${s}_~~`,
|
|
327
|
-
)
|
|
328
|
-
})
|
|
329
|
-
}
|
|
330
|
-
|
|
331
223
|
// ─── Unified status-card primitive ──────────────────────────────────────────
|
|
332
224
|
//
|
|
333
225
|
// Both status surfaces (🤖 agent + 🛠 worker) render through `renderStatusCard`.
|
|
@@ -375,17 +267,16 @@ export interface StatusCardOpts {
|
|
|
375
267
|
*/
|
|
376
268
|
historyWindow?: number
|
|
377
269
|
/**
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
* `SUBORDINATE_LINE_INDENT`, so the whole block sits one level in from the
|
|
381
|
-
* agent card's left margin.
|
|
270
|
+
* Body line rendered when the card has NO steps and NO children at all — the
|
|
271
|
+
* 🛠 worker card's `starting…` placeholder for a just-dispatched worker.
|
|
382
272
|
*
|
|
383
|
-
* This
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
273
|
+
* This used to be string-concatenated onto the renderer's return value by
|
|
274
|
+
* `renderWorkerActivity` (#3846), which put one user-visible card line
|
|
275
|
+
* outside the primitive that owns line assembly, hard-break stacking and the
|
|
276
|
+
* char budget. It is a normal body line now, emitted by `emitSection` like
|
|
277
|
+
* every other one.
|
|
387
278
|
*/
|
|
388
|
-
|
|
279
|
+
emptyPlaceholder?: string
|
|
389
280
|
}
|
|
390
281
|
|
|
391
282
|
/**
|
|
@@ -395,8 +286,13 @@ export interface StatusCardOpts {
|
|
|
395
286
|
* Pipeline: header → escape+clip every raw step/child → rolling-window each
|
|
396
287
|
* group with `+N earlier…` → wrap (parent done-styled when children present;
|
|
397
288
|
* newest-in-progress `→` bold else `✓` italic; NESTED_PREFIX for children) →
|
|
398
|
-
* optional `✓ N steps` footer → optional result block →
|
|
399
|
-
*
|
|
289
|
+
* optional `✓ N steps` footer → optional result block → fitCardToBudget.
|
|
290
|
+
*
|
|
291
|
+
* Every card this primitive emits is FLUSH at the left margin (#3842). The
|
|
292
|
+
* whole-card `└─ ` + one-level indent that #3820/#3821 put on the worker card
|
|
293
|
+
* is gone: it cost horizontal space on a phone and claimed a parent/child
|
|
294
|
+
* relationship that does not always hold. Intra-card nesting (the `↳` child
|
|
295
|
+
* block here, `WORKER_STEP_INDENT` on the combined card) is unaffected.
|
|
400
296
|
*/
|
|
401
297
|
export function renderStatusCard(opts: StatusCardOpts): string | null {
|
|
402
298
|
const { header, final = false, liveSuffix = '', stepCount, result } = opts
|
|
@@ -409,7 +305,7 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
|
|
|
409
305
|
const steps = rawSteps.map(escapeStepLine)
|
|
410
306
|
const children = rawChildren.map(escapeStepLine)
|
|
411
307
|
|
|
412
|
-
const
|
|
308
|
+
const chrome = header != null
|
|
413
309
|
? renderActivityHeader(
|
|
414
310
|
header.emoji,
|
|
415
311
|
header.label,
|
|
@@ -426,145 +322,139 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
|
|
|
426
322
|
)
|
|
427
323
|
: []
|
|
428
324
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
if (
|
|
432
|
-
// Parent lines all render done — the live → step lives in the nested block.
|
|
433
|
-
const shownParent = steps.slice(-window)
|
|
434
|
-
const hiddenParent = steps.length - shownParent.length
|
|
435
|
-
if (hiddenParent > 0) out.push(`_✓ +${hiddenParent} earlier…_`)
|
|
436
|
-
for (const s of shownParent) out.push(`~~_✓ ${s}_~~`)
|
|
437
|
-
// Child block.
|
|
438
|
-
const shownChild = children.slice(-window)
|
|
439
|
-
const hiddenChild = children.length - shownChild.length
|
|
440
|
-
if (hiddenChild > 0) out.push(`${NESTED_PREFIX}_+${hiddenChild} earlier…_`)
|
|
441
|
-
const lastChildIdx = shownChild.length - 1
|
|
442
|
-
shownChild.forEach((s, i) => {
|
|
443
|
-
out.push(
|
|
444
|
-
i === lastChildIdx && !final
|
|
445
|
-
? `${NESTED_PREFIX}**→ ${s}${liveSuffix}**`
|
|
446
|
-
: `${NESTED_PREFIX}~~_${s}_~~`,
|
|
447
|
-
)
|
|
448
|
-
})
|
|
449
|
-
} else {
|
|
450
|
-
renderStepFeed(out, steps, final, liveSuffix, window)
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
if (final && stepCount != null && stepCount > 0) {
|
|
454
|
-
out.push(`_✓ ${stepCount} steps_`)
|
|
455
|
-
}
|
|
456
|
-
|
|
325
|
+
// Fixed footer/result lines — kept at every shrink level.
|
|
326
|
+
const footer: string[] = []
|
|
327
|
+
if (final && stepCount != null && stepCount > 0) footer.push(`_✓ ${stepCount} steps_`)
|
|
457
328
|
if (result != null && result.text.length > 0) {
|
|
458
|
-
|
|
459
|
-
|
|
329
|
+
footer.push(WORKER_RESULT_RULE)
|
|
330
|
+
footer.push(`${result.emoji} _${escapeMarkdown(truncate(result.text, WORKER_RESULT_MAX))}_`)
|
|
460
331
|
}
|
|
461
332
|
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
//
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
const
|
|
472
|
-
|
|
333
|
+
// The active "body" group the shrink levels erode: children when present
|
|
334
|
+
// (the parent trail collapses to a single `+N` marker), else parent steps.
|
|
335
|
+
const rawBody = hasChildren ? rawChildren : rawSteps
|
|
336
|
+
const escapedBody = hasChildren ? children : steps
|
|
337
|
+
const bodyIndent = hasChildren ? NESTED_PREFIX : ''
|
|
338
|
+
// The `↳` glyph already marks the nested block, so its bullets carry no ✓.
|
|
339
|
+
const bodyDoneMark = hasChildren ? '' : '✓ '
|
|
340
|
+
|
|
341
|
+
/** Level 0 — the full card, everything at its natural window. */
|
|
342
|
+
const fullSpec = (): CardSpec => ({
|
|
343
|
+
chrome,
|
|
344
|
+
sections: hasChildren
|
|
345
|
+
? [
|
|
346
|
+
// Parent lines all render done — the live `→` lives in the nested block.
|
|
347
|
+
{ steps, window, allDone: true },
|
|
348
|
+
{
|
|
349
|
+
steps: children,
|
|
350
|
+
window,
|
|
351
|
+
allDone: final,
|
|
352
|
+
liveSuffix,
|
|
353
|
+
indent: NESTED_PREFIX,
|
|
354
|
+
doneMark: '',
|
|
355
|
+
},
|
|
356
|
+
]
|
|
357
|
+
: [{ steps, window, allDone: final, liveSuffix, placeholder: opts.emptyPlaceholder }],
|
|
358
|
+
footer,
|
|
473
359
|
})
|
|
474
|
-
if (joined.length <= STATUS_CARD_CHAR_BUDGET) return joined
|
|
475
|
-
return fitCardToBudget(opts, headerLines)
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/** Subtle horizontal rule between the running feed and the finished result. */
|
|
479
|
-
const WORKER_RESULT_RULE = '─────'
|
|
480
|
-
/** Hard cap on the terminal result paragraph. */
|
|
481
|
-
const WORKER_RESULT_MAX = 320
|
|
482
|
-
|
|
483
|
-
/**
|
|
484
|
-
* Char-budget backstop. Keeps the header / footer / result block fixed and
|
|
485
|
-
* drops the oldest body bullets one at a time, re-inserting a `+N earlier…`
|
|
486
|
-
* marker, until the card fits STATUS_CARD_CHAR_BUDGET. In the extreme case
|
|
487
|
-
* (a single newest bullet that is itself oversized) it truncates the RAW
|
|
488
|
-
* newest text, THEN escapes, THEN wraps — never slicing already-escaped markdown.
|
|
489
|
-
*/
|
|
490
|
-
function fitCardToBudget(opts: StatusCardOpts, headerLines: string[]): string {
|
|
491
|
-
const { final = false, liveSuffix = '', stepCount, result } = opts
|
|
492
|
-
const rawSteps = opts.steps.filter((s) => s != null)
|
|
493
|
-
const rawChildren = (opts.childSteps ?? []).map((s) => s.trim()).filter((s) => s.length > 0)
|
|
494
|
-
const hasChildren = rawChildren.length > 0
|
|
495
|
-
const subordinate = opts.subordinate === true
|
|
496
|
-
// Subordinate nesting (#3820) costs a fixed prefix on EVERY line — the header
|
|
497
|
-
// prefix and the body indent are the same length by invariant (see
|
|
498
|
-
// SUBORDINATE_HEADER_PREFIX), so it is one flat per-line charge rather than a
|
|
499
|
-
// line-1 special case. Charged into the arithmetic below so the extreme
|
|
500
|
-
// single-oversized-bullet branch still lands under the wire cap.
|
|
501
|
-
const nestCost = subordinate ? SUBORDINATE_LINE_INDENT.length : 0
|
|
502
|
-
const stack = (lines: string[]): string =>
|
|
503
|
-
stackCardLines(subordinate ? nestSubordinateCardLines(lines) : lines, { collapseSafe: true })
|
|
504
|
-
|
|
505
|
-
// Fixed footer/result lines (always kept).
|
|
506
|
-
const footerLines: string[] = []
|
|
507
|
-
if (final && stepCount != null && stepCount > 0) footerLines.push(`_✓ ${stepCount} steps_`)
|
|
508
|
-
if (result != null && result.text.length > 0) {
|
|
509
|
-
footerLines.push(WORKER_RESULT_RULE)
|
|
510
|
-
footerLines.push(`${result.emoji} _${escapeMarkdown(truncate(result.text, WORKER_RESULT_MAX))}_`)
|
|
511
|
-
}
|
|
512
|
-
const fixedCost =
|
|
513
|
-
[...headerLines, ...footerLines].join('\n').length +
|
|
514
|
-
(headerLines.length + footerLines.length) * nestCost
|
|
515
360
|
|
|
516
|
-
|
|
517
|
-
//
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
const prefix = hasChildren ? NESTED_PREFIX : ''
|
|
361
|
+
const lines = cardSpecLines(fullSpec())
|
|
362
|
+
// `lines` always carries the two header lines, so it is never empty — but
|
|
363
|
+
// guard against a degenerate header-less future caller.
|
|
364
|
+
if (lines.length === 0) return null
|
|
521
365
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
// Parent-collapsed marker line when we are dropping children but parent steps exist.
|
|
366
|
+
// Parent-collapsed marker line used once the shrink levels start dropping
|
|
367
|
+
// children (the parent trail is spent bytes on an over-budget card).
|
|
526
368
|
const parentMarker =
|
|
527
369
|
hasChildren && rawSteps.length > 0 ? `_✓ +${rawSteps.length} earlier…_` : null
|
|
528
370
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
371
|
+
/**
|
|
372
|
+
* Shrink levels for the status card:
|
|
373
|
+
* 0 — the full card.
|
|
374
|
+
* 1 … len(body)-1 — drop that many OLDEST body bullets, re-inserting a
|
|
375
|
+
* `+N earlier…` marker; done bullets lose their
|
|
376
|
+
* strike/✓ chrome (`doneStyle: 'plain'`).
|
|
377
|
+
* len(body) (deepest) — a single newest bullet that is ITSELF oversized:
|
|
378
|
+
* truncate the RAW text, THEN escape, THEN wrap, so
|
|
379
|
+
* an already-escaped markdown escape is never sliced.
|
|
380
|
+
*/
|
|
381
|
+
const deepest = Math.max(1, escapedBody.length)
|
|
382
|
+
return fitCardToBudget((level) => {
|
|
383
|
+
if (level === 0) return { spec: fullSpec() }
|
|
384
|
+
const markerSection: CardSection = {
|
|
385
|
+
steps: [],
|
|
386
|
+
window: 1,
|
|
387
|
+
placeholder: parentMarker ?? undefined,
|
|
388
|
+
}
|
|
389
|
+
if (level < escapedBody.length) {
|
|
390
|
+
return {
|
|
391
|
+
spec: {
|
|
392
|
+
chrome,
|
|
393
|
+
sections: [
|
|
394
|
+
markerSection,
|
|
395
|
+
{
|
|
396
|
+
steps: escapedBody,
|
|
397
|
+
// Showing all but the `level` oldest — `emitSection` derives the
|
|
398
|
+
// `+level earlier…` marker from the hidden remainder.
|
|
399
|
+
window: escapedBody.length - level,
|
|
400
|
+
allDone: final,
|
|
401
|
+
liveSuffix,
|
|
402
|
+
indent: bodyIndent,
|
|
403
|
+
doneMark: bodyDoneMark,
|
|
404
|
+
doneStyle: 'plain',
|
|
405
|
+
},
|
|
406
|
+
],
|
|
407
|
+
footer,
|
|
408
|
+
},
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
spec: {
|
|
413
|
+
chrome,
|
|
414
|
+
sections: [markerSection, { steps: [], window: 1, placeholder: truncatedNewestLine() }],
|
|
415
|
+
footer,
|
|
416
|
+
},
|
|
417
|
+
}
|
|
418
|
+
}, deepest)
|
|
541
419
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
fixedCost
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
420
|
+
/**
|
|
421
|
+
* The deepest shrink level's single bullet. Charges the fixed header/footer
|
|
422
|
+
* (and the parent marker) against `STATUS_CARD_CHAR_BUDGET`, then clips the
|
|
423
|
+
* RAW newest text to what is left, re-checking after escaping because
|
|
424
|
+
* escaping can expand the string (`&` → `&`).
|
|
425
|
+
*/
|
|
426
|
+
function truncatedNewestLine(): string {
|
|
427
|
+
const fixedCost = [...chrome, ...footer].join('\n').length
|
|
428
|
+
const rawNewest = rawBody.length > 0 ? cleanStepLine(rawBody[rawBody.length - 1]) : ''
|
|
429
|
+
const wrapperOverhead = final
|
|
430
|
+
? (bodyIndent + '_✓ _').length
|
|
431
|
+
: (bodyIndent + '**→ **').length + liveSuffix.length
|
|
432
|
+
const headerFooterCost =
|
|
433
|
+
fixedCost +
|
|
434
|
+
(fixedCost > 0 ? 1 : 0) +
|
|
435
|
+
(parentMarker != null ? parentMarker.length + 1 : 0)
|
|
436
|
+
const budget = STATUS_CARD_CHAR_BUDGET - headerFooterCost - wrapperOverhead
|
|
437
|
+
let raw = rawNewest.slice(0, Math.max(0, budget))
|
|
438
|
+
let newest = escapeMarkdown(raw)
|
|
439
|
+
while (
|
|
440
|
+
raw.length > 0 &&
|
|
441
|
+
wrapperOverhead + headerFooterCost + newest.length > STATUS_CARD_CHAR_BUDGET
|
|
442
|
+
) {
|
|
443
|
+
const excess = wrapperOverhead + headerFooterCost + newest.length - STATUS_CARD_CHAR_BUDGET
|
|
444
|
+
raw = raw.slice(0, Math.max(0, raw.length - excess - 1))
|
|
445
|
+
newest = escapeMarkdown(raw)
|
|
446
|
+
}
|
|
447
|
+
return final
|
|
448
|
+
? `${bodyIndent}_✓ ${newest}_`
|
|
449
|
+
: `${bodyIndent}**→ ${newest}${liveSuffix}**`
|
|
559
450
|
}
|
|
560
|
-
const newestLine = final ? `${prefix}_✓ ${newest}_` : `${prefix}**→ ${newest}${liveSuffix}**`
|
|
561
|
-
const lines: string[] = [...headerLines]
|
|
562
|
-
if (parentMarker != null) lines.push(parentMarker)
|
|
563
|
-
lines.push(newestLine)
|
|
564
|
-
lines.push(...footerLines)
|
|
565
|
-
return stack(lines)
|
|
566
451
|
}
|
|
567
452
|
|
|
453
|
+
/** Subtle horizontal rule between the running feed and the finished result. */
|
|
454
|
+
const WORKER_RESULT_RULE = '─────'
|
|
455
|
+
/** Hard cap on the terminal result paragraph. */
|
|
456
|
+
const WORKER_RESULT_MAX = 320
|
|
457
|
+
|
|
568
458
|
/**
|
|
569
459
|
* Render the accumulated feed as ready Telegram HTML — one action per line,
|
|
570
460
|
* newest last. The current (newest) step is bold with a `→`; finished steps
|
|
@@ -796,10 +686,13 @@ function glanceLine(rows: CombinedWorkerRow[]): string {
|
|
|
796
686
|
const oldestMs = rows.reduce((m, r) => Math.max(m, r.elapsedMs), 0)
|
|
797
687
|
const tools = rows.reduce((n, r) => n + r.toolCount, 0)
|
|
798
688
|
const tok = rows.reduce((n, r) => n + (r.totalTokens ?? 0), 0)
|
|
799
|
-
|
|
689
|
+
// Aggregate glance, so it is NOT a `metricsRun` (no per-entity state/model,
|
|
690
|
+
// and it leads with the swarm count rather than elapsed). It still reads the
|
|
691
|
+
// same elapsed / tool-word / token primitives, so the vocabulary that DOES
|
|
692
|
+
// repeat across cards cannot drift.
|
|
800
693
|
return (
|
|
801
694
|
`🛠 **WORKERS** · _${rows.length} running · oldest ${formatFeedElapsed(oldestMs)}` +
|
|
802
|
-
` · ${tools} ${toolWord}${tokenSegment(tok)}_`
|
|
695
|
+
` · ${tools} ${toolWord(tools)}${tokenSegment(tok)}_`
|
|
803
696
|
)
|
|
804
697
|
}
|
|
805
698
|
|
|
@@ -807,23 +700,26 @@ function glanceLine(rows: CombinedWorkerRow[]): string {
|
|
|
807
700
|
* Render N≥1 live workers into ONE combined feed body (ready Telegram
|
|
808
701
|
* markdown; callers send verbatim — do NOT re-escape). Layout:
|
|
809
702
|
*
|
|
810
|
-
*
|
|
811
|
-
*
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
703
|
+
* 🛠 **WORKERS** · _N running · oldest {elapsed} · {n} tools · {t} tok_
|
|
704
|
+
* **1. {desc1}** _· {elapsed} · {n} tools_
|
|
705
|
+
* ~~_✓ {earlier step}_~~
|
|
706
|
+
* **→ {newest step}**
|
|
707
|
+
* **2. {desc2}** _· {elapsed} · {n} tools_
|
|
708
|
+
* **→ {newest step}**
|
|
709
|
+
* _+M more working…_
|
|
817
710
|
*
|
|
818
|
-
*
|
|
819
|
-
* `
|
|
820
|
-
*
|
|
711
|
+
* FLUSH (#3842): the card sits at the left margin like every other card. The
|
|
712
|
+
* whole-card `└─ ` + one-level indent from #3820/#3821 is gone — it burned a
|
|
713
|
+
* level of horizontal phone width to assert a parent/child relationship with
|
|
714
|
+
* the 🤖 agent card that does not always hold (this card is not always below
|
|
715
|
+
* it).
|
|
821
716
|
*
|
|
822
|
-
* INDENT:
|
|
823
|
-
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
717
|
+
* INDENT: exactly ONE level of indentation survives, and it is the level that
|
|
718
|
+
* earns its keep — every step line carries a leading `WORKER_STEP_INDENT` (a
|
|
719
|
+
* U+2800 run — neither ASCII spaces nor U+00A0; Telegram left-trims BOTH, see
|
|
720
|
+
* the constant's doc comment) so one worker's steps are visibly separated from
|
|
721
|
+
* the next worker's. The glance line, the numbered row headers and the spill
|
|
722
|
+
* line stay flush.
|
|
827
723
|
*
|
|
828
724
|
* NUMBERING (#3298): when the card tracks 2+ rows AND a row carries `ordinal`,
|
|
829
725
|
* its header gets a stable `{ordinal}. ` prefix. Ordinals are assigned by the
|
|
@@ -869,91 +765,69 @@ export function renderCombinedWorkerFeed(
|
|
|
869
765
|
|
|
870
766
|
const rowHeader = (r: CombinedWorkerRow): string => {
|
|
871
767
|
const desc = escapeMarkdown(
|
|
872
|
-
truncate(
|
|
768
|
+
truncate(cleanStepLine(r.description) || 'background task', COMBINED_ROW_DESC_MAX),
|
|
873
769
|
)
|
|
874
|
-
const toolWord = r.toolCount === 1 ? 'tool' : 'tools'
|
|
875
|
-
const tokPart = tokenSegment(r.totalTokens)
|
|
876
|
-
const modelLabel = formatModelLabel(r.model)
|
|
877
|
-
const modelPart = modelLabel != null ? ` · ${escapeMarkdown(modelLabel)}` : ''
|
|
878
770
|
// Stable ordinal prefix INSIDE the bold span, before the already-escaped
|
|
879
771
|
// description — no new escaping surface, and the gateway md→HTML conversion
|
|
880
772
|
// has no ordered-list auto-formatting on bolded text.
|
|
881
773
|
const num = numbered && r.ordinal != null ? `${r.ordinal}. ` : ''
|
|
882
|
-
|
|
774
|
+
// The metrics run is composed by the SAME function as the 🤖 agent header
|
|
775
|
+
// and the 🛠 single-worker header (`metricsRun`, card-layout.ts). A combined
|
|
776
|
+
// row is always live, so it reports 'running'; before #3844 this line spelled
|
|
777
|
+
// the run out by hand and agreed with the other two only by inspection.
|
|
778
|
+
return `**${num}${desc}** _· ${metricsRun(r.elapsedMs, r.toolCount, 'running', r.totalTokens, r.model)}_`
|
|
883
779
|
}
|
|
884
780
|
|
|
885
781
|
// Raw (unescaped) history for a worker, oldest→newest, empty lines stripped.
|
|
886
782
|
// Falls back to the single currentStep when no history was supplied.
|
|
887
783
|
const rowHistory = (r: CombinedWorkerRow): string[] => {
|
|
888
784
|
const src = r.historyLines != null && r.historyLines.length > 0 ? r.historyLines : [r.currentStep]
|
|
889
|
-
return src.filter((s) => s != null &&
|
|
785
|
+
return src.filter((s) => s != null && cleanStepLine(s).length > 0)
|
|
890
786
|
}
|
|
891
787
|
|
|
892
|
-
const compose = (visibleCount: number):
|
|
788
|
+
const compose = (visibleCount: number): CardCandidate => {
|
|
893
789
|
const shown = rows.slice(0, visibleCount)
|
|
894
790
|
const hidden = rows.length - shown.length
|
|
895
791
|
// Per-worker depth follows Ken's deterministic curve max(3, 7−w) (#3349):
|
|
896
792
|
// the curve drives DEPTH; the total-line budget below drives ROW COUNT.
|
|
897
793
|
const depth = combinedHistoryDepth(shown.length)
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
const hist = rowHistory(r)
|
|
903
|
-
if (hist.length === 0) {
|
|
904
|
-
bodyOut.push(`${WORKER_STEP_INDENT}→ _starting…_`)
|
|
905
|
-
continue
|
|
906
|
-
}
|
|
907
|
-
// Paint the last-K history lines with the SAME `✓`/`→` idiom as the
|
|
908
|
-
// single-worker card: escape each raw line through the shared per-line
|
|
909
|
-
// pipeline (escapeStepLine), then renderStepFeed strikes the prior steps
|
|
910
|
-
// and bolds the newest in-progress step. The window equals the depth so a
|
|
911
|
-
// per-worker `+N earlier…` marker never appears inside the combined feed.
|
|
912
|
-
//
|
|
913
|
-
// WORKER_STEP_INDENT nests every step line one level under its worker
|
|
914
|
-
// header, so the boundary between two workers is visible at a glance on a
|
|
915
|
-
// phone (the header lines stay at the left margin, their steps sit in).
|
|
916
|
-
// It is a U+2800 run. NOT ASCII spaces and NOT U+00A0: Telegram's
|
|
917
|
-
// server-side parser left-trims a leading Unicode-whitespace run, so both
|
|
918
|
-
// render flat (#3662 shipped U+00A0 and was inert). U+2800 is category So,
|
|
919
|
-
// not Zs. See the constant's doc comment for the live evidence.
|
|
920
|
-
const esc = hist.slice(-depth).map(escapeStepLine)
|
|
921
|
-
renderStepFeed(bodyOut, esc, false, '', depth, WORKER_STEP_INDENT)
|
|
922
|
-
}
|
|
923
|
-
const out = [...chrome, ...bodyOut]
|
|
924
|
-
if (hidden > 0) out.push(`_+${hidden} more working…_`)
|
|
925
|
-
// collapseSafe (#3666): this card is PINNED, and Telegram's pinned bar
|
|
926
|
-
// renders it collapsed to one line with the newlines dropped and nothing
|
|
927
|
-
// substituted. Without the separator the glance line runs straight into
|
|
928
|
-
// row 1's ordinal and every step glyph mashes into the previous line.
|
|
794
|
+
// Each worker is ONE SECTION of the shared card spec: its row header, then
|
|
795
|
+
// its last-K history lines painted with the SAME `✓`/`→` idiom as every
|
|
796
|
+
// other card (emitSection). The window equals the depth, so a per-worker
|
|
797
|
+
// `+N earlier…` marker never appears inside the combined feed.
|
|
929
798
|
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
//
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
799
|
+
// WORKER_STEP_INDENT nests every step line one level under its worker
|
|
800
|
+
// header, so the boundary between two workers is visible at a glance on a
|
|
801
|
+
// phone (the header lines stay at the left margin, their steps sit in).
|
|
802
|
+
// It is a U+2800 run. NOT ASCII spaces and NOT U+00A0: Telegram's
|
|
803
|
+
// server-side parser left-trims a leading Unicode-whitespace run, so both
|
|
804
|
+
// render flat (#3662 shipped U+00A0 and was inert). U+2800 is category So,
|
|
805
|
+
// not Zs. See the constant's doc comment for the live evidence.
|
|
806
|
+
const sections: CardSection[] = shown.map((r) => ({
|
|
807
|
+
header: [rowHeader(r)],
|
|
808
|
+
steps: rowHistory(r).slice(-depth).map(escapeStepLine),
|
|
809
|
+
window: depth,
|
|
810
|
+
indent: WORKER_STEP_INDENT,
|
|
811
|
+
placeholder: '→ _starting…_',
|
|
812
|
+
}))
|
|
813
|
+
const footer = hidden > 0 ? [`_+${hidden} more working…_`] : []
|
|
814
|
+
// No whole-card nesting (#3842): the glance / row-header / spill lines land
|
|
815
|
+
// flush and only the step lines carry their WORKER_STEP_INDENT — one level
|
|
816
|
+
// of hierarchy, not two.
|
|
817
|
+
const spec: CardSpec = { chrome: [glanceLine(rows)], sections, footer }
|
|
818
|
+
// The per-worker BODY lines (row headers + their steps) — the glance line and
|
|
819
|
+
// the spill line are fixed chrome and sit outside this ceiling.
|
|
820
|
+
const bodyLines = cardSpecLines(spec).length - 1 - footer.length
|
|
821
|
+
return { spec, overflow: bodyLines > MAX_COMBINED_BODY_LINES }
|
|
940
822
|
}
|
|
941
823
|
|
|
942
|
-
// Cap to maxRows first, then
|
|
943
|
-
// body-line budget (#3349: bounds a big
|
|
944
|
-
// shown workers) OR the wire char budget
|
|
945
|
-
// collapse into the `+M more working…`
|
|
946
|
-
// keeps the head of the list).
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
while (
|
|
950
|
-
(bodyLines > MAX_COMBINED_BODY_LINES || body.length > STATUS_CARD_CHAR_BUDGET) &&
|
|
951
|
-
visible > 1
|
|
952
|
-
) {
|
|
953
|
-
visible -= 1
|
|
954
|
-
;({ body, bodyLines } = compose(visible))
|
|
955
|
-
}
|
|
956
|
-
return body
|
|
824
|
+
// Cap to maxRows first, then let the ONE shared budget enforcer shrink the
|
|
825
|
+
// visible set while EITHER the total body-line budget (#3349: bounds a big
|
|
826
|
+
// swarm without stealing depth from the shown workers) OR the wire char budget
|
|
827
|
+
// is exceeded. Newest (trailing) rows collapse into the `+M more working…`
|
|
828
|
+
// spill (`rows.slice(0, visibleCount)` keeps the head of the list).
|
|
829
|
+
const visible = Math.min(rows.length, maxRows)
|
|
830
|
+
return fitCardToBudget((level) => compose(Math.max(1, visible - level)), visible - 1)
|
|
957
831
|
}
|
|
958
832
|
|
|
959
833
|
/**
|