mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
feat: enable permission-aware subagents
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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, 70–85% 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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user