1133 lines
40 KiB
TypeScript
1133 lines
40 KiB
TypeScript
/**
|
|
* Agent Hub overlay component.
|
|
*
|
|
* One overlay, two views:
|
|
* - Table view: every registered agent except Main (Main IS the ambient
|
|
* chat), live from the global AgentRegistry — status, unread irc count,
|
|
* current/last task, last activity. Navigate with keys, wheel, hover, and
|
|
* click; `r` revives a parked agent, `x` aborts + releases one.
|
|
* - Chat view: per-agent transcript (incremental session-file tail, absorbed
|
|
* from the old session observer overlay) plus an input line. Submitting
|
|
* revives a parked agent, then prompts/steers it; the message lands in the
|
|
* agent's persisted history via the normal prompt path.
|
|
*
|
|
* Replaces the old SessionObserverOverlayComponent (ctrl+s observer).
|
|
*/
|
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
import {
|
|
Container,
|
|
matchesKey,
|
|
type OverlayHandle,
|
|
padding,
|
|
routeSelectListMouse,
|
|
routeSgrMouseInput,
|
|
type SelectListMouseTarget,
|
|
type TUI,
|
|
visibleWidth,
|
|
wrapTextWithAnsi,
|
|
} from "@oh-my-pi/pi-tui";
|
|
import { formatAge, formatNumber, getProjectDir, logger } from "@oh-my-pi/pi-utils";
|
|
import type { KeyId } from "../../config/keybindings";
|
|
import type { Settings } from "../../config/settings";
|
|
import type { MessageRenderer } from "../../extensibility/extensions/types";
|
|
import { IrcBus } from "../../irc/bus";
|
|
import { AgentLifecycleManager } from "../../registry/agent-lifecycle";
|
|
import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry";
|
|
import { registerPersistedSubagents } from "../../registry/persisted-agents";
|
|
import { USER_INTERRUPT_LABEL } from "../../session/messages";
|
|
import { shortenPath, truncateToWidth } from "../../tools/render-utils";
|
|
import { formatLocalDateTimeWithOffset } from "../../utils/local-date";
|
|
import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
|
|
import { theme } from "../theme/theme";
|
|
import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
|
|
import {
|
|
type AgentMetrics,
|
|
type AggregateMetrics,
|
|
aggregateMetrics,
|
|
progressMetrics,
|
|
projectAgentTree,
|
|
STATUS_ORDER,
|
|
} from "./agent-hub-projection";
|
|
import {
|
|
clampHubLine,
|
|
contextGauge,
|
|
formatChildIds,
|
|
formatCost,
|
|
formatMetricDuration,
|
|
formatMetrics,
|
|
formatRoleBadge,
|
|
modelBadge,
|
|
type RosterRender,
|
|
sanitizeDisplayText,
|
|
sanitizeLine,
|
|
statusGlyph,
|
|
statusText,
|
|
treeBranch,
|
|
} from "./agent-hub-renderer";
|
|
import { AgentTranscriptViewer } from "./agent-transcript-viewer";
|
|
import {
|
|
bottomBorder,
|
|
divider,
|
|
dividerSplit,
|
|
row,
|
|
splitBodyWidth,
|
|
splitRow,
|
|
topBorder,
|
|
topBorderSplit,
|
|
} from "./overlay-box";
|
|
|
|
type HubViewMode = "roster" | "tree";
|
|
|
|
/** Refresh cadence for the relative-time column. */
|
|
const AGE_TICK_MS = 5_000;
|
|
const DATA_CHANGE_RENDER_COALESCE_MS = 100;
|
|
/** Double-tap window for the table's left-left "close hub" gesture. */
|
|
const LEFT_TAP_WINDOW_MS = 500;
|
|
|
|
/** Two-pane mode needs a useful roster and a readable inspector. */
|
|
const SPLIT_MIN_WIDTH = 96;
|
|
const DETAIL_MIN_WIDTH = 34;
|
|
const ROSTER_MIN_WIDTH = 48;
|
|
/** Result of one host-backed transcript read for the Agent Hub viewer. */
|
|
export interface AgentHubRemoteTranscript {
|
|
text: string;
|
|
newSize: number;
|
|
/** Terminal read failure reported by the host; guests should surface it instead of retrying hot. */
|
|
error?: string;
|
|
}
|
|
|
|
/** Guest-side proxy for hub actions executed on the collab host. */
|
|
export interface AgentHubRemote {
|
|
chat(id: string, text: string): void;
|
|
kill(id: string): void;
|
|
revive(id: string): void;
|
|
/** Mirrors readFileIncremental: text from fromByte (complete JSONL lines), newSize = next fromByte base; null = temporarily unavailable. */
|
|
readTranscript(id: string, fromByte: number): Promise<AgentHubRemoteTranscript | null>;
|
|
}
|
|
|
|
export interface AgentHubDeps {
|
|
/** Progress/status snapshot source (task lifecycle + progress channels). */
|
|
observers: SessionObserverRegistry;
|
|
/** Production settings used to resolve textual model-role tags. */
|
|
settings?: Settings;
|
|
/** Keys that toggle the hub closed from inside (app.agents.hub + app.session.observe). */
|
|
hubKeys: KeyId[];
|
|
onDone: () => void;
|
|
requestRender: () => void;
|
|
/** Injectable for tests; defaults to the process-global registry. */
|
|
registry?: AgentRegistry;
|
|
/** Injectable for tests; defaults to the process-global lifecycle manager. */
|
|
lifecycle?: AgentLifecycleManager;
|
|
/** Injectable for tests; defaults to the process-global bus. */
|
|
irc?: IrcBus;
|
|
/** TUI handle for transcript components; tests omit it and get a render-only stub. */
|
|
ui?: TUI;
|
|
/** Tool lookup for transcript renderers (labels, custom render functions). */
|
|
getTool?: (name: string) => AgentTool | undefined;
|
|
/** Whether the active registry entry came from a built-in factory. */
|
|
isBuiltInTool?: (name: string) => boolean;
|
|
/** Extension message renderers for custom messages in the transcript. */
|
|
getMessageRenderer?: (customType: string) => MessageRenderer | undefined;
|
|
/** Cwd used by tool renderers for path shortening; defaults to the project dir. */
|
|
cwd?: string;
|
|
/** Mirrors the main transcript's thinking-block visibility. */
|
|
hideThinkingBlock?: () => boolean;
|
|
proseOnlyThinking?: () => boolean;
|
|
/** Keys toggling tool output expansion (app.tools.expand). */
|
|
expandKeys?: KeyId[];
|
|
/** Focus the main view on this agent's live session (ctx.focusAgentSession). When absent (collab guest, tests), Enter opens the in-hub chat view instead. */
|
|
focusAgent?: (id: string) => Promise<void>;
|
|
/** Current main session file; used to seed parked historical subagents after restart. */
|
|
sessionFile?: string | null;
|
|
|
|
/** Collab guest: route actions/transcripts to the host instead of local sessions. */
|
|
remote?: AgentHubRemote;
|
|
}
|
|
|
|
export class AgentHubOverlayComponent extends Container implements SelectListMouseTarget {
|
|
#registry: AgentRegistry;
|
|
#observers: SessionObserverRegistry;
|
|
#settings: Settings | undefined;
|
|
#irc: IrcBus;
|
|
#lifecycle: () => AgentLifecycleManager;
|
|
#onDone: () => void;
|
|
#requestRender: () => void;
|
|
#hubKeys: KeyId[];
|
|
#unsubscribers: Array<() => void> = [];
|
|
#ageTimer: NodeJS.Timeout | undefined;
|
|
#dataChangeTimer?: NodeJS.Timeout;
|
|
#remote: AgentHubRemote | undefined;
|
|
#disposed = false;
|
|
/** Resolves after persisted historical subagents have been registered and rows refreshed. */
|
|
readonly persistedSubagentsReady: Promise<void>;
|
|
/** Prevent the async persisted-session scan from flashing a false empty state. */
|
|
#loadingPersistedSubagents = false;
|
|
|
|
// Table state
|
|
#rows: AgentRef[] = [];
|
|
#statusCounts: Record<AgentStatus, number> = { running: 0, idle: 0, parked: 0, aborted: 0 };
|
|
#selectedRow = 0;
|
|
#hoveredRow: number | null = null;
|
|
/** Per-render screen-line to agent-row map, shared by click and hover routing. */
|
|
#hitRows: Array<number | undefined> = [];
|
|
#notice: string | undefined;
|
|
/** Captured row order from the first refresh; keeps the hub stable while open. */
|
|
#rowOrder: Map<string, number> | undefined;
|
|
#nextRowOrder = 0;
|
|
/** Double-tap window state for the table's left-left "close hub" gesture. */
|
|
#lastLeftTap = 0;
|
|
/** Operational ordering by default; tree mode groups descendants under their spawner. */
|
|
#viewMode: HubViewMode = "roster";
|
|
#treeDepthById = new Map<string, number>();
|
|
#treeParentById = new Map<string, string>();
|
|
#treeLastSiblingById = new Map<string, boolean>();
|
|
/** Current observer index and summary data, rebuilt on source changes rather than every paint. */
|
|
#observedById = new Map<string, ObservableSession>();
|
|
#aggregate: AggregateMetrics = {
|
|
tokens: 0,
|
|
requests: 0,
|
|
tools: 0,
|
|
cost: 0,
|
|
durationMs: 0,
|
|
durationKind: "active",
|
|
reportedAgents: 0,
|
|
activeDurationAgents: 0,
|
|
};
|
|
#childrenByParent = new Map<string, AgentRef[]>();
|
|
/** Transcript-derived fallback stats are sampled only on the bounded age cadence. */
|
|
#sessionMetrics = new WeakMap<object, { metrics: AgentMetrics | undefined }>();
|
|
/** Avoid a cadence-time row scan for the common persisted-only roster. */
|
|
#hasFallbackLiveSessions = false;
|
|
/** On narrow terminals Tab replaces the roster with the selected-agent inspector. */
|
|
#narrowDetailsOpen = false;
|
|
#lastRenderWasSplit = false;
|
|
#lastSplitRosterWidth: number | undefined;
|
|
/** Scroll offset for the selected-agent inspector when its content overflows. */
|
|
#detailScrollOffset = 0;
|
|
#detailAgentId: string | undefined;
|
|
|
|
// Transcript-viewer launch deps (passed through to AgentTranscriptViewer).
|
|
#ui: TUI;
|
|
#getTool: ((name: string) => AgentTool | undefined) | undefined;
|
|
#isBuiltInTool: ((name: string) => boolean) | undefined;
|
|
#getMessageRenderer: ((customType: string) => MessageRenderer | undefined) | undefined;
|
|
#cwd: string;
|
|
#hideThinkingBlock: (() => boolean) | undefined;
|
|
#proseOnlyThinking: (() => boolean) | undefined;
|
|
#expandKeys: KeyId[];
|
|
#focusAgent: ((id: string) => Promise<void>) | undefined;
|
|
|
|
// Fullscreen transcript overlay opened by openChat(), if any.
|
|
#transcriptOverlay: OverlayHandle | undefined;
|
|
#transcriptViewer: AgentTranscriptViewer | undefined;
|
|
|
|
constructor(deps: AgentHubDeps) {
|
|
super();
|
|
this.#registry = deps.registry ?? AgentRegistry.global();
|
|
this.#observers = deps.observers;
|
|
this.#settings = deps.settings;
|
|
this.#irc = deps.irc ?? IrcBus.global();
|
|
// Lazy: the lifecycle global self-constructs against the global
|
|
// registry, so only touch it when revive/kill actually needs it.
|
|
this.#lifecycle = () => deps.lifecycle ?? AgentLifecycleManager.global();
|
|
this.#onDone = deps.onDone;
|
|
this.#requestRender = deps.requestRender;
|
|
this.#hubKeys = deps.hubKeys;
|
|
this.#remote = deps.remote;
|
|
this.#loadingPersistedSubagents = !this.#remote && Boolean(deps.sessionFile?.endsWith(".jsonl"));
|
|
this.#ui =
|
|
deps.ui ??
|
|
({
|
|
requestRender: () => deps.requestRender(),
|
|
requestComponentRender: () => deps.requestRender(),
|
|
} as unknown as TUI);
|
|
this.#getTool = deps.getTool;
|
|
this.#isBuiltInTool = deps.isBuiltInTool;
|
|
this.#getMessageRenderer = deps.getMessageRenderer;
|
|
this.#cwd = deps.cwd ?? getProjectDir();
|
|
this.#hideThinkingBlock = deps.hideThinkingBlock;
|
|
this.#proseOnlyThinking = deps.proseOnlyThinking;
|
|
this.#expandKeys = deps.expandKeys ?? ["ctrl+o"];
|
|
this.#focusAgent = deps.focusAgent;
|
|
|
|
this.#unsubscribers.push(this.#registry.onChange(() => this.#scheduleDataChange()));
|
|
this.#unsubscribers.push(this.#observers.onChange(() => this.#scheduleDataChange()));
|
|
this.#ageTimer = setInterval(() => {
|
|
if (this.#hasFallbackLiveSessions) {
|
|
this.#refreshAggregate(true);
|
|
}
|
|
this.#requestRender();
|
|
}, AGE_TICK_MS);
|
|
this.#ageTimer.unref?.();
|
|
|
|
this.persistedSubagentsReady = this.#remote
|
|
? Promise.resolve()
|
|
: registerPersistedSubagents(this.#registry, deps.sessionFile, {
|
|
shouldContinue: () => !this.#disposed,
|
|
})
|
|
.then(() => {
|
|
if (!this.#disposed) this.#refreshRows();
|
|
})
|
|
.catch((error: unknown) => {
|
|
logger.warn("Failed to register persisted subagents", { error });
|
|
})
|
|
.finally(() => {
|
|
this.#loadingPersistedSubagents = false;
|
|
if (!this.#disposed) this.#requestRender();
|
|
});
|
|
this.#refreshRows();
|
|
}
|
|
|
|
/**
|
|
* Whether the current table view has no agents to show (every registered agent
|
|
* except Main). Persisted historical rows may arrive later; callers that need
|
|
* those included must wait for {@link persistedSubagentsReady} first.
|
|
*/
|
|
get isEmpty(): boolean {
|
|
return this.#rows.length === 0;
|
|
}
|
|
|
|
/** Tear down every subscription and timer. Called by the overlay owner on close. */
|
|
override dispose(): void {
|
|
if (this.#disposed) return;
|
|
this.#disposed = true;
|
|
for (const unsubscribe of this.#unsubscribers.splice(0)) unsubscribe();
|
|
if (this.#ageTimer) {
|
|
clearInterval(this.#ageTimer);
|
|
this.#ageTimer = undefined;
|
|
}
|
|
if (this.#dataChangeTimer) {
|
|
clearTimeout(this.#dataChangeTimer);
|
|
this.#dataChangeTimer = undefined;
|
|
}
|
|
this.#closeTranscriptOverlay();
|
|
}
|
|
|
|
override render(width: number): readonly string[] {
|
|
const termHeight = this.#ui.terminal?.rows || process.stdout.rows || 40;
|
|
const frame = this.#renderTable(width, termHeight).map(line => clampHubLine(line, width));
|
|
if (frame.length <= termHeight) return frame;
|
|
|
|
// A tiny terminal can leave less room than the fixed chrome needs. Keep
|
|
// the title and footer visible instead of spilling into scrollback.
|
|
const footerLines = Math.min(3, frame.length);
|
|
const bodyEnd = Math.max(0, termHeight - footerLines);
|
|
return [...frame.slice(0, bodyEnd), ...frame.slice(-footerLines)].slice(0, termHeight);
|
|
}
|
|
|
|
handleInput(keyData: string): void {
|
|
if (
|
|
routeSgrMouseInput(keyData, event => {
|
|
const split = this.#lastSplitRosterWidth;
|
|
if (split !== undefined && event.wheel === null && event.col > split + 2) return false;
|
|
return routeSelectListMouse(this, event, event.row);
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// The hub/observe keys always close the overlay (toggle semantics)
|
|
for (const key of this.#hubKeys) {
|
|
if (matchesKey(keyData, key)) {
|
|
this.#onDone();
|
|
return;
|
|
}
|
|
}
|
|
this.#handleTableInput(keyData);
|
|
}
|
|
|
|
/**
|
|
* Seed the table's left-left close detector with the current time so a single
|
|
* subsequent `←` (within {@link LEFT_TAP_WINDOW_MS}) dismisses the hub.
|
|
*
|
|
* The editor's own double-tap detector consumes the `←←` that opens the hub,
|
|
* leaving this detector at its fresh `0` — without this handoff the user would
|
|
* have to press `←←` a second time to escape. Called by the opener when the hub
|
|
* was raised by that gesture.
|
|
*/
|
|
armCloseTap(): void {
|
|
this.#lastLeftTap = Date.now();
|
|
}
|
|
|
|
/**
|
|
* Open the fullscreen transcript viewer for an agent id (public for table Enter
|
|
* and tests). Mounts {@link AgentTranscriptViewer} as a `fullscreen` overlay so it
|
|
* owns the alternate screen; the hub table stays mounted underneath and is
|
|
* restored when the viewer closes. No-op without a real TUI (render-only test stub).
|
|
*/
|
|
openChat(id: string): void {
|
|
if (this.#disposed || !this.#registry.get(id)) return;
|
|
if (typeof this.#ui.showOverlay !== "function") return;
|
|
this.#closeTranscriptOverlay();
|
|
this.#notice = undefined;
|
|
let viewer: AgentTranscriptViewer;
|
|
viewer = new AgentTranscriptViewer({
|
|
agentId: id,
|
|
registry: this.#registry,
|
|
remote: this.#remote,
|
|
observers: this.#observers,
|
|
lifecycle: this.#remote ? undefined : this.#lifecycle,
|
|
ui: this.#ui,
|
|
getTool: this.#getTool,
|
|
isBuiltInTool: this.#isBuiltInTool,
|
|
getMessageRenderer: this.#getMessageRenderer,
|
|
cwd: this.#cwd,
|
|
hideThinkingBlock: this.#hideThinkingBlock,
|
|
proseOnlyThinking: this.#proseOnlyThinking,
|
|
expandKeys: this.#expandKeys,
|
|
hubKeys: this.#hubKeys,
|
|
requestRender: this.#requestRender,
|
|
onClose: () => this.#closeTranscriptOverlay(viewer),
|
|
onHubClose: () => {
|
|
if (this.#disposed) return;
|
|
this.#closeTranscriptOverlay(viewer);
|
|
if (!this.#disposed) this.#onDone();
|
|
},
|
|
});
|
|
this.#transcriptViewer = viewer;
|
|
this.#transcriptOverlay = this.#ui.showOverlay(viewer, { width: "100%", margin: 0, fullscreen: true });
|
|
this.#ui.setFocus(viewer);
|
|
this.#requestRender();
|
|
}
|
|
|
|
/** Close and dispose the transcript overlay, restoring focus to the hub table. */
|
|
#closeTranscriptOverlay(expectedViewer?: AgentTranscriptViewer): void {
|
|
if (expectedViewer && this.#transcriptViewer !== expectedViewer) return;
|
|
const overlay = this.#transcriptOverlay;
|
|
const viewer = this.#transcriptViewer;
|
|
if (!overlay && !viewer) return;
|
|
overlay?.hide();
|
|
this.#transcriptOverlay = undefined;
|
|
viewer?.dispose();
|
|
this.#transcriptViewer = undefined;
|
|
if (!this.#disposed) {
|
|
if (typeof this.#ui.setFocus === "function") this.#ui.setFocus(this);
|
|
this.#requestRender();
|
|
}
|
|
}
|
|
|
|
// ========================================================================
|
|
// Live data plumbing
|
|
// ========================================================================
|
|
|
|
#scheduleDataChange(): void {
|
|
if (this.#dataChangeTimer) return;
|
|
this.#dataChangeTimer = setTimeout(() => {
|
|
this.#dataChangeTimer = undefined;
|
|
this.#onDataChange();
|
|
}, DATA_CHANGE_RENDER_COALESCE_MS);
|
|
this.#dataChangeTimer.unref?.();
|
|
}
|
|
|
|
#onDataChange(): void {
|
|
this.#refreshRows();
|
|
this.#requestRender();
|
|
}
|
|
|
|
#refreshRows(): void {
|
|
const selectedId = this.#rows[this.#selectedRow]?.id;
|
|
const refs = this.#registry.list().filter(ref => ref.id !== MAIN_AGENT_ID);
|
|
this.#observedById = new Map();
|
|
for (const session of this.#observers.getSessions()) this.#observedById.set(session.id, session);
|
|
const rowOrder = this.#rowOrder;
|
|
let rosterRows: AgentRef[];
|
|
if (!rowOrder) {
|
|
rosterRows = refs.sort(
|
|
(a, b) =>
|
|
STATUS_ORDER[a.status] - STATUS_ORDER[b.status] ||
|
|
b.lastActivity - a.lastActivity ||
|
|
a.id.localeCompare(b.id),
|
|
);
|
|
this.#rowOrder = new Map();
|
|
for (const ref of rosterRows) this.#rowOrder.set(ref.id, this.#nextRowOrder++);
|
|
} else {
|
|
rosterRows = refs.sort(
|
|
(a, b) => (rowOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (rowOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER),
|
|
);
|
|
for (const ref of rosterRows) {
|
|
if (!rowOrder.has(ref.id)) rowOrder.set(ref.id, this.#nextRowOrder++);
|
|
}
|
|
}
|
|
|
|
if (this.#viewMode === "tree") {
|
|
const tree = projectAgentTree(rosterRows);
|
|
this.#rows = tree.rows;
|
|
this.#treeDepthById = tree.depthById;
|
|
this.#treeParentById = tree.parentById;
|
|
this.#treeLastSiblingById = tree.lastSiblingById;
|
|
} else {
|
|
this.#rows = rosterRows;
|
|
this.#treeDepthById.clear();
|
|
this.#treeParentById.clear();
|
|
this.#treeLastSiblingById.clear();
|
|
}
|
|
const keptIndex = selectedId ? this.#rows.findIndex(ref => ref.id === selectedId) : -1;
|
|
this.#selectedRow = keptIndex >= 0 ? keptIndex : Math.min(this.#selectedRow, Math.max(0, this.#rows.length - 1));
|
|
const detailAgentId = this.#rows[this.#selectedRow]?.id;
|
|
if (detailAgentId !== this.#detailAgentId) {
|
|
this.#detailAgentId = detailAgentId;
|
|
this.#detailScrollOffset = 0;
|
|
}
|
|
|
|
this.#childrenByParent.clear();
|
|
for (const ref of rosterRows) {
|
|
const parent = ref.parentId ?? MAIN_AGENT_ID;
|
|
const children = this.#childrenByParent.get(parent);
|
|
if (children) children.push(ref);
|
|
else this.#childrenByParent.set(parent, [ref]);
|
|
}
|
|
this.#statusCounts = { running: 0, idle: 0, parked: 0, aborted: 0 };
|
|
for (const ref of rosterRows) this.#statusCounts[ref.status]++;
|
|
this.#refreshAggregate();
|
|
}
|
|
|
|
#metricsFor(ref: AgentRef, observed: ObservableSession | undefined): AgentMetrics | undefined {
|
|
if (observed?.progress) return progressMetrics(observed);
|
|
if (ref.history?.metrics) return ref.history.metrics;
|
|
const session = this.#fallbackStatsSession(ref, observed);
|
|
return session ? this.#sessionMetrics.get(session)?.metrics : undefined;
|
|
}
|
|
|
|
#fallbackStatsSession(
|
|
ref: AgentRef,
|
|
observed: ObservableSession | undefined,
|
|
): NonNullable<AgentRef["session"]> | undefined {
|
|
if (observed?.progress) return undefined;
|
|
const session = ref.session;
|
|
return session && typeof session.getSessionStats === "function" ? session : undefined;
|
|
}
|
|
|
|
// ========================================================================
|
|
// Table view
|
|
// ========================================================================
|
|
|
|
#renderTable(width: number, termHeight: number): string[] {
|
|
this.#hitRows.length = 0;
|
|
const contentRows = Math.max(1, termHeight - 4);
|
|
const observedById = this.#observedById;
|
|
const split = this.#splitRosterWidth(width);
|
|
this.#lastRenderWasSplit = split !== undefined;
|
|
this.#lastSplitRosterWidth = split;
|
|
const selected = this.#rows[this.#selectedRow];
|
|
const lines: string[] = [];
|
|
|
|
if (split !== undefined) {
|
|
const detailWidth = splitBodyWidth(width, split);
|
|
const roster = this.#renderRosterPanel(split, contentRows, observedById);
|
|
const details = this.#renderDetailPanel(selected, detailWidth, contentRows, observedById);
|
|
lines.push(topBorderSplit(width, "Agent Hub", split));
|
|
for (let i = 0; i < contentRows; i++) {
|
|
const hit = roster.hitRows[i];
|
|
if (hit !== undefined) this.#hitRows[lines.length] = hit;
|
|
lines.push(splitRow(roster.lines[i] ?? "", details[i] ?? "", width, split));
|
|
}
|
|
lines.push(dividerSplit(width, split));
|
|
lines.push(row(this.#footer(false, Math.max(1, width - 4)), width));
|
|
lines.push(bottomBorder(width));
|
|
return lines;
|
|
}
|
|
|
|
const innerWidth = Math.max(1, width - 4);
|
|
if (this.#narrowDetailsOpen && selected) {
|
|
const details = this.#renderDetailPanel(selected, innerWidth, contentRows, observedById);
|
|
lines.push(topBorder(width, `Agent Hub · ${selected.id}`));
|
|
for (const detail of details) lines.push(row(detail, width));
|
|
} else {
|
|
const roster = this.#renderRosterPanel(innerWidth, contentRows, observedById);
|
|
lines.push(topBorder(width, "Agent Hub"));
|
|
for (let i = 0; i < contentRows; i++) {
|
|
const hit = roster.hitRows[i];
|
|
if (hit !== undefined) this.#hitRows[lines.length] = hit;
|
|
lines.push(row(roster.lines[i] ?? "", width));
|
|
}
|
|
}
|
|
lines.push(divider(width));
|
|
lines.push(row(this.#footer(this.#narrowDetailsOpen, innerWidth), width));
|
|
lines.push(bottomBorder(width));
|
|
return lines;
|
|
}
|
|
|
|
#splitRosterWidth(width: number): number | undefined {
|
|
if (width < SPLIT_MIN_WIDTH) return undefined;
|
|
const rosterWidth = Math.max(ROSTER_MIN_WIDTH, Math.min(Math.floor(width * 0.58), width - DETAIL_MIN_WIDTH - 7));
|
|
return splitBodyWidth(width, rosterWidth) >= DETAIL_MIN_WIDTH ? rosterWidth : undefined;
|
|
}
|
|
|
|
#footer(showingNarrowDetails: boolean, availableWidth: number): string {
|
|
const nextView = this.#viewMode === "roster" ? "by parent" : "flat";
|
|
if (showingNarrowDetails) {
|
|
return theme.fg("dim", `Tab:roster PgUp/PgDn:scroll Enter:open t:${nextView} Esc:roster`);
|
|
}
|
|
if (availableWidth < 96) {
|
|
return theme.fg("dim", `j/k:select Enter:open t:${nextView} Tab:details r/x:manage Esc:close`);
|
|
}
|
|
return theme.fg(
|
|
"dim",
|
|
`j/k/wheel:select PgUp/PgDn:details Enter/click:open t:${nextView} r:revive x:kill Esc:close`,
|
|
);
|
|
}
|
|
|
|
#renderRosterPanel(width: number, rows: number, observedById: ReadonlyMap<string, ObservableSession>): RosterRender {
|
|
const lines = this.#summaryLines(width);
|
|
const hitRows: Array<number | undefined> = Array.from({ length: lines.length });
|
|
if (rows >= 8) {
|
|
lines.push("");
|
|
hitRows.push(undefined);
|
|
}
|
|
|
|
const noticeLines = this.#notice ? [theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width)))] : [];
|
|
const budget = Math.max(0, rows - lines.length - noticeLines.length);
|
|
if (this.#rows.length === 0) {
|
|
if (this.#loadingPersistedSubagents) {
|
|
if (budget > 0) {
|
|
lines.push(`${statusGlyph("running")} ${theme.fg("accent", "Loading saved agents…")}`);
|
|
hitRows.push(undefined);
|
|
}
|
|
} else {
|
|
const emptyState = [
|
|
`${theme.fg("muted", theme.status.shadowed)} ${theme.bold("No agents in this session")}`,
|
|
theme.fg("dim", "Finished, parked, and killed subagents remain with the session that created them."),
|
|
theme.fg("dim", "Resume that session with omp-dev --continue, or spawn a task here."),
|
|
];
|
|
for (const line of emptyState.slice(0, budget)) {
|
|
lines.push(line);
|
|
hitRows.push(undefined);
|
|
}
|
|
}
|
|
} else if (budget > 0) {
|
|
const window = this.#renderRosterWindow(width, budget, observedById);
|
|
lines.push(...window.lines);
|
|
hitRows.push(...window.hitRows);
|
|
}
|
|
for (const notice of noticeLines) {
|
|
lines.push(notice);
|
|
hitRows.push(undefined);
|
|
}
|
|
while (lines.length < rows) {
|
|
lines.push("");
|
|
hitRows.push(undefined);
|
|
}
|
|
return { lines: lines.slice(0, rows), hitRows: hitRows.slice(0, rows) };
|
|
}
|
|
|
|
#renderRosterWindow(
|
|
width: number,
|
|
budget: number,
|
|
_observedById: ReadonlyMap<string, ObservableSession>,
|
|
): RosterRender {
|
|
const lines: string[] = [];
|
|
const hitRows: Array<number | undefined> = [];
|
|
const rendered = new Map<number, string[]>();
|
|
const entryAt = (index: number): string[] => {
|
|
const cached = rendered.get(index);
|
|
if (cached) return cached;
|
|
const entry = this.#renderEntry(
|
|
this.#rows[index],
|
|
index === this.#selectedRow,
|
|
width,
|
|
this.#observableFor(this.#rows[index].id),
|
|
index === this.#hoveredRow,
|
|
);
|
|
rendered.set(index, entry);
|
|
return entry;
|
|
};
|
|
const appendEntry = (index: number, entry = entryAt(index)): void => {
|
|
for (const line of entry) {
|
|
lines.push(line);
|
|
hitRows.push(index);
|
|
}
|
|
};
|
|
|
|
let start = this.#selectedRow;
|
|
let end = this.#selectedRow + 1;
|
|
let used = entryAt(this.#selectedRow).length;
|
|
if (used > budget) {
|
|
appendEntry(this.#selectedRow, entryAt(this.#selectedRow).slice(0, budget));
|
|
return { lines, hitRows };
|
|
}
|
|
|
|
// Grow a window around the selection. Only visible entries are rendered,
|
|
// so the 5,000-agent Hub retains bounded paint cost.
|
|
for (let grew = true; grew; ) {
|
|
grew = false;
|
|
if (end < this.#rows.length) {
|
|
const next = entryAt(end);
|
|
if (used + next.length <= budget) {
|
|
used += next.length;
|
|
end++;
|
|
grew = true;
|
|
}
|
|
}
|
|
if (start > 0) {
|
|
const previous = entryAt(start - 1);
|
|
if (used + previous.length <= budget) {
|
|
start--;
|
|
used += previous.length;
|
|
grew = true;
|
|
}
|
|
}
|
|
}
|
|
// Overflow labels consume real rows. Trim the farthest visible neighbors
|
|
// before painting them so the selected entry and both labels fit.
|
|
for (
|
|
let markerRows = Number(start > 0) + Number(end < this.#rows.length);
|
|
used + markerRows > budget && start < end;
|
|
markerRows = Number(start > 0) + Number(end < this.#rows.length)
|
|
) {
|
|
if (end - 1 > this.#selectedRow) {
|
|
end--;
|
|
used -= entryAt(end).length;
|
|
} else if (start < this.#selectedRow) {
|
|
used -= entryAt(start).length;
|
|
start++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
const showTopOverflow = start > 0 && used < budget;
|
|
const showBottomOverflow = end < this.#rows.length && used + Number(showTopOverflow) < budget;
|
|
if (showTopOverflow) {
|
|
lines.push(theme.fg("dim", `… ${start} more`));
|
|
hitRows.push(undefined);
|
|
}
|
|
for (let i = start; i < end; i++) appendEntry(i);
|
|
if (showBottomOverflow) {
|
|
lines.push(theme.fg("dim", `… ${this.#rows.length - end} more`));
|
|
hitRows.push(undefined);
|
|
}
|
|
return { lines, hitRows };
|
|
}
|
|
|
|
#summaryLines(width: number): string[] {
|
|
const active = (label: string): string => theme.bg("selectedBg", theme.bold(theme.fg("accent", ` ${label} `)));
|
|
const inactive = (label: string): string => theme.fg("muted", ` ${label} `);
|
|
const projection =
|
|
this.#viewMode === "roster"
|
|
? `${active("Flat")}${theme.fg("dim", "/")}${inactive("By parent")}`
|
|
: `${inactive("Flat")}${theme.fg("dim", "/")}${active("By parent")}`;
|
|
const counts = this.#statusSummary();
|
|
const header = `${theme.bold("Roster")}${theme.fg("dim", theme.sep.dot)}${projection}${counts ? theme.fg("dim", theme.sep.dot) + counts : ""}`;
|
|
const lines = wrapTextWithAnsi(header, Math.max(1, width));
|
|
|
|
const metrics = this.#aggregate;
|
|
if (metrics.reportedAgents === 0) {
|
|
lines.push(
|
|
...wrapTextWithAnsi(
|
|
theme.fg("dim", `Usage —${theme.sep.dot}0/${this.#rows.length} measured`),
|
|
Math.max(1, width),
|
|
),
|
|
);
|
|
return lines;
|
|
}
|
|
const activeTime = formatMetricDuration(metrics);
|
|
const usage = [
|
|
theme.fg("statusLineCost", formatCost(metrics.cost)),
|
|
theme.fg("dim", activeTime ? `${activeTime} agent time` : "agent time —"),
|
|
theme.fg("dim", `${formatNumber(metrics.requests)} req`),
|
|
theme.fg("dim", `${formatNumber(metrics.tools)} tools`),
|
|
theme.fg("dim", `${formatNumber(metrics.tokens)} tok`),
|
|
theme.fg("dim", `${metrics.activeDurationAgents}/${metrics.reportedAgents} timed`),
|
|
theme.fg("dim", `${metrics.reportedAgents}/${this.#rows.length} measured`),
|
|
].join(theme.fg("dim", theme.sep.dot));
|
|
lines.push(...wrapTextWithAnsi(usage, Math.max(1, width)));
|
|
return lines;
|
|
}
|
|
|
|
#statusSummary(): string {
|
|
const parts: string[] = [];
|
|
for (const status of ["running", "idle", "parked", "aborted"] as const) {
|
|
const count = this.#statusCounts[status];
|
|
if (count > 0) parts.push(`${statusGlyph(status)} ${statusText(status, `${count} ${status}`)}`);
|
|
}
|
|
return parts.join(theme.sep.dot);
|
|
}
|
|
|
|
#refreshAggregate(refreshFallback = false): void {
|
|
const result = aggregateMetrics({
|
|
rows: this.#rows,
|
|
observedById: this.#observedById,
|
|
metricsFor: (ref, observed) => this.#metricsFor(ref, observed),
|
|
fallbackStatsSession: (ref, observed) => this.#fallbackStatsSession(ref, observed),
|
|
sessionMetrics: this.#sessionMetrics,
|
|
refreshFallback,
|
|
});
|
|
this.#aggregate = result.metrics;
|
|
this.#hasFallbackLiveSessions = result.hasFallbackLiveSessions;
|
|
}
|
|
|
|
#observableFor(id: string): ObservableSession | undefined {
|
|
return this.#observedById.get(id) ?? this.#observers.getSession(id);
|
|
}
|
|
|
|
#renderDetailPanel(
|
|
ref: AgentRef | undefined,
|
|
width: number,
|
|
rows: number,
|
|
_observedById: ReadonlyMap<string, ObservableSession>,
|
|
): string[] {
|
|
if (!ref) return [theme.fg("dim", "Select an agent to inspect"), ...Array.from({ length: rows - 1 }, () => "")];
|
|
const observed = this.#observableFor(ref.id);
|
|
const progress = observed?.progress;
|
|
const metrics = this.#metricsFor(ref, observed);
|
|
const children = this.#childrenByParent.get(ref.id) ?? [];
|
|
const lines: string[] = [];
|
|
const add = (line = ""): void => {
|
|
lines.push(truncateToWidth(line, width));
|
|
};
|
|
const addWrapped = (text: string, maxRows = 2): void => {
|
|
for (const wrapped of wrapTextWithAnsi(sanitizeLine(text), Math.max(1, width)).slice(0, maxRows)) add(wrapped);
|
|
};
|
|
const section = (label: string, contentRows = 0): void => {
|
|
if (lines.length > 0 && lines.length + 1 + contentRows < rows) add();
|
|
add(theme.bold(theme.fg("accent", label)));
|
|
};
|
|
|
|
add(`${statusGlyph(ref.status)} ${theme.bold(sanitizeDisplayText(ref.displayName || ref.id))}`);
|
|
if (ref.displayName && ref.displayName !== ref.id) add(theme.fg("dim", sanitizeDisplayText(ref.id)));
|
|
const lifecycleDetails = [
|
|
metrics ? formatMetricDuration(metrics) : undefined,
|
|
`active ${formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000)))}`,
|
|
].filter(Boolean);
|
|
add(
|
|
`${statusText(ref.status, ref.status)}${theme.fg("dim", `${theme.sep.dot}${lifecycleDetails.join(theme.sep.dot)}`)}`,
|
|
);
|
|
const modelDetails: string[] = [];
|
|
const modelRole = progress?.modelRole ?? ref.history?.modelRole;
|
|
if (modelRole && this.#settings) modelDetails.push(formatRoleBadge(modelRole, this.#settings));
|
|
const badge = modelBadge(ref, observed);
|
|
if (badge) modelDetails.push(badge);
|
|
if (modelDetails.length > 0) add(modelDetails.join(theme.sep.dot));
|
|
|
|
const task = observed?.description ?? progress?.task ?? ref.activity;
|
|
if (task) {
|
|
section("Task");
|
|
addWrapped(task);
|
|
}
|
|
|
|
const current = progress?.currentTool
|
|
? `${progress.currentTool}${progress.currentToolArgs ? ` · ${progress.currentToolArgs}` : ""}`
|
|
: (progress?.lastIntent ?? ref.activity);
|
|
if (current) {
|
|
section("Current");
|
|
addWrapped(current);
|
|
if (progress?.retryState) {
|
|
add(theme.fg("warning", `retry ${progress.retryState.attempt}/${progress.retryState.maxAttempts}`));
|
|
}
|
|
}
|
|
|
|
section("Usage", 1);
|
|
if (metrics) {
|
|
addWrapped(formatMetrics(metrics), 3);
|
|
if (metrics.contextTokens !== undefined && metrics.contextWindow) {
|
|
add(contextGauge(metrics.contextTokens, metrics.contextWindow));
|
|
}
|
|
} else {
|
|
add(theme.fg("dim", "usage —"));
|
|
}
|
|
|
|
section("Lineage");
|
|
add(
|
|
`Spawned by ${sanitizeDisplayText(ref.parentId ?? MAIN_AGENT_ID)}${children.length > 0 ? ` · ${children.length} children` : ""}`,
|
|
);
|
|
if (children.length > 0) add(theme.fg("dim", formatChildIds(children, width)));
|
|
add(theme.fg("dim", `Registered ${formatLocalDateTimeWithOffset(new Date(ref.createdAt))}`));
|
|
|
|
section("Changes");
|
|
add(
|
|
theme.fg(
|
|
"dim",
|
|
ref.kind === "advisor" || ref.history?.readOnly
|
|
? "Read-only · 0 LoC"
|
|
: "Shared workspace · per-agent LoC not attributable",
|
|
),
|
|
);
|
|
const artifacts = ref.history;
|
|
if (artifacts?.outputPath) addWrapped(`Output ${shortenPath(artifacts.outputPath)}`);
|
|
if (artifacts?.patchPath) addWrapped(`Patch ${shortenPath(artifacts.patchPath)}`);
|
|
if (artifacts?.branchName) addWrapped(`Worktree branch ${artifacts.branchName}`);
|
|
|
|
const maxScroll = Math.max(0, lines.length - rows);
|
|
this.#detailScrollOffset = Math.min(this.#detailScrollOffset, maxScroll);
|
|
const visible = lines.slice(this.#detailScrollOffset, this.#detailScrollOffset + rows);
|
|
while (visible.length < rows) visible.push("");
|
|
return visible;
|
|
}
|
|
|
|
/**
|
|
* One agent entry keeps identity/model metadata on one line when it fits,
|
|
* then packs task and all five usage metrics together below. Narrow rows wrap
|
|
* only those dense secondary fields.
|
|
*/
|
|
#renderEntry(
|
|
ref: AgentRef,
|
|
selected: boolean,
|
|
width: number,
|
|
observed: ObservableSession | undefined,
|
|
hovered = false,
|
|
): string[] {
|
|
const max = Math.max(1, width);
|
|
const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
|
|
const depth = this.#viewMode === "tree" ? (this.#treeDepthById.get(ref.id) ?? 0) : 0;
|
|
const branch =
|
|
this.#viewMode === "tree"
|
|
? treeBranch(ref, max, this.#treeDepthById, this.#treeParentById, this.#treeLastSiblingById)
|
|
: "";
|
|
const id = sanitizeDisplayText(ref.id);
|
|
const styledId = selected ? theme.bold(theme.fg("accent", id)) : theme.bold(id);
|
|
const fields: string[] = [`${cursor} ${statusGlyph(ref.status)} ${branch}${styledId}`];
|
|
if (ref.displayName && ref.displayName !== ref.id) {
|
|
fields.push(theme.fg("dim", sanitizeDisplayText(ref.displayName)));
|
|
}
|
|
if (this.#viewMode === "roster" && ref.parentId && ref.parentId !== MAIN_AGENT_ID) {
|
|
fields.push(theme.fg("dim", `↳ ${sanitizeDisplayText(ref.parentId)}`));
|
|
}
|
|
if (ref.kind === "advisor") {
|
|
fields.push(theme.fg("warning", "read-only"));
|
|
}
|
|
const unread = this.#irc.unreadCount(ref.id);
|
|
if (unread > 0) {
|
|
fields.push(theme.fg("warning", `⧉ ${unread}`));
|
|
}
|
|
const left = fields.join(" ");
|
|
|
|
const meta: string[] = [];
|
|
const modelRole = observed?.progress?.modelRole ?? ref.history?.modelRole;
|
|
if (modelRole && this.#settings) {
|
|
meta.push(formatRoleBadge(modelRole, this.#settings));
|
|
}
|
|
const badge = modelBadge(ref, observed);
|
|
if (badge) meta.push(badge);
|
|
meta.push(theme.fg("dim", formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000)))));
|
|
const right = meta.join(theme.sep.dot);
|
|
|
|
const leftWidth = visibleWidth(left);
|
|
const rightWidth = visibleWidth(right);
|
|
const entry: string[] = [];
|
|
const detailIndent = Math.min(max - 1, 4 + depth * 2);
|
|
if (leftWidth + 2 + rightWidth <= max) {
|
|
entry.push(left + padding(max - leftWidth - rightWidth) + right);
|
|
} else {
|
|
entry.push(truncateToWidth(left.replace(/[\r\n]+/g, " "), max));
|
|
entry.push(`${padding(Math.max(0, detailIndent))}${truncateToWidth(right, Math.max(1, max - detailIndent))}`);
|
|
}
|
|
|
|
const metrics = this.#metricsFor(ref, observed);
|
|
const usage = metrics ? theme.fg("dim", formatMetrics(metrics)) : theme.fg("dim", "usage —");
|
|
const task = observed?.description ?? observed?.progress?.task ?? ref.activity;
|
|
const detailWidth = Math.max(1, max - detailIndent);
|
|
const details = task
|
|
? `${theme.fg("muted", sanitizeLine(task, detailWidth))}${theme.fg("dim", theme.sep.dot)}${usage}`
|
|
: usage;
|
|
for (const wrapped of wrapTextWithAnsi(details, detailWidth)) {
|
|
entry.push(`${padding(Math.max(0, detailIndent))}${wrapped}`);
|
|
}
|
|
if (!hovered) return entry;
|
|
return entry.map(lineRow => {
|
|
const rowWidth = visibleWidth(lineRow);
|
|
return theme.bg("selectedBg", rowWidth < max ? lineRow + padding(max - rowWidth) : lineRow);
|
|
});
|
|
}
|
|
|
|
#scrollDetails(direction: -1 | 1): void {
|
|
this.#detailScrollOffset = Math.max(0, this.#detailScrollOffset + direction * 5);
|
|
this.#requestRender();
|
|
}
|
|
|
|
#selectRow(index: number): void {
|
|
if (index !== this.#selectedRow) {
|
|
this.#detailScrollOffset = 0;
|
|
this.#detailAgentId = this.#rows[index]?.id;
|
|
}
|
|
this.#selectedRow = index;
|
|
}
|
|
|
|
handleWheel(delta: -1 | 1): void {
|
|
this.#hoveredRow = null;
|
|
if (this.#rows.length > 0) {
|
|
this.#selectRow(Math.max(0, Math.min(this.#selectedRow + delta, this.#rows.length - 1)));
|
|
}
|
|
this.#requestRender();
|
|
}
|
|
|
|
hitTest(line: number): number | undefined {
|
|
return this.#hitRows[line];
|
|
}
|
|
|
|
setHoverIndex(index: number | null): void {
|
|
if (index === this.#hoveredRow) return;
|
|
this.#hoveredRow = index;
|
|
this.#requestRender();
|
|
}
|
|
|
|
clickItem(index: number): void {
|
|
const selected = this.#rows[index];
|
|
if (!selected) return;
|
|
this.#hoveredRow = index;
|
|
this.#selectRow(index);
|
|
this.#requestRender();
|
|
this.#activateAgent(selected);
|
|
}
|
|
|
|
#handleTableInput(keyData: string): void {
|
|
if (matchesKey(keyData, "escape")) {
|
|
if (this.#narrowDetailsOpen && !this.#lastRenderWasSplit) {
|
|
this.#narrowDetailsOpen = false;
|
|
this.#requestRender();
|
|
} else {
|
|
this.#onDone();
|
|
}
|
|
return;
|
|
}
|
|
if ((matchesKey(keyData, "tab") || keyData === "\t") && !this.#lastRenderWasSplit) {
|
|
if (this.#rows.length > 0) this.#narrowDetailsOpen = !this.#narrowDetailsOpen;
|
|
this.#requestRender();
|
|
return;
|
|
}
|
|
if (this.#lastRenderWasSplit || this.#narrowDetailsOpen) {
|
|
if (matchesKey(keyData, "pageUp")) {
|
|
this.#scrollDetails(-1);
|
|
return;
|
|
}
|
|
if (matchesKey(keyData, "pageDown")) {
|
|
this.#scrollDetails(1);
|
|
return;
|
|
}
|
|
}
|
|
if (keyData === "t") {
|
|
this.#hoveredRow = null;
|
|
this.#viewMode = this.#viewMode === "roster" ? "tree" : "roster";
|
|
this.#refreshRows();
|
|
this.#requestRender();
|
|
return;
|
|
}
|
|
if (matchesKey(keyData, "left")) {
|
|
if (this.#narrowDetailsOpen && !this.#lastRenderWasSplit) {
|
|
this.#narrowDetailsOpen = false;
|
|
this.#requestRender();
|
|
return;
|
|
}
|
|
const now = Date.now();
|
|
if (now - this.#lastLeftTap < LEFT_TAP_WINDOW_MS) {
|
|
this.#lastLeftTap = 0;
|
|
this.#onDone();
|
|
} else {
|
|
this.#lastLeftTap = now;
|
|
}
|
|
return;
|
|
}
|
|
this.#hoveredRow = null;
|
|
if (matchesKey(keyData, "j") || matchesSelectDown(keyData)) {
|
|
if (this.#rows.length > 0) {
|
|
this.#selectRow(Math.min(this.#selectedRow + 1, this.#rows.length - 1));
|
|
}
|
|
this.#requestRender();
|
|
return;
|
|
}
|
|
if (matchesKey(keyData, "k") || matchesSelectUp(keyData)) {
|
|
if (this.#rows.length > 0) {
|
|
this.#selectRow(Math.max(this.#selectedRow - 1, 0));
|
|
}
|
|
this.#requestRender();
|
|
return;
|
|
}
|
|
if (matchesKey(keyData, "enter") || keyData === "\r" || keyData === "\n") {
|
|
const selected = this.#rows[this.#selectedRow];
|
|
if (selected) this.#activateAgent(selected);
|
|
return;
|
|
}
|
|
if (keyData === "r") {
|
|
this.#reviveSelected();
|
|
return;
|
|
}
|
|
if (keyData === "x") {
|
|
this.#killSelected();
|
|
return;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enter on a row: focus the main view on the agent's live session and close
|
|
* the hub. The transcript then renders through the regular session pipeline —
|
|
* exact parity by construction. Collab guests (no local sessions) keep the
|
|
* in-hub chat view.
|
|
*/
|
|
#activateAgent(ref: AgentRef): void {
|
|
this.#notice = undefined;
|
|
const focusAgent = this.#focusAgent;
|
|
// Advisor refs are read-only transcripts with no live/ revivable session;
|
|
// open the in-hub chat view (file-backed) instead of trying to focus one.
|
|
if (ref.kind === "advisor" || this.#remote || !focusAgent) {
|
|
this.openChat(ref.id);
|
|
return;
|
|
}
|
|
void (async () => {
|
|
try {
|
|
await focusAgent(ref.id); // ensureLive inside revives parked agents; no parking, no session files
|
|
this.#onDone();
|
|
} catch (error) {
|
|
this.#notice = error instanceof Error ? error.message : String(error);
|
|
this.#requestRender();
|
|
}
|
|
})();
|
|
}
|
|
|
|
#reviveSelected(): void {
|
|
const ref = this.#rows[this.#selectedRow];
|
|
if (!ref) return;
|
|
if (ref.kind === "advisor") {
|
|
this.#notice = `"${ref.id}" is a read-only advisor transcript — nothing to revive.`;
|
|
this.#requestRender();
|
|
return;
|
|
}
|
|
if (ref.status !== "parked") {
|
|
this.#notice = `Agent "${ref.id}" is ${ref.status} — only parked agents can be revived.`;
|
|
this.#requestRender();
|
|
return;
|
|
}
|
|
this.#notice = undefined;
|
|
if (this.#remote) {
|
|
this.#remote.revive(ref.id);
|
|
this.#requestRender();
|
|
return;
|
|
}
|
|
// Fire-and-forget; failures surface as an inline notice
|
|
this.#lifecycle()
|
|
.ensureLive(ref.id)
|
|
.catch((error: unknown) => {
|
|
this.#notice = error instanceof Error ? error.message : String(error);
|
|
this.#requestRender();
|
|
});
|
|
this.#requestRender();
|
|
}
|
|
|
|
#killSelected(): void {
|
|
const ref = this.#rows[this.#selectedRow];
|
|
if (!ref) return;
|
|
if (ref.kind === "advisor") {
|
|
this.#notice = `"${ref.id}" is a read-only advisor transcript — cannot be killed.`;
|
|
this.#requestRender();
|
|
return;
|
|
}
|
|
this.#notice = undefined;
|
|
if (this.#remote) {
|
|
this.#remote.kill(ref.id);
|
|
this.#refreshRows();
|
|
this.#requestRender();
|
|
return;
|
|
}
|
|
void (async () => {
|
|
try {
|
|
if (ref.status === "running" && ref.session) {
|
|
await ref.session.abort({ reason: USER_INTERRUPT_LABEL });
|
|
}
|
|
await this.#lifecycle().release(ref.id, ref, { tombstone: true });
|
|
} catch (error) {
|
|
logger.warn("Agent hub: kill failed", { id: ref.id, error: String(error) });
|
|
this.#notice = error instanceof Error ? error.message : String(error);
|
|
}
|
|
this.#refreshRows();
|
|
this.#requestRender();
|
|
})();
|
|
}
|
|
}
|