/* * SPDX-License-Identifier: MIT * Structural interaction pattern adapted from ZiChuanLan/pi-subagents-lite, * itself forked from @tintinweb/pi-subagents v0.14.2. * Copyright (c) 2026 tintinweb * Copyright (c) 2026 lan * Modified for pi-amq: render-only authoritative projection; no AgentManager. */ import { isBackspace, isCharacter, isDown, isEnter, isEscape, isReleaseEvent, isUp, printableText, } from "./tui-keys.js"; import { borderedSections, wrapOverlayLines } from "./tui-overlay.js"; export type FleetLifecycle = "idle" | "busy" | "paused" | "review" | "offline" | "ambiguous" | "done" | "failed" | "cancelled"; export interface FleetAgentRecord { readonly id: string; readonly handle: string; readonly role: string; readonly model: string; readonly state: FleetLifecycle; readonly spend: number | null; readonly lastActivity: string; readonly mode: "main" | "rpc" | "json"; readonly generation?: string; readonly conversation?: readonly string[]; readonly details?: FleetWorkerDetails; } export interface FleetWorkerDetails { readonly attemptState: string; readonly tierDegraded: boolean; readonly peerPending: number; readonly waitReason: string | null; readonly waitCorrelation: string | null; readonly silentStall: boolean; readonly noProgressStall: boolean; readonly blockers: readonly string[]; readonly awaitingReportReview?: boolean; } export interface FleetProjectionSource { snapshot(): readonly FleetAgentRecord[]; subscribe(listener: () => void): () => void; } export interface FleetListStyle { readonly accent: (value: string) => string; readonly muted: (value: string) => string; readonly dim: (value: string) => string; readonly success: (value: string) => string; readonly warning: (value: string) => string; readonly error: (value: string) => string; readonly bold: (value: string) => string; } export interface FleetListPresentation { readonly style?: FleetListStyle; readonly now?: () => number; } export interface FleetVisibilityOptions { readonly now?: number; readonly cancelledRetentionMs?: number; } const DEFAULT_CANCELLED_RETENTION_MS = 5 * 60 * 1_000; export function projectVisibleFleet( main: FleetAgentRecord | undefined, workers: readonly FleetAgentRecord[], options: FleetVisibilityOptions = {}, ): readonly FleetAgentRecord[] { const visibleWorkers = workers.filter((worker) => !isExpiredCancelled(worker, options)); if (visibleWorkers.length === 0) return []; return main ? [main, ...visibleWorkers] : [...visibleWorkers]; } export function adjacentFleetRecord( records: readonly FleetAgentRecord[], recordId: string, delta: -1 | 1, ): FleetAgentRecord | undefined { if (records.length === 0) return undefined; const current = records.findIndex((record) => record.id === recordId); if (current === -1) return records[0]; const next = Math.max(0, Math.min(records.length - 1, current + delta)); return records[next]; } export class MutableFleetProjectionSource implements FleetProjectionSource { #records: readonly FleetAgentRecord[]; readonly #listeners = new Set<() => void>(); constructor(initial: readonly FleetAgentRecord[]) { this.#records = [...initial]; } snapshot(): readonly FleetAgentRecord[] { return this.#records.map((record) => ({ ...record })); } update(records: readonly FleetAgentRecord[]): void { this.#records = [...records]; for (const listener of this.#listeners) listener(); } subscribe(listener: () => void): () => void { this.#listeners.add(listener); return () => this.#listeners.delete(listener); } } export class FleetListModel { readonly #source: FleetProjectionSource; readonly #invalidate: () => void; readonly #style: FleetListStyle; readonly #now: () => number; #selected = 0; #selectedRecordId: string | undefined; #open = false; #invalidations = 0; readonly #unsubscribe: () => void; constructor( source: FleetProjectionSource, invalidate: () => void, presentation: FleetListPresentation = {}, ) { this.#source = source; this.#invalidate = invalidate; this.#style = presentation.style ?? PLAIN_STYLE; this.#now = presentation.now ?? Date.now; this.#selectedRecordId = source.snapshot()[0]?.id; this.#unsubscribe = source.subscribe(() => { const records = this.#source.snapshot(); const preserved = this.#selectedRecordId === undefined ? -1 : records.findIndex((record) => record.id === this.#selectedRecordId); if (preserved !== -1) this.#selected = preserved; else this.#selected = Math.min(this.#selected, Math.max(0, records.length - 1)); this.#selectedRecordId = records[this.#selected]?.id; this.#invalidations += 1; this.#invalidate(); }); } get invalidations(): number { return this.#invalidations; } get selected(): FleetAgentRecord | undefined { return this.#source.snapshot()[this.#selected]; } get open(): boolean { return this.#open; } focus(): void { if (this.#open) return; this.#open = true; this.#invalidate(); } close(): void { if (!this.#open) return; this.#open = false; this.#invalidate(); } move(delta: -1 | 1): void { const count = this.#source.snapshot().length; if (count === 0) return; const next = Math.max(0, Math.min(count - 1, this.#selected + delta)); if (next === this.#selected) return; this.#selected = next; this.#selectedRecordId = this.#source.snapshot()[next]?.id; this.#invalidate(); } select(recordId: string): boolean { const index = this.#source.snapshot().findIndex((record) => record.id === recordId); if (index === -1) return false; this.#selectedRecordId = recordId; if (index === this.#selected) return true; this.#selected = index; this.#invalidate(); return true; } render(width = 120): string[] { const records = this.#source.snapshot(); if (records.length === 0) return []; const workerCount = records.filter((record) => record.mode !== "main").length; return [ `${this.#style.accent(this.#style.bold("Fleet"))}${this.#style.muted(` · ${workerCount} ${workerCount === 1 ? "worker" : "workers"}`)}`, ...records.flatMap((record, index) => this.#renderRecord(record, index, width)), ]; } #renderRecord(record: FleetAgentRecord, index: number, width: number): string[] { const selected = this.#open && index === this.#selected; const appearance = stateAppearance(record.state, this.#style); const label = record.mode === "main" ? "main" : record.role; const handle = record.mode === "main" || record.handle === label ? "" : ` · ${shortHandle(record.handle)}`; const firstPrefix = selected ? this.#style.accent("›") : " "; const styledLabel = selected ? this.#style.accent(this.#style.bold(label)) : this.#style.bold(label); const first = `${firstPrefix} ${appearance.icon} ${styledLabel}${this.#style.dim(handle)}`; const activity = relativeActivity(record.lastActivity, this.#now()); const spend = record.spend === null ? "" : ` · $${record.spend.toFixed(4)}`; const suffix = ` · ${activity}${spend}`; const modelWidth = Math.max(8, Math.min(36, width - suffix.length - record.state.length - 10)); const second = ` ${this.#style.muted(truncateText(record.model, modelWidth))}` + this.#style.dim(" · ") + appearance.state + this.#style.dim(suffix); return [first, second]; } dispose(): void { this.#unsubscribe(); } } export type FleetInputAction = | { readonly consume: false } | { readonly consume: true; readonly action: "focus" | "move" | "detail" | "close" }; export function handleFleetInput(options: { readonly data: string; readonly editorText: string; readonly list: FleetListModel; readonly detailOpen?: boolean; }): FleetInputAction { // Raw TUI listeners run before the focused overlay. Yield while detail owns focus so its // navigation does not mutate the list underneath it or get consumed before delivery. if (options.detailOpen) return { consume: false }; if (options.editorText !== "") return { consume: false }; // One keystroke arrives twice on this raw hook: press and release. Acting on both moves the // selection two rows per press, which reads as an unusable list. if (isReleaseEvent(options.data)) return { consume: false }; const { data, list } = options; if (!list.open && isDown(data)) { if (!list.selected) return { consume: false }; list.focus(); return { consume: true, action: "focus" }; } if (!list.open) return { consume: false }; if (isDown(data)) { list.move(1); return { consume: true, action: "move" }; } if (isUp(data)) { list.move(-1); return { consume: true, action: "move" }; } if (isEnter(data)) return { consume: true, action: "detail" }; if (isEscape(data)) { list.close(); return { consume: true, action: "close" }; } return { consume: false }; } export function renderFleetDetail(record: FleetAgentRecord): string[] { return [ `${record.handle}/${record.role} (${record.state})`, `model=${record.model} activity=${record.lastActivity}`, ...(record.mode === "json" ? ["one-shot worker, cannot steer"] : []), "", ...(record.conversation ?? ["No conversation projection available."]), ]; } export function canSteerFleetRecord( record: FleetAgentRecord, currentGeneration: string | undefined, ): boolean { return record.mode === "rpc" && record.generation !== undefined && record.generation === currentGeneration; } export type FleetControlStatus = "idle" | "composing" | "requested" | "observed" | "indeterminate"; interface FleetDetailSections { readonly header: readonly string[]; readonly transcript: readonly string[]; readonly footer: readonly string[]; } export class FleetDetailController { #recordId: string; #generation: string | undefined; readonly #resolve: (recordId: string) => FleetAgentRecord | undefined; readonly #steer: (recordId: string, generation: string, message: string) => Promise; readonly #cancel: (recordId: string, generation: string) => Promise; readonly #move: (recordId: string, delta: -1 | 1) => FleetAgentRecord | undefined; readonly #onSelectionChange: (record: FleetAgentRecord) => void; #composer = ""; #status: FleetControlStatus = "idle"; #diagnostic = ""; #xArmed = false; constructor(options: { readonly record: FleetAgentRecord; readonly resolve: (recordId: string) => FleetAgentRecord | undefined; readonly steer: (recordId: string, generation: string, message: string) => Promise; readonly cancel: (recordId: string, generation: string) => Promise; readonly move?: (recordId: string, delta: -1 | 1) => FleetAgentRecord | undefined; readonly onSelectionChange?: (record: FleetAgentRecord) => void; }) { this.#recordId = options.record.id; this.#generation = options.record.generation; this.#resolve = options.resolve; this.#steer = options.steer; this.#cancel = options.cancel; this.#move = options.move ?? (() => undefined); this.#onSelectionChange = options.onSelectionChange ?? (() => {}); } get status(): FleetControlStatus { return this.#status; } get currentRecord(): FleetAgentRecord | undefined { return this.#resolve(this.#recordId); } render(): string[] { const sections = this.renderSections(); return [...sections.header, ...sections.transcript, ...sections.footer]; } renderSections(): FleetDetailSections { const record = this.currentRecord; if (!record) { return { header: ["Worker projection disappeared."], transcript: [], footer: ["control=indeterminate"], }; } const controls = record.mode === "rpc" ? ["↑/↓: switch worker · Enter: compose steer · x,x: stop · Esc: close"] : ["one-shot worker, cannot steer", "↑/↓: switch worker · x,x: stop · Esc: close"]; const details = record.details; return { header: [ `Worker: ${record.handle}`, `Role: ${record.role}`, `Mode: ${record.mode === "rpc" ? "interactive RPC" : "one-shot"}`, `Model: ${record.model}`, `State: ${record.state}`, `Activity: ${record.lastActivity}`, ...(record.spend === null ? [] : [`Spend: $${record.spend.toFixed(4)}`]), ...(details && details.attemptState !== record.state ? [`Attempt: ${details.attemptState}`] : []), ...(details?.tierDegraded ? ["Model fallback: tier degraded"] : []), ...(details && details.peerPending > 0 ? [`Peer messages pending: ${details.peerPending}`] : []), ...(details?.waitReason ? [`Wait: ${details.waitReason}${details.waitCorrelation ? ` · correlation: ${details.waitCorrelation}` : ""}`] : []), ...(details?.silentStall ? ["Stall: silent"] : []), ...(details?.noProgressStall ? ["Stall: no progress"] : []), ...(details?.awaitingReportReview ? ["Review: completed, report format mismatch"] : []), ...(details && details.blockers.length > 0 ? [`Blockers: ${details.blockers.join(", ")}`] : []), ], transcript: [...(record.conversation ?? ["No conversation projection available."])], footer: [ ...controls, ...(this.#status === "composing" ? [`steer> ${this.#composer}`] : []), ...(this.#status === "idle" ? [] : [`control=${this.#status}${this.#diagnostic ? `: ${this.#diagnostic}` : ""}`]), ], }; } handleInput(data: string): { readonly close: boolean; readonly consumed: boolean } { if (isEscape(data)) { if (this.#status === "composing") { this.#composer = ""; this.#status = "idle"; return { close: false, consumed: true }; } return { close: true, consumed: true }; } if (this.#status === "composing") { if (isEnter(data)) { const message = this.#composer.trim(); this.#composer = ""; if (message) void this.#request("steer", message); else this.#status = "idle"; return { close: false, consumed: true }; } if (isBackspace(data)) this.#composer = this.#composer.slice(0, -1); else { const text = printableText(data); if (text !== undefined) this.#composer += text; } return { close: false, consumed: true }; } if (isDown(data) || isUp(data)) { this.#navigate(isDown(data) ? 1 : -1); return { close: false, consumed: true }; } if (isEnter(data)) { const record = this.#resolve(this.#recordId); if (record?.mode === "rpc" && this.#isGenerationCurrent(record)) { this.#status = "composing"; } else { this.#status = "indeterminate"; this.#diagnostic = record?.mode === "json" ? "one-shot worker, cannot steer" : "worker generation changed"; } return { close: false, consumed: true }; } if (isCharacter(data, "x")) { if (this.#xArmed) { this.#xArmed = false; void this.#request("cancel"); } else { this.#xArmed = true; } return { close: false, consumed: true }; } this.#xArmed = false; return { close: false, consumed: false }; } #navigate(delta: -1 | 1): void { const next = this.#move(this.#recordId, delta); if (!next || next.id === this.#recordId) return; this.#recordId = next.id; this.#generation = next.generation; this.#composer = ""; this.#status = "idle"; this.#diagnostic = ""; this.#xArmed = false; this.#onSelectionChange(next); } #isGenerationCurrent(record: FleetAgentRecord): record is FleetAgentRecord & { generation: string } { return this.#generation !== undefined && record.generation === this.#generation; } async #request(kind: "steer" | "cancel", message?: string): Promise { const record = this.#resolve(this.#recordId); if (!record || !this.#isGenerationCurrent(record)) { this.#status = "indeterminate"; this.#diagnostic = "worker generation changed"; return; } this.#status = "requested"; this.#diagnostic = kind; try { if (kind === "steer") await this.#steer(this.#recordId, record.generation, message!); else await this.#cancel(this.#recordId, record.generation); this.#status = "observed"; } catch (error) { this.#status = "indeterminate"; this.#diagnostic = error instanceof Error ? error.message : String(error); } } } export function handleFleetDetailOverlayInput(options: { readonly data: string; readonly controller: FleetDetailController; readonly close: () => void; readonly invalidate: () => void; }): void { const result = options.controller.handleInput(options.data); if (result.close) { // Release raw-input ownership before Pi resolves and hides the overlay. Kitty may deliver // the Esc release and the next arrow press in this same synchronous terminal-input batch. options.close(); } else if (result.consumed) { options.invalidate(); } } const FLEET_DETAIL_SECTION_ROWS = 20; export function renderFleetDetailOverlay( controller: FleetDetailController, width: number, ): string[] { const frameWidth = Math.max(20, width); const contentWidth = Math.max(1, frameWidth - 4); const handle = controller.currentRecord?.handle ?? "unavailable"; const sections = controller.renderSections(); const header = wrapOverlayLines(sections.header, contentWidth); const footer = wrapOverlayLines(sections.footer, contentWidth); const transcript = wrapOverlayLines(sections.transcript, contentWidth); const transcriptRows = Math.max(1, FLEET_DETAIL_SECTION_ROWS - header.length - footer.length); const hiddenRows = Math.max(0, transcript.length - transcriptRows); const visibleTranscript = hiddenRows === 0 ? transcript : [ `… ${hiddenRows + 1} earlier lines`, ...transcript.slice(-(transcriptRows - 1)), ]; const content = [...header, ...visibleTranscript]; while (content.length < FLEET_DETAIL_SECTION_ROWS - footer.length) content.push(""); const visibleTranscriptRows = content.slice(header.length); return borderedSections( `Worker detail · ${handle}`, [ { lines: header }, { label: "Conversation", lines: visibleTranscriptRows }, { label: "Controls", lines: footer }, ], frameWidth, ); } const identity = (value: string): string => value; const PLAIN_STYLE: FleetListStyle = { accent: identity, muted: identity, dim: identity, success: identity, warning: identity, error: identity, bold: identity, }; function stateAppearance( state: FleetLifecycle, style: FleetListStyle, ): { readonly icon: string; readonly state: string } { switch (state) { case "busy": return { icon: style.success("●"), state: style.success(state) }; case "idle": return { icon: style.muted("○"), state: style.muted(state) }; case "paused": return { icon: style.warning("Ⅱ"), state: style.warning(state) }; case "review": return { icon: style.warning("?"), state: style.warning(state) }; case "offline": return { icon: style.dim("◇"), state: style.dim(state) }; case "done": return { icon: style.success("✓"), state: style.success(state) }; case "failed": return { icon: style.error("✕"), state: style.error(state) }; case "cancelled": return { icon: style.warning("◌"), state: style.warning(state) }; case "ambiguous": return { icon: style.error("!"), state: style.error(state) }; } } function shortHandle(handle: string): string { return truncateText(handle, 10); } function truncateText(value: string, maximum: number): string { if (value.length <= maximum) return value; return `${value.slice(0, Math.max(1, maximum - 1))}…`; } function isExpiredCancelled(record: FleetAgentRecord, options: FleetVisibilityOptions): boolean { if (record.state !== "cancelled") return false; const now = options.now ?? Date.now(); const retentionMs = options.cancelledRetentionMs ?? DEFAULT_CANCELLED_RETENTION_MS; const timestamp = Date.parse(record.lastActivity); return Number.isFinite(timestamp) && now - timestamp >= retentionMs; } function relativeActivity(value: string, now: number): string { const timestamp = Date.parse(value); if (!Number.isFinite(timestamp)) return "unknown"; const elapsedSeconds = Math.max(0, Math.floor((now - timestamp) / 1_000)); if (elapsedSeconds < 5) return "now"; if (elapsedSeconds < 60) return `${elapsedSeconds}s ago`; const elapsedMinutes = Math.floor(elapsedSeconds / 60); if (elapsedMinutes < 60) return `${elapsedMinutes}m ago`; const elapsedHours = Math.floor(elapsedMinutes / 60); if (elapsedHours < 24) return `${elapsedHours}h ago`; return `${Math.floor(elapsedHours / 24)}d ago`; }