diff --git a/src/engine/run-registry.ts b/src/engine/run-registry.ts index 8769dad..56e67e8 100644 --- a/src/engine/run-registry.ts +++ b/src/engine/run-registry.ts @@ -28,6 +28,10 @@ export interface RunRecord { costTotal?: number; /** SPEC-6-1: latest context tokens (calcContextTokens(usage)) — live snapshot. */ contextTokens?: number; + /** #117: the model's context window, resolved via getModelContextWindow at the tool wiring + * and persisted here so registry-backed previews (fleet preview row) render the ctx% segment + * identically to the transcript card. */ + maxContext?: number; /** #32: context-token snapshot at the end of turn 1 (the armory substrate baseline). * Set once on the first assistant message_end; live-only (not journaled). The widget * compares current contextTokens against this to label the tok/ctx% segment as diff --git a/src/panel/fleet-panel.ts b/src/panel/fleet-panel.ts index 3c7a0f2..589ecd5 100644 --- a/src/panel/fleet-panel.ts +++ b/src/panel/fleet-panel.ts @@ -739,6 +739,14 @@ export class FleetPanel extends Container { if (sel) this.startRun(sel.value); return; } + // #115: the fleet footer promises r:Run-new — dispatch with the first registered agent + // (agents-view order). No agents → notify instead of a dead key. + if (matchesKey(data, "r") && this.view === "fleet") { + const first = [...this.deps.registry.values()][0]; + if (!first) { this.onNotify("no agents registered — add one in the agents tab", "warning"); return; } + this.startRun(first.name); + return; + } if (matchesKey(data, "i") && this.view === "agents") { const sel = this.list.getSelectedItem(); if (sel) { this.infoAgent = this.deps.registry.get(sel.value) ?? null; this.renderShell(); } diff --git a/src/panel/present.ts b/src/panel/present.ts index 07c3898..fd4150f 100644 --- a/src/panel/present.ts +++ b/src/panel/present.ts @@ -118,10 +118,9 @@ export function previewLine(selectedId: string | null | undefined, src: PreviewS const rec = src.registry?.get(selectedId); if (rec) { if (rec.status !== "running") return ""; - // maxContext rides as an override — cardSnapshot's copy list predates the ctx% - // segment, and RunRecord carries it as an optional runtime field (not yet declared). - const maxCtx = (rec as { maxContext?: number }).maxContext; - return stateLine(cardSnapshot(rec, { maxContext: maxCtx }), now, frame); + // maxContext rides as an override — cardSnapshot's copy list predates the ctx% segment; + // the field is declared on RunRecord since #117 (persisted by the tool wiring at emitCard). + return stateLine(cardSnapshot(rec, { maxContext: rec.maxContext }), now, frame); } for (const b of src.bgRuns?.values() ?? []) { if (b.runId === selectedId) { diff --git a/src/panel/widget-rows.ts b/src/panel/widget-rows.ts index 2b74273..f7ca994 100644 --- a/src/panel/widget-rows.ts +++ b/src/panel/widget-rows.ts @@ -198,10 +198,15 @@ export function widgetTotalsSegments(active: WidgetRun[], _now: number = Date.no const cost = active.reduce((acc, r) => acc + (r.costTotal ?? 0), 0); const tok = active.reduce((acc, r) => acc + (r.contextTokens ?? 0), 0); const segs: Segment[] = [{ text: `${spin} `, status: "running" }]; - if (running > 0) segs.push({ text: `${running} running`, token: "text" }); - if (cost > 0) segs.push({ text: `$${cost.toFixed(2)}`, token: "text" }); - if (tok > 0) segs.push({ text: `${fmtTokens(tok)} tok`, token: "text" }); - return segs.length > 1 ? segs : [{ text: `${spin} `, status: "running" }, { text: `${active.length} active`, token: "text" }]; + const values: Segment[] = []; + if (running > 0) values.push({ text: `${running} running`, token: "text" }); + if (cost > 0) values.push({ text: `$${cost.toFixed(2)}`, token: "text" }); + if (tok > 0) values.push({ text: `${fmtTokens(tok)} tok`, token: "text" }); + if (values.length === 0) values.push({ text: `${active.length} active`, token: "text" }); + // #116: the mockup joins value segments with muted ` · ` — the dd9a segment refactor dropped it + // (segments rendered glued: `⣾ 1 running$0.011727K tok`). + const sep: Segment = { text: " · ", token: "muted" }; + return [segs[0]!, ...values.flatMap((v, i) => (i > 0 ? [sep, v] : [v]))]; } /** Segment form of the widget lines (same shape/order as renderWidgetLines — see above). */ diff --git a/src/tools/subagent.ts b/src/tools/subagent.ts index 908cdda..f4c48a7 100644 --- a/src/tools/subagent.ts +++ b/src/tools/subagent.ts @@ -267,7 +267,11 @@ export function createSubagentTool(deps: SubagentToolDeps) { if (!rec) return; const cardOverrides: Partial = {}; const maxContext = deps.getModelContextWindow?.(rec.model); - if (maxContext !== undefined) cardOverrides.maxContext = maxContext; + if (maxContext !== undefined) { + cardOverrides.maxContext = maxContext; + // #117: persist to the record so the fleet preview mirrors the card's ctx% segment. + if (rec.maxContext !== maxContext) deps.runRegistry.update(rec.runId, { maxContext }); + } // pi's updateDisplay reads result.content unconditionally (image-block pass) — a partial // MUST carry the result envelope shape: content array + details. The card rides in details. onUpdate({ content: [] as Array<{ type: string; text?: string }>, details: { card: cardSnapshot(rec, cardOverrides) } }); diff --git a/test/tool-onupdate.test.mts b/test/tool-onupdate.test.mts index 9d29724..e6a20dc 100644 --- a/test/tool-onupdate.test.mts +++ b/test/tool-onupdate.test.mts @@ -120,3 +120,25 @@ test("#104 live path: cards stream DURING the run (TDZ regression — hoisted re ok(Array.isArray(first.content), "partial carries a content array"); ok(first.details.card.runId.startsWith("fl-"), `card carries a real runId: ${first.details.card.runId}`); }); + +test("#117: emitCard persists maxContext to the registry record (fleet preview ctx%)", async () => { + const runRegistry = new RunRegistry(); + const cards: Array<{ content: unknown[]; details: { card: { runId: string } } }> = []; + const deps = { + registry: new Map([["g", CARD_AGENT]]), + runRegistry, + lock: createSingleSlotLock(), + todoSync: new ArmoryTodoAdapter(), + backendRegistry: regWith(cardFactory([{ type: "turn_start" }])), + parentModel: { provider: "p", id: "m" }, + parentCwd: cardTmp, + defaultModelFallback: undefined, + getModelContextWindow: () => 256_000, + }; + const tool = createSubagentTool(deps as any); + const res = await tool.execute("tc3", { agent: "g", task: "t" } as never, undefined as never, (p: unknown) => { cards.push(p as never); }, {}); + assert.ok(!res.isError, `not an error: ${JSON.stringify(res).slice(0, 200)}`); + assert.ok(cards.length >= 1, "at least one live card"); + const runId = cards[0]!.details.card.runId; + assert.equal(runRegistry.get(runId)?.maxContext, 256_000, "registry record carries the context window"); +}); diff --git a/test/widget-segments.test.mts b/test/widget-segments.test.mts index b4097b0..efa3f3e 100644 --- a/test/widget-segments.test.mts +++ b/test/widget-segments.test.mts @@ -75,3 +75,17 @@ test("totals strip: present only when >1 active, glyph carries running, money/to ok(two.some((s) => s.token === "text" && /^\$\d/.test(s.text)), "money segment"); ok(two.some((s) => s.token === "text" && s.text.includes("K tok")), "tok segment"); }); + +test("totals strip: value segments joined with muted · separators (#116)", () => { + const two = widgetTotalsSegments([ + toWidgetRun(fg({ costTotal: 0.5, contextTokens: 1300 })), + toWidgetRunFromBg(bg({ status: "queued" })), + ], 2000); + const values = two.slice(1).filter((s) => s.text !== " · "); // tail after the spinner segment + const seps = two.filter((s) => s.text === " · "); + strictEqual(seps.length, values.length - 1, "exactly n-1 separators between value segments"); + ok(values.length >= 2, "fixture has ≥2 value segments"); + ok(seps.every((s) => s.token === "muted"), "separators carry muted token"); + strictEqual(two[two.length - 1]!.text !== " · ", true, "no trailing separator"); + strictEqual(two[0]!.text !== " · ", true, "no leading separator"); +});