chore: merge upstream/main into feat/secret-friendly-names

This commit is contained in:
Mathews-Tom
2026-07-06 19:58:29 +05:30
69 changed files with 1672 additions and 212 deletions
@@ -42,6 +42,8 @@ describe("SkillMessageComponent", () => {
// New look: an icon-tagged "skill" header with the name and a single meta line.
expect(text).toContain("skill");
expect(text).toContain("atomic-commit");
expect(text).toContain("skill atomic-commit");
expect(text).not.toContain("skill atomic-commit");
expect(text).toContain("88 lines");
// The card is drawn with an outline.
@@ -34,6 +34,7 @@ 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;
@@ -198,6 +199,7 @@ export class AgentHubOverlayComponent extends Container {
#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>;
@@ -251,8 +253,8 @@ export class AgentHubOverlayComponent extends Container {
this.#expandKeys = deps.expandKeys ?? ["ctrl+o"];
this.#focusAgent = deps.focusAgent;
this.#unsubscribers.push(this.#registry.onChange(() => this.#onDataChange()));
this.#unsubscribers.push(this.#observers.onChange(() => this.#onDataChange()));
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?.();
@@ -285,6 +287,10 @@ export class AgentHubOverlayComponent extends Container {
clearInterval(this.#ageTimer);
this.#ageTimer = undefined;
}
if (this.#dataChangeTimer) {
clearTimeout(this.#dataChangeTimer);
this.#dataChangeTimer = undefined;
}
this.#closeTranscriptOverlay();
}
@@ -355,6 +361,15 @@ export class AgentHubOverlayComponent extends Container {
// 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();
@@ -50,7 +50,7 @@ export class SkillMessageComponent extends Container {
// Header: icon-tag + skill name, with the invocation args trailing dimmed.
const tag = theme.fg("customMessageLabel", theme.bold(`${theme.icon.extensionSkill} skill`));
let header = `${tag} ${theme.fg("customMessageText", theme.bold(name))}`;
let header = `${tag} ${theme.fg("customMessageText", theme.bold(name))}`;
if (args) {
header += ` ${theme.fg("dim", args)}`;
}
-7
View File
@@ -1,6 +1,3 @@
import { emergencyTerminalRestore } from "@oh-my-pi/pi-tui";
import { postmortem } from "@oh-my-pi/pi-utils";
/**
* Interactive mode and embeddable RPC client exports for the coding agent.
*
@@ -11,7 +8,3 @@ import { postmortem } from "@oh-my-pi/pi-utils";
export * from "./interactive-mode";
export * from "./rpc/rpc-client";
export * from "./rpc/rpc-types";
postmortem.register("terminal-restore", () => {
emergencyTerminalRestore();
});
@@ -161,8 +161,11 @@ import {
} from "./loop-limit";
import { OAuthManualInputManager } from "./oauth-manual-input";
import { countRunningSubagentBadgeAgents, getRunningSubagentBadgeRegistry } from "./running-subagent-badge";
import type { ObservableSession } from "./session-observer-registry";
import { SessionObserverRegistry } from "./session-observer-registry";
import {
type ObservableSession,
type SessionObserverChangeKind,
SessionObserverRegistry,
} from "./session-observer-registry";
import { createSessionTeardown, type SessionTeardown } from "./session-teardown";
import { runProviderSetupWizard } from "./setup-wizard/lazy";
import { interruptHint } from "./shared";
@@ -343,9 +346,12 @@ class AnchoredLiveContainer extends Container implements NativeScrollbackLiveReg
* before it auto-clears, mirroring the todo HUD's auto-clear timer. */
const MODEL_CYCLE_TRACK_CLEAR_MS = 4000;
const SUBAGENT_HUD_VISIBLE_LIMIT = 8;
const SUBAGENT_OBSERVER_UI_COALESCE_MS = 100;
/**
* Build the anchored subagent HUD block: a bold accent "Subagents" header plus
* one tree row per running agent in the same `Id: description` shape the
* a bounded set of running-agent rows in the same `Id: description` shape the
* inline task rows use (muted task preview when no description was given).
* Layout mirrors the Todos HUD exactly: unindented header, then
* `renderTreeList` rows (dim connectors) shifted right by one space.
@@ -361,9 +367,11 @@ export function renderSubagentHudLines(sessions: ObservableSession[], columns: n
if (running.length === 0) return [];
const dot = theme.styledSymbol("status.done", "accent");
const visible = running.slice(0, SUBAGENT_HUD_VISIBLE_LIMIT);
const hiddenCount = running.length - visible.length;
const rows = renderTreeList(
{
items: running,
items: visible,
expanded: true,
renderItem: session => {
const displayId = formatTaskId(session.id);
@@ -385,6 +393,9 @@ export function renderSubagentHudLines(sessions: ObservableSession[], columns: n
},
theme,
);
if (hiddenCount > 0) {
rows.push(theme.fg("dim", `… ${hiddenCount} more running — open Agent Hub for full list`));
}
return ["", theme.bold(theme.fg("accent", "Subagents")), ...rows.map(line => ` ${line}`)];
}
@@ -588,6 +599,8 @@ export class InteractiveMode implements InteractiveModeContext {
#observerRegistry: SessionObserverRegistry;
#eventBus?: EventBus;
#eventBusUnsubscribers: Array<() => void> = [];
#observerUiSyncTimer?: NodeJS.Timeout;
#observerUiSyncNeedsTodoReconcile = false;
#agentRegistryUnsubscribe?: () => void;
#agentRegistrySubscriptionTarget?: AgentRegistry;
#mcpStatusOrder: string[] = [];
@@ -917,17 +930,8 @@ export class InteractiveMode implements InteractiveModeContext {
}
this.#observerRegistry.setMainSession(this.sessionManager.getSessionFile() ?? undefined);
this.syncRunningSubagentBadge();
this.#observerRegistry.onChange(() => {
this.syncRunningSubagentBadge();
// Auto-checkmark todos whose matching subagent just succeeded, then
// re-render so the running override (the static "live" glyph when a
// subagent is doing the work for a still-pending todo) updates as
// subagents start, finish, or fail.
this.#reconcileTodosWithSubagents();
this.#syncTodoAutoClearTimer();
this.#renderTodoList();
this.#renderSubagentList();
this.ui.requestRender();
this.#observerRegistry.onChange(kind => {
this.#scheduleObserverUiSync(kind);
});
// Load initial todos
@@ -1521,19 +1525,18 @@ export class InteractiveMode implements InteractiveModeContext {
}
/** Refresh the running-subagents status badge from the active local or collab registry. */
syncRunningSubagentBadge(): void {
syncRunningSubagentBadge(options: { requestRender?: boolean } = {}): void {
const registry = getRunningSubagentBadgeRegistry(this.collabGuest);
if (this.#agentRegistrySubscriptionTarget !== registry) {
this.#agentRegistryUnsubscribe?.();
this.#agentRegistrySubscriptionTarget = registry;
this.#agentRegistryUnsubscribe = registry.onChange(() => {
this.syncRunningSubagentBadge();
this.ui.requestRender();
});
}
const count = countRunningSubagentBadgeAgents(registry);
this.statusLine.setSubagentCount(count);
this.ui.requestRender();
if (options.requestRender !== false) this.ui.requestRender();
}
rebuildChatFromMessages(): void {
@@ -1754,6 +1757,38 @@ export class InteractiveMode implements InteractiveModeContext {
return active ?? nonEmpty[nonEmpty.length - 1];
}
#scheduleObserverUiSync(kind: SessionObserverChangeKind): void {
if (kind !== "progress") {
this.#observerUiSyncNeedsTodoReconcile = true;
}
if (this.#observerUiSyncTimer) return;
this.#observerUiSyncTimer = setTimeout(() => {
this.#observerUiSyncTimer = undefined;
this.#flushObserverUiSync();
}, SUBAGENT_OBSERVER_UI_COALESCE_MS);
this.#observerUiSyncTimer.unref?.();
}
#flushObserverUiSync(): void {
this.syncRunningSubagentBadge({ requestRender: false });
if (this.#observerUiSyncNeedsTodoReconcile) {
this.#observerUiSyncNeedsTodoReconcile = false;
this.#reconcileTodosWithSubagents();
}
this.#syncTodoAutoClearTimer();
this.#renderTodoList();
this.#renderSubagentList();
this.ui.requestRender();
}
#cancelObserverUiSyncTimer(): void {
if (this.#observerUiSyncTimer) {
clearTimeout(this.#observerUiSyncTimer);
this.#observerUiSyncTimer = undefined;
}
this.#observerUiSyncNeedsTodoReconcile = false;
}
#renderTodoList(): void {
this.todoContainer.clear();
const phases = this.todoPhases.filter(phase => phase.tasks.length > 0);
@@ -3306,6 +3341,7 @@ export class InteractiveMode implements InteractiveModeContext {
}
this.#cleanupMicAnimation();
this.#cancelTodoAutoClearTimer();
this.#cancelObserverUiSyncTimer();
this.#cancelGoalContinuation();
if (this.#sttController) {
this.#sttController.dispose();
@@ -24,6 +24,9 @@ export interface ObservableSession {
progress?: AgentProgress;
}
/** Coarse source of an observer change; callers use it to separate lifecycle work from high-frequency progress. */
export type SessionObserverChangeKind = "main" | "reset" | "lifecycle" | "progress";
const STATUS_MAP: Record<string, ObservableSession["status"]> = {
started: "active",
completed: "completed",
@@ -33,20 +36,20 @@ const STATUS_MAP: Record<string, ObservableSession["status"]> = {
export class SessionObserverRegistry {
#sessions = new Map<string, ObservableSession>();
#listeners = new Set<() => void>();
#listeners = new Set<(kind: SessionObserverChangeKind) => void>();
#eventBusUnsubscribers: Array<() => void> = [];
#sortOrderById = new Map<string, number>();
#parentSortOrderById = new Map<string, number>();
#nextSortOrder = 0;
/** Add a change listener. Returns unsubscribe function. */
onChange(cb: () => void): () => void {
onChange(cb: (kind: SessionObserverChangeKind) => void): () => void {
this.#listeners.add(cb);
return () => this.#listeners.delete(cb);
}
#notifyListeners(): void {
for (const cb of this.#listeners) cb();
#notifyListeners(kind: SessionObserverChangeKind): void {
for (const cb of this.#listeners) cb(kind);
}
#ensureSortOrder(id: string): number {
@@ -85,7 +88,7 @@ export class SessionObserverRegistry {
sessionFile: sessionFile ?? existing?.sessionFile,
lastUpdate: Date.now(),
});
this.#notifyListeners();
this.#notifyListeners("main");
}
getSessions(): ObservableSession[] {
@@ -121,7 +124,7 @@ export class SessionObserverRegistry {
this.#sortOrderById.clear();
this.#parentSortOrderById.clear();
this.#nextSortOrder = 0;
this.#notifyListeners();
this.#notifyListeners("reset");
}
dispose(): void {
@@ -171,7 +174,7 @@ export class SessionObserverRegistry {
lastUpdate: Date.now(),
});
}
this.#notifyListeners();
this.#notifyListeners("lifecycle");
}),
);
@@ -208,7 +211,7 @@ export class SessionObserverRegistry {
progress,
});
}
this.#notifyListeners();
this.#notifyListeners("progress");
}),
);
}