665 lines
25 KiB
TypeScript
665 lines
25 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. Select with j/k, Enter opens a chat,
|
||
* `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, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||
import { Container, Ellipsis, matchesKey, type OverlayHandle, padding, type TUI, visibleWidth } from "@oh-my-pi/pi-tui";
|
||
import { formatAge, getProjectDir, logger } from "@oh-my-pi/pi-utils";
|
||
import type { KeyId } from "../../config/keybindings";
|
||
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 { parseThinkingLevel } from "../../thinking";
|
||
import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
|
||
import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
|
||
import { theme } from "../theme/theme";
|
||
import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
|
||
import { AgentTranscriptViewer } from "./agent-transcript-viewer";
|
||
import { DynamicBorder } from "./dynamic-border";
|
||
|
||
/** 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;
|
||
|
||
/** Compute the max content width for the current terminal, accounting for chrome. */
|
||
function contentWidth(): number {
|
||
return Math.max(TRUNCATE_LENGTHS.SHORT, (process.stdout.columns || 80) - 6);
|
||
}
|
||
|
||
/** Sanitize a line for TUI display: replace tabs, then truncate to viewport width. */
|
||
function sanitizeLine(text: string, maxWidth?: number): string {
|
||
const singleLine = replaceTabs(text).replace(/[\r\n]+/g, " ");
|
||
return truncateToWidth(singleLine, maxWidth ?? contentWidth());
|
||
}
|
||
|
||
function clampHubLine(line: string, width: number): string {
|
||
return truncateToWidth(line.replace(/[\r\n]+/g, " "), Math.max(1, width - 2), Ellipsis.Omit);
|
||
}
|
||
|
||
const STATUS_ORDER: Record<AgentStatus, number> = { running: 0, idle: 1, parked: 2, aborted: 3 };
|
||
|
||
/** Status glyph, colored per theme status conventions. The title-line counts spell out the words. */
|
||
function statusGlyph(status: AgentStatus): string {
|
||
switch (status) {
|
||
case "running":
|
||
return theme.fg("accent", theme.status.running);
|
||
case "idle":
|
||
return theme.fg("success", theme.status.enabled);
|
||
case "parked":
|
||
return theme.fg("muted", theme.status.shadowed);
|
||
case "aborted":
|
||
return theme.fg("error", theme.status.aborted);
|
||
}
|
||
}
|
||
|
||
/** Model id + thinking level (`sonnet-4-6 ◒ high`), level colored per theme. */
|
||
function formatModelBadge(modelId: string, level: ThinkingLevel | undefined): string {
|
||
const model = theme.fg("muted", replaceTabs(modelId));
|
||
if (!level || level === ThinkingLevel.Off || level === ThinkingLevel.Inherit) return model;
|
||
const display = theme.thinking[level as keyof typeof theme.thinking] ?? level;
|
||
return `${model} ${theme.getThinkingBorderColor(level)(display)}`;
|
||
}
|
||
|
||
/** Format a resolved selector, preserving provider identity when requested. */
|
||
function formatResolvedModelBadge(resolved: string, preserveProvider = false): string {
|
||
// Model ids may themselves contain colons (`qwen3:14b`), so only treat the
|
||
// suffix as a thinking level when it parses as one.
|
||
const colon = resolved.lastIndexOf(":");
|
||
const level = colon >= 0 ? parseThinkingLevel(resolved.slice(colon + 1)) : undefined;
|
||
const selector = level !== undefined ? resolved.slice(0, colon) : resolved;
|
||
const label = preserveProvider ? selector : selector.slice(selector.indexOf("/") + 1);
|
||
return formatModelBadge(label, level);
|
||
}
|
||
|
||
/**
|
||
* Active model + reasoning level for a hub row: live session state when the
|
||
* agent is attached, else the executor-reported `resolvedModel` selector
|
||
* (`provider/id`, optionally `:<level>`). Active retry fallbacks retain their
|
||
* provider and carry an explicit marker. Undefined when no model is known
|
||
* (e.g. a parked historical agent restored from disk).
|
||
*/
|
||
function modelBadge(ref: AgentRef, observed: ObservableSession | undefined): string | undefined {
|
||
const progress = observed?.progress;
|
||
// Prefer the live session's own resolved fallback selector; else honor the
|
||
// executor-reported fallback flag. The latter covers observer-only rows (no
|
||
// live session) AND live rows whose fallback armed no session retry state —
|
||
// e.g. the Fireworks Fast → base degrade, which emits `retry_fallback_applied`
|
||
// without populating `#activeRetryFallback`, so `retryFallbackModel` is undefined.
|
||
const fallbackSelector =
|
||
ref.session?.retryFallbackModel ?? (progress?.resolvedModelIsFallback ? progress.resolvedModel : undefined);
|
||
if (fallbackSelector) {
|
||
return `${theme.fg("warning", "fallback →")} ${formatResolvedModelBadge(fallbackSelector, true)}`;
|
||
}
|
||
const model = ref.session?.model;
|
||
if (model) {
|
||
const level = model.thinking ? ref.session?.thinkingLevel : undefined;
|
||
return formatModelBadge(model.id, level);
|
||
}
|
||
const resolved = progress?.resolvedModel;
|
||
return resolved ? formatResolvedModelBadge(resolved) : undefined;
|
||
}
|
||
|
||
/** 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;
|
||
/** 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;
|
||
/** 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 {
|
||
#registry: AgentRegistry;
|
||
#observers: SessionObserverRegistry;
|
||
#irc: IrcBus;
|
||
#lifecycle: () => AgentLifecycleManager;
|
||
#onDone: () => void;
|
||
#requestRender: () => void;
|
||
#hubKeys: KeyId[];
|
||
#unsubscribers: Array<() => void> = [];
|
||
#ageTimer: NodeJS.Timeout | undefined;
|
||
#dataChangeTimer?: NodeJS.Timeout;
|
||
#remote: AgentHubRemote | undefined;
|
||
/** Resolves after persisted historical subagents have been registered and rows refreshed. */
|
||
readonly persistedSubagentsReady: Promise<void>;
|
||
|
||
// Table state
|
||
#rows: AgentRef[] = [];
|
||
#statusCounts: Record<AgentStatus, number> = { running: 0, idle: 0, parked: 0, aborted: 0 };
|
||
#selectedRow = 0;
|
||
#notice: string | undefined;
|
||
/** Captured row order from the first refresh; keeps the hub stable while open. */
|
||
#rowOrder: Map<string, number> | undefined;
|
||
/** Double-tap window state for the table's left-left "close hub" gesture. */
|
||
#lastLeftTap = 0;
|
||
|
||
// Transcript-viewer launch deps (passed through to AgentTranscriptViewer).
|
||
#ui: TUI;
|
||
#getTool: ((name: string) => AgentTool | undefined) | 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.#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.#ui =
|
||
deps.ui ??
|
||
({
|
||
requestRender: () => deps.requestRender(),
|
||
requestComponentRender: () => deps.requestRender(),
|
||
} as unknown as TUI);
|
||
this.#getTool = deps.getTool;
|
||
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(() => this.#requestRender(), AGE_TICK_MS);
|
||
this.#ageTimer.unref?.();
|
||
|
||
this.persistedSubagentsReady = this.#remote
|
||
? Promise.resolve()
|
||
: registerPersistedSubagents(this.#registry, deps.sessionFile)
|
||
.catch((error: unknown) => {
|
||
logger.warn("Failed to register persisted subagents", { error });
|
||
})
|
||
.then(() => {
|
||
this.#refreshRows();
|
||
})
|
||
.finally(() => 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 {
|
||
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[] {
|
||
return this.#renderTable(width).map(line => clampHubLine(line, width));
|
||
}
|
||
|
||
handleInput(keyData: string): void {
|
||
// 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.#registry.get(id)) return;
|
||
if (typeof this.#ui.showOverlay !== "function") return;
|
||
this.#closeTranscriptOverlay();
|
||
this.#notice = undefined;
|
||
const 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,
|
||
getMessageRenderer: this.#getMessageRenderer,
|
||
cwd: this.#cwd,
|
||
hideThinkingBlock: this.#hideThinkingBlock,
|
||
proseOnlyThinking: this.#proseOnlyThinking,
|
||
expandKeys: this.#expandKeys,
|
||
hubKeys: this.#hubKeys,
|
||
requestRender: this.#requestRender,
|
||
onClose: () => this.#closeTranscriptOverlay(),
|
||
onHubClose: () => {
|
||
this.#closeTranscriptOverlay();
|
||
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(): void {
|
||
this.#transcriptOverlay?.hide();
|
||
this.#transcriptOverlay = undefined;
|
||
this.#transcriptViewer?.dispose();
|
||
this.#transcriptViewer = undefined;
|
||
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);
|
||
const counts: Record<AgentStatus, number> = { running: 0, idle: 0, parked: 0, aborted: 0 };
|
||
for (const ref of refs) counts[ref.status]++;
|
||
this.#statusCounts = counts;
|
||
|
||
if (!this.#rowOrder) {
|
||
// First refresh (usually the constructor): order by status, then recency.
|
||
this.#rows = refs.sort(
|
||
(a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || b.lastActivity - a.lastActivity,
|
||
);
|
||
this.#rowOrder = new Map(this.#rows.map((ref, i) => [ref.id, i]));
|
||
} else {
|
||
// After the hub is open, freeze the relative order so keyboard selection
|
||
// does not jump around as agents heartbeat or update activity. New agents
|
||
// are appended at the end and then stay put.
|
||
this.#rows = refs.sort((a, b) => {
|
||
const statusDiff = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
|
||
if (statusDiff !== 0) return statusDiff;
|
||
const aOrder = this.#rowOrder!.get(a.id) ?? Number.MAX_SAFE_INTEGER;
|
||
const bOrder = this.#rowOrder!.get(b.id) ?? Number.MAX_SAFE_INTEGER;
|
||
return aOrder - bOrder;
|
||
});
|
||
for (const ref of this.#rows) {
|
||
if (!this.#rowOrder.has(ref.id)) {
|
||
this.#rowOrder.set(ref.id, this.#rowOrder.size);
|
||
}
|
||
}
|
||
}
|
||
|
||
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));
|
||
}
|
||
|
||
#observableFor(id: string): ObservableSession | undefined {
|
||
return this.#observers.getSession(id);
|
||
}
|
||
|
||
// ========================================================================
|
||
// Table view
|
||
// ========================================================================
|
||
|
||
#renderTable(width: number): string[] {
|
||
const lines: string[] = [];
|
||
lines.push(...new DynamicBorder().render(width));
|
||
const counts = this.#statusSummary();
|
||
lines.push(` ${theme.fg("accent", "Agent Hub")}${counts ? theme.fg("dim", `${theme.sep.dot}${counts}`) : ""}`);
|
||
lines.push(...new DynamicBorder().render(width));
|
||
|
||
if (this.#rows.length === 0) {
|
||
lines.push(` ${theme.fg("dim", "no subagents yet — task spawns appear here")}`);
|
||
} else {
|
||
const termHeight = process.stdout.rows || 40;
|
||
// Chrome: 2 borders + title + notice? + blank + hints + border
|
||
const budget = Math.max(4, termHeight - 7 - (this.#notice ? 1 : 0));
|
||
// Render outward from the selection and stop once the viewport is full.
|
||
// Cache rendered entries so a boundary probe is not paid twice when the
|
||
// same index is later accepted into the window.
|
||
const rendered = new Map<number, string[]>();
|
||
const entryAt = (index: number): string[] => {
|
||
const cached = rendered.get(index);
|
||
if (cached) return cached;
|
||
const ref = this.#rows[index];
|
||
if (!ref) return [];
|
||
const entry = this.#renderEntry(ref, index === this.#selectedRow, width);
|
||
rendered.set(index, entry);
|
||
return entry;
|
||
};
|
||
let start = this.#selectedRow;
|
||
let end = this.#selectedRow + 1;
|
||
let used = entryAt(start).length;
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
if (start > 0) {
|
||
lines.push(` ${theme.fg("dim", `… ${start} more`)}`);
|
||
}
|
||
for (let i = start; i < end; i++) {
|
||
lines.push(...entryAt(i));
|
||
}
|
||
if (end < this.#rows.length) {
|
||
lines.push(` ${theme.fg("dim", `… ${this.#rows.length - end} more`)}`);
|
||
}
|
||
}
|
||
|
||
if (this.#notice) {
|
||
lines.push(` ${theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width - 2)))}`);
|
||
}
|
||
lines.push("");
|
||
lines.push(` ${theme.fg("dim", "j/k:select Enter:open r:revive x:kill Esc/←←:close")}`);
|
||
lines.push(...new DynamicBorder().render(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(`${count} ${status}`);
|
||
}
|
||
return parts.join(theme.sep.dot);
|
||
}
|
||
|
||
/**
|
||
* One agent entry, 1-2 lines:
|
||
* `❯ ⟳ Name type ↳ parent ⧉ 2 ········ model ◒ level · age` — identity
|
||
* left, metadata right-aligned (inlined when the terminal is too narrow) —
|
||
* plus an indented dim task line when the agent's work is known.
|
||
*/
|
||
#renderEntry(ref: AgentRef, selected: boolean, width: number): string[] {
|
||
const max = Math.max(1, width - 2);
|
||
const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
|
||
const fields: string[] = [`${cursor} ${statusGlyph(ref.status)} ${theme.bold(replaceTabs(ref.id))}`];
|
||
if (ref.displayName && ref.displayName !== ref.id) {
|
||
fields.push(theme.fg("dim", replaceTabs(ref.displayName)));
|
||
}
|
||
if (ref.parentId && ref.parentId !== MAIN_AGENT_ID) {
|
||
fields.push(theme.fg("dim", `↳ ${replaceTabs(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 observed = this.#observableFor(ref.id);
|
||
const meta: string[] = [];
|
||
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 line =
|
||
leftWidth + 2 + rightWidth <= max
|
||
? left + padding(max - leftWidth - rightWidth) + right
|
||
: truncateToWidth(`${left} ${right}`.replace(/[\r\n]+/g, " "), max);
|
||
const entry = [line];
|
||
|
||
const task = observed?.description ?? observed?.progress?.task ?? ref.activity;
|
||
if (task) {
|
||
entry.push(` ${theme.fg("muted", sanitizeLine(task, Math.max(10, max - 5)))}`);
|
||
}
|
||
return entry;
|
||
}
|
||
|
||
#handleTableInput(keyData: string): void {
|
||
if (matchesKey(keyData, "escape")) {
|
||
this.#onDone();
|
||
return;
|
||
}
|
||
if (matchesKey(keyData, "left")) {
|
||
const now = Date.now();
|
||
if (now - this.#lastLeftTap < LEFT_TAP_WINDOW_MS) {
|
||
this.#lastLeftTap = 0;
|
||
this.#onDone();
|
||
} else {
|
||
this.#lastLeftTap = now;
|
||
}
|
||
return;
|
||
}
|
||
if (matchesKey(keyData, "j") || matchesSelectDown(keyData)) {
|
||
if (this.#rows.length > 0) {
|
||
this.#selectedRow = Math.min(this.#selectedRow + 1, this.#rows.length - 1);
|
||
}
|
||
this.#requestRender();
|
||
return;
|
||
}
|
||
if (matchesKey(keyData, "k") || matchesSelectUp(keyData)) {
|
||
if (this.#rows.length > 0) {
|
||
this.#selectedRow = 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();
|
||
})();
|
||
}
|
||
}
|