Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e527f8a
docs(spec): fleet presentation redesign — 3-surface TUI/UX (#104)
rz1989s Sep 2, 2026
d9345bb
docs(plan): fleet presentation P1 — 10-task TDD implementation plan (…
rz1989s Sep 2, 2026
a770be9
fix(deps): pnpm-patch @getpipher/vision 0.5.2 — lib headers type brea…
rz1989s Sep 2, 2026
b4d5a32
feat(present): status token map + glyph vocabulary with ascii preset …
rz1989s Sep 2, 2026
e6ad1dc
fix(test): derive resolveDispatchCwd assertion from layout, not hardc…
rz1989s Sep 2, 2026
0c79384
feat(present): ANSI-aware width measure/truncate/excerpt (#104)
rz1989s Sep 2, 2026
69b642a
feat(tools): forward live RunCardState through the partial-result cha…
rz1989s Sep 2, 2026
14e64f2
fix(tools): hoist res above emitCard — TDZ killed every live card emi…
rz1989s Sep 3, 2026
afcd63d
feat(transcript): pure run-card builders — live frame + honest final …
rz1989s Sep 3, 2026
61bd376
fix(present): eventDot + filesTouched join the glyph vocabulary (#104…
rz1989s Sep 3, 2026
3c87222
feat(tools): live fleet run-cards in the transcript via render slots …
rz1989s Sep 3, 2026
9ef55ac
feat(todo-sync): read-only listFleetTodos projection on the port (#104)
rz1989s Sep 3, 2026
69a6ff0
feat(transcript): live orchestration entry + findings block at burst …
rz1989s Sep 3, 2026
828d24a
fix(index): baseline-snapshot burst capture — findings no longer re-r…
rz1989s Sep 3, 2026
0a726b9
fix(transcript): lastEventClass dot via GLYPHS.eventDot, not a bare l…
rz1989s Sep 3, 2026
95edcf8
feat(panel): totals header, state-machine footer, capability-aware ac…
rz1989s Sep 3, 2026
8f008d7
fix(panel): bg/lifecycle rows stay byte-identical unthemed — color-on…
rz1989s Sep 3, 2026
c4f9a58
feat(widget): colorized component widget with totals strip (#104)
rz1989s Sep 3, 2026
dd9aa9f
refactor(widget): renderWidgetLines delegates to widgetSegments; tota…
rz1989s Sep 3, 2026
0e6a794
feat(panel): emoji purge in gate-line + README presentation-surface s…
rz1989s Sep 3, 2026
150f6b7
fix(tools): partial emissions carry pi's result envelope — bare {card…
rz1989s Sep 3, 2026
93d87b1
Merge origin/main into feat/104 — governance PR #107 + deps alignment
rz1989s Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -568,4 +568,25 @@ MIT — see [LICENSE](./LICENSE). © RECTOR ([@rz1989s](https://github.com/rz198

Built with Ihsan · Maintained by [RECTOR](https://github.com/rz1989s) · [getpipher](https://github.com/getpipher)

</div>
</div>
## Presentation surface (unreleased — #104)

The redesign unifies the three fleet surfaces under one visual language: your pi theme's tokens only (`accent`/`text`/`muted`/`dim`/`warning`/`success`/`error`), a single glyph vocabulary (▶ ⏸ ✓ ✗ ⏳, braille spinners, box-drawing cards), and the `usage —` honesty rule (missing data renders `—`, never an estimate).

**In-transcript run cards.** When a `subagent` dispatch fires, the tool row becomes a live card — spinner, agent, model, task, and a state line (last event · turn · elapsed · tok · ctx%) driven by the child run's events. On settle it collapses to one honest line:

```
╰─ ✓ reviewer · 4m12s · 598K tok · $0.30 · ✎3 · verdict: Ship
```

(Expand — the native tool-expand key — for the full envelope; failed runs show `✗ … · — · —` with the reason.)

**Orchestration + findings entries.** While a burst of runs is live, a TUI-only entry (zero LLM tokens) shows the waiting-on tree, the fleet TODO projection, and the gate state; when the burst settles, a findings block records each run's outcome plus degradations (fallback used, zero-tool flag, language drift):

```
── findings ────────────────────────────────
✓ reviewer 4m12s 598K tok $0.30 — Ship
✗ scheduler — — — worker exited without result (TODO reverted ⚠)
```

**Widget + panel.** The above-editor widget is now a colorized component (totals strip when >1 active; one status-token segment per run). The panel gains a totals header, status-colored rows, a state-machine footer (keys that matter now), and capability-aware actions (aborted runs offer re-run, not stop). All existing keybindings are unchanged.
943 changes: 943 additions & 0 deletions docs/superpowers/plans/2026-09-03-fleet-presentation-p1.md

Large diffs are not rendered by default.

290 changes: 290 additions & 0 deletions docs/superpowers/specs/2026-09-03-spec-fleet-presentation-redesign.md

Large diffs are not rendered by default.

97 changes: 97 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import { RunRegistry } from "./engine/run-registry.ts";
import { SessionRejectionError } from "./engine/session-rejection.ts";
import { createSingleSlotLock, createForegroundLock } from "./engine/concurrency-lock.ts";
import { ArmoryTodoAdapter } from "./todo-sync/adapter.ts";
import { Container, Text } from "@earendil-works/pi-tui";
import { cardSnapshot, type RunCardState } from "./transcript/card-state.ts";
import { orchestrationLines } from "./transcript/orchestration.ts";
import { findingsFromRuns } from "./transcript/findings.ts";
import type { FleetTodoRow } from "./todo-sync/port.ts";
import type { BgRunStatus } from "./panel/rows.ts";
import { ArmoryMemoryAdapter } from "./memory-hydrate/adapter.ts";
import { ArmoryVisionAdapter } from "./vision/adapter.ts";
import { buildChildLoader } from "./engine/child-loader.ts";
Expand Down Expand Up @@ -271,6 +277,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
let fleetWidget: FleetWidgetController | null = null;
// SPEC-6-4: hoisted so session_shutdown can dispose the bus + unsubscribe fleet:rpc.
let fleetBus: FleetEventBus | null = null;
// #104: orchestration entry disposables (subscription + todos refresh timer) — hoisted so
// session_shutdown can tear them down alongside fleetWidget.dispose().
let orchestrationUnsub: (() => void) | null = null;
let orchestrationTodosTimer: NodeJS.Timeout | null = null;
let unsubscribeRpc: (() => void) | null = null;
// SPEC-6-3: hoisted so /fleet command handler + session_shutdown can reach them.
let wfController: WorkflowController | null = null;
Expand Down Expand Up @@ -588,6 +598,90 @@ export default async function (pi: ExtensionAPI): Promise<void> {
});
fleetWidget.start();

// #104: live orchestration entry (TUI-only; zero LLM tokens) + findings block at burst end.
// Entry renderers are SYNC: cachedTodos is refreshed by a controller-owned 5s interval that
// starts lazily on burst open and clears on idle + dispose (.unref()'d — never blocks exit).
let burstOpen = false;
const burstRuns = new Map<string, RunCardState>();
// Runs that pre-date the current burst (snapshot at open). The registry is cumulative for the
// process lifetime, so without this baseline every findings block would re-report ALL prior
// bursts' runs (review-found defect). bg runs always join the burst (bgRuns holds actives only).
let baselineRunIds = new Set<string>();
let cachedTodos: FleetTodoRow[] = [];
const refreshTodos = (): void => {
deps.todoSync?.listFleetTodos()
.then((rows) => { cachedTodos = rows; })
.catch(() => { cachedTodos = []; });
};
const startTodosTimer = (): void => {
if (orchestrationTodosTimer) return;
orchestrationTodosTimer = setInterval(refreshTodos, 5000);
orchestrationTodosTimer.unref?.();
refreshTodos(); // fresh cache the moment the burst opens
};
const stopTodosTimer = (): void => {
if (orchestrationTodosTimer) { clearInterval(orchestrationTodosTimer); orchestrationTodosTimer = null; }
};
const bgToCard = (b: BgRunStatus, nowMs: number): RunCardState => ({
runId: b.runId, agent: b.lifecycle, model: b.backend, task: b.task,
status: b.status as RunCardState["status"],
startedAt: b.elapsedMs != null ? nowMs - b.elapsedMs : nowMs,
});
const activeGate = (): string | undefined => {
for (const rec of deps.lifecycleRuns.values()) {
if (rec.status === "checkpoint") {
// Phases stay "running" while the record checkpoints (FleetRunStatus has no checkpoint state).
const phase = rec.phases.find((ph) => ph.status === "running") ?? rec.phases[rec.phases.length - 1];
return phase?.name;
}
}
return undefined;
};
const endBurst = (): void => {
burstOpen = false;
const rows = findingsFromRuns([...burstRuns.values()].filter((r) => !baselineRunIds.has(r.runId)));
try { if (rows.length > 0) pi.appendEntry("fleet-findings", { rows }); } catch { /* TUI-only; never breaks the run */ }
burstRuns.clear();
stopTodosTimer();
};
orchestrationUnsub?.(); orchestrationUnsub = null; // idempotent re-entry (session_start re-fire)
orchestrationUnsub = deps.runRegistry.subscribe(() => {
const nowMs = Date.now();
const reg = deps.runRegistry.list();
const bg = [...bgRuns.values()];
// Registry runs are live at spawn (FleetRunStatus has no queued); bg runs can queue pre-dispatch.
const activeCount = reg.filter((r) => r.status === "running").length
+ bg.filter((b) => b.status === "running" || b.status === "queued").length;
// Baseline FIRST on the opening fire: everything already terminal pre-dates this burst and
// must never re-appear in findings. The opening run itself is `running` at this instant, so
// it stays out of the baseline and joins the burst below. A pre-burst run still RUNNING when
// a new burst opens joins the burst (it settles within it) — documented, accepted edge.
if (activeCount > 0 && !burstOpen) {
burstOpen = true;
baselineRunIds = new Set(reg.filter((r) => r.status !== "running").map((r) => r.runId));
startTodosTimer();
try { pi.appendEntry("fleet-orchestration", { startedAt: nowMs }); } catch { /* TUI-only */ }
}
if (!burstOpen) return;
for (const r of reg) {
if (baselineRunIds.has(r.runId)) continue; // pre-burst terminal records never re-report
burstRuns.set(r.runId, cardSnapshot(r));
}
for (const b of bg) burstRuns.set(b.runId, bgToCard(b, nowMs));
if (activeCount === 0 && burstOpen) endBurst();
});
pi.registerEntryRenderer("fleet-orchestration", (_entry, _options, theme) => {
if (!burstOpen) return undefined; // idle → the entry hides itself (renders nothing)
try {
const nowMs = Date.now();
const runs = [
...deps.runRegistry.list().map((r) => cardSnapshot(r)),
...[...bgRuns.values()].map((b) => bgToCard(b, nowMs)),
];
return new Text(theme.fg("dim", orchestrationLines(runs, cachedTodos, activeGate(), nowMs).join("\n")), 0, 0);
} catch { return undefined; }
});

// SPEC-6-1: per-session TierStore (cwd-aware project path) + real TierRegistry (builtins + global + project).
const tierStore = new TierStore({
projectPath: join(dir, "tiers.json"),
Expand Down Expand Up @@ -694,6 +788,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
unsubscribeRpc?.();
unsubscribeRpc = null;
if (fleetWidget) { fleetWidget.dispose(); fleetWidget = null; }
// #104: orchestration entry teardown (subscription + todos timer) — same disposal path.
orchestrationUnsub?.(); orchestrationUnsub = null;
if (orchestrationTodosTimer) { clearInterval(orchestrationTodosTimer); orchestrationTodosTimer = null; }
// SPEC-6-3: abort in-flight workflow children via the session-wide adapter signal.
// Terminal runs are not re-journaled — only non-terminal spawns observe the abort.
wfSessionAbort?.abort();
Expand Down
8 changes: 5 additions & 3 deletions src/panel/fleet-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
// their own runId; the lifecycle's child spawns use their own runIds), but we
// dedup defensively in case a future change overlaps them.
import type { RunRecord } from "../engine/run-registry.ts";
import { fleetRow, renderBgRow, type BgRunStatus } from "./rows.ts";
import { fleetRow, renderBgRow, type BgRunStatus, type RowTheme } from "./rows.ts";
import type { SelectItem } from "@earendil-works/pi-tui";

export interface FleetItemSources {
runRegistry: { list(): RunRecord[] };
bgRuns?: { values(): IterableIterator<BgRunStatus> };
/** #104: when present, row glyph+status segments are theme-colored. */
theme?: RowTheme;
}

export function buildFleetItems(src: FleetItemSources): SelectItem[] {
Expand All @@ -23,13 +25,13 @@ export function buildFleetItems(src: FleetItemSources): SelectItem[] {
for (const r of src.runRegistry.list()) {
if (seen.has(r.runId)) continue;
seen.add(r.runId);
items.push({ value: r.runId, label: fleetRow(r) });
items.push({ value: r.runId, label: fleetRow(r, undefined, src.theme) });
}
if (src.bgRuns) {
for (const b of src.bgRuns.values()) {
if (seen.has(b.runId)) continue;
seen.add(b.runId);
items.push({ value: b.runId, label: renderBgRow(b) });
items.push({ value: b.runId, label: renderBgRow(b, src.theme) });
}
}
return items;
Expand Down
80 changes: 47 additions & 33 deletions src/panel/fleet-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
} from "@earendil-works/pi-tui";
import type { AgentDef, ThinkingLevel } from "../registry/frontmatter.ts";
import { agentsRow, agentInfo, backendsRow, backendInfo, lifecycleRow, lifecyclePhaseTimeline, scheduleRow } from "./rows.ts";
import { fleetRow, renderBgRow } from "./rows.ts";
import { totalsLine, footerFor, actionsForRun, type FooterState } from "./present.ts";
import { buildFleetItems } from "./fleet-items.ts";
import { runsRow, runTimelineRow } from "./runs-rows.ts";
import { messageBody, toolBody, messageHeader, toolHeader } from "./conversation-rows.ts";
Expand Down Expand Up @@ -89,6 +91,7 @@ export class FleetPanel extends Container {
private readonly onNotify: (msg: string, type?: "info" | "warning" | "error") => void;
private view: View = "fleet";
private list: SelectList;
private frame = 0; // #104: totals spinner frame (monotonic, advances per renderShell)
private runMode = false;
private taskInput: Input | null = null;
private linkInput: Input | null = null;
Expand Down Expand Up @@ -210,15 +213,15 @@ export class FleetPanel extends Container {
private buildList(): SelectList {
const items: SelectItem[] =
this.view === "fleet"
? buildFleetItems({ runRegistry: this.deps.runRegistry, bgRuns: this.deps.bgRuns })
? buildFleetItems({ runRegistry: this.deps.runRegistry, bgRuns: this.deps.bgRuns, theme: this.theme })
: this.view === "lifecycle"
? [...this.deps.lifecycleRuns.values()].map((l: LifecycleRunRecord) => ({ value: l.runId, label: lifecycleRow(l) }))
? [...this.deps.lifecycleRuns.values()].map((l: LifecycleRunRecord) => ({ value: l.runId, label: lifecycleRow(l, this.theme) }))
: this.view === "runs"
? buildRunsIndex(this.deps.runLog?.dir ?? "").map((r: RunMeta) => ({ value: r.runId, label: runsRow(r, this.deps.getModelContextWindow) }))
? buildRunsIndex(this.deps.runLog?.dir ?? "").map((r: RunMeta) => ({ value: r.runId, label: runsRow(r, this.deps.getModelContextWindow, this.theme) }))
: this.view === "agents"
? [...this.deps.registry.values()].map((a: AgentDef) => ({ value: a.name, label: agentsRow(a) }))
: this.view === "scheduled"
? (this.deps.scheduler?.list() ?? []).map((s: Schedule) => ({ value: s.id, label: scheduleRow(s) }))
? (this.deps.scheduler?.list() ?? []).map((s: Schedule) => ({ value: s.id, label: scheduleRow(s, this.theme) }))
: this.view === "tiers"
? (this.deps.tierRegistry ? buildTiersItems({ tierRegistry: this.deps.tierRegistry, runRegistry: this.deps.runRegistry }) : [])
: this.view === "workflows"
Expand Down Expand Up @@ -266,7 +269,19 @@ export class FleetPanel extends Container {
const tabs = (["fleet", "lifecycle", "runs", "agents", "backends", "scheduled", "tiers", "workflows"] as View[])
.map((v) => (v === this.view ? this.theme.fg("accent", this.theme.bold(`[${v}]`)) : this.theme.fg("dim", v)))
.join(" ");
this.addChild(new Text(accent(this.theme.bold(" FLEET")) + " " + tabs, 0, 0));
// #104: totals header right-aligned on the tab row (spinner frame = monotonic counter).
this.frame = (this.frame + 1) % 8;
const activeRows: { status: string }[] = [
...this.deps.runRegistry.list().map((r) => ({ status: r.status })),
...(this.deps.bgRuns ? [...this.deps.bgRuns.values()].map((b) => ({ status: b.status })) : []),
];
const costTotal = this.deps.runRegistry.list().reduce((acc, r) => acc + (r.costTotal ?? 0), 0);
const contextTokens = this.deps.runRegistry.list().reduce((acc, r) => acc + (r.contextTokens ?? 0), 0);
const totals = totalsLine(activeRows, { costTotal, contextTokens }, this.frame);
const tabLine = accent(this.theme.bold(" FLEET")) + " " + tabs;
const width = 80;
const pad = Math.max(1, width - 30 - totals.length);
this.addChild(new Text(tabLine + " ".repeat(pad) + this.theme.fg("dim", totals), 0, 0));
this.addChild(new Spacer(1));

if (this.runMode && (this.taskInput || this.linkInput)) {
Expand Down Expand Up @@ -429,31 +444,25 @@ export class FleetPanel extends Container {
}

this.addChild(new Spacer(1));
const hint =
this.fullMessageEvent
? " esc:Back"
: this.infoAgent || this.selectedBackend || this.selectedLifecycle || this.selectedSchedule || this.selectedRun
? (this.selectedRun ? " enter:Full-message esc:Back" : this.selectedLifecycle ? " v:View-evidence g:Re-run-gate esc:Back" : " esc:Back")
: this.pendingCheckpoint
? " c:Continue v:Revise a:Abort"
: this.lcRevising
? " enter:Submit-feedback esc:Cancel"
: this.view === "fleet"
? " r:Run-new s:Steer x:Stop o:Open-todo tab:Lifecycle q:Quit"
: this.view === "lifecycle"
? " r:Run-lifecycle i:Info tab:Runs q:Quit"
: this.view === "runs"
? " enter:Replay r:Resume f:Fork tab:Agents q:Quit"
: this.view === "agents"
? " r:Run e:Edit i:Info d:Reload tab:Backends q:Quit"
: this.view === "scheduled"
? " a:Add p:Pause/resume d:Delete i:Info tab:Tiers q:Quit"
: this.view === "tiers"
? " m:Models c:costCap f:contextFloor a:Add d:Delete g:scope tab:Workflows q:Quit"
: this.view === "workflows"
? " r:Run e:Edit-and-resume o:Open p:Pause u:Resume x:Stop s:Save-as v:View-result tab:Fleet q:Quit"
: " r:Refresh i:Info tab:Fleet q:Quit";
this.addChild(new Text(this.theme.fg("dim", hint), 0, 0));
// #104: state-machine footer (single source — was a per-view if-chain).
const sel = this.selectedRun;
const selStatus = sel?.status ?? this.selectedLifecycle?.status;
const mode: FooterState["mode"] =
this.pendingCheckpoint ? "checkpoint"
: this.lcRevising ? "input"
: (this.fullMessageEvent || this.infoAgent || this.selectedBackend || this.selectedSchedule) ? "modal"
: (this.selectedRun || this.selectedLifecycle) ? "row-selected"
: "browse";
const footer = footerFor({
view: this.view,
mode,
running: selStatus === "running",
aborted: selStatus === "aborted" || selStatus === "failed",
// No foreground status is "paused" today (bg-only state) — seam kept for when one lands.
paused: (selStatus as string | undefined) === "paused",
canSteer: sel ? (this.deps.runRegistry.get(sel.runId)?.session?.supportsSteer ?? false) : undefined,
});
this.addChild(new Text(this.theme.fg("dim", footer), 0, 0));
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder(accent));
this.invalidate();
Expand Down Expand Up @@ -695,9 +704,14 @@ export class FleetPanel extends Container {
return;
}
// SPEC-5b-4: Fleet view — s:Steer (pi-only) + x:Stop (any backend) on the selected running row.
if (this.view === "fleet") {
if (matchesKey(data, "s")) { this.startSteer(); return; }
if (matchesKey(data, "x")) { this.executeStop(); return; }
if (this.view === "fleet" && (matchesKey(data, "s") || matchesKey(data, "x"))) {
const key = matchesKey(data, "s") ? "s" : "x";
const sel = this.list.getSelectedItem();
const run = sel ? this.deps.runRegistry.get(sel.value) : undefined;
const allowed = actionsForRun(run?.status ?? "").some((a) => a.key === key);
if (!allowed) { this.onNotify(`run is ${run?.status ?? "unknown"} — no ${key} action`, "warning"); return; }
if (key === "s") { this.startSteer(); return; }
this.executeStop(); return;
}
// SPEC-5b-1: Runs view — enter/i:Replay R:Resume F:Fork
if (this.view === "runs" && this.deps.runLog) {
Expand Down
Loading
Loading