feat: enable permission-aware subagents

This commit is contained in:
叶林立
2026-08-26 10:49:26 +08:00
parent d3bf562189
commit 7571ba6dd9
185 changed files with 48365 additions and 26 deletions
+216
View File
@@ -0,0 +1,216 @@
/**
* agent-mention.ts — what `@` can address, and the suggestions pi renders for it.
*
* A subagent is addressable whether or not it is currently running: a live
* record is messaged or resumed, an evicted one whose session is still on disk
* is reopened, and an agent *type* with no instance at all is started. That is
* the point of the handle — `@explore` means the Explore agent, not "the
* Explore process that happens to exist right now" — so the roster below unions
* all three, and the dispatcher and the popup read the same list.
*
* Rows are per *agent*, not per handle. An agent given a `name` holds two names
* (its alias and its type-derived handle) and both resolve, but it lists once,
* under the alias, with its type moved into the description so the row still
* says what it is.
*
* pi's `CombinedAutocompleteProvider` already owns `@`, where it means "attach a
* file". Extensions can wrap it (`ctx.ui.addAutocompleteProvider`), so this
* provider adds the `@` tokens that name an agent and delegates everything else
* — including all of `applyCompletion`, whose `@`-branch already inserts
* `item.value` plus a trailing space, which is exactly what a handle needs.
*
* Matching mirrors Claude Code: case-insensitive prefix, not fuzzy. What it does
* NOT mirror is Claude Code dropping files whenever an agent matches. Here `@` is
* pi's file picker first, and the handles are additive, so a token matching both
* lists both — agents first. Suppressing on any match sounds narrow and is not:
* an empty token prefix-matches every handle, so a bare `@` — the gesture people
* use to browse files — would offer no files at all, and a single letter
* beginning any handle would do the same.
*
* Both halves ship under ONE `prefix`, which is sound because wherever BOTH sides
* produce rows they measured the same span. pi's `extractAtPrefix` takes the
* token after the last of `{space, tab, ", ', =}` and keeps it only if it starts
* with `@`; `MENTION_TRIGGER` matches `@[\w-]*` at the cursor, after start-of-line
* or `[\s。、?!]`. Where those two disagree, exactly one side answers and there
* is nothing to merge: `@src/index.ts` and `@"my file` are pi's alone (no handle
* matches), `=@ex` is pi's alone (`=` is a delimiter to pi, not a boundary to us),
* and `。@ex` is ours alone (the reverse). A merged response therefore never
* carries a prefix from one side and an item from the other.
*
* Offering never-started types is a deliberate step beyond Claude Code, whose
* registry holds only live tasks, so an agent you had not launched yet was
* unaddressable.
*/
import type { AutocompleteItem, AutocompleteProvider, AutocompleteSuggestions } from "@earendil-works/pi-tui";
import type { AgentManager } from "../agent-manager.js";
import { handleBase, MENTION_TRIGGER } from "../mention.js";
import type { AgentRecord, AgentTombstone } from "../types.js";
/**
* One thing `@` can address, and what sending to it will do. `typeLabel` is the
* agent's `display_name`, resolved by the caller: this module stays independent
* of the type registry, but the popup must agree with FleetView and the widget,
* which both render the label rather than the raw type.
*/
export type MentionTarget =
| { kind: "record"; handle: string; record: AgentRecord; typeLabel: string }
| { kind: "tombstone"; handle: string; entry: AgentTombstone; typeLabel: string }
| { kind: "type"; handle: string; type: string; description: string };
/** The registry facts the roster needs, so it stays independent of agent-types. */
export type TypeInfo = { name: string; description: string };
/**
* Everything `@` can reach, in the order the popup lists it: steerable agents
* first, then the other live ones earliest-launched, then agent types with no
* live instance. A type whose handle a record already holds is omitted — that
* name addresses the existing agent, which is what makes `@explore` mean
* "message the one that's running" and only otherwise "start one".
*/
export function mentionRoster(
manager: AgentManager,
types: readonly TypeInfo[],
// Identity by default: a caller with no registry to consult gets the raw
// type, which is also what `getConfig` falls back to when no label is set.
displayNameOf: (type: string) => string = type => type,
): MentionTarget[] {
const live = (r: AgentRecord) => r.status === "running" || r.status === "queued";
const records = manager.listAgents()
.filter(r => r.handle !== undefined && r.parentAgentId === undefined)
.sort((a, b) => (Number(live(b)) - Number(live(a))) || (a.startedAt - b.startedAt));
const taken = new Set<string>();
const targets: MentionTarget[] = [];
// One row per agent, not per handle. An aliased agent lists under its alias
// only — both names resolve, but showing two rows for one agent reads as two
// agents. The type handle stays addressable whether or not it is listed.
for (const record of records) {
const handle = record.alias ?? record.handle!;
taken.add(handle.toLowerCase());
if (record.handle) taken.add(record.handle.toLowerCase());
targets.push({ kind: "record", handle, record, typeLabel: displayNameOf(record.type) });
}
// Then agents that are gone but whose conversation can be reopened. After the
// live ones: a running agent is the likelier target, and this keeps the
// ordering "what exists now, then what can be brought back, then what can be
// started".
for (const entry of manager.listTombstones()) {
const handle = entry.alias ?? entry.handle;
if (taken.has(handle.toLowerCase())) continue;
taken.add(handle.toLowerCase());
taken.add(entry.handle.toLowerCase());
targets.push({ kind: "tombstone", handle, entry, typeLabel: displayNameOf(entry.type) });
}
for (const type of types) {
const handle = handleBase(type.name);
if (taken.has(handle)) continue;
taken.add(handle);
targets.push({ kind: "type", handle, type: type.name, description: type.description });
}
return targets;
}
export function createMentionProvider(
current: AutocompleteProvider,
roster: () => MentionTarget[],
isEnabled: () => boolean,
): AutocompleteProvider {
// One warning per provider, not per keystroke: `getSuggestions` runs on every
// character typed after `@`, so an unguarded log would bury the terminal in
// the time it takes to finish a word.
let warnedInnerFailure = false;
return {
// Only `@` — the contract is "characters that should naturally trigger
// THIS provider", and pi unions each wrapper's own set onto the outermost
// one itself (interactive-mode.js:432), so re-declaring the wrapped
// provider's characters here would both misreport us and duplicate that.
triggerCharacters: ["@"],
async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
const mine = isEnabled() ? mentionItems(roster(), lines[cursorLine] ?? "", cursorCol) : null;
// Asked unconditionally: pi owns `@` and must keep answering for it even
// when a handle matches too. That is the same work vanilla pi does on any
// `@` keystroke — a capped `fd` search, or nothing at all when the host
// configured no `fd` path — but we now do it on tokens we used to answer
// alone, so it must not be able to take the popup down with it. The
// wrapped provider is not always pi's: another extension may sit inside
// us, and before this it was never called for a token naming an agent.
// try/catch, not `.catch()`: a provider that throws SYNCHRONOUSLY never
// returns the promise a `.catch()` would attach to, and the throw escapes
// this method as a rejection — which pi does not handle either
// (components/editor.js:1892 awaits with no catch of its own).
let theirs: AutocompleteSuggestions | null = null;
try {
theirs = await current.getSuggestions(lines, cursorLine, cursorCol, options);
} catch (err) {
// Safe to treat as "no files": pi discards any response whose request is
// no longer current, so an aborted search that surfaces as a rejection
// cannot leave a stale popup behind (`isAutocompleteRequestCurrent`).
// Warned rather than swallowed outright — the failure is invisible in
// the popup, and the same `console.warn` channel already carries this
// extension's other non-fatal failures.
if (!warnedInnerFailure) {
warnedInnerFailure = true;
console.warn("[pi-subagents] the autocomplete provider below us failed; showing agent rows only:", err);
}
theirs = null;
}
if (!mine) return theirs;
if (!theirs) return mine;
// Agents first: there are a handful of them against pi's 20 file rows, and
// a handle buried under fuzzy path matches is a handle nobody finds. The
// prefix is ours by the span argument in the header — identical to pi's
// whenever both sides have something to say.
return { items: [...mine.items, ...theirs.items], prefix: mine.prefix };
},
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
},
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
},
};
}
/** Suggestions for the `@…` token under the cursor, or null when it names no agent. */
function mentionItems(roster: MentionTarget[], line: string, cursorCol: number): AutocompleteSuggestions | null {
const match = MENTION_TRIGGER.exec(line.slice(0, cursorCol));
if (!match) return null;
const typed = match[2].toLowerCase();
const items: AutocompleteItem[] = [];
for (const target of roster) {
if (!target.handle.toLowerCase().startsWith(typed)) continue;
items.push({ value: `@${target.handle}`, label: `@${target.handle}`, description: describeTarget(target) });
}
return items.length > 0 ? { items, prefix: `@${match[2]}` } : null;
}
/** Name the action that will actually happen, so the list never mispromises. */
function describeTarget(target: MentionTarget): string {
if (target.kind === "type") return `start agent · ${summarize(target.description)}`;
if (target.kind === "tombstone") {
// No status: the record is gone, and "completed" would imply one is still
// being tracked. The type carries the identity the handle may not.
return `resume · ${target.typeLabel} · ${target.entry.description}`;
}
const { status, description, alias } = target.record;
const action = status === "running" || status === "queued" ? "send message" : "resume";
// A row listed under its alias has lost the type its handle would have shown,
// so name it — `@auth-audit` alone says nothing about what the agent is.
// A type-derived row already reads as its type and would just repeat itself.
const identity = alias ? `${target.typeLabel} · ` : "";
return `${action} · ${identity}${status} · ${description}`;
}
/** First sentence of an agent description, clipped — these run to paragraphs. */
function summarize(description: string): string {
const first = (description.match(/^.*?[.!?](?=\s|$)/s)?.[0] ?? description).replace(/\s+/g, " ").trim();
return first.length > 60 ? `${first.slice(0, 59).trimEnd()}` : first;
}
+629
View File
@@ -0,0 +1,629 @@
/**
* agent-widget.ts — Persistent widget showing running/completed agents above the editor.
*
* Displays a tree of agents with animated spinners, live stats, and activity descriptions.
* Uses the callback form of setWidget for themed rendering.
*/
import { truncateToWidth } from "@earendil-works/pi-tui";
import { renderAgentName } from "../agent-color.js";
import type { AgentManager } from "../agent-manager.js";
import { getConfig } from "../agent-types.js";
import type { AgentInvocation, SubagentType, WidgetMode } from "../types.js";
import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
// ---- Constants ----
/** Maximum number of rendered lines before overflow collapse kicks in. */
const MAX_WIDGET_LINES = 12;
/** Braille spinner frames for animated running indicator. */
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
/** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */
export const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]);
/** Tool name → human-readable action for activity descriptions. */
const TOOL_DISPLAY: Record<string, string> = {
read: "reading",
bash: "running command",
edit: "editing",
write: "writing",
grep: "searching",
find: "finding files",
ls: "listing",
};
// ---- Types ----
export type Theme = {
fg(color: string, text: string): string;
bold(text: string): string;
};
export type UICtx = {
setStatus(key: string, text: string | undefined): void;
setWidget(
key: string,
content: undefined | ((tui: any, theme: Theme) => { render(): string[]; invalidate(): void }),
options?: { placement?: "aboveEditor" | "belowEditor" },
): void;
};
/** Per-agent live activity state. */
export interface AgentActivity {
activeTools: Map<string, string>;
toolUses: number;
responseText: string;
session?: SessionLike;
/** Current turn count. */
turnCount: number;
/** Effective max turns for this agent (undefined = unlimited). */
maxTurns?: number;
}
/** Metadata attached to Agent tool results for custom rendering. */
export interface AgentDetails {
displayName: string;
description: string;
subagentType: string;
toolUses: number;
tokens: string;
durationMs: number;
status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error" | "background";
/** Human-readable description of what the agent is currently doing. */
activity?: string;
/** Current spinner frame index (for animated running indicator). */
spinnerFrame?: number;
/** Short model name if different from parent (e.g. "haiku", "sonnet"). */
modelName?: string;
/** Notable config tags (e.g. ["thinking: high", "isolated"]). */
tags?: string[];
/** Current turn count. */
turnCount?: number;
/** Effective max turns (undefined = unlimited). */
maxTurns?: number;
/** Estimated cost in USD; 0 when the model has no pricing data. */
cost?: number;
agentId?: string;
error?: string;
}
// ---- Formatting helpers ----
/** Apply foreground styling while restoring it after nested foreground/full ANSI resets. */
export function fgPreservingNestedStyles(theme: Theme, color: string, text: string): string {
const styledEmpty = theme.fg(color, "");
const styleStart = styledEmpty.replace(/\u001b\[(?:0|39)m/g, "");
return theme.fg(color, text.replace(/\u001b\[(?:0|39)m/g, reset => `${reset}${styleStart}`));
}
/** Format a token count compactly: "33.8k token", "1.2M token". */
export function formatTokens(count: number): string {
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M token`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k token`;
return `${count} token`;
}
/**
* Format a cost as `~$0.0042`, or "" when there is nothing to show.
*
* The tilde is load-bearing: this is pi's own estimate from the model's listed
* rates, not a billed figure, and the surfaces that print it sit next to token
* counts that ARE exact.
*
* Nothing is printed for zero, which is also what a model with no pricing data
* reports: `$0.00` beside a local model's tokens would claim its cost was
* measured and found to be nothing, rather than never measured at all. For the
* same reason a real cost too small for four decimals reads `<$0.0001` — it was
* measured, and rounding it to `~$0.0000` would say the opposite.
*/
export function formatCost(cost: number): string {
if (!(cost > 0)) return ""; // also catches NaN
if (cost < 0.0001) return "<$0.0001";
if (cost >= 1) return `~$${cost.toFixed(2)}`;
// Under a dollar: cents at minimum, four decimals at most, nothing trailing.
// Most single runs land between a tenth of a cent and a dime, where rounding
// to cents would collapse a 4x difference in spend into the same figure.
const rounded = Number(cost.toFixed(4));
const decimals = (String(rounded).split(".")[1] ?? "").length;
return `~$${rounded.toFixed(Math.max(2, decimals))}`;
}
/**
* Token count with optional context-fill % and compaction-count annotations.
* Thresholds for percent: <70% dim, 7085% warning, ≥85% error.
* Compaction count rendered as `⇊N` in dim.
*
* "12.3k token" — no annotations
* "12.3k token (45%)" — percent only
* "12.3k token (⇊2)" — compactions only (e.g. right after compact)
* "12.3k token (45% · ⇊2)" — both
*/
export function formatSessionTokens(
tokens: number,
percent: number | null,
theme: Theme,
compactions = 0,
): string {
const tokenStr = formatTokens(tokens);
const annot: string[] = [];
if (percent !== null) {
const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim";
annot.push(theme.fg(color, `${Math.round(percent)}%`));
}
if (compactions > 0) {
annot.push(theme.fg("dim", `${compactions}`));
}
if (annot.length === 0) return tokenStr;
return `${tokenStr} (${annot.join(" · ")})`;
}
/** Format turn count with optional max limit: "↻5≤30" or "↻5". */
export function formatTurns(turnCount: number, maxTurns?: number | null): string {
return maxTurns != null ? `${turnCount}${maxTurns}` : `${turnCount}`;
}
/** Format milliseconds as human-readable duration. */
export function formatMs(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`;
}
/** Format duration from start/completed timestamps. */
export function formatDuration(startedAt: number, completedAt?: number): string {
if (completedAt) return formatMs(completedAt - startedAt);
return `${formatMs(Date.now() - startedAt)} (running)`;
}
/** Get display name for any agent type (built-in or custom). */
export function getDisplayName(type: SubagentType): string {
return getConfig(type).displayName;
}
/** Short label for prompt mode: "twin" for append, nothing for replace (the default). */
export function getPromptModeLabel(type: SubagentType): string | undefined {
const config = getConfig(type);
return config.promptMode === "append" ? "twin" : undefined;
}
/** Mode label is not included — callers add it where they want it. */
export function buildInvocationTags(
invocation: AgentInvocation | undefined,
): { modelName?: string; tags: string[] } {
const tags: string[] = [];
if (!invocation) return { tags };
if (invocation.thinking) tags.push(`thinking: ${invocation.thinking}`);
if (invocation.isolated) tags.push("isolated");
if (invocation.isolation === "worktree") tags.push("worktree");
if (invocation.inheritContext) tags.push("inherit context");
if (invocation.runInBackground) tags.push("background");
if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`);
return { modelName: invocation.modelName, tags };
}
/** Truncate text to a single line, max `len` chars. */
function truncateLine(text: string, len = 60): string {
const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
if (line.length <= len) return line;
return line.slice(0, len) + "…";
}
/** Build a human-readable activity string from currently-running tools or response text. */
export function describeActivity(activeTools: Map<string, string>, responseText?: string): string {
if (activeTools.size > 0) {
const groups = new Map<string, number>();
for (const toolName of activeTools.values()) {
const action = TOOL_DISPLAY[toolName] ?? toolName;
groups.set(action, (groups.get(action) ?? 0) + 1);
}
const parts: string[] = [];
for (const [action, count] of groups) {
if (count > 1) {
parts.push(`${action} ${count} ${action === "searching" ? "patterns" : "files"}`);
} else {
parts.push(action);
}
}
return parts.join(", ") + "…";
}
// No tools active — show truncated response text if available
if (responseText && responseText.trim().length > 0) {
return truncateLine(responseText);
}
return "thinking…";
}
// ---- Widget manager ----
export class AgentWidget {
private uiCtx: UICtx | undefined;
private widgetFrame = 0;
private widgetInterval: ReturnType<typeof setInterval> | undefined;
/** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
private finishedTurnAge = new Map<string, number>();
/** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
private static readonly ERROR_LINGER_TURNS = 2;
/** Whether the widget callback is currently registered with the TUI. */
private widgetRegistered = false;
/** Cached TUI reference from widget factory callback, used for requestRender(). */
private tui: any | undefined;
/** Last status bar text, used to avoid redundant setStatus calls. */
private lastStatusText: string | undefined;
constructor(
private manager: AgentManager,
private agentActivity: Map<string, AgentActivity>,
/**
* Read live at render time. Selects which agents the widget shows — see
* `WidgetMode`. Defaults to `"all"` when a caller supplies no policy; the
* extension supplies one defaulting to `"background"`.
*/
private mode: () => WidgetMode = () => "all",
/**
* Read live at render time, like `mode`. Whether running agents show an
* estimated cost beside their token count. Defaults to off — the extension
* supplies the user's `showCost` setting.
*/
private showCost: () => boolean = () => false,
) {}
/**
* Agents eligible for the widget, per the current `WidgetMode`:
* - `off`: none (the widget's existing empty-state path hides it entirely).
* - `background`: drop only agents *known* to be foreground
* (`isBackground === false`); keep everything else — background, queued,
* scheduled, or RPC-spawned (`undefined`). Keying off the `isBackground`
* record flag rather than the UI-only `invocation` snapshot (which only the
* Agent-tool path sets), and excluding rather than allow-listing, means
* only proven-foreground runs drop out — nothing else silently vanishes.
* - `all`: every agent.
*/
private widgetAgents() {
const all = this.manager.listAgents().filter(a => !a.parentAgentId);
switch (this.mode()) {
case "off": return [];
case "background": return all.filter(a => a.isBackground !== false);
default: return all;
}
}
/** Set the UI context (grabbed from first tool execution). */
setUICtx(ctx: UICtx) {
if (ctx !== this.uiCtx) {
// UICtx changed — the widget registered on the old context is gone.
// Force re-registration on next update().
this.uiCtx = ctx;
this.widgetRegistered = false;
this.tui = undefined;
this.lastStatusText = undefined;
}
}
/**
* Called on each new turn (tool_execution_start).
* Ages finished agents and clears those that have lingered long enough.
*/
onTurnStart() {
// Age all finished agents
for (const [id, age] of this.finishedTurnAge) {
this.finishedTurnAge.set(id, age + 1);
}
// Trigger a widget refresh (will filter out expired agents)
this.update();
}
/** Ensure the widget update timer is running. */
ensureTimer() {
if (!this.widgetInterval) {
this.widgetInterval = setInterval(() => this.update(), 80);
}
}
/** Check if a finished agent should still be shown in the widget. */
private shouldShowFinished(agentId: string, status: string): boolean {
const age = this.finishedTurnAge.get(agentId) ?? 0;
const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1;
return age < maxAge;
}
/** Record an agent as finished (call when agent completes). */
markFinished(agentId: string) {
if (!this.finishedTurnAge.has(agentId)) {
this.finishedTurnAge.set(agentId, 0);
}
}
/**
* Drop an agent's finished-age (call when a settled agent starts running
* again, i.e. a background resume). markFinished only seeds an age it has not
* seen before, so a resumed agent would otherwise keep the age from its
* previous run — already past the linger limit, hiding the new run's
* completion line entirely.
*/
markRunning(agentId: string) {
this.finishedTurnAge.delete(agentId);
}
/** Render a finished agent line. */
private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string; lifetimeUsage?: LifetimeUsage }, theme: Theme): string {
const modeLabel = getPromptModeLabel(a.type);
const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
let icon: string;
let statusText: string;
if (a.status === "completed") {
icon = theme.fg("success", "✓");
statusText = "";
} else if (a.status === "steered") {
icon = theme.fg("warning", "✓");
statusText = theme.fg("warning", " (turn limit)");
} else if (a.status === "stopped") {
icon = theme.fg("dim", "■");
statusText = theme.fg("dim", " stopped");
} else if (a.status === "error") {
icon = theme.fg("error", "✗");
const errMsg = a.error ? `: ${a.error.slice(0, 60)}` : "";
statusText = theme.fg("error", ` error${errMsg}`);
} else {
// aborted
icon = theme.fg("error", "✗");
statusText = theme.fg("warning", " aborted");
}
const parts: string[] = [];
const activity = this.agentActivity.get(a.id);
if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
// From the record, not the activity tracker: that entry is deleted the
// moment an agent finishes, and "what did it cost" is a question asked
// about finished agents.
const costText = this.showCost() ? formatCost(getLifetimeCost(a.lifetimeUsage)) : "";
if (costText) parts.push(costText);
parts.push(duration);
const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
return `${icon} ${renderAgentName(a.type, theme, { fallbackColor: "dim" })}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
}
/**
* Render the widget content. Called from the registered widget's render() callback,
* reading live state each time instead of capturing it in a closure.
*/
private renderWidget(tui: any, theme: Theme): string[] {
const allAgents = this.widgetAgents();
const running = allAgents.filter(a => a.status === "running");
const queued = allAgents.filter(a => a.status === "queued");
const finished = allAgents.filter(a =>
a.status !== "running" && a.status !== "queued" && a.completedAt
&& this.shouldShowFinished(a.id, a.status),
);
const hasActive = running.length > 0 || queued.length > 0;
const hasFinished = finished.length > 0;
// Nothing to show — return empty (widget will be unregistered by update())
if (!hasActive && !hasFinished) return [];
const w = tui.terminal.columns;
const truncate = (line: string) => truncateToWidth(line, w);
const headingColor = hasActive ? "accent" : "dim";
const headingIcon = hasActive ? "●" : "○";
const frame = SPINNER[this.widgetFrame % SPINNER.length];
// Build sections separately for overflow-aware assembly.
// Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line.
const finishedLines: string[] = [];
for (const a of finished) {
finishedLines.push(truncate(theme.fg("dim", "├─") + " " + this.renderFinishedLine(a, theme)));
}
const runningLines: string[][] = []; // each entry is [header, activity]
for (const a of running) {
const modeLabel = getPromptModeLabel(a.type);
const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
const elapsed = formatMs(Date.now() - a.startedAt);
const bg = this.agentActivity.get(a.id);
const toolUses = bg?.toolUses ?? a.toolUses;
// Spend comes from the record, never from the activity tracker: the record
// is the one that survives the agent finishing, and the one nested-tools
// folds a hidden child's spend into. Reading the tracker while an agent
// runs and the record once it stops made the figure jump at completion.
const tokens = getLifetimeTotal(a.lifetimeUsage);
const contextPercent = getSessionContextPercent(bg?.session);
const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, a.compactionCount) : "";
const costText = this.showCost() ? formatCost(getLifetimeCost(a.lifetimeUsage)) : "";
const parts: string[] = [];
if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
if (tokenText) parts.push(tokenText);
if (costText) parts.push(costText);
parts.push(elapsed);
const statsText = parts.join(" · ");
const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
runningLines.push([
truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${renderAgentName(a.type, theme, { bold: true })}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
truncate(theme.fg("dim", "│ ") + theme.fg("dim", `${activity}`)),
]);
}
const queuedLine = queued.length > 0
? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
: undefined;
// Assemble with overflow cap (heading + overflow indicator = 2 reserved lines).
const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
if (totalBody <= maxBody) {
// Everything fits — add all lines and fix up connectors for the last item.
lines.push(...finishedLines);
for (const pair of runningLines) lines.push(...pair);
if (queuedLine) lines.push(queuedLine);
// Fix last connector: swap ├─ → └─ and │ → space for activity lines.
if (lines.length > 1) {
const last = lines.length - 1;
lines[last] = lines[last].replace("├─", "└─");
// If last item is a running agent activity line, fix indent of that line
// and fix the header line above it.
if (runningLines.length > 0 && !queuedLine) {
// The last two lines are the last running agent's header + activity.
if (last >= 2) {
lines[last - 1] = lines[last - 1].replace("├─", "└─");
lines[last] = lines[last].replace("│ ", " ");
}
}
}
} else {
// Overflow — prioritize: running > queued > finished.
// Reserve 1 line for overflow indicator.
let budget = maxBody - 1;
let hiddenRunning = 0;
let hiddenFinished = 0;
// Reserve the queued line's row up front. It is a single summary of N
// waiting agents, so it cannot be folded into the "+N more" count (which
// is denominated in agents) without either under-reporting it as 1 or
// inflating the total with agents that were never getting their own rows.
// Reserving costs at most one running agent — which IS counted below —
// and makes the drop unreachable. It matters most exactly when it used to
// vanish: the pool is saturated and the queue is what the user needs to see.
const queuedReserve = queuedLine ? 1 : 0;
budget -= queuedReserve;
// 1. Running agents (2 lines each)
for (const pair of runningLines) {
if (budget >= 2) {
lines.push(...pair);
budget -= 2;
} else {
hiddenRunning++;
}
}
// 2. Queued line (always fits — its row was reserved above)
if (queuedLine) {
budget += queuedReserve;
lines.push(queuedLine);
budget--;
}
// 3. Finished agents
for (const fl of finishedLines) {
if (budget >= 1) {
lines.push(fl);
budget--;
} else {
hiddenFinished++;
}
}
// Overflow summary
const overflowParts: string[] = [];
if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
const overflowText = overflowParts.join(", ");
lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`)
);
}
return lines;
}
/** Force an immediate widget update. */
update() {
if (!this.uiCtx) return;
const allAgents = this.widgetAgents();
// Lightweight existence checks — full categorization happens in renderWidget()
let runningCount = 0;
let queuedCount = 0;
let hasFinished = false;
for (const a of allAgents) {
if (a.status === "running") { runningCount++; }
else if (a.status === "queued") { queuedCount++; }
else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) { hasFinished = true; }
}
const hasActive = runningCount > 0 || queuedCount > 0;
// Nothing to show — clear widget
if (!hasActive && !hasFinished) {
if (this.widgetRegistered) {
this.uiCtx.setWidget("agents", undefined);
this.widgetRegistered = false;
this.tui = undefined;
}
if (this.lastStatusText !== undefined) {
this.uiCtx.setStatus("subagents", undefined);
this.lastStatusText = undefined;
}
if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
// Clean up stale entries
for (const [id] of this.finishedTurnAge) {
if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
}
return;
}
// Status bar — only call setStatus when the text actually changes
let newStatusText: string | undefined;
if (hasActive) {
const statusParts: string[] = [];
if (runningCount > 0) statusParts.push(`${runningCount} running`);
if (queuedCount > 0) statusParts.push(`${queuedCount} queued`);
const total = runningCount + queuedCount;
newStatusText = `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`;
}
if (newStatusText !== this.lastStatusText) {
this.uiCtx.setStatus("subagents", newStatusText);
this.lastStatusText = newStatusText;
}
this.widgetFrame++;
// Register widget callback once; subsequent updates use requestRender()
// which re-invokes render() without replacing the component (avoids layout thrashing).
if (!this.widgetRegistered) {
this.uiCtx.setWidget("agents", (tui, theme) => {
this.tui = tui;
return {
render: () => this.renderWidget(tui, theme),
invalidate: () => {
// Theme changed — force re-registration so factory captures fresh theme.
this.widgetRegistered = false;
this.tui = undefined;
},
};
}, { placement: "aboveEditor" });
this.widgetRegistered = true;
} else {
// Widget already registered — just request a re-render of existing components.
this.tui?.requestRender();
}
}
dispose() {
if (this.widgetInterval) {
clearInterval(this.widgetInterval);
this.widgetInterval = undefined;
}
if (this.uiCtx) {
this.uiCtx.setWidget("agents", undefined);
this.uiCtx.setStatus("subagents", undefined);
}
this.widgetRegistered = false;
this.tui = undefined;
this.lastStatusText = undefined;
}
}
+373
View File
@@ -0,0 +1,373 @@
/**
* conversation-viewer.ts — Live conversation overlay for viewing agent sessions.
*
* Displays a scrollable, live-updating view of an agent's conversation.
* Subscribes to session events for real-time streaming updates.
*/
import type { AgentSession } from "@earendil-works/pi-coding-agent";
import { type Component, Input, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
import { renderAgentName } from "../agent-color.js";
import { extractText } from "../context.js";
import type { AgentRecord } from "../types.js";
import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent } from "../usage.js";
import type { Theme } from "./agent-widget.js";
import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatCost, formatDuration, formatSessionTokens, getPromptModeLabel } from "./agent-widget.js";
import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
/** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
const CHROME_LINES_BASE = 6;
const MIN_VIEWPORT = 3;
/** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
export const VIEWPORT_HEIGHT_PCT = 70;
export class ConversationViewer implements Component {
private scrollOffset = 0;
private autoScroll = true;
private unsubscribe: (() => void) | undefined;
private lastInnerW = 0;
private closed = false;
/** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
private stopArmed = false;
private keys: ViewerKeys;
/** Steering composer — present while the user is typing a message to the agent. */
private composer: Input | undefined;
constructor(
private tui: TUI,
private session: AgentSession,
private record: AgentRecord,
private activity: AgentActivity | undefined,
private theme: Theme,
private done: (result: undefined) => void,
/** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
private onStop?: () => void,
/** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
keybindings?: ViewerKeybindings,
/** Send a steering message to the agent. Omitted → no compose affordance. */
private onSteer?: (message: string) => void,
/**
* Whether the header shows an estimated cost after the token count. Read
* once, at construction: the overlay is opened from a menu, so the setting
* cannot change while it is on screen.
*/
private showCost = false,
) {
this.keys = createViewerKeys(keybindings);
this.unsubscribe = session.subscribe(() => {
if (this.closed) return;
this.tui.requestRender();
});
}
handleInput(data: string): void {
// While composing a steer message, the input owns all keys (Enter sends,
// Esc cancels — both wired in openComposer()). Editing keys flow through.
if (this.composer) {
this.composer.handleInput(data);
this.tui.requestRender();
return;
}
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
this.closed = true;
this.done(undefined);
return;
}
// Enter opens the steering composer (only while the agent can still be
// steered) — then type + Enter sends, Esc or an empty submit returns. When
// not steerable, fall through so the key still disarms a pending stop.
if (matchesKey(data, "enter") && this.canSteer()) {
this.stopArmed = false;
this.openComposer();
return;
}
// Stop/abort the agent (only while it can still be stopped). Two-press:
// first "x" arms, second confirms — any other key disarms.
if (matchesKey(data, "x")) {
if (this.isStoppable()) {
if (this.stopArmed) {
this.stopArmed = false;
this.onStop?.();
} else {
this.stopArmed = true;
}
this.tui.requestRender();
}
return;
}
if (this.stopArmed) this.stopArmed = false;
const totalLines = this.buildContentLines(this.lastInnerW).length;
const viewportHeight = this.viewportHeight();
const maxScroll = Math.max(0, totalLines - viewportHeight);
if (this.keys.scrollUp(data)) {
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
this.autoScroll = this.scrollOffset >= maxScroll;
} else if (this.keys.scrollDown(data)) {
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
this.autoScroll = this.scrollOffset >= maxScroll;
} else if (this.keys.pageUp(data)) {
this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
this.autoScroll = false;
} else if (this.keys.pageDown(data)) {
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
this.autoScroll = this.scrollOffset >= maxScroll;
} else if (matchesKey(data, "home")) {
this.scrollOffset = 0;
this.autoScroll = false;
} else if (matchesKey(data, "end")) {
this.scrollOffset = maxScroll;
this.autoScroll = true;
}
}
render(width: number): string[] {
if (width < 6) return []; // too narrow for any meaningful rendering
const th = this.theme;
const innerW = width - 4; // border + padding
this.lastInnerW = innerW;
const lines: string[] = [];
const pad = (s: string, len: number) => {
const vis = visibleWidth(s);
return s + " ".repeat(Math.max(0, len - vis));
};
const row = (content: string) =>
th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW, "...", true) + " " + th.fg("border", "│");
const hrTop = th.fg("border", `${"─".repeat(width - 2)}`);
const hrBot = th.fg("border", `${"─".repeat(width - 2)}`);
const hrMid = row(th.fg("dim", "─".repeat(innerW)));
// Header
lines.push(hrTop);
const modeLabel = getPromptModeLabel(this.record.type);
const modeTag = modeLabel ? ` ${th.fg("dim", `(${modeLabel})`)}` : "";
const statusIcon = this.record.status === "running"
? th.fg("accent", "●")
: this.record.status === "completed"
? th.fg("success", "✓")
: this.record.status === "error"
? th.fg("error", "✗")
: th.fg("dim", "○");
const duration = formatDuration(this.record.startedAt, this.record.completedAt);
const headerParts: string[] = [duration];
const toolUses = this.activity?.toolUses ?? this.record.toolUses;
if (toolUses > 0) headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
// Spend from the record, context from the live session: the record is the
// only total that survives the agent finishing and the only one carrying a
// nested child's spend.
const tokens = getLifetimeTotal(this.record.lifetimeUsage);
if (tokens > 0) {
const percent = getSessionContextPercent(this.activity?.session);
headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
}
const cost = this.showCost ? formatCost(getLifetimeCost(this.record.lifetimeUsage)) : "";
if (cost) headerParts.push(cost);
lines.push(row(
`${statusIcon} ${renderAgentName(this.record.type, th, { bold: true })}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`,
));
const invocationLine = this.invocationLine();
if (invocationLine) lines.push(row(invocationLine));
lines.push(hrMid);
// Content area — rebuild every render (live data, no cache needed)
const contentLines = this.buildContentLines(innerW);
const viewportHeight = this.viewportHeight();
const maxScroll = Math.max(0, contentLines.length - viewportHeight);
if (this.autoScroll) {
this.scrollOffset = maxScroll;
}
const visibleStart = Math.min(this.scrollOffset, maxScroll);
const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
for (let i = 0; i < viewportHeight; i++) {
lines.push(row(visible[i] ?? ""));
}
// Footer
lines.push(hrMid);
if (this.composer) {
// Composer row: the Input renders its own `> ` prompt and cursor.
lines.push(row(this.composer.render(innerW)[0] ?? ""));
const composeHint = th.fg("dim", "Enter send · Esc cancel");
const composeLeft = th.fg("accent", "✎ steer");
const composeGap = Math.max(1, innerW - visibleWidth(composeLeft) - visibleWidth(composeHint));
lines.push(row(composeLeft + " ".repeat(composeGap) + composeHint));
} else {
// Actions on the left, navigation on the right. The scroll hint keeps its
// full key list so the less-obvious bindings stay discoverable; it leads
// the right group so "Esc close" is the only part that truncates first.
const sep = th.fg("dim", " · ");
const actions: string[] = [];
if (this.canSteer()) actions.push(th.fg("dim", "Enter steer"));
if (this.isStoppable()) {
actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop"));
}
const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
// Prepend the line-count/scroll-% readout only when there's spare width —
// it's the first thing dropped so it never crowds out the hints.
const scrollPct = contentLines.length <= viewportHeight
? "100%"
: `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
const count = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
const withCount = [count, ...actions].join(sep);
const footerLeft = visibleWidth(withCount) + visibleWidth(footerRight) + 1 <= innerW
? withCount
: actions.join(sep);
const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
}
lines.push(hrBot);
return lines;
}
/** Stoppable only when a stop handler exists and the agent is still active. */
private isStoppable(): boolean {
return !!this.onStop && (this.record.status === "running" || this.record.status === "queued");
}
/** Steerable only when a steer handler exists and the agent is still active. */
private canSteer(): boolean {
return !!this.onSteer && (this.record.status === "running" || this.record.status === "queued");
}
/** Open the inline steering composer and route subsequent input to it. */
private openComposer(): void {
const input = new Input();
input.focused = true;
input.onSubmit = (value: string) => {
const message = value.trim();
this.composer = undefined;
if (message) this.onSteer?.(message);
this.tui.requestRender();
};
input.onEscape = () => {
this.composer = undefined;
this.tui.requestRender();
};
this.composer = input;
this.tui.requestRender();
}
invalidate(): void { /* no cached state to clear */ }
dispose(): void {
this.closed = true;
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = undefined;
}
}
// ---- Private ----
private viewportHeight(): number {
// Cap mirrors the overlay's maxHeight — otherwise the viewer would render
// more lines than the overlay shows and clip the footer.
const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
return Math.max(MIN_VIEWPORT, maxRows - this.chromeLines());
}
private chromeLines(): number {
// The composer adds one row above the footer hint while it's open.
return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0) + (this.composer ? 1 : 0);
}
private invocationLine(): string | undefined {
const { modelName, tags } = buildInvocationTags(this.record.invocation);
const parts = modelName ? [modelName, ...tags] : tags;
if (parts.length === 0) return undefined;
return this.theme.fg("dim", `${parts.join(" · ")}`);
}
private buildContentLines(width: number): string[] {
if (width <= 0) return [];
const th = this.theme;
const messages = this.session.messages;
const lines: string[] = [];
if (messages.length === 0) {
lines.push(th.fg("dim", "(waiting for first message...)"));
return lines;
}
let needsSeparator = false;
for (const msg of messages) {
if (msg.role === "user") {
const text = typeof msg.content === "string"
? msg.content
: extractText(msg.content);
if (!text.trim()) continue;
if (needsSeparator) lines.push(th.fg("dim", "───"));
lines.push(th.fg("accent", "[User]"));
for (const line of wrapTextWithAnsi(text.trim(), width)) {
lines.push(line);
}
} else if (msg.role === "assistant") {
const textParts: string[] = [];
const toolCalls: string[] = [];
for (const c of msg.content) {
if (c.type === "text" && c.text) textParts.push(c.text);
else if (c.type === "toolCall") {
toolCalls.push((c as any).name ?? (c as any).toolName ?? "unknown");
}
}
if (needsSeparator) lines.push(th.fg("dim", "───"));
lines.push(th.bold("[Assistant]"));
if (textParts.length > 0) {
for (const line of wrapTextWithAnsi(textParts.join("\n").trim(), width)) {
lines.push(line);
}
}
for (const name of toolCalls) {
lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${name}]`), width));
}
} else if (msg.role === "toolResult") {
const text = extractText(msg.content);
const truncated = text.length > 500 ? text.slice(0, 500) + "... (truncated)" : text;
if (!truncated.trim()) continue;
if (needsSeparator) lines.push(th.fg("dim", "───"));
lines.push(th.fg("dim", "[Result]"));
for (const line of wrapTextWithAnsi(truncated.trim(), width)) {
lines.push(th.fg("dim", line));
}
} else if ((msg as any).role === "bashExecution") {
const bash = msg as any;
if (needsSeparator) lines.push(th.fg("dim", "───"));
lines.push(truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width));
if (bash.output?.trim()) {
const out = bash.output.length > 500
? bash.output.slice(0, 500) + "... (truncated)"
: bash.output;
for (const line of wrapTextWithAnsi(out.trim(), width)) {
lines.push(th.fg("dim", line));
}
}
} else {
continue;
}
needsSeparator = true;
}
// Streaming indicator for running agents
if (this.record.status === "running" && this.activity) {
const act = describeActivity(this.activity.activeTools, this.activity.responseText);
lines.push("");
lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
}
return lines.map(l => truncateToWidth(l, width));
}
}
+402
View File
@@ -0,0 +1,402 @@
/**
* fleet-list.ts — Claude Code-style "FleetView" list rendered below the editor.
*
* Shows `main` + each running/queued subagent as a navigable list. Pressing ↓ (or
* ←) at an empty prompt activates the list; ↑/↓ move the selection (filled ● marker),
* Enter opens the selected agent's live conversation overlay, Esc returns to the prompt.
* A viewer stays open when its agent finishes; finished agents linger briefly in the list.
*
* Mechanics (see plan): the list is a `belowEditor` widget (render-only), and ALL key
* handling goes through `onTerminalInput` — which fires before the focused editor and
* can `consume` keys — gated on `getEditorText() === ""` so normal typing is untouched.
*/
import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import { hasAgentBadge, renderAgentName } from "../agent-color.js";
import type { AgentManager } from "../agent-manager.js";
import type { AgentRecord } from "../types.js";
import { getLifetimeCost, getLifetimeTotal } from "../usage.js";
import { type AgentActivity, formatCost, type Theme } from "./agent-widget.js";
import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
/** Widget key for the below-editor fleet list. */
const FLEET_KEY = "fleet";
/** Max agent rows shown at once; extras collapse into a "↓ N more" indicator. */
const MAX_AGENT_ROWS = 5;
/** Re-render cadence so elapsed/token stats tick while agents run. */
const TICK_MS = 200;
/** How long a finished agent lingers in the list before it drops out. */
const FINISHED_LINGER_MS = 4000;
/** Minimal UI surface the FleetView needs from `ctx.ui` (structural subset). */
export type FleetUICtx = {
setWidget(
key: string,
content: undefined | ((tui: any, theme: Theme) => { render(width: number): string[]; invalidate(): void; dispose?(): void }),
options?: { placement?: "aboveEditor" | "belowEditor" },
): void;
onTerminalInput(handler: (data: string) => { consume?: boolean; data?: string } | undefined): () => void;
getEditorText(): string;
notify(message: string, type?: "info" | "warning" | "error"): void;
custom<T>(
factory: (tui: any, theme: Theme, keybindings: any, done: (result: T) => void) => { render(width: number): string[]; invalidate(): void; dispose?(): void },
options?: { overlay?: boolean; overlayOptions?: unknown; onHandle?: (handle: unknown) => void },
): Promise<T>;
};
type MainEntry = { kind: "main" };
type AgentEntry = { kind: "agent"; record: AgentRecord };
type FleetEntry = MainEntry | AgentEntry;
/** `11s` — integer seconds, no decimal/suffix (matches Claude Code, unlike formatMs). */
export function formatFleetElapsed(ms: number): string {
return `${Math.max(0, Math.round(ms / 1000))}s`;
}
/** `↓ 13.1k tokens` — down-arrow prefix, compact magnitude, plural "tokens". */
export function formatFleetTokens(count: number): string {
let compact: string;
if (count >= 1_000_000) compact = `${(count / 1_000_000).toFixed(1)}M`;
else if (count >= 1_000) compact = `${(count / 1_000).toFixed(1)}k`;
else compact = `${count}`;
return `${compact} tokens`;
}
/**
* Place `right` flush to `width`, truncating `left` first so the stats survive.
* The final clamp guarantees the line never exceeds `width` (which would wrap and
* desync pi's line-diff → flicker) even on a terminal too narrow for the stats.
*/
function rightAlign(left: string, right: string, width: number): string {
const rightW = visibleWidth(right);
const maxLeft = Math.max(0, width - rightW - 1);
const leftClamped = truncateToWidth(left, maxLeft);
const gap = Math.max(1, width - visibleWidth(leftClamped) - rightW);
return truncateToWidth(leftClamped + " ".repeat(gap) + right, width);
}
export class FleetList {
private ui: FleetUICtx | undefined;
private tui: any | undefined;
private inputUnsub: (() => void) | undefined;
private widgetRegistered = false;
private timer: ReturnType<typeof setInterval> | undefined;
private enabled = true;
/** Whether arrow keys currently navigate the list (vs. flow to the editor). */
private active = false;
/** 0 = `main`, 1..N = subagents. */
private selectedIndex = 0;
/** Set while a conversation overlay is open; calling it closes the overlay. */
private viewerClose: (() => void) | undefined;
private viewingAgentId: string | undefined;
constructor(
private manager: AgentManager,
private agentActivity: Map<string, AgentActivity>,
/**
* Read live at render time. Whether each row shows an estimated cost after
* its token count. Defaults to off — the extension supplies the user's
* `showCost` setting.
*/
private showCost: () => boolean = () => false,
) {}
// ---- Lifecycle ----
setEnabled(enabled: boolean): void {
if (enabled === this.enabled) return;
this.enabled = enabled;
if (!enabled) this.active = false;
this.update();
}
/** Capture the UI context and (re)register the global input handler. */
setUICtx(ui: FleetUICtx): void {
if (ui === this.ui) return;
this.inputUnsub?.();
this.ui = ui;
this.widgetRegistered = false;
this.tui = undefined;
this.inputUnsub = ui.onTerminalInput(data => this.handleKey(data));
}
/** Ensure the re-render timer is running (called when an agent spawns). */
ensureTimer(): void {
if (!this.timer) this.timer = setInterval(() => this.update(), TICK_MS);
}
/**
* Called when an agent finishes. The viewer (if open on it) stays open so the
* final output remains readable, and the row lingers in the list — just refresh.
*/
onAgentFinished(_id: string): void {
this.update();
}
dispose(): void {
if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
this.inputUnsub?.();
this.inputUnsub = undefined;
if (this.viewerClose) { this.viewerClose(); this.viewerClose = undefined; }
this.viewingAgentId = undefined;
if (this.ui && this.widgetRegistered) this.ui.setWidget(FLEET_KEY, undefined);
this.widgetRegistered = false;
this.tui = undefined;
this.active = false;
// Null last so a `viewerClose()` microtask above can't re-register the widget.
this.ui = undefined;
}
/** Re-register/refresh the below-editor widget; clears it when no agents remain. */
update(): void {
if (!this.ui) return;
const hasAgents = this.enabled && this.agentRecords().length > 0;
if (!hasAgents) {
if (this.widgetRegistered) {
this.ui.setWidget(FLEET_KEY, undefined);
this.widgetRegistered = false;
this.tui = undefined;
}
if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
this.active = false;
this.selectedIndex = 0;
return;
}
this.clampSelection();
this.ensureTimer(); // keep stats ticking whenever the list is shown (e.g. after a re-enable)
if (!this.widgetRegistered) {
this.ui.setWidget(FLEET_KEY, (tui, theme) => {
this.tui = tui;
return {
render: (w: number) => this.renderBar(w, theme),
invalidate: () => { this.widgetRegistered = false; this.tui = undefined; },
};
}, { placement: "belowEditor" });
this.widgetRegistered = true;
} else {
this.tui?.requestRender();
}
}
// ---- Roster ----
/**
* Agents shown in the list, ordered earliest-launched first so the ones you
* started sooner sit at the top. Every row is openable (has a session), so Enter
* never dead-ends. Included: running/queued, plus the agent currently being
* viewed, plus recently-finished ones (they linger briefly before dropping out).
* Pending agents with no session yet are hidden until they start.
* (`listAgents()` is newest-first, so we re-sort.)
*/
private agentRecords(): AgentRecord[] {
const now = Date.now();
return this.manager.listAgents()
.filter(a => !a.parentAgentId && a.session && (
a.status === "running" || a.status === "queued"
|| a.id === this.viewingAgentId
|| (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS)
))
.sort((a, b) => a.startedAt - b.startedAt);
}
private roster(): FleetEntry[] {
return [{ kind: "main" }, ...this.agentRecords().map(record => ({ kind: "agent" as const, record }))];
}
private clampSelection(): void {
const max = this.roster().length - 1;
if (this.selectedIndex > max) this.selectedIndex = Math.max(0, max);
if (this.selectedIndex < 0) this.selectedIndex = 0;
}
// ---- Key handling ----
/** Returns `{consume:true}` to swallow a key, or undefined to let it through. */
handleKey(data: string): { consume?: boolean; data?: string } | undefined {
if (!this.enabled || !this.ui) return undefined;
// Input listeners receive BOTH key-press and key-release (the kitty protocol
// emits both, and matchesKey matches either) — act on press only, or every
// tap would move/fire twice. Repeats still pass through for held-key nav.
if (isKeyRelease(data)) return undefined;
// While an overlay is open, let it own all input.
if (this.viewerClose) return undefined;
// Input listeners fire BEFORE the focused component, and dialogs
// (ctx.ui.select/confirm/input, pi's own menus) swap the prompt editor out
// while getEditorText() still reads the detached — empty — editor. So when
// anything but the editor owns the keyboard, stay out of its keys (#123).
if (!this.editorHasFocus()) {
if (this.active) this.deactivate();
return undefined;
}
if (!this.active) {
// Activate: ↓ or ← at an empty prompt moves focus into the list.
const isActivator = matchesKey(data, "down") || matchesKey(data, "left");
if (isActivator && this.agentRecords().length > 0 && this.ui.getEditorText() === "") {
this.active = true;
this.selectedIndex = 0;
this.update();
return { consume: true };
}
return undefined;
}
// Active — arrows navigate, Enter opens, Esc / Up-past-top exits.
if (matchesKey(data, "down")) {
const max = this.roster().length - 1;
this.selectedIndex = Math.min(max, this.selectedIndex + 1);
this.update();
return { consume: true };
}
if (matchesKey(data, "up")) {
if (this.selectedIndex === 0) { this.deactivate(); return { consume: true }; }
this.selectedIndex -= 1;
this.update();
return { consume: true };
}
if (matchesKey(data, "escape")) { this.deactivate(); return { consume: true }; }
if (matchesKey(data, Key.enter)) { this.openSelected(); return { consume: true }; }
// Any other key cancels navigation and flows to the editor.
this.deactivate();
return undefined;
}
/**
* True when pi's prompt editor owns the keyboard. pi's editor is an `Editor`
* subclass (CustomEditor) while every dialog/selector is not, and the loader
* aliases pi-tui to pi's own copy, so `instanceof` is a reliable identity
* check. `focusedComponent` is TUI-private (no public accessor), hence the
* best-effort peek: unknowable focus (no tui seen yet, nothing focused)
* counts as the editor so activation keeps working.
*/
private editorHasFocus(): boolean {
const focused = (this.tui as { focusedComponent?: unknown } | undefined)?.focusedComponent;
return focused == null || focused instanceof Editor;
}
private deactivate(): void {
this.active = false;
this.selectedIndex = 0;
this.update();
}
private openSelected(): void {
const entry = this.roster()[this.selectedIndex];
if (!entry || entry.kind === "main") {
// `main` = return to the prompt; the native transcript is already shown.
this.deactivate();
return;
}
const record = entry.record;
if (!this.ui) return;
if (!record.session) {
this.ui.notify(`Agent is ${record.status} — no session available.`, "info");
return;
}
const session = record.session;
const activity = this.agentActivity.get(record.id);
this.viewingAgentId = record.id;
void this.ui.custom<undefined>(
(tui, theme, keybindings, done) => {
this.viewerClose = () => done(undefined);
return new ConversationViewer(
tui,
session,
record,
activity,
theme,
done,
() => {
if (this.manager.abort(record.id)) this.ui?.notify(`Stopped "${record.description}".`, "info");
},
keybindings,
(message: string) => this.manager.steer(record.id, message),
this.showCost(),
);
},
{
overlay: true,
overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
},
).then(() => this.clearViewer(), () => this.clearViewer());
}
/** Reset overlay state and return to the list (on close, auto-close, or error). */
private clearViewer(): void {
// Keep the cursor on the agent we were viewing — re-resolve by id so it
// still feels natural if the list reordered (an earlier agent finished)
// while the overlay was open. If that agent is gone, leave the index for
// update()'s clamp to settle.
if (this.viewingAgentId) {
const idx = this.roster().findIndex(e => e.kind === "agent" && e.record.id === this.viewingAgentId);
if (idx >= 0) this.selectedIndex = idx;
}
this.viewerClose = undefined;
this.viewingAgentId = undefined;
this.update();
}
// ---- Rendering ----
private renderBar(width: number, theme: Theme): string[] {
const agents = this.roster().slice(1) as AgentEntry[];
if (agents.length === 0) return [];
// Clamp locally so a render between a roster shrink and the next update()
// (e.g. on terminal resize) never loses the selection marker.
const sel = Math.min(this.selectedIndex, agents.length);
const hint = this.active
? "↑↓ select · enter view · esc back"
: "esc to interrupt · ← for agents · ↓ to manage";
const lines: string[] = [];
lines.push(truncateToWidth(" " + theme.fg("dim", hint), width));
lines.push("");
lines.push(truncateToWidth(` ${this.bullet(0, sel, theme)} main`, width));
// Window the agent rows so the selected one stays visible.
const visible = Math.min(MAX_AGENT_ROWS, agents.length);
const selAgent = Math.max(0, sel - 1);
const start = selAgent < visible ? 0 : selAgent - visible + 1;
const hiddenBelow = agents.length - (start + visible);
if (start > 0) lines.push(rightAlign("", theme.fg("dim", `${start} more`), width));
for (let a = start; a < start + visible; a++) {
lines.push(this.renderAgentRow(a + 1, sel, agents[a].record, width, theme));
}
if (hiddenBelow > 0) lines.push(rightAlign("", theme.fg("dim", `${hiddenBelow} more`), width));
return lines;
}
private bullet(rosterIndex: number, sel: number, theme: Theme): string {
return rosterIndex === sel ? theme.fg("accent", "●") : theme.fg("dim", "○");
}
private renderAgentRow(rosterIndex: number, sel: number, record: AgentRecord, width: number, theme: Theme): string {
// The selected row renders in the theme's primary text color so it reads as
// one selection (#230). A configured badge survives — Claude Code's FleetView
// keeps the agent color on the selected row too and only bolds it — which also
// keeps the row's width fixed as the selection moves.
const selected = rosterIndex === sel;
const name = renderAgentName(record.type, theme, selected
? { fallbackColor: "text", bold: hasAgentBadge(record.type) }
: { fallbackColor: "muted" });
const description = selected ? theme.fg("text", record.description) : record.description;
const left = ` ${this.bullet(rosterIndex, sel, theme)} ${name} ${description}`;
// The record, not the activity tracker — see the note in AgentWidget's
// running line: only the record carries a nested child's spend, and only it
// outlives the agent.
const tokens = getLifetimeTotal(record.lifetimeUsage);
const elapsedMs = (record.completedAt ?? Date.now()) - record.startedAt; // freezes once finished
const cost = this.showCost() ? formatCost(getLifetimeCost(record.lifetimeUsage)) : "";
const stats = `${formatFleetElapsed(elapsedMs)} · ${formatFleetTokens(tokens)}${cost ? ` · ${cost}` : ""}`;
const right = selected ? theme.fg("text", stats) : theme.fg("dim", stats);
return rightAlign(left, right, width);
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* schedule-menu.ts — `/agents → Scheduled jobs` submenu.
*
* Minimal v1 surface: list scheduled jobs, select one to inspect details +
* confirm cancellation. No create wizard (the `Agent` tool's `schedule` param
* is the canonical creation path), no toggle/cleanup (cancel is enough for
* "I scheduled something dumb, get rid of it"). Add management surfaces here
* if real demand emerges.
*/
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import type { SubagentScheduler } from "../schedule.js";
import type { ScheduledSubagent } from "../types.js";
import { selectItem } from "./select-item.js";
/** Format an ISO timestamp as relative time ("in 4h", "2d ago", "—"). */
function relTime(iso: string | undefined, now = Date.now()): string {
if (!iso) return "—";
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return "—";
const diff = t - now;
const abs = Math.abs(diff);
const future = diff > 0;
if (abs < 60_000) return future ? "in <1m" : "<1m ago";
const m = Math.round(abs / 60_000);
if (m < 60) return future ? `in ${m}m` : `${m}m ago`;
const h = Math.round(abs / 3_600_000);
if (h < 24) return future ? `in ${h}h` : `${h}h ago`;
const d = Math.round(abs / 86_400_000);
return future ? `in ${d}d` : `${d}d ago`;
}
/** One-line status icon. */
function statusIcon(j: ScheduledSubagent): string {
if (!j.enabled) return "✗";
if (j.lastStatus === "error") return "!";
if (j.lastStatus === "running") return "⋯";
return "✓";
}
/** Compact selectable row — name, schedule, agent type, next/last run, count. */
function formatJob(j: ScheduledSubagent, scheduler: SubagentScheduler): string {
const next = scheduler.getNextRun(j.id);
return [
statusIcon(j),
j.name.padEnd(18).slice(0, 18),
j.schedule.padEnd(14).slice(0, 14),
`[${j.subagent_type}]`,
`next ${relTime(next)}`,
`last ${relTime(j.lastRun)}`,
`runs ${j.runCount}`,
].join(" ");
}
/** Multi-line details block for the cancel confirm. */
function formatDetails(j: ScheduledSubagent, scheduler: SubagentScheduler): string {
const next = scheduler.getNextRun(j.id) ?? "—";
return [
`name: ${j.name}`,
`schedule: ${j.schedule} (${j.scheduleType})`,
`agent: ${j.subagent_type}`,
`prompt: ${j.prompt.slice(0, 200)}${j.prompt.length > 200 ? "…" : ""}`,
`created: ${j.createdAt}`,
`last run: ${j.lastRun ?? "—"} (${j.lastStatus ?? "—"})`,
`next run: ${next}`,
`runs: ${j.runCount}`,
].join("\n");
}
/**
* List scheduled jobs; selecting one opens a cancel-confirm with details.
* Returns when the user backs out or after a cancellation.
*/
export async function showSchedulesMenu(
ctx: ExtensionCommandContext,
scheduler: SubagentScheduler,
): Promise<void> {
if (!scheduler.isActive()) {
ctx.ui.notify("Scheduler is not active in this session.", "warning");
return;
}
const jobs = scheduler.list();
if (jobs.length === 0) {
ctx.ui.notify("No scheduled jobs.", "info");
return;
}
// Numbered + item-paired: two jobs whose names agree in the first 18
// characters format identically, and matching the returned string back
// against a parallel label array cancelled whichever came first.
const job = await selectItem(
ctx.ui,
`Scheduled jobs (${jobs.length}) — select to cancel`,
jobs,
j => formatJob(j, scheduler),
);
if (!job) return;
const ok = await ctx.ui.confirm(`Cancel "${job.name}"?`, formatDetails(job, scheduler));
if (!ok) return;
scheduler.removeJob(job.id);
ctx.ui.notify(`Cancelled "${job.name}".`, "info");
}
+45
View File
@@ -0,0 +1,45 @@
/**
* select-item.ts — pick an item from a list via `ctx.ui.select`, safely.
*
* Pi's dialog API is `select(title, options: string[]) => Promise<string | undefined>`:
* strings in, string out, with no index or value form. Callers therefore have to
* map the returned string back to the item it came from, and the obvious way —
* `labels.indexOf(choice)` over a parallel array — silently resolves to the
* FIRST match whenever two rows format identically. Row formatters here truncate
* (job names to 18 chars, agent descriptions to whatever fits), and the text they
* truncate is LLM-authored, so collisions are ordinary rather than exotic.
*
* This numbers every row, which makes the labels unique by construction — no
* data-dependent branch that only executes in the case nobody exercises — and
* keeps each label paired with its item so a later edit that sorts or filters
* between building and resolving cannot desync them.
*/
/** Minimal shape of the `ctx.ui` surface this needs. */
export interface SelectUI {
select(title: string, options: string[]): Promise<string | undefined>;
}
/**
* Show a numbered picker and return the chosen item (not its label).
*
* Returns undefined when the user escapes, or when the returned string is not
* one we offered.
*/
export async function selectItem<T>(
ui: SelectUI,
title: string,
items: readonly T[],
format: (item: T, index: number) => string,
): Promise<T | undefined> {
// Pad the number so a 10+ item list keeps its columns aligned.
const width = String(items.length).length;
const rows = items.map((item, i) => ({
item,
label: `${String(i + 1).padStart(width)}. ${format(item, i)}`,
}));
const choice = await ui.select(title, rows.map(r => r.label));
if (!choice) return undefined;
return rows.find(r => r.label === choice)?.item;
}
+39
View File
@@ -0,0 +1,39 @@
/**
* viewer-keys.ts — Scroll key matchers for the conversation viewer.
*
* Resolves `tui.select.*` through the user's keybindings when pi provides a
* manager, falling back to the previous hardcoded keys otherwise. The viewer's
* k/j and shift+arrow aliases always work alongside whatever is bound.
*/
import { type KeyId, matchesKey } from "@earendil-works/pi-tui";
/** The `tui.select.*` keybinding ids the viewer resolves. */
export type ViewerScrollKeybinding =
| "tui.select.up"
| "tui.select.down"
| "tui.select.pageUp"
| "tui.select.pageDown";
/** Structural subset of pi-tui's `KeybindingsManager` (which satisfies it). */
export interface ViewerKeybindings {
matches(data: string, keybinding: ViewerScrollKeybinding): boolean;
}
export interface ViewerKeys {
scrollUp(data: string): boolean;
scrollDown(data: string): boolean;
pageUp(data: string): boolean;
pageDown(data: string): boolean;
}
export function createViewerKeys(keybindings?: ViewerKeybindings): ViewerKeys {
const matches = (data: string, id: ViewerScrollKeybinding, fallback: KeyId): boolean =>
keybindings ? keybindings.matches(data, id) : matchesKey(data, fallback);
return {
scrollUp: (data) => matches(data, "tui.select.up", "up") || matchesKey(data, "k"),
scrollDown: (data) => matches(data, "tui.select.down", "down") || matchesKey(data, "j"),
pageUp: (data) => matches(data, "tui.select.pageUp", "pageUp") || matchesKey(data, "shift+up"),
pageDown: (data) => matches(data, "tui.select.pageDown", "pageDown") || matchesKey(data, "shift+down"),
};
}