Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions extensions/shared/editor-layers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import assert from "node:assert/strict";
import test from "node:test";
import type {
ExtensionAPI,
ExtensionContext,
ExtensionFactory,
} from "@earendil-works/pi-coding-agent";
import subagents from "../subagents/index.ts";
import suggestions from "../suggestions/index.ts";
import workflows from "../workflows/index.ts";

type EditorFactory = NonNullable<
ReturnType<ExtensionContext["ui"]["getEditorComponent"]>
>;

function createEventBus() {
const handlers = new Map<string, Set<(data: unknown) => void>>();
return {
emit(channel: string, data: unknown) {
for (const handler of handlers.get(channel) ?? []) handler(data);
},
on(channel: string, handler: (data: unknown) => void) {
const listeners = handlers.get(channel) ?? new Set();
listeners.add(handler);
handlers.set(channel, listeners);
return () => listeners.delete(handler);
},
};
}

function editorLifecycleHarness() {
const lifecycle = new Map<
string,
Array<(event: unknown, ctx: ExtensionContext) => unknown>
>();
const events = createEventBus();
let editorFactory: EditorFactory | undefined;
let editorWrites = 0;

const load = (factory: ExtensionFactory) => {
let activeTools: string[] = [];
const api = {
events,
on(event: string, handler: unknown) {
lifecycle.set(event, [
...(lifecycle.get(event) ?? []),
handler as (event: unknown, ctx: ExtensionContext) => unknown,
]);
},
registerTool(tool: { name: string }) {
activeTools = [
...activeTools.filter((name) => name !== tool.name),
tool.name,
];
},
getActiveTools: () => [...activeTools],
setActiveTools(names: string[]) {
activeTools = [...names];
},
getAllTools: () => [],
registerCommand() {},
registerMessageRenderer() {},
registerEntryRenderer() {},
getThinkingLevel: () => "off",
sendMessage() {},
appendEntry() {},
} as unknown as ExtensionAPI;
factory(api);
};

load(subagents);
load(suggestions);
load(workflows);

const ctx = {
cwd: process.cwd(),
mode: "tui",
hasUI: true,
isIdle: () => true,
isProjectTrusted: () => false,
sessionManager: {
getLeafId: () => "leaf",
getBranch: () => [],
getSessionId: () => "session",
getEntries: () => [],
},
ui: {
theme: { fg: (_color: string, text: string) => text },
getEditorComponent: () => editorFactory,
setEditorComponent(factory: EditorFactory | undefined) {
editorFactory = factory;
editorWrites += 1;
},
setStatus() {},
setWidget() {},
notify() {},
},
} as unknown as ExtensionContext;

const emit = async (event: string) => {
for (const handler of lifecycle.get(event) ?? []) {
await handler({ type: event }, ctx);
}
};

return {
emit,
editorFactory: () => editorFactory,
editorWrites: () => editorWrites,
};
}

test("one session binds all OpenPI editor layers with one UI write", async () => {
const first = editorLifecycleHarness();
await first.emit("session_start");
await new Promise((resolve) => setTimeout(resolve, 10));

assert.equal(first.editorWrites(), 1);
assert.equal(typeof first.editorFactory(), "function");

await first.emit("session_shutdown");

const resumed = editorLifecycleHarness();
await resumed.emit("session_start");
await new Promise((resolve) => setTimeout(resolve, 10));

assert.equal(resumed.editorWrites(), 1);
assert.equal(typeof resumed.editorFactory(), "function");
assert.notEqual(resumed.editorFactory(), first.editorFactory());
});
150 changes: 150 additions & 0 deletions extensions/shared/editor-layers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {
CustomEditor,
type ExtensionAPI,
type ExtensionContext,
type KeybindingsManager,
} from "@earendil-works/pi-coding-agent";
import type { EditorComponent, EditorTheme, TUI } from "@earendil-works/pi-tui";

const CLAIM_CHANNEL = "openpi:editor-layers:claim";
const REGISTER_CHANNEL = "openpi:editor-layers:register";
const REMOVE_CHANNEL = "openpi:editor-layers:remove";

type EditorFactory = NonNullable<
ReturnType<ExtensionContext["ui"]["getEditorComponent"]>
>;

export interface EditorLayer {
readonly id: string;
readonly order: number;
readonly wrap: (
base: EditorComponent,
tui: TUI,
theme: EditorTheme,
keybindings: KeybindingsManager,
) => EditorComponent;
}

