Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 24 additions & 2 deletions extensions/shared/activity-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,36 @@ test("status text names its own view command", () => {
done: 2,
failed: 0,
}),
"subagents: 1 running · 2 done · /subagents to view",
"subagents: 1 running · 2 done · /subagents to view",
);
assert.equal(
formatActivityStatus(identityTheme, "workflows", {
running: 0,
done: 0,
failed: 3,
}),
"workflows: 3 failed · /workflows to view",
"workflows: 3 failed · /workflows to view",
);
});

test("the pushed footer status uses a still marker; strip visibility owns the hint", () => {
const counts = { running: 1, done: 0, failed: 0 };
// setStatus stores a finished string, so this line cannot animate: a spinner
// frame would freeze on whatever event happened to write it last.
const visible = formatActivityStatus(
identityTheme,
"subagents",
counts,
true,
);
assert.equal(visible, "subagents: ● 1 running");
assert.doesNotMatch(visible, /to view/);

const hidden = formatActivityStatus(
identityTheme,
"subagents",
counts,
false,
);
assert.equal(hidden, "subagents: ● 1 running · /subagents to view");
});
29 changes: 23 additions & 6 deletions extensions/shared/activity-status.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";

/**
* Still marker for the push-based footer status. It deliberately does not
* import the subagent spinner: `shared/` must not depend on a single
* extension's internals, and a pushed string cannot animate anyway.
*/
const RUNNING_MARK = "●";

type Theme = ExtensionContext["ui"]["theme"];

export interface ActivityCounts {
Expand All @@ -8,8 +15,6 @@ export interface ActivityCounts {
failed: number;
}

const SQUARE = "■";

/**
* Settled work is an unread notice, not a session tally: `done`/`failed` stay
* visible until the user's next explicit request acknowledges them, while
Expand Down Expand Up @@ -44,22 +49,34 @@ export function hasActivity(counts: ActivityCounts) {
return counts.running + counts.done + counts.failed > 0;
}

/**
* `ui.setStatus` stores a finished string and is only called when the watched
* work changes, so this line is NOT re-evaluated per frame. A spinner here
* would freeze on whatever frame the last event happened to land on, which
* reads as a hung UI. The animated glyph belongs to the strip, which owns a
* render loop; the footer states the count with a still marker.
*/
export function formatActivityStatus(
theme: Theme,
label: "subagents" | "workflows",
counts: ActivityCounts,
stripVisible = false,
) {
const parts: string[] = [];
if (counts.running > 0) {
parts.push(theme.fg("warning", `${SQUARE} ${counts.running} running`));
parts.push(
theme.fg("warning", `${RUNNING_MARK} ${counts.running} running`),
);
}
if (counts.done > 0) {
parts.push(theme.fg("success", `${SQUARE} ${counts.done} done`));
parts.push(theme.fg("success", ` ${counts.done} done`));
}
if (counts.failed > 0) {
parts.push(theme.fg("error", `${SQUARE} ${counts.failed} failed`));
parts.push(theme.fg("error", `✗ ${counts.failed} failed`));
}
if (!stripVisible) {
parts.push(theme.fg("accent", `/${label}`) + theme.fg("dim", " to view"));
}
parts.push(theme.fg("accent", `/${label}`) + theme.fg("dim", " to view"));

return `${theme.fg("muted", `${label}:`)} ${parts.join(theme.fg("dim", " · "))}`;
}
6 changes: 4 additions & 2 deletions extensions/subagents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,17 +383,19 @@ export default function (pi: ExtensionAPI) {

const updateStatus = (manager: SubagentManagerShape) => {
if (!ui) return;
// Refresh the strip first: when it is on screen it already carries the
// "↓ to manage" affordance, so the footer must not repeat the command.
updateSubagentWidget();
const counts = unreadActivityCounts(
manager.view.list(),
settledAcknowledgedAt,
);
ui.setStatus(
"subagents",
hasActivity(counts)
? formatActivityStatus(ui.theme, "subagents", counts)
? formatActivityStatus(ui.theme, "subagents", counts, widgetVisible)
: undefined,
);
updateSubagentWidget();
};

const openDashboard = async (ctx: ExtensionContext, initialId?: string) => {
Expand Down
122 changes: 114 additions & 8 deletions extensions/subagents/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { BelowEditorStripState } from "../shared/below-editor-navigation.ts";
import {
normalizeSubagentTitle,
selectSubagentStripEntry,
type SubagentStripEntry,
SubagentStripWidget,
} from "./navigation.ts";
import type { SubagentSnapshot } from "./src/domain.ts";
Expand Down Expand Up @@ -41,6 +42,19 @@ const theme = {
bold: (text: string) => text,
} as unknown as Theme;

const SPINNERS = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/;

function stripWidget(entry: SubagentStripEntry | undefined) {
const strip = new BelowEditorStripState();
const widget = new SubagentStripWidget(
{ requestRender() {} } as unknown as TUI,
theme,
strip,
() => entry,
);
return { strip, widget };
}

test("subagent titles are sanitized and bounded at ingress", () => {
assert.equal(
normalizeSubagentTitle(" review\u001b]52;c;payload\u0007\nnow "),
Expand Down Expand Up @@ -74,17 +88,11 @@ test("strip selection prefers newest running, then newest unread settled", () =>
});

test("subagent strip matches Workflow's bounded one-line affordance", () => {
const strip = new BelowEditorStripState();
const entry = selectSubagentStripEntry(
[snapshot("sa-1", "running", Date.now() - 2_000)],
0,
);
const widget = new SubagentStripWidget(
{ requestRender() {} } as unknown as TUI,
theme,
strip,
() => entry,
);
const { strip, widget } = stripWidget(entry);
try {
const idle = widget.render(100);
assert.equal(idle.length, 1);
Expand All @@ -98,7 +106,7 @@ test("subagent strip matches Workflow's bounded one-line affordance", () => {
assert.ok(visibleWidth(focused[0]!) <= 54);
assert.match(focused[0]!, /enter open/);

for (const width of [1, 8, 20]) {
for (const width of [1, 8, 20, 40]) {
const narrow = widget.render(width);
assert.equal(narrow.length, 1);
assert.ok(visibleWidth(narrow[0]!) <= width);
Expand All @@ -107,3 +115,101 @@ test("subagent strip matches Workflow's bounded one-line affordance", () => {
widget.dispose();
}
});

test("unfocused strip shows exactly one leading glyph: spinner or settle mark", () => {
const running = stripWidget(
selectSubagentStripEntry([snapshot("sa-1", "running", Date.now())], 0),
);
try {
const line = running.widget.render(100)[0]!;
assert.match(line, /^ [⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] /);
assert.doesNotMatch(line, /■|○|❯|✓|✗/);
} finally {
running.widget.dispose();
}

const done = stripWidget(
selectSubagentStripEntry([snapshot("sa-2", "done", 1, 2)], 0),
);
try {
const line = done.widget.render(100)[0]!;
assert.match(line, /✓/);
assert.doesNotMatch(line, SPINNERS);
assert.doesNotMatch(line, /■|○|❯|✗/);
} finally {
done.widget.dispose();
}

const failed = stripWidget(
selectSubagentStripEntry([snapshot("sa-3", "error", 1, 2)], 0),
);
try {
const line = failed.widget.render(100)[0]!;
assert.match(line, /✗/);
assert.doesNotMatch(line, SPINNERS);
assert.doesNotMatch(line, /■|○|❯|✓/);
} finally {
failed.widget.dispose();
}
});

test("focused strip shows the marker and no status glyph", () => {
const { strip, widget } = stripWidget(
selectSubagentStripEntry([snapshot("sa-1", "running", Date.now())], 0),
);
strip.focused = true;
try {
const line = widget.render(100)[0]!;
assert.match(line, /❯/);
assert.doesNotMatch(line, SPINNERS);
assert.doesNotMatch(line, /■|○|✓|✗/);
} finally {
widget.dispose();
}
});

test("strip carries no model label and no context percentage", () => {
const { widget } = stripWidget(
selectSubagentStripEntry(
[snapshot("sa-1", "running", Date.now(), undefined)],
0,
),
);
try {
const line = widget.render(100)[0]!;
assert.doesNotMatch(line, /gpt-5\.6-sol/);
assert.doesNotMatch(line, /%/);
} finally {
widget.dispose();
}
});

test("a single subagent shows no count; several show a readable one", () => {
const single = stripWidget(
selectSubagentStripEntry([snapshot("solo", "running", 3)], 0),
);
try {
const line = single.widget.render(100)[0]!;
assert.doesNotMatch(line, /agents/);
assert.doesNotMatch(line, /\d\/\d/);
} finally {
single.widget.dispose();
}

const entry = selectSubagentStripEntry(
[
snapshot("done-1", "done", 1, 5),
snapshot("done-2", "done", 2, 6),
snapshot("run", "running", 3),
],
0,
);
const several = stripWidget(entry);
try {
const line = several.widget.render(100)[0]!;
assert.match(line, /2\/3 done/);
assert.doesNotMatch(line, /agents/);
} finally {
several.widget.dispose();
}
});
41 changes: 25 additions & 16 deletions extensions/subagents/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
} from "../shared/activity-status.ts";
import { sanitizeTerminalText } from "../shared/terminal-text.ts";
import { formatElapsed, type SubagentSnapshot } from "./src/domain.ts";
import { formatContextUtilization } from "./src/format.ts";
import { statusGlyph } from "./src/ui/takeover.ts";
import { SPINNER_INTERVAL_MS } from "./src/ui/transcript.ts";

export interface SubagentStripEntry {
snapshot: SubagentSnapshot;
Expand Down Expand Up @@ -58,13 +59,10 @@ function statusColor(status: SubagentSnapshot["status"]) {
return "error" as const;
}

function statusSquare(snapshot: SubagentSnapshot, theme: Theme) {
return theme.fg(statusColor(snapshot.status), "■");
}

/** One-line subagent manager entry with the same affordance as Workflow. */
export class SubagentStripWidget {
private readonly timer: ReturnType<typeof setInterval>;
private timer?: ReturnType<typeof setInterval>;
private timerInterval = 0;
private readonly tui: TUI;
private readonly theme: Theme;
private readonly strip: BelowEditorStripState;
Expand All @@ -80,12 +78,23 @@ export class SubagentStripWidget {
this.theme = theme;
this.strip = strip;
this.getEntry = getEntry;
this.timer = setInterval(() => this.tui.requestRender(), 500);
this.refreshTimer(false);
}

/** A running subagent animates a spinner, so repaint at its cadence. */
private refreshTimer(running: boolean) {
const interval = running ? SPINNER_INTERVAL_MS : 500;
if (interval === this.timerInterval) return;
if (this.timer) clearInterval(this.timer);
this.timerInterval = interval;
this.timer = setInterval(() => this.tui.requestRender(), interval);
this.timer.unref?.();
}

dispose() {
clearInterval(this.timer);
if (this.timer) clearInterval(this.timer);
this.timer = undefined;
this.timerInterval = 0;
}

invalidate() {}
Expand All @@ -94,23 +103,23 @@ export class SubagentStripWidget {
const entry = this.getEntry();
if (!entry || width <= 0) return [];
const { snapshot, counts } = entry;
const marker = this.strip.focused
this.refreshTimer(snapshot.status === "running");
// One glyph column: the focus marker when selected, the status glyph —
// spinner, ✓, ✗ — otherwise. Model and context stay in Pi's footer and
// the dashboard; this strip owns navigation and progress.
const glyph = this.strip.focused
? this.theme.fg("accent", "❯")
: this.theme.fg("dim", "○");
: statusGlyph(snapshot, this.theme);
const titleText = normalizeSubagentTitle(snapshot.title, snapshot.id);
const title = this.strip.focused
? this.theme.bold(this.theme.fg("accent", titleText))
: this.theme.fg("text", titleText);
const model = snapshot.meta.modelLabel
? cleanLine(snapshot.meta.modelLabel)
: undefined;
const left = ` ${marker} ${statusSquare(snapshot, this.theme)} ${title}${model ? this.theme.fg("dim", ` · ${model}`) : ""}`;
const left = ` ${glyph} ${title}`;
const settled = counts.done + counts.failed;
const total = counts.running + settled;
const metrics = [
`${settled}/${total} agents`,
total > 1 ? `${settled}/${total} done` : "",
formatElapsed(snapshot),
formatContextUtilization(snapshot.usage),
this.strip.focused ? "enter open · ↑ back" : "↓ to manage",
]
.filter((part): part is string => Boolean(part))
Expand Down
Loading
Loading