interface EditorLayerRegistration {
readonly ctx: ExtensionContext;
readonly layer: EditorLayer;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

function readRegistration(value: unknown) {
if (!isRecord(value) || !isRecord(value.layer)) return undefined;
const { ctx, layer } = value;
if (
!isRecord(ctx) ||
typeof layer.id !== "string" ||
typeof layer.order !== "number" ||
typeof layer.wrap !== "function"
) {
return undefined;
}
return {
ctx: ctx as unknown as ExtensionContext,
layer: layer as unknown as EditorLayer,
} satisfies EditorLayerRegistration;
}

function readLayerId(value: unknown) {
if (!isRecord(value) || typeof value.id !== "string") return undefined;
return value.id;
}

function composeEditorFactory(
previous: EditorFactory | undefined,
layers: readonly EditorLayer[],
) {
return ((tui, theme, keybindings) => {
let editor =
previous?.(tui, theme, keybindings) ??
new CustomEditor(tui, theme, keybindings);
for (const layer of layers) {
editor = layer.wrap(editor, tui, theme, keybindings);
}
return editor;
}) satisfies EditorFactory;
}

/**
* Each extension is evaluated in its own jiti module graph, so ordinary module
* singletons are not shared. The first OpenPI editor contributor claims the
* runtime EventBus and coordinates the rest through that host-owned boundary.
*/
function ensureCoordinator(pi: ExtensionAPI) {
const claim = { claimed: false };
pi.events.emit(CLAIM_CHANNEL, claim);
if (claim.claimed) return;

let ctx: ExtensionContext | undefined;
let installTimer: ReturnType<typeof setTimeout> | undefined;
const layers = new Map<string, EditorLayer>();

const cancelInstall = () => {
if (installTimer) clearTimeout(installTimer);
installTimer = undefined;
};

const install = () => {
installTimer = undefined;
const current = ctx;
if (!current || current.mode !== "tui" || layers.size === 0) return;
const ordered = [...layers.values()].sort(
(left, right) =>
left.order - right.order || left.id.localeCompare(right.id),
);
current.ui.setEditorComponent(
composeEditorFactory(current.ui.getEditorComponent(), ordered),
);
};

const scheduleInstall = () => {
if (installTimer) return;
installTimer = setTimeout(install, 0);
};

pi.events.on(CLAIM_CHANNEL, (value) => {
if (isRecord(value) && value.claimed === false) value.claimed = true;
});
pi.events.on(REGISTER_CHANNEL, (value) => {
const registration = readRegistration(value);
if (!registration || registration.ctx.mode !== "tui") return;
if (ctx !== registration.ctx) {
cancelInstall();
layers.clear();
ctx = registration.ctx;
}
layers.set(registration.layer.id, registration.layer);
scheduleInstall();
});
pi.events.on(REMOVE_CHANNEL, (value) => {
const id = readLayerId(value);
if (!id) return;
layers.delete(id);
if (layers.size > 0) return;
cancelInstall();
ctx = undefined;
});
}

export function registerEditorLayer(
pi: ExtensionAPI,
ctx: ExtensionContext,
layer: EditorLayer,
) {
if (ctx.mode !== "tui") return;
ensureCoordinator(pi);
pi.events.emit(REGISTER_CHANNEL, {
ctx,
layer,
} satisfies EditorLayerRegistration);
}

export function removeEditorLayer(pi: ExtensionAPI, id: string) {
pi.events.emit(REMOVE_CHANNEL, { id });
}
48 changes: 28 additions & 20 deletions extensions/subagents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import type {
ExtensionUIContext,
} from "@earendil-works/pi-coding-agent";
import {
CustomEditor,
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
defineTool,
Expand Down Expand Up @@ -69,6 +68,10 @@ import {
OPENPI_TOOL_SURFACE,
patchOwnedTools,
} from "../shared/tool-surface.ts";
import {
registerEditorLayer,
removeEditorLayer,
} from "../shared/editor-layers.ts";
import { formatContextUtilization } from "./src/format.ts";
import { SubagentManager, type SubagentManagerShape } from "./src/manager.ts";
import {
Expand Down Expand Up @@ -200,6 +203,7 @@ export default function (pi: ExtensionAPI) {
let navigationManager: SubagentManagerShape | undefined;
let widgetVisible = false;
let requestWidgetRender: (() => void) | undefined;
let navigationLayerRegistered = false;
let dashboardOpen = false;
const resultDelivery = createDeferredResultDelivery<SubagentSnapshot>();
const hideLifecycleTools = () =>
Expand Down Expand Up @@ -292,26 +296,26 @@ export default function (pi: ExtensionAPI) {

const installSubagentNavigation = (ctx: ExtensionContext) => {
if (ctx.mode !== "tui") return;
const previous = ctx.ui.getEditorComponent();
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
const base =
previous?.(tui, theme, keybindings) ??
new CustomEditor(tui, theme, keybindings);
return new BelowEditorNavigationEditor(
base,
keybindings,
stripState,
() => Boolean(stripEntry()),
() => {
const entry = stripEntry();
if (entry) void openDashboard(ctx, entry.snapshot.id);
},
() => {
requestWidgetRender?.();
tui.requestRender();
},
);
registerEditorLayer(pi, ctx, {
id: "subagents",
order: 100,
wrap: (base, tui, _theme, keybindings) =>
new BelowEditorNavigationEditor(
base,
keybindings,
stripState,
() => Boolean(stripEntry()),
() => {
const entry = stripEntry();
if (entry) void openDashboard(ctx, entry.snapshot.id);
},
() => {
requestWidgetRender?.();
tui.requestRender();
},
),
});
navigationLayerRegistered = true;
};

/**
Expand Down Expand Up @@ -447,6 +451,10 @@ export default function (pi: ExtensionAPI) {
pi.on("agent_settled", () => flushResults(false));

pi.on("session_shutdown", async () => {
if (navigationLayerRegistered) {
removeEditorLayer(pi, "subagents");
navigationLayerRegistered = false;
}
resultDelivery.clear();
unsubStatus?.();
unsubStatus = undefined;
Expand Down
Loading
Loading