feat(coding-agent): simplify remote plugin services

This commit is contained in:
Mario Zechner 2026-08-14 16:45:14 +02:00
parent d458bfc073
commit 4cb81e2bd9
15 changed files with 991 additions and 1042 deletions

View file

@ -1,69 +1,79 @@
import type { AppKeybinding } from "../../../src/core/keybindings.ts";
import type {
ClientSnapshot,
CodingAgentEvent,
CodingAgentSnapshot,
ServerWireMessage,
SessionRequest,
WireView,
} from "./protocol.ts";
import { isRpcOptions, type RemoteState, type Service } from "./kernel.ts";
import type { ServerWireMessage, SessionRequest, StateSnapshot } from "./protocol.ts";
export interface ClientTransport {
close(): void;
request(request: SessionRequest): Promise<void>;
request(request: SessionRequest, signal?: AbortSignal): Promise<unknown>;
start(listener: (message: ServerWireMessage) => void): void;
}
/** Replicated client state: one app snapshot plus active targeted views. */
export class CodingAgentClientStore {
readonly events: CodingAgentEvent[] = [];
class ClientRemoteState<T> implements RemoteState<T> {
private readonly listeners = new Set<(value: T) => void>();
private current: T;
constructor(initial: T) {
this.current = structuredClone(initial);
}
get value(): T {
return this.current;
}
set(value: T): void {
this.current = structuredClone(value);
for (const listener of this.listeners) listener(this.current);
}
subscribe(listener: (value: T) => void): () => void {
this.listeners.add(listener);
listener(this.current);
return () => this.listeners.delete(listener);
}
}
export class ClientStateStore {
updates = 0;
private readonly listeners = new Set<() => void>();
private readonly viewMap = new Map<string, WireView>();
private appState: CodingAgentSnapshot | undefined;
get app(): CodingAgentSnapshot {
if (!this.appState) throw new Error("Session snapshot has not arrived");
return this.appState;
}
get views(): readonly WireView[] {
return [...this.viewMap.values()];
}
private readonly states = new Map<string, ClientRemoteState<unknown>>();
apply(message: ServerWireMessage): void {
if (message.type === "response") return;
if (message.type === "snapshot") {
this.replace(message.snapshot);
} else if (message.type === "event") {
this.events.push(structuredClone(message.event));
if (message.event.type === "config_update") {
this.appState = { ...this.app, configuration: structuredClone(message.event.value) };
} else {
this.appState = { ...this.app, providers: structuredClone(message.event.providers) };
}
} else if (message.type === "view_updated") {
this.viewMap.set(message.view.id, structuredClone(message.view));
} else {
this.viewMap.delete(message.viewId);
}
if (message.type === "snapshot") this.replace(message.states);
else this.update(message.service, message.property, message.value);
for (const listener of this.listeners) listener();
}
get<T>(service: string, property: string): RemoteState<T> {
const state = this.states.get(`${service}.${property}`);
if (!state) throw new Error(`Remote state not provided: ${service}.${property}`);
return state as RemoteState<T>;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
listener();
return () => this.listeners.delete(listener);
}
private replace(snapshot: ClientSnapshot): void {
this.appState = structuredClone(snapshot.app);
this.viewMap.clear();
for (const view of snapshot.views) this.viewMap.set(view.id, structuredClone(view));
private replace(snapshot: StateSnapshot): void {
for (const [service, properties] of Object.entries(snapshot)) {
for (const [property, value] of Object.entries(properties)) this.update(service, property, value, false);
}
}
private update(service: string, property: string, value: unknown, count = true): void {
const key = `${service}.${property}`;
const current = this.states.get(key);
if (current) current.set(value);
else this.states.set(key, new ClientRemoteState(value));
if (count) this.updates++;
}
}
export class SessionClient {
readonly ready: Promise<void>;
readonly store = new CodingAgentClientStore();
readonly store = new ClientStateStore();
private readonly proxies = new Map<string, object>();
private readonly resolveReady: () => void;
private readonly transport: ClientTransport;
private receivedSnapshot = false;
@ -88,15 +98,39 @@ export class SessionClient {
this.transport.close();
}
invokeAction(id: AppKeybinding): Promise<void> {
return this.transport.request({ type: "invoke_action", id });
}
sendView(viewId: string, message: unknown): Promise<void> {
return this.transport.request({ type: "view_message", viewId, message });
}
submit(input: string): Promise<void> {
return this.transport.request({ type: "submit", input });
use<T>(service: Service<T>): T {
let proxy = this.proxies.get(service.id);
if (!proxy) {
proxy = new Proxy(
{},
{
get: (_target, property) => {
if (typeof property !== "string") return undefined;
try {
return this.store.get(service.id, property);
} catch {
return (...args: unknown[]) => {
const options = args.at(-1);
if (!isRpcOptions(options)) {
return this.transport.request({ type: "rpc", service: service.id, method: property, args });
}
return this.transport.request(
{
type: "rpc",
service: service.id,
method: property,
args: args.slice(0, -1),
rpcOptions: true,
},
options.signal,
);
};
}
},
},
);
this.proxies.set(service.id, proxy);
}
return proxy as T;
}
}

View file

@ -1,48 +1,50 @@
/** Minimal session-side plugin lifecycle. The app owns every service and registry. */
/**
* Shared tokens and two-sided plugin shape.
* Remote state values and ordinary remote method arguments/results must be JSON-serializable.
* RpcOptions are recognized by the proxy and sent through the transport control plane.
*/
export interface AppPlugin<Api> {
export interface Service<T> {
readonly id: string;
readonly _type?: T;
}
export function defineService<T>(id: string): Service<T> {
return { id };
}
const rpcOptionsMarker = Symbol("rpc-options");
export interface RpcOptions {
readonly signal?: AbortSignal;
readonly [rpcOptionsMarker]: true;
}
export function rpcOptions(options: { signal?: AbortSignal } = {}): RpcOptions {
return { ...options, [rpcOptionsMarker]: true };
}
export function isRpcOptions(value: unknown): value is RpcOptions {
return typeof value === "object" && value !== null && rpcOptionsMarker in value;
}
export interface RemoteState<T> {
readonly value: T;
subscribe(listener: (value: T) => void): () => void;
}
export interface MutableRemoteState<T> extends RemoteState<T> {
set(value: T): void;
}
export interface AppPlugin<SessionContext, ClientContext> {
id: string;
setup(api: Api): void | Promise<void>;
session?(context: SessionContext): void | Promise<void>;
client?(context: ClientContext): void | Promise<void>;
}
export interface AppInstance<Api, Driver> {
readonly driver: Driver;
activate(): void | Promise<void>;
api(owner: string): Api;
close(): void;
}
export interface AppDefinition<Api, Driver> {
id: string;
create(): AppInstance<Api, Driver>;
plugins: readonly AppPlugin<Api>[];
}
export function defineApp<Api, Driver>(definition: AppDefinition<Api, Driver>): AppDefinition<Api, Driver> {
return definition;
}
export function definePlugin<Api>(plugin: AppPlugin<Api>): AppPlugin<Api> {
export function definePlugin<SessionContext, ClientContext>(
plugin: AppPlugin<SessionContext, ClientContext>,
): AppPlugin<SessionContext, ClientContext> {
return plugin;
}
export class TestAppRuntime<Api, Driver> {
readonly definition: AppDefinition<Api, Driver>;
readonly driver: Driver;
private readonly instance: AppInstance<Api, Driver>;
constructor(definition: AppDefinition<Api, Driver>) {
this.definition = definition;
this.instance = definition.create();
this.driver = this.instance.driver;
}
async start(): Promise<void> {
for (const plugin of this.definition.plugins) await plugin.setup(this.instance.api(plugin.id));
await this.instance.activate();
}
close(): void {
this.instance.close();
}
}

View file

@ -1,16 +1,96 @@
import { defineApp, definePlugin } from "./kernel.ts";
import type { ModelSelectorState, ModelSpec, ThinkingLevel } from "./protocol.ts";
import { type CodingAgentApi, type CodingAgentApp, type CodingAgentPlugin, createSessionInstance } from "./session.ts";
import { type AppPlugin, definePlugin, defineService } from "./kernel.ts";
import { type ModelSpec, Models, type ModelsService, type ModelsState, type ThinkingLevel } from "./protocol.ts";
import { ProviderRegistry } from "./providers.ts";
import type { SessionContext } from "./session.ts";
import { registerModelTui } from "./tui/model-selector.ts";
import type { TuiContext } from "./tui/shared.ts";
export const providersBuiltin = definePlugin<CodingAgentApi>({
export type CodingAgentPlugin = AppPlugin<SessionContext, TuiContext>;
const Providers = defineService<ProviderRegistry>("providers");
export const Settings = defineService<{
get(key: string): unknown;
set(key: string, value: unknown): Promise<void>;
}>("settings");
export const providersBuiltin = definePlugin<SessionContext, TuiContext>({
id: "@pi/providers-builtin",
setup(pi) {
pi.providers.add((draft) => {
session(context) {
const providers = new ProviderRegistry();
const values = new Map<string, unknown>();
const settings = {
get: (key: string) => values.get(key),
async set(key: string, value: unknown) {
values.set(key, structuredClone(value));
},
};
const state = context.state<ModelsState>({
catalog: providers.snapshot(),
configuration: { model: undefined, thinkingLevel: "high" },
refresh: { status: "idle" },
});
const sessionController = new AbortController();
const stop = providers.subscribe((catalog) => state.set({ ...state.value, catalog }));
const models: ModelsService = {
state,
async cycleThinking() {
const current = state.value.configuration;
const selected = current.model
? state.value.catalog.availableModels.find(
(model) => model.provider === current.model?.provider && model.modelId === current.model.modelId,
)
: undefined;
if (selected && !selected.reasoning) throw new Error("Current model does not support thinking");
const levels: readonly ThinkingLevel[] = ["off", "low", "high"];
const thinkingLevel = levels[(levels.indexOf(current.thinkingLevel) + 1) % levels.length] ?? "off";
state.set({ ...state.value, configuration: { ...current, thinkingLevel } });
},
async refresh(options) {
const previousRefresh = state.value.refresh;
state.set({ ...state.value, refresh: { status: "refreshing" } });
const signal = options?.signal
? AbortSignal.any([sessionController.signal, options.signal])
: sessionController.signal;
try {
const errors = await providers.refresh(signal);
signal.throwIfAborted();
state.set({
...state.value,
refresh:
errors.size === 0 ? { status: "done" } : { status: "warning", errors: Object.fromEntries(errors) },
});
} catch (error) {
if (signal.aborted) state.set({ ...state.value, refresh: previousRefresh });
throw error;
}
},
async select(model) {
const selected = state.value.catalog.availableModels.find(
(candidate) => candidate.provider === model.provider && candidate.modelId === model.modelId,
);
if (!selected) throw new Error(`Unknown model: ${model.provider}/${model.modelId}`);
const configuration = {
model: { provider: selected.provider, modelId: selected.modelId },
thinkingLevel: selected.reasoning ? state.value.configuration.thinkingLevel : ("off" as const),
};
state.set({ ...state.value, configuration });
await settings.set("defaultModel", configuration.model);
},
};
context.provide(Providers, providers);
context.provide(Settings, settings);
context.provide(Models, models);
providers.add("@pi/providers-builtin", (draft) => {
draft.native("acme", [{ provider: "acme", modelId: "base", name: "Base", reasoning: true }]);
draft.native("unconfigured", [
{ provider: "unconfigured", modelId: "hidden", name: "Hidden", reasoning: true },
]);
});
context.onActivate(() => providers.rebuild());
context.onClose(() => {
stop();
sessionController.abort();
});
},
});
@ -20,8 +100,8 @@ export function providersCatalog(
let catalog: readonly ModelSpec[] = [{ provider: "acme", modelId: "cached", name: "Cached", reasoning: true }];
return definePlugin({
id: "@pi/providers-catalog",
setup(pi: CodingAgentApi) {
pi.providers.add((draft) => {
session(context: SessionContext) {
context.use(Providers).add("@pi/providers-catalog", (draft) => {
draft.models("acme", (models) => [...models, ...catalog]);
draft.refresh("acme", "remote", async (signal) => {
const next = await refreshCatalog(signal);
@ -33,10 +113,10 @@ export function providersCatalog(
});
}
export const providersModelsJson = definePlugin<CodingAgentApi>({
export const providersModelsJson: CodingAgentPlugin = definePlugin({
id: "@pi/providers-models-json",
setup(pi) {
pi.providers.add((draft) => {
session(context) {
context.use(Providers).add("@pi/providers-models-json", (draft) => {
draft.models("acme", (models) =>
models.map((model) => (model.modelId === "base" ? { ...model, name: "Base (models.json)" } : model)),
);
@ -44,155 +124,36 @@ export const providersModelsJson = definePlugin<CodingAgentApi>({
},
});
export const auth = definePlugin<CodingAgentApi>({
export const auth: CodingAgentPlugin = definePlugin({
id: "@pi/auth",
setup(pi) {
pi.providers.add((draft) => draft.configured("acme"));
session(context) {
context.use(Providers).add("@pi/auth", (draft) => draft.configured("acme"));
},
});
export const thinkingControl = definePlugin<CodingAgentApi>({
export const thinkingControl: CodingAgentPlugin = definePlugin({
id: "@pi/thinking-control",
setup(pi) {
pi.actions.add((draft) => {
draft.set("app.thinking.cycle", {
description: "Cycle thinking level",
async handler() {
const current = pi.session.getConfiguration();
const selected = current.model
? pi.providers
.snapshot()
.availableModels.find(
(model) =>
model.provider === current.model?.provider && model.modelId === current.model.modelId,
)
: undefined;
if (selected && !selected.reasoning) throw new Error("Current model does not support thinking");
const levels: readonly ThinkingLevel[] = ["off", "low", "high"];
const next = levels[(levels.indexOf(current.thinkingLevel) + 1) % levels.length] ?? "off";
await pi.session.configure({ ...current, thinkingLevel: next });
},
});
});
client(context) {
context.actions.register("app.thinking.cycle", () => context.use(Models).cycleThinking());
},
});
type ModelSelectorMessage = { type: "cancel" } | { type: "select"; provider: string; modelId: string };
function isModelSelectorMessage(value: unknown): value is ModelSelectorMessage {
if (typeof value !== "object" || value === null || !("type" in value)) return false;
const candidate = value as { type?: unknown; provider?: unknown; modelId?: unknown };
return (
candidate.type === "cancel" ||
(candidate.type === "select" && typeof candidate.provider === "string" && typeof candidate.modelId === "string")
);
}
export const modelSelection = definePlugin<CodingAgentApi>({
export const modelSelection: CodingAgentPlugin = definePlugin({
id: "@pi/model-selection",
setup(pi) {
pi.commands.add((draft) => {
draft.set("model", {
argumentHint: "[provider/]model",
description: "Select model",
async handler(args, context) {
const resolveExact = (query: string): ModelSpec | undefined => {
const models = pi.providers.snapshot().availableModels;
const canonical = models.find((model) => `${model.provider}/${model.modelId}` === query);
if (canonical) return canonical;
const bare = models.filter((model) => model.modelId === query);
return bare.length === 1 ? bare[0] : undefined;
};
const select = async (model: ModelSpec): Promise<void> => {
const current = pi.session.getConfiguration();
await pi.session.configure({
model: { provider: model.provider, modelId: model.modelId },
thinkingLevel: model.reasoning ? current.thinkingLevel : "off",
});
await pi.settings.set("defaultModel", { provider: model.provider, modelId: model.modelId });
};
if (args) {
let exact = resolveExact(args);
if (!exact) {
await pi.providers.refresh(context.signal);
exact = resolveExact(args);
}
if (exact) {
await select(exact);
return;
}
}
let refresh: ModelSelectorState["refresh"] = { status: "refreshing" };
const createState = (): ModelSelectorState => ({
schema: 1,
query: args,
catalogRevision: pi.providers.snapshot().revision,
refresh,
});
const view = pi.view("model-selector", createState(), { to: [context.originatorClientId] });
const stopRebuild = pi.providers.onRebuild(() => {
view.state = createState();
});
view.on(async (message) => {
if (!isModelSelectorMessage(message)) return;
if (message.type === "cancel") {
view.close();
return;
}
const selected = pi.providers
.snapshot()
.availableModels.find(
(model) => model.provider === message.provider && model.modelId === message.modelId,
);
if (!selected) return;
await select(selected);
view.close();
});
const refreshController = new AbortController();
const refreshSignal = AbortSignal.any([context.signal, refreshController.signal]);
void pi.providers
.refresh(refreshSignal)
.then((result) => {
refresh =
result.errors.size === 0
? { status: "done" }
: { status: "warning", errors: Object.fromEntries(result.errors) };
view.state = createState();
})
.catch((error: unknown) => {
if (refreshSignal.aborted) return;
refresh = {
status: "warning",
errors: { refresh: error instanceof Error ? error.message : String(error) },
};
view.state = createState();
});
await view.done;
refreshController.abort();
stopRebuild();
},
});
});
client(context) {
registerModelTui(context, context.use(Models));
},
});
export function createCodingAgentApp(
export function createCodingAgentPlugins(
refreshCatalog: (signal: AbortSignal) => Promise<readonly ModelSpec[]>,
): CodingAgentApp {
return defineApp({
id: "coding-agent",
create: createSessionInstance,
plugins: [
providersBuiltin,
providersCatalog(refreshCatalog),
providersModelsJson,
auth,
thinkingControl,
modelSelection,
],
});
): readonly CodingAgentPlugin[] {
return [
providersBuiltin,
providersCatalog(refreshCatalog),
providersModelsJson,
auth,
thinkingControl,
modelSelection,
];
}

View file

@ -1,4 +1,4 @@
import type { AppKeybinding } from "../../../src/core/keybindings.ts";
import { defineService, type RemoteState, type RpcOptions } from "./kernel.ts";
export type ThinkingLevel = "off" | "low" | "high";
@ -23,50 +23,43 @@ export interface ProviderSnapshot {
models: readonly ModelSpec[];
}
export interface CodingAgentSnapshot {
actions: ReadonlyArray<{ id: AppKeybinding; description: string }>;
commands: ReadonlyArray<{ name: string; description: string; argumentHint?: string }>;
export type RefreshState =
| { status: "idle" }
| { status: "refreshing" }
| { status: "done" }
| { status: "warning"; errors: Readonly<Record<string, string>> };
export interface ModelsState {
catalog: ProviderSnapshot;
configuration: LaneConfiguration;
providers: ProviderSnapshot;
refresh: RefreshState;
}
export type CodingAgentEvent =
| { type: "config_update"; previous: LaneConfiguration; value: LaneConfiguration }
| { type: "providers_changed"; providers: ProviderSnapshot };
export interface ModelSelectorState {
schema: 1;
query: string;
catalogRevision: number;
refresh:
| { status: "refreshing" }
| { status: "done" }
| { status: "warning"; errors: Readonly<Record<string, string>> };
export interface ModelsService {
state: RemoteState<ModelsState>;
cycleThinking(): Promise<void>;
refresh(options?: RpcOptions): Promise<void>;
select(model: ModelRef): Promise<void>;
}
export interface WireView {
id: string;
component: string;
state: unknown;
}
export const Models = defineService<ModelsService>("models");
export interface ClientSnapshot {
app: CodingAgentSnapshot;
views: readonly WireView[];
}
export type StateSnapshot = Record<string, Record<string, unknown>>;
export type SessionRequest =
| { type: "submit"; input: string }
| { type: "invoke_action"; id: AppKeybinding }
| { type: "view_message"; viewId: string; message: unknown };
export type SessionRequest = {
type: "rpc";
service: string;
method: string;
args: unknown[];
rpcOptions?: true;
};
export type ClientWireMessage =
| { type: "hello"; clientId: string }
| { type: "request"; id: number; request: SessionRequest };
| { type: "request"; id: number; request: SessionRequest }
| { type: "cancel"; id: number };
export type ServerWireMessage =
| { type: "snapshot"; snapshot: ClientSnapshot }
| { type: "event"; event: CodingAgentEvent }
| { type: "view_updated"; view: WireView }
| { type: "view_closed"; viewId: string }
| { type: "response"; id: number; error?: string };
| { type: "snapshot"; states: StateSnapshot }
| { type: "state_update"; service: string; property: string; value: unknown }
| { type: "response"; id: number; result?: unknown; error?: string };

View file

@ -0,0 +1,152 @@
import type { ModelSpec, ProviderSnapshot } from "./protocol.ts";
type Contribution<D> = (draft: D, owner: string) => void;
class ReplayRegistry<D, S> {
private readonly build: (draft: D) => S;
private readonly contributions: Array<{ owner: string; run: Contribution<D> }> = [];
private readonly createDraft: () => D;
private state: S;
constructor(createDraft: () => D, build: (draft: D) => S) {
this.build = build;
this.createDraft = createDraft;
this.state = build(createDraft());
}
add(owner: string, run: Contribution<D>): void {
this.contributions.push({ owner, run });
}
rebuild(): S {
const draft = this.createDraft();
for (const { owner, run } of this.contributions) run(draft, owner);
this.state = this.build(draft);
return this.state;
}
current(): S {
return this.state;
}
}
interface ProviderEntry {
configured: boolean;
models: ModelSpec[];
refreshers: Array<{ id: string; refresh(signal: AbortSignal): Promise<void> }>;
}
class ProviderDraftState {
readonly entries = new Map<string, ProviderEntry>();
entry(id: string): ProviderEntry {
let entry = this.entries.get(id);
if (!entry) {
entry = { configured: false, models: [], refreshers: [] };
this.entries.set(id, entry);
}
return entry;
}
}
export class ProviderDraft {
private readonly owner: string;
private readonly state: ProviderDraftState;
constructor(owner: string, state: ProviderDraftState) {
this.owner = owner;
this.state = state;
}
native(id: string, models: readonly ModelSpec[]): void {
this.state.entry(id).models = models.map((model) => ({ ...model, provider: id }));
}
models(id: string, transform: (models: readonly ModelSpec[]) => readonly ModelSpec[]): void {
const entry = this.state.entry(id);
entry.models = transform(entry.models.map((model) => ({ ...model }))).map((model) => ({
...model,
provider: id,
}));
}
configured(id: string): void {
this.state.entry(id).configured = true;
}
refresh(id: string, localId: string, refresh: (signal: AbortSignal) => Promise<void>): void {
this.state.entry(id).refreshers.push({ id: `${this.owner}:${localId}`, refresh });
}
}
interface ProviderState {
availableModels: readonly ModelSpec[];
models: readonly ModelSpec[];
refreshers: ReadonlyArray<{ id: string; refresh(signal: AbortSignal): Promise<void> }>;
}
export class ProviderRegistry {
private readonly listeners = new Set<(snapshot: ProviderSnapshot) => void>();
private readonly registry = new ReplayRegistry(
() => new ProviderDraftState(),
(draft): ProviderState => {
const models: ModelSpec[] = [];
const availableModels: ModelSpec[] = [];
const refreshers: Array<{ id: string; refresh(signal: AbortSignal): Promise<void> }> = [];
for (const entry of draft.entries.values()) {
models.push(...entry.models.map((model) => ({ ...model })));
if (entry.configured) availableModels.push(...entry.models.map((model) => ({ ...model })));
refreshers.push(...entry.refreshers);
}
const byId = (left: ModelSpec, right: ModelSpec) =>
`${left.provider}/${left.modelId}`.localeCompare(`${right.provider}/${right.modelId}`);
return { availableModels: availableModels.sort(byId), models: models.sort(byId), refreshers };
},
);
private revision = 0;
private serialized = "";
add(owner: string, contribution: (draft: ProviderDraft) => void): void {
this.registry.add(owner, (draft) => contribution(new ProviderDraft(owner, draft)));
}
rebuild(): void {
const state = this.registry.rebuild();
const serialized = JSON.stringify({ availableModels: state.availableModels, models: state.models });
if (serialized === this.serialized) return;
this.serialized = serialized;
this.revision++;
for (const listener of this.listeners) listener(this.snapshot());
}
snapshot(): ProviderSnapshot {
const state = this.registry.current();
return {
revision: this.revision,
availableModels: state.availableModels.map((model) => ({ ...model })),
models: state.models.map((model) => ({ ...model })),
};
}
subscribe(listener: (snapshot: ProviderSnapshot) => void): () => void {
this.listeners.add(listener);
listener(this.snapshot());
return () => this.listeners.delete(listener);
}
async refresh(signal: AbortSignal): Promise<ReadonlyMap<string, string>> {
signal.throwIfAborted();
const errors = new Map<string, string>();
await Promise.all(
this.registry.current().refreshers.map(async ({ id, refresh }) => {
try {
await refresh(signal);
} catch (error) {
if (!signal.aborted) errors.set(id, error instanceof Error ? error.message : String(error));
}
}),
);
if (!signal.aborted) this.rebuild();
return errors;
}
}

View file

@ -1,38 +1,35 @@
import { TestAppRuntime } from "./kernel.ts";
import { createCodingAgentApp } from "./plugins.ts";
import { createCodingAgentPlugins } from "./plugins.ts";
import { SessionRuntime } from "./session.ts";
import { SessionTcpServer } from "./transport.ts";
async function main(): Promise<void> {
const host = process.argv[2] ?? "127.0.0.1";
const port = Number(process.argv[3] ?? "7777");
const runtime = new TestAppRuntime(
createCodingAgentApp(async (signal) => {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(resolve, 750);
signal.addEventListener(
"abort",
() => {
clearTimeout(timeout);
reject(signal.reason);
},
{ once: true },
);
});
return [
{ provider: "acme", modelId: "fresh", name: "Fresh from catalogue", reasoning: false },
{ provider: "acme", modelId: "reasoning", name: "Reasoning model", reasoning: true },
];
}),
);
const plugins = createCodingAgentPlugins(async (signal) => {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(resolve, 750);
signal.addEventListener(
"abort",
() => {
clearTimeout(timeout);
reject(signal.reason);
},
{ once: true },
);
});
return [
{ provider: "acme", modelId: "fresh", name: "Fresh from catalogue", reasoning: false },
{ provider: "acme", modelId: "reasoning", name: "Reasoning model", reasoning: true },
];
});
const runtime = new SessionRuntime(plugins);
await runtime.start();
const server = new SessionTcpServer(runtime.driver, { host, port });
const address = await server.start();
process.stdout.write(`Session listening on ${address.host}:${address.port}\n`);
await new Promise<void>((resolve) => {
const stop = () => resolve();
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
process.once("SIGINT", resolve);
process.once("SIGTERM", resolve);
});
await server.close();
runtime.close();

View file

@ -1,8 +1,8 @@
import { ProcessTerminal } from "@earendil-works/pi-tui";
import { SessionClient } from "./client.ts";
import { createCodingAgentPlugins } from "./plugins.ts";
import { TcpClientTransport } from "./transport.ts";
import { MinimalCodingAgentTui } from "./tui/app.ts";
import { modelSelectorTui } from "./tui/model-selector.ts";
async function main(): Promise<void> {
const host = process.argv[2] ?? "127.0.0.1";
@ -10,7 +10,11 @@ async function main(): Promise<void> {
const transport = await TcpClientTransport.connect({ host, port, clientId: "tui" });
const client = new SessionClient(transport);
await client.ready;
const app = new MinimalCodingAgentTui(new ProcessTerminal(), client, [modelSelectorTui]);
const app = new MinimalCodingAgentTui(
new ProcessTerminal(),
client,
createCodingAgentPlugins(async () => []),
);
const stop = () => app.stop();
process.once("SIGINT", stop);
process.once("SIGTERM", stop);

View file

@ -1,76 +1,34 @@
import { type AppKeybinding, KEYBINDINGS } from "../../../src/core/keybindings.ts";
import type { AppDefinition, AppInstance, AppPlugin } from "./kernel.ts";
import type {
ClientSnapshot,
CodingAgentEvent,
CodingAgentSnapshot,
LaneConfiguration,
ModelSpec,
ProviderSnapshot,
ServerWireMessage,
SessionRequest,
WireView,
} from "./protocol.ts";
import { type AppPlugin, type MutableRemoteState, rpcOptions, type Service } from "./kernel.ts";
import type { ServerWireMessage, SessionRequest, StateSnapshot } from "./protocol.ts";
type Contribution<D> = (draft: D, owner: string) => void;
class SessionRemoteState<T> implements MutableRemoteState<T> {
private readonly listeners = new Set<(value: T) => void>();
private current: T;
class ReplayRegistry<D, S> {
private readonly build: (draft: D) => S;
private readonly contributions: Array<{ owner: string; run: Contribution<D> }> = [];
private readonly createDraft: () => D;
private state: S;
constructor(createDraft: () => D, build: (draft: D) => S) {
this.build = build;
this.createDraft = createDraft;
this.state = build(createDraft());
constructor(initial: T) {
this.current = structuredClone(initial);
}
add(owner: string, run: Contribution<D>): void {
this.contributions.push({ owner, run });
get value(): T {
return this.current;
}
rebuild(): S {
const draft = this.createDraft();
for (const { owner, run } of this.contributions) run(draft, owner);
this.state = this.build(draft);
return this.state;
set(value: T): void {
this.current = structuredClone(value);
for (const listener of this.listeners) listener(this.current);
}
current(): S {
return this.state;
subscribe(listener: (value: T) => void): () => void {
this.listeners.add(listener);
listener(this.current);
return () => this.listeners.delete(listener);
}
}
export interface InvocationContext {
originatorClientId: string;
signal: AbortSignal;
}
interface ActionDefinition {
description: string;
handler(context: InvocationContext): Promise<void>;
}
interface CommandDefinition {
argumentHint?: string;
description: string;
handler(args: string, context: InvocationContext): Promise<void>;
}
type ViewMessageHandler = (message: unknown, fromClientId: string) => void | Promise<void>;
interface ViewRecord extends WireView {
closed: boolean;
handler: ViewMessageHandler | undefined;
resolveDone(): void;
to: readonly string[];
}
class ClientHub {
private readonly connections = new Map<string, (message: ServerWireMessage) => void>();
connect(clientId: string, snapshot: () => ClientSnapshot, send: (message: ServerWireMessage) => void): () => void {
connect(clientId: string, snapshot: () => StateSnapshot, send: (message: ServerWireMessage) => void): () => void {
const pending: ServerWireMessage[] = [];
let ready = false;
const connection = (message: ServerWireMessage) => {
@ -78,7 +36,7 @@ class ClientHub {
else pending.push(message);
};
this.connections.set(clientId, connection);
send({ type: "snapshot", snapshot: structuredClone(snapshot()) });
send({ type: "snapshot", states: structuredClone(snapshot()) });
ready = true;
for (const message of pending) send(message);
return () => {
@ -89,388 +47,96 @@ class ClientHub {
broadcast(message: ServerWireMessage): void {
for (const send of this.connections.values()) send(structuredClone(message));
}
send(clientIds: readonly string[], message: ServerWireMessage): void {
for (const clientId of clientIds) this.connections.get(clientId)?.(structuredClone(message));
}
}
interface ViewController {
close(id: string): void;
setHandler(id: string, handler: ViewMessageHandler): void;
update(id: string, state: unknown): void;
}
export class ViewHandle<S> {
readonly done: Promise<void>;
readonly id: string;
private readonly controller: ViewController;
constructor(id: string, done: Promise<void>, controller: ViewController) {
this.controller = controller;
this.done = done;
this.id = id;
}
set state(state: S) {
this.controller.update(this.id, state);
}
on(handler: ViewMessageHandler): void {
this.controller.setHandler(this.id, handler);
}
close(): void {
this.controller.close(this.id);
}
}
class ViewService implements ViewController {
private readonly hub: ClientHub;
private readonly records = new Map<string, ViewRecord>();
private nextId = 1;
openedCount = 0;
constructor(hub: ClientHub) {
this.hub = hub;
}
open<S>(component: string, state: S, to: readonly string[]): ViewHandle<S> {
const id = `view-${this.nextId++}`;
let resolveDone!: () => void;
const done = new Promise<void>((resolve) => {
resolveDone = resolve;
});
const record: ViewRecord = {
closed: false,
component,
handler: undefined,
id,
resolveDone,
state: structuredClone(state),
to: [...to],
};
this.records.set(id, record);
this.openedCount++;
this.hub.send(record.to, { type: "view_updated", view: this.toWire(record) });
return new ViewHandle(id, done, this);
}
forClient(clientId: string): WireView[] {
return [...this.records.values()]
.filter((record) => !record.closed && record.to.includes(clientId))
.map((record) => this.toWire(record));
}
async send(id: string, clientId: string, message: unknown): Promise<void> {
const record = this.records.get(id);
if (!record || record.closed || !record.to.includes(clientId)) return;
await record.handler?.(structuredClone(message), clientId);
}
update(id: string, state: unknown): void {
const record = this.records.get(id);
if (!record || record.closed) return;
record.state = structuredClone(state);
this.hub.send(record.to, { type: "view_updated", view: this.toWire(record) });
}
setHandler(id: string, handler: ViewMessageHandler): void {
const record = this.records.get(id);
if (record && !record.closed) record.handler = handler;
}
close(id: string): void {
const record = this.records.get(id);
if (!record || record.closed) return;
record.closed = true;
this.hub.send(record.to, { type: "view_closed", viewId: id });
record.resolveDone();
}
closeAll(): void {
for (const id of this.records.keys()) this.close(id);
}
private toWire(record: ViewRecord): WireView {
return { id: record.id, component: record.component, state: structuredClone(record.state) };
}
}
interface ProviderEntry {
configured: boolean;
models: ModelSpec[];
refreshers: Array<{ id: string; refresh(signal: AbortSignal): Promise<void> }>;
}
class ProviderDraftState {
readonly entries = new Map<string, ProviderEntry>();
entry(id: string): ProviderEntry {
let entry = this.entries.get(id);
if (!entry) {
entry = { configured: false, models: [], refreshers: [] };
this.entries.set(id, entry);
}
return entry;
}
}
export class ProviderDraft {
private readonly owner: string;
private readonly state: ProviderDraftState;
constructor(owner: string, state: ProviderDraftState) {
this.owner = owner;
this.state = state;
}
native(id: string, models: readonly ModelSpec[]): void {
this.state.entry(id).models = models.map((model) => ({ ...model, provider: id }));
}
models(id: string, transform: (models: readonly ModelSpec[]) => readonly ModelSpec[]): void {
const entry = this.state.entry(id);
entry.models = transform(entry.models.map((model) => ({ ...model }))).map((model) => ({
...model,
provider: id,
}));
}
configured(id: string): void {
this.state.entry(id).configured = true;
}
refresh(id: string, localId: string, refresh: (signal: AbortSignal) => Promise<void>): void {
this.state.entry(id).refreshers.push({ id: `${this.owner}:${localId}`, refresh });
}
}
interface ProviderState {
availableModels: readonly ModelSpec[];
models: readonly ModelSpec[];
refreshers: ReadonlyArray<{ id: string; refresh(signal: AbortSignal): Promise<void> }>;
}
export class ProviderRegistry {
private readonly listeners = new Set<(snapshot: ProviderSnapshot) => void>();
private readonly registry = new ReplayRegistry(
() => new ProviderDraftState(),
(draft): ProviderState => {
const models: ModelSpec[] = [];
const availableModels: ModelSpec[] = [];
const refreshers: Array<{ id: string; refresh(signal: AbortSignal): Promise<void> }> = [];
for (const entry of draft.entries.values()) {
models.push(...entry.models.map((model) => ({ ...model })));
if (entry.configured) availableModels.push(...entry.models.map((model) => ({ ...model })));
refreshers.push(...entry.refreshers);
}
const byId = (left: ModelSpec, right: ModelSpec) =>
`${left.provider}/${left.modelId}`.localeCompare(`${right.provider}/${right.modelId}`);
return { availableModels: availableModels.sort(byId), models: models.sort(byId), refreshers };
},
);
private revision = 0;
private serialized = "";
add(owner: string, contribution: (draft: ProviderDraft) => void): void {
this.registry.add(owner, (draft) => contribution(new ProviderDraft(owner, draft)));
}
rebuild(): ProviderSnapshot {
const state = this.registry.rebuild();
const serialized = JSON.stringify({ availableModels: state.availableModels, models: state.models });
if (serialized !== this.serialized) {
this.serialized = serialized;
this.revision++;
for (const listener of this.listeners) listener(this.snapshot());
}
return this.snapshot();
}
snapshot(): ProviderSnapshot {
const state = this.registry.current();
return {
revision: this.revision,
availableModels: state.availableModels.map((model) => ({ ...model })),
models: state.models.map((model) => ({ ...model })),
};
}
onRebuild(listener: (snapshot: ProviderSnapshot) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
async refresh(signal: AbortSignal): Promise<{ aborted: boolean; errors: ReadonlyMap<string, string> }> {
signal.throwIfAborted();
const errors = new Map<string, string>();
await Promise.all(
this.registry.current().refreshers.map(async ({ id, refresh }) => {
try {
await refresh(signal);
} catch (error) {
if (!signal.aborted) errors.set(id, error instanceof Error ? error.message : String(error));
}
}),
);
if (!signal.aborted) this.rebuild();
return { aborted: signal.aborted, errors };
}
}
export interface CodingAgentApi {
actions: { add(contribution: (draft: Map<string, ActionDefinition>) => void): void };
commands: { add(contribution: (draft: Map<string, CommandDefinition>) => void): void };
providers: {
add(contribution: (draft: ProviderDraft) => void): void;
onRebuild(listener: (snapshot: ProviderSnapshot) => void): () => void;
refresh(signal: AbortSignal): Promise<{ aborted: boolean; errors: ReadonlyMap<string, string> }>;
snapshot(): ProviderSnapshot;
};
session: {
configure(configuration: LaneConfiguration): Promise<void>;
getConfiguration(): LaneConfiguration;
};
settings: { set(key: string, value: unknown): Promise<void> };
view<S>(component: string, state: S, options: { to: readonly string[] }): ViewHandle<S>;
export interface SessionContext {
onActivate(callback: () => void | Promise<void>): void;
onClose(callback: () => void): void;
provide<T>(service: Service<T>, implementation: T): void;
state<T>(initial: T): MutableRemoteState<T>;
use<T>(service: Service<T>): T;
}
export interface SessionDriver {
readonly openedViewCount: number;
readonly trace: readonly string[];
connect(clientId: string, send: (message: ServerWireMessage) => void): () => void;
getConfiguration(): LaneConfiguration;
invokeAction(clientId: string, id: AppKeybinding): Promise<void>;
providers: ProviderRegistry;
request(clientId: string, request: SessionRequest): Promise<void>;
settings: ReadonlyMap<string, unknown>;
submit(clientId: string, input: string): Promise<void>;
request(clientId: string, request: SessionRequest, signal: AbortSignal): Promise<unknown>;
use<T>(service: Service<T>): T;
}
export type CodingAgentPlugin = AppPlugin<CodingAgentApi>;
export type CodingAgentApp = AppDefinition<CodingAgentApi, SessionDriver>;
export class SessionRuntime<ClientContext> {
readonly driver: SessionDriver;
private readonly activateCallbacks: Array<() => void | Promise<void>> = [];
private readonly closeCallbacks: Array<() => void> = [];
private readonly hub = new ClientHub();
private readonly plugins: readonly AppPlugin<SessionContext, ClientContext>[];
private readonly services = new Map<string, object>();
private readonly trace: string[] = [];
export function createSessionInstance(): AppInstance<CodingAgentApi, SessionDriver> {
const actions = new ReplayRegistry<Map<string, ActionDefinition>, ReadonlyMap<string, ActionDefinition>>(
() => new Map(),
(draft) => new Map(draft),
);
const commands = new ReplayRegistry<Map<string, CommandDefinition>, ReadonlyMap<string, CommandDefinition>>(
() => new Map(),
(draft) => new Map(draft),
);
const providers = new ProviderRegistry();
const settings = new Map<string, unknown>();
const trace: string[] = [];
const activeInvocations = new Set<AbortController>();
const hub = new ClientHub();
const views = new ViewService(hub);
let configuration: LaneConfiguration = { model: undefined, thinkingLevel: "high" };
constructor(plugins: readonly AppPlugin<SessionContext, ClientContext>[]) {
this.plugins = plugins;
this.driver = {
trace: this.trace,
connect: (clientId, send) => this.hub.connect(clientId, () => this.snapshot(), send),
request: async (_clientId, request, signal) => {
const service = this.services.get(request.service) as Record<string, unknown> | undefined;
const method = service?.[request.method];
if (typeof method !== "function") throw new Error(`Unknown RPC: ${request.service}.${request.method}`);
this.trace.push(`rpc:${request.service}.${request.method}`);
const args = request.rpcOptions ? [...request.args, rpcOptions({ signal })] : request.args;
return Reflect.apply(method, service, args);
},
use: (service) => this.use(service),
};
}
const appSnapshot = (): CodingAgentSnapshot => ({
actions: [...actions.current()]
.filter((entry): entry is [AppKeybinding, ActionDefinition] => Object.hasOwn(KEYBINDINGS, entry[0]))
.map(([id, action]) => ({ id, description: action.description })),
commands: [...commands.current()].map(([name, command]) => ({
name,
description: command.description,
...(command.argumentHint ? { argumentHint: command.argumentHint } : {}),
})),
configuration: structuredClone(configuration),
providers: providers.snapshot(),
});
providers.onRebuild((snapshot) =>
hub.broadcast({ type: "event", event: { type: "providers_changed", providers: snapshot } }),
);
async start(): Promise<void> {
const context: SessionContext = {
onActivate: (callback) => this.activateCallbacks.push(callback),
onClose: (callback) => this.closeCallbacks.push(callback),
provide: (service, implementation) => this.provide(service, implementation),
state: (initial) => new SessionRemoteState(initial),
use: (service) => this.use(service),
};
for (const plugin of this.plugins) await plugin.session?.(context);
for (const activate of this.activateCallbacks) await activate();
}
const invoke = async (clientId: string, run: (context: InvocationContext) => Promise<void>): Promise<void> => {
const controller = new AbortController();
activeInvocations.add(controller);
try {
await run({ originatorClientId: clientId, signal: controller.signal });
} finally {
activeInvocations.delete(controller);
close(): void {
for (const close of this.closeCallbacks.splice(0).reverse()) close();
}
private provide<T>(service: Service<T>, implementation: T): void {
if (this.services.has(service.id)) throw new Error(`Service already provided: ${service.id}`);
this.services.set(service.id, implementation as object);
for (const [property, value] of Object.entries(implementation as object)) {
if (!(value instanceof SessionRemoteState)) continue;
value.subscribe((next) => {
this.hub.broadcast({
type: "state_update",
service: service.id,
property,
value: structuredClone(next),
});
});
}
};
}
const submit = async (clientId: string, input: string): Promise<void> => {
if (!input.startsWith("/")) throw new Error("Only slash commands are supported by this test app");
const separator = input.indexOf(" ");
const name = input.slice(1, separator < 0 ? undefined : separator);
const args = separator < 0 ? "" : input.slice(separator + 1).trim();
const command = commands.current().get(name);
if (!command) throw new Error(`Unknown command: ${name}`);
trace.push(`command:${name}`);
await invoke(clientId, (context) => command.handler(args, context));
};
private use<T>(service: Service<T>): T {
const implementation = this.services.get(service.id);
if (!implementation) throw new Error(`Service not provided: ${service.id}`);
return implementation as T;
}
const driver: SessionDriver = {
get openedViewCount() {
return views.openedCount;
},
trace,
connect: (clientId, send) =>
hub.connect(clientId, () => ({ app: appSnapshot(), views: views.forClient(clientId) }), send),
getConfiguration: () => structuredClone(configuration),
invokeAction: async (clientId, id) => {
const action = actions.current().get(id);
if (!action) throw new Error(`Unknown action: ${id}`);
trace.push(`action:${id}`);
await invoke(clientId, action.handler);
},
providers,
request: async (clientId, request) => {
if (request.type === "submit") await submit(clientId, request.input);
else if (request.type === "invoke_action") await driver.invokeAction(clientId, request.id);
else await views.send(request.viewId, clientId, request.message);
},
settings,
submit,
};
return {
driver,
activate() {
actions.rebuild();
commands.rebuild();
providers.rebuild();
},
api(owner) {
return {
actions: { add: (contribution) => actions.add(owner, (draft) => contribution(draft)) },
commands: { add: (contribution) => commands.add(owner, (draft) => contribution(draft)) },
providers: {
add: (contribution) => providers.add(owner, contribution),
onRebuild: (listener) => providers.onRebuild(listener),
refresh: (signal) => providers.refresh(signal),
snapshot: () => providers.snapshot(),
},
session: {
configure: async (next) => {
const previous = structuredClone(configuration);
configuration = structuredClone(next);
trace.push(`config:${next.model?.provider}/${next.model?.modelId}`);
const event: CodingAgentEvent = { type: "config_update", previous, value: structuredClone(next) };
hub.broadcast({ type: "event", event });
},
getConfiguration: () => structuredClone(configuration),
},
settings: {
set: async (key, value) => {
settings.set(key, structuredClone(value));
trace.push(`setting:${key}`);
},
},
view: (component, state, options) => {
trace.push(`view:open:${component}`);
return views.open(component, state, options.to);
},
};
},
close() {
for (const controller of activeInvocations) controller.abort();
views.closeAll();
},
};
private snapshot(): StateSnapshot {
const snapshot: StateSnapshot = {};
for (const [serviceId, implementation] of this.services) {
const states: Record<string, unknown> = {};
for (const [property, value] of Object.entries(implementation)) {
if (value instanceof SessionRemoteState) states[property] = structuredClone(value.value);
}
if (Object.keys(states).length > 0) snapshot[serviceId] = states;
}
return snapshot;
}
}

View file

@ -76,6 +76,7 @@ export class SessionTcpServer {
private accept(socket: Socket): void {
this.sockets.add(socket);
const peer = new JsonLineSocket<ClientWireMessage, ServerWireMessage>(socket);
const requests = new Map<number, AbortController>();
let clientId: string | undefined;
let disconnect: (() => void) | undefined;
peer.onMessage((message) => {
@ -89,17 +90,28 @@ export class SessionTcpServer {
peer.send({ type: "response", id: message.id, error: "Client has not sent hello" });
return;
}
void this.driver.request(clientId, message.request).then(
() => peer.send({ type: "response", id: message.id }),
(error: unknown) =>
peer.send({
type: "response",
id: message.id,
error: error instanceof Error ? error.message : String(error),
}),
);
if (message.type === "cancel") {
requests.get(message.id)?.abort();
return;
}
const controller = new AbortController();
requests.set(message.id, controller);
void this.driver
.request(clientId, message.request, controller.signal)
.then(
(result) => peer.send({ type: "response", id: message.id, result }),
(error: unknown) =>
peer.send({
type: "response",
id: message.id,
error: error instanceof Error ? error.message : String(error),
}),
)
.finally(() => requests.delete(message.id));
});
socket.once("close", () => {
for (const controller of requests.values()) controller.abort();
requests.clear();
disconnect?.();
this.sockets.delete(socket);
});
@ -109,7 +121,10 @@ export class SessionTcpServer {
export class TcpClientTransport implements ClientTransport {
private readonly buffered: ServerWireMessage[] = [];
private readonly peer: JsonLineSocket<ServerWireMessage, ClientWireMessage>;
private readonly pending = new Map<number, { resolve(): void; reject(error: Error): void }>();
private readonly pending = new Map<
number,
{ resolve(value: unknown): void; reject(error: unknown): void; stopAbort(): void }
>();
private readonly socket: Socket;
private listener: ((message: ServerWireMessage) => void) | undefined;
private nextRequestId = 1;
@ -122,8 +137,9 @@ export class TcpClientTransport implements ClientTransport {
const pending = this.pending.get(message.id);
if (!pending) return;
this.pending.delete(message.id);
pending.stopAbort();
if (message.error) pending.reject(new Error(message.error));
else pending.resolve();
else pending.resolve(message.result);
} else if (this.listener) {
this.listener(message);
} else {
@ -131,7 +147,10 @@ export class TcpClientTransport implements ClientTransport {
}
});
socket.once("close", () => {
for (const pending of this.pending.values()) pending.reject(new Error("Session connection closed"));
for (const pending of this.pending.values()) {
pending.stopAbort();
pending.reject(new Error("Session connection closed"));
}
this.pending.clear();
});
this.peer.send({ type: "hello", clientId });
@ -151,10 +170,18 @@ export class TcpClientTransport implements ClientTransport {
for (const message of this.buffered.splice(0)) listener(message);
}
request(request: SessionRequest): Promise<void> {
request(request: SessionRequest, signal?: AbortSignal): Promise<unknown> {
if (signal?.aborted) return Promise.reject(signal.reason);
const id = this.nextRequestId++;
return new Promise<void>((resolve, reject) => {
this.pending.set(id, { resolve, reject });
return new Promise<unknown>((resolve, reject) => {
const abort = () => {
if (!this.pending.delete(id)) return;
this.peer.send({ type: "cancel", id });
reject(signal?.reason ?? new Error("RPC aborted"));
};
const stopAbort = () => signal?.removeEventListener("abort", abort);
this.pending.set(id, { resolve, reject, stopAbort });
signal?.addEventListener("abort", abort, { once: true });
this.peer.send({ type: "request", id, request });
});
}
@ -165,6 +192,7 @@ export class TcpClientTransport implements ClientTransport {
}
export class LoopbackTransport implements ClientTransport {
private readonly active = new Set<AbortController>();
private readonly buffered: ServerWireMessage[] = [];
private readonly disconnect: () => void;
private readonly driver: SessionDriver;
@ -185,11 +213,34 @@ export class LoopbackTransport implements ClientTransport {
for (const message of this.buffered.splice(0)) listener(message);
}
request(request: SessionRequest): Promise<void> {
return this.driver.request(this.clientId, request);
request(request: SessionRequest, signal?: AbortSignal): Promise<unknown> {
if (signal?.aborted) return Promise.reject(signal.reason);
const controller = new AbortController();
this.active.add(controller);
return new Promise<unknown>((resolve, reject) => {
let settled = false;
const finish = (settle: () => void) => {
if (settled) return;
settled = true;
signal?.removeEventListener("abort", abort);
this.active.delete(controller);
settle();
};
const abort = () => {
controller.abort(signal?.reason);
finish(() => reject(signal?.reason ?? new Error("RPC aborted")));
};
signal?.addEventListener("abort", abort, { once: true });
void this.driver.request(this.clientId, request, controller.signal).then(
(value) => finish(() => resolve(value)),
(error: unknown) => finish(() => reject(error)),
);
});
}
close(): void {
for (const controller of this.active) controller.abort();
this.active.clear();
this.disconnect();
}
}

View file

@ -8,34 +8,61 @@ import {
TuiAltScreen,
VStack,
} from "@earendil-works/pi-tui";
import { KeybindingsManager } from "../../../../src/core/keybindings.ts";
import { type AppKeybinding, KeybindingsManager } from "../../../../src/core/keybindings.ts";
import { CustomEditor } from "../../../../src/modes/interactive/components/custom-editor.ts";
import type { SessionClient } from "../client.ts";
import type { CodingAgentEvent } from "../protocol.ts";
import type { CodingAgentPlugin } from "../plugins.ts";
import { type LaneConfiguration, Models, type ModelsService } from "../protocol.ts";
import { PluginFooter } from "./footer.ts";
import { accent, bold, border, dim, errorStyle, type TuiPlugin, type ViewRenderer } from "./shared.ts";
import {
accent,
bold,
border,
dim,
errorStyle,
type TuiCommand,
type TuiContext,
type ViewRenderer,
} from "./shared.ts";
/** Alternate-screen projection of the remote client store. */
/** Alternate-screen client whose commands and views are supplied by plugins. */
export class MinimalCodingAgentTui {
readonly done: Promise<void>;
readonly tui: TuiAltScreen;
private readonly actions = new Map<AppKeybinding, () => void | Promise<void>>();
private readonly client: SessionClient;
private readonly commands = new Map<string, TuiCommand>();
private readonly editor: CustomEditor;
private readonly editorContainer = new Container();
private readonly models: ModelsService;
private readonly renderers = new Map<string, ViewRenderer>();
private readonly resolveDone: () => void;
private readonly root: VStack;
private readonly statusContainer = new Container();
private readonly transcript = new Container();
private eventIndex = 0;
private activeView: string | undefined;
private lastClearTime = 0;
private lastConfiguration: LaneConfiguration;
private query = "";
private stopped = false;
private unsubscribe: (() => void) | undefined;
constructor(terminal: Terminal, client: SessionClient, plugins: readonly TuiPlugin[]) {
constructor(terminal: Terminal, client: SessionClient, plugins: readonly CodingAgentPlugin[]) {
this.client = client;
for (const plugin of plugins) plugin.setup(this.renderers);
this.models = client.use(Models);
this.lastConfiguration = structuredClone(this.models.state.value.configuration);
const context: TuiContext = {
actions: { register: (id, handler) => this.actions.set(id, handler) },
commands: { register: (command) => this.commands.set(command.name, command) },
use: (service) => client.use(service),
views: {
close: () => this.closeView(),
open: (component, query = "") => this.openView(component, query),
register: (component, renderer) => this.renderers.set(component, renderer),
},
};
for (const plugin of plugins) plugin.client?.(context);
this.tui = new TuiAltScreen(terminal);
this.editor = new CustomEditor(
this.tui,
@ -52,20 +79,38 @@ export class MinimalCodingAgentTui {
new KeybindingsManager(),
{ paddingX: 1 },
);
for (const [id, handler] of this.actions) {
this.editor.onAction(
id,
() => void Promise.resolve(handler()).catch((error: unknown) => this.showError(error)),
);
}
this.editor.onAction("app.clear", () => this.handleClear());
this.editor.onSubmit = (value) => {
this.editor.addToHistory(value);
this.submit(value);
};
this.editor.setAutocompleteProvider(
new CombinedAutocompleteProvider(
[
...[...this.commands.values()].map(({ name, description, argumentHint }) => ({
name,
description,
argumentHint,
})),
{ name: "quit", description: "Exit the plugin application" },
],
process.cwd(),
),
);
this.editorContainer.addChild(this.editor);
this.transcript.addChild(new Text(`${bold(accent("pi"))}${dim(" plugin application")}`, 1, 0));
this.transcript.addChild(new Spacer(1));
this.transcript.addChild(new Text("Remote session connected. Type /model to select a model.", 1, 0));
const dock = new VStack([
{ component: this.statusContainer, shrink: 1, minSize: 0 },
{ component: this.editorContainer, shrink: 1, minSize: 3 },
{ component: new PluginFooter(client.store), basis: 2, shrink: 0 },
{ component: new PluginFooter(this.models.state, () => client.store.updates), basis: 2, shrink: 0 },
]);
this.root = new VStack([
{
@ -82,7 +127,6 @@ export class MinimalCodingAgentTui {
},
{ component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
]);
let settleDone!: () => void;
this.done = new Promise<void>((resolve) => {
settleDone = resolve;
@ -92,19 +136,8 @@ export class MinimalCodingAgentTui {
start(): void {
if (this.unsubscribe || this.stopped) return;
for (const action of this.client.store.app.actions) {
this.editor.onAction(action.id, () => {
void this.client.invokeAction(action.id).catch((error: unknown) => this.showError(error));
});
}
this.editor.setAutocompleteProvider(
new CombinedAutocompleteProvider(
[...this.client.store.app.commands, { name: "quit", description: "Exit the plugin application" }],
process.cwd(),
),
);
this.unsubscribe = this.client.store.subscribe(() => {
this.reduceEvents();
this.applyConfiguration();
this.renderView();
this.updateEditorBorder();
this.tui.requestRender();
@ -115,18 +148,29 @@ export class MinimalCodingAgentTui {
}
submit(input: string): void {
const command = input.trim();
if (!command) return;
if (command === "/quit" || command === "quit") {
const value = input.trim();
if (!value) return;
if (value === "/quit" || value === "quit") {
this.stop();
return;
}
if (command !== "/model") {
this.transcript.addChild(new Spacer(1));
this.transcript.addChild(new Text(`${accent(">")} ${command}`, 1, 0));
this.showStatus(`Running ${command}`);
if (!value.startsWith("/")) {
this.showError(new Error("Only slash commands are supported by this test app"));
return;
}
void this.client.submit(command).catch((error: unknown) => this.showError(error));
const separator = value.indexOf(" ");
const name = value.slice(1, separator < 0 ? undefined : separator);
const args = separator < 0 ? "" : value.slice(separator + 1).trim();
const command = this.commands.get(name);
if (!command) {
this.showError(new Error(`Unknown command: ${name}`));
return;
}
if (name !== "model") {
this.transcript.addChild(new Spacer(1));
this.transcript.addChild(new Text(`${accent("> ")}${value}`, 1, 0));
}
void Promise.resolve(command.run(args)).catch((error: unknown) => this.showError(error));
this.tui.requestRender();
}
@ -140,47 +184,52 @@ export class MinimalCodingAgentTui {
this.resolveDone();
}
private reduceEvents(): void {
while (this.eventIndex < this.client.store.events.length) {
this.applyEvent(this.client.store.events[this.eventIndex++]!);
private applyConfiguration(): void {
const current = this.models.state.value.configuration;
if (
this.lastConfiguration.model?.provider !== current.model?.provider ||
this.lastConfiguration.model?.modelId !== current.model?.modelId
) {
this.showStatus(current.model ? `Model: ${current.model.modelId}` : "Model cleared");
} else if (this.lastConfiguration.thinkingLevel !== current.thinkingLevel) {
this.showStatus(`Thinking level: ${current.thinkingLevel}`);
}
this.lastConfiguration = structuredClone(current);
}
private applyEvent(event: CodingAgentEvent): void {
if (event.type !== "config_update") return;
if (
event.previous.model?.provider !== event.value.model?.provider ||
event.previous.model?.modelId !== event.value.model?.modelId
) {
this.showStatus(event.value.model ? `Model: ${event.value.model.modelId}` : "Model cleared");
} else if (event.previous.thinkingLevel !== event.value.thinkingLevel) {
this.showStatus(`Thinking level: ${event.value.thinkingLevel}`);
}
private openView(component: string, query: string): void {
this.activeView = component;
this.query = query;
this.renderView();
this.tui.requestRender();
}
private closeView(): void {
this.activeView = undefined;
this.query = "";
this.renderView();
this.tui.requestRender();
}
private renderView(): void {
this.editorContainer.clear();
const view = this.client.store.views[0];
if (!view) {
this.query = "";
if (!this.activeView) {
this.editorContainer.addChild(this.editor);
this.tui.setFocus(this.editor);
return;
}
const renderer = this.renderers.get(view.component);
const renderer = this.renderers.get(this.activeView);
if (!renderer) {
this.editorContainer.addChild(new Text(dim(`No TUI renderer for ${view.component}`), 1, 0));
this.editorContainer.addChild(new Text(dim(`No TUI renderer for ${this.activeView}`), 1, 0));
this.tui.setFocus(null);
return;
}
const rendered = renderer({
app: this.client.store.app,
close: () => this.closeView(),
query: this.query,
onQueryChange: (query) => {
setQuery: (query) => {
this.query = query;
},
send: (message) => this.client.sendView(view.id, message),
view,
});
this.editorContainer.addChild(rendered.component);
this.tui.setFocus(rendered.focus);
@ -209,13 +258,13 @@ export class MinimalCodingAgentTui {
}
private updateEditorBorder(): void {
const snapshot = this.client.store.app;
const current = snapshot.configuration.model;
const currentSpec = current
? snapshot.providers.availableModels.find(
const state = this.models.state.value;
const current = state.configuration.model;
const selected = current
? state.catalog.availableModels.find(
(model) => model.provider === current.provider && model.modelId === current.modelId,
)
: undefined;
this.editor.borderColor = currentSpec?.reasoning ? accent : border;
this.editor.borderColor = selected?.reasoning ? accent : border;
}
}

View file

@ -1,38 +1,41 @@
import { homedir } from "node:os";
import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import type { CodingAgentClientStore } from "../client.ts";
import type { RemoteState } from "../kernel.ts";
import type { ModelsState } from "../protocol.ts";
import { dim } from "./shared.ts";
export class PluginFooter implements Component {
private readonly store: CodingAgentClientStore;
private readonly models: RemoteState<ModelsState>;
private readonly updateCount: () => number;
constructor(store: CodingAgentClientStore) {
this.store = store;
constructor(models: RemoteState<ModelsState>, updateCount: () => number) {
this.models = models;
this.updateCount = updateCount;
}
invalidate(): void {}
render(width: number): string[] {
const snapshot = this.store.app;
const state = this.models.value;
const home = homedir();
const cwd = process.cwd();
const displayCwd = cwd === home ? "~" : cwd.startsWith(`${home}/`) ? `~/${cwd.slice(home.length + 1)}` : cwd;
const current = snapshot.configuration.model;
const current = state.configuration.model;
const currentSpec = current
? snapshot.providers.availableModels.find(
? state.catalog.availableModels.find(
(model) => model.provider === current.provider && model.modelId === current.modelId,
)
: undefined;
const model = current
? `(${current.provider}) ${current.modelId}${
currentSpec?.reasoning
? snapshot.configuration.thinkingLevel === "off"
? state.configuration.thinkingLevel === "off"
? " • thinking off"
: ` • ${snapshot.configuration.thinkingLevel}`
: ` • ${state.configuration.thinkingLevel}`
: ""
}`
: "no-model";
const left = `events ${this.store.events.length} · catalogue r${snapshot.providers.revision}`;
const left = `updates ${this.updateCount()} · catalogue r${state.catalog.revision}`;
const right = truncateToWidth(model, Math.max(0, width - visibleWidth(left) - 2), "");
const padding = " ".repeat(Math.max(2, width - visibleWidth(left) - visibleWidth(right)));
return [truncateToWidth(dim(displayCwd), width, dim("...")), dim(`${left}${padding}${right}`)];

View file

@ -1,60 +1,91 @@
import { type Component, Container, type Focusable, getKeybindings, Input, Spacer, Text } from "@earendil-works/pi-tui";
import type { ModelSelectorState, ModelSpec } from "../protocol.ts";
import { accent, border, dim, success, type TuiPlugin, warning } from "./shared.ts";
import { rpcOptions } from "../kernel.ts";
import type { ModelSpec, ModelsService, ModelsState } from "../protocol.ts";
import { accent, border, dim, success, type TuiContext, warning } from "./shared.ts";
class Rule implements Component {
invalidate(): void {}
render(width: number): string[] {
return [border("─".repeat(Math.max(1, width)))];
}
}
export function isModelSelectorState(value: unknown): value is ModelSelectorState {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Partial<ModelSelectorState>;
return candidate.schema === 1 && typeof candidate.catalogRevision === "number";
export function registerModelTui(context: TuiContext, models: ModelsService): void {
let refreshController: AbortController | undefined;
const resolve = (query: string): ModelSpec | undefined => {
const available = models.state.value.catalog.availableModels;
const qualified = available.find((model) => `${model.provider}/${model.modelId}` === query);
if (qualified) return qualified;
const unqualified = available.filter((model) => model.modelId === query);
return unqualified.length === 1 ? unqualified[0] : undefined;
};
context.commands.register({
name: "model",
description: "Select model",
argumentHint: "[provider/]model",
async run(args) {
if (args) {
let model = resolve(args);
if (!model) {
await models.refresh();
model = resolve(args);
}
if (model) return models.select(model);
}
refreshController?.abort();
const controller = new AbortController();
refreshController = controller;
context.views.open("model-selector", args);
void models
.refresh(rpcOptions({ signal: controller.signal }))
.catch(() => {})
.finally(() => {
if (refreshController === controller) refreshController = undefined;
});
},
});
context.views.register("model-selector", (view) => {
const close = () => {
refreshController?.abort();
refreshController = undefined;
view.close();
};
const component = new ModelSelector({
state: models.state.value,
query: view.query,
setQuery: view.setQuery,
cancel: close,
select: async (model) => {
await models.select(model);
close();
},
});
return { component, focus: component };
});
}
export const modelSelectorTui: TuiPlugin = {
id: "@pi/model-selection-tui",
setup(renderers) {
renderers.set("model-selector", (context) => {
if (!isModelSelectorState(context.view.state)) throw new Error("Invalid model-selector state");
const component = new ModelSelectorComponent({
state: context.view.state,
models: context.app.providers.availableModels,
query: context.query || context.view.state.query,
onQueryChange: context.onQueryChange,
send: context.send,
});
return { component, focus: component };
});
},
};
export class ModelSelectorComponent extends Container implements Focusable {
class ModelSelector extends Container implements Focusable {
private readonly input = new Input();
private readonly list = new Container();
private readonly models: readonly ModelSpec[];
private readonly onQueryChange: (query: string) => void;
private readonly send: (message: unknown) => Promise<void>;
private readonly state: ModelSelectorState;
private filteredModels: readonly ModelSpec[] = [];
private selectedIndex = 0;
private readonly cancel: () => void;
private readonly select: (model: ModelSpec) => Promise<void>;
private readonly setQuery: (query: string) => void;
private readonly state: ModelsState;
private filtered: readonly ModelSpec[] = [];
private selected = 0;
private _focused = false;
constructor(options: {
state: ModelSelectorState;
models: readonly ModelSpec[];
state: ModelsState;
query: string;
onQueryChange(query: string): void;
send(message: unknown): Promise<void>;
setQuery(query: string): void;
cancel(): void;
select(model: ModelSpec): Promise<void>;
}) {
super();
this.models = options.models;
this.onQueryChange = options.onQueryChange;
this.send = options.send;
this.cancel = options.cancel;
this.select = options.select;
this.setQuery = options.setQuery;
this.state = options.state;
this.input.setValue(options.query);
this.addChild(new Rule());
@ -68,61 +99,51 @@ export class ModelSelectorComponent extends Container implements Focusable {
this.addChild(this.list);
this.addChild(new Spacer(1));
this.addChild(new Rule());
this.updateList();
this.update();
}
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.input.focused = value;
}
handleInput(data: string): void {
const keybindings = getKeybindings();
if (keybindings.matches(data, "tui.select.up")) {
if (this.filteredModels.length > 0) {
this.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1;
this.updateList();
}
} else if (keybindings.matches(data, "tui.select.down")) {
if (this.filteredModels.length > 0) {
this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1;
this.updateList();
}
} else if (keybindings.matches(data, "tui.select.confirm")) {
const model = this.filteredModels[this.selectedIndex];
if (model) void this.send({ type: "select", provider: model.provider, modelId: model.modelId });
} else if (keybindings.matches(data, "tui.select.cancel")) {
void this.send({ type: "cancel" });
const keys = getKeybindings();
if (keys.matches(data, "tui.select.up")) {
if (this.filtered.length) this.selected = this.selected ? this.selected - 1 : this.filtered.length - 1;
} else if (keys.matches(data, "tui.select.down")) {
if (this.filtered.length) this.selected = this.selected === this.filtered.length - 1 ? 0 : this.selected + 1;
} else if (keys.matches(data, "tui.select.confirm")) {
const model = this.filtered[this.selected];
if (model) void this.select(model);
return;
} else if (keys.matches(data, "tui.select.cancel")) {
this.cancel();
return;
} else {
this.input.handleInput(data);
this.onQueryChange(this.input.getValue());
this.selectedIndex = 0;
this.updateList();
this.setQuery(this.input.getValue());
this.selected = 0;
}
this.update();
}
private updateList(): void {
private update(): void {
const query = this.input.getValue().trim().toLowerCase();
this.filteredModels = query
? this.models.filter((model) =>
`${model.provider} ${model.modelId} ${model.name}`.toLowerCase().includes(query),
)
: this.models;
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
const models = this.state.catalog.availableModels;
this.filtered = query
? models.filter((model) => `${model.provider} ${model.modelId} ${model.name}`.toLowerCase().includes(query))
: models;
this.selected = Math.min(this.selected, Math.max(0, this.filtered.length - 1));
this.list.clear();
const maxVisible = 10;
const start = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredModels.length - maxVisible),
);
for (let index = start; index < Math.min(start + maxVisible, this.filteredModels.length); index++) {
const model = this.filteredModels[index];
const start = Math.max(0, Math.min(this.selected - 5, this.filtered.length - 10));
for (let index = start; index < Math.min(start + 10, this.filtered.length); index++) {
const model = this.filtered[index];
if (!model) continue;
const selected = index === this.selectedIndex;
const selected = index === this.selected;
this.list.addChild(
new Text(
`${selected ? accent("→ ") : " "}${selected ? accent(model.modelId) : model.modelId} ${dim(`[${model.provider}]`)}`,
@ -131,22 +152,20 @@ export class ModelSelectorComponent extends Container implements Focusable {
),
);
}
if (this.filteredModels.length === 0) {
this.list.addChild(new Text(dim(" No matching models"), 0, 0));
} else {
const selected = this.filteredModels[this.selectedIndex];
if (selected) {
if (!this.filtered.length) this.list.addChild(new Text(dim(" No matching models"), 0, 0));
else {
const model = this.filtered[this.selected];
if (model) {
this.list.addChild(new Spacer(1));
this.list.addChild(new Text(dim(` Model Name: ${selected.name}`), 0, 0));
this.list.addChild(new Text(dim(` Model Name: ${model.name}`), 0, 0));
}
}
this.list.addChild(new Spacer(1));
if (this.state.refresh.status === "refreshing") {
if (this.state.refresh.status === "refreshing")
this.list.addChild(new Text(dim(" Refreshing model catalogs…"), 0, 0));
} else if (this.state.refresh.status === "warning") {
else if (this.state.refresh.status === "warning")
this.list.addChild(new Text(warning(" Could not refresh model catalogs; showing cached models."), 0, 0));
} else {
else if (this.state.refresh.status === "done")
this.list.addChild(new Text(success(" Model catalogs refreshed."), 0, 0));
}
}
}

View file

@ -1,5 +1,6 @@
import type { Component } from "@earendil-works/pi-tui";
import type { CodingAgentSnapshot, WireView } from "../protocol.ts";
import type { AppKeybinding } from "../../../../src/core/keybindings.ts";
import type { Service } from "../kernel.ts";
const style =
(open: number, close: number) =>
@ -14,22 +15,33 @@ export const warning = style(33, 39);
export const errorStyle = style(31, 39);
export const border = (text: string): string => `\x1b[38;5;240m${text}\x1b[39m`;
export interface TuiCommand {
name: string;
description: string;
argumentHint?: string;
run(args: string): void | Promise<void>;
}
export interface RenderedView {
component: Component;
focus: Component;
}
export interface ViewRendererContext {
app: CodingAgentSnapshot;
export interface LocalViewContext {
close(): void;
query: string;
onQueryChange(query: string): void;
send(message: unknown): Promise<void>;
view: WireView;
setQuery(query: string): void;
}
export type ViewRenderer = (context: ViewRendererContext) => RenderedView;
export type ViewRenderer = (context: LocalViewContext) => RenderedView;
export interface TuiPlugin {
id: string;
setup(renderers: Map<string, ViewRenderer>): void;
export interface TuiContext {
actions: { register(id: AppKeybinding, handler: () => void | Promise<void>): void };
commands: { register(command: TuiCommand): void };
use<T>(service: Service<T>): T;
views: {
close(): void;
open(component: string, query?: string): void;
register(component: string, renderer: ViewRenderer): void;
};
}

View file

@ -1,50 +1,40 @@
import { describe, expect, it, vi } from "vitest";
import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts";
import { SessionClient } from "./fixtures/plugin-app/client.ts";
import { defineApp, TestAppRuntime } from "./fixtures/plugin-app/kernel.ts";
import { createCodingAgentApp } from "./fixtures/plugin-app/plugins.ts";
import type { ModelSelectorState, ModelSpec, WireView } from "./fixtures/plugin-app/protocol.ts";
import { createCodingAgentPlugins, Settings } from "./fixtures/plugin-app/plugins.ts";
import { type ModelSpec, Models } from "./fixtures/plugin-app/protocol.ts";
import { SessionRuntime } from "./fixtures/plugin-app/session.ts";
import { LoopbackTransport } from "./fixtures/plugin-app/transport.ts";
import { modelSelectorTui } from "./fixtures/plugin-app/tui/model-selector.ts";
import type { ViewRenderer } from "./fixtures/plugin-app/tui/shared.ts";
import { MinimalCodingAgentTui } from "./fixtures/plugin-app/tui/app.ts";
interface Deferred<T> {
promise: Promise<T>;
reject(error: unknown): void;
resolve(value: T): void;
}
function createDeferred<T>(): Deferred<T> {
let rejectPromise!: (error: unknown) => void;
let resolvePromise!: (value: T) => void;
const promise = new Promise<T>((resolve, reject) => {
rejectPromise = reject;
const promise = new Promise<T>((resolve) => {
resolvePromise = resolve;
});
return { promise, reject: rejectPromise, resolve: resolvePromise };
return { promise, resolve: resolvePromise };
}
function modelView(client: SessionClient): WireView & { state: ModelSelectorState } {
const view = client.store.views.find((candidate) => candidate.component === "model-selector");
if (!view || typeof view.state !== "object" || view.state === null || !("schema" in view.state)) {
throw new Error("Model selector view is not open");
}
return view as WireView & { state: ModelSelectorState };
}
describe("app-as-plugins /model vertical slice", () => {
it("builds the session from plugins and runs /model over a serializable client boundary", async () => {
describe("app-as-plugins model service", () => {
it("replicates RemoteState and invokes typed methods over a serializable boundary", async () => {
const catalogue = createDeferred<readonly ModelSpec[]>();
let refreshCalls = 0;
const definition = createCodingAgentApp((_signal) => {
const plugins = createCodingAgentPlugins(() => {
refreshCalls++;
return catalogue.promise;
});
const runtime = new TestAppRuntime(definition);
const runtime = new SessionRuntime(plugins);
await runtime.start();
const client = new SessionClient(new LoopbackTransport(runtime.driver, "tui-1"));
await client.ready;
const models = client.use(Models);
expect(definition.plugins.map((plugin) => plugin.id)).toEqual([
expect(plugins.map((plugin) => plugin.id)).toEqual([
"@pi/providers-builtin",
"@pi/providers-catalog",
"@pi/providers-models-json",
@ -52,97 +42,72 @@ describe("app-as-plugins /model vertical slice", () => {
"@pi/thinking-control",
"@pi/model-selection",
]);
expect(client.store.app.actions).toEqual([{ id: "app.thinking.cycle", description: "Cycle thinking level" }]);
expect(client.store.app.commands).toEqual([
{ name: "model", argumentHint: "[provider/]model", description: "Select model" },
]);
expect(client.store.app.providers.availableModels.map((model) => model.modelId)).toEqual(["base", "cached"]);
expect(client.store.app.providers.models.map((model) => model.modelId)).toEqual(["base", "cached", "hidden"]);
expect(models.state.value.catalog.availableModels.map((model) => model.modelId)).toEqual(["base", "cached"]);
expect(models.state.value.catalog.models.map((model) => model.modelId)).toEqual(["base", "cached", "hidden"]);
const invocation = client.submit("/model");
expect(modelView(client).state.refresh).toEqual({ status: "refreshing" });
const observed: string[] = [];
const unsubscribe = models.state.subscribe((state) => observed.push(state.refresh.status));
expect(observed).toEqual(["idle"]);
const refresh = models.refresh();
await vi.waitFor(() => expect(models.state.value.refresh).toEqual({ status: "refreshing" }));
expect(refreshCalls).toBe(1);
catalogue.resolve([{ provider: "ignored", modelId: "fresh", name: "Fresh", reasoning: false }]);
await vi.waitFor(() => expect(modelView(client).state.refresh).toEqual({ status: "done" }));
expect(client.store.app.providers.availableModels.map((model) => model.modelId)).toEqual(["base", "fresh"]);
await client.sendView(modelView(client).id, { type: "select", provider: "acme", modelId: "fresh" });
await invocation;
expect(client.store.views).toEqual([]);
expect(runtime.driver.getConfiguration()).toEqual({
await refresh;
expect(models.state.value.refresh).toEqual({ status: "done" });
expect(models.state.value.catalog.availableModels.map((model) => model.modelId)).toEqual(["base", "fresh"]);
await models.select({ provider: "acme", modelId: "fresh" });
expect(models.state.value.configuration).toEqual({
model: { provider: "acme", modelId: "fresh" },
thinkingLevel: "off",
});
expect(runtime.driver.settings.get("defaultModel")).toEqual({ provider: "acme", modelId: "fresh" });
expect(client.store.events.filter((event) => event.type === "config_update")).toHaveLength(1);
expect(runtime.driver.trace).toEqual([
"command:model",
"view:open:model-selector",
"config:acme/fresh",
"setting:defaultModel",
]);
const openedViews = runtime.driver.openedViewCount;
await client.submit("/model acme/base");
expect(runtime.driver.openedViewCount).toBe(openedViews);
expect(refreshCalls).toBe(1);
expect(runtime.driver.getConfiguration().model).toEqual({ provider: "acme", modelId: "base" });
expect(runtime.driver.use(Settings).get("defaultModel")).toEqual({ provider: "acme", modelId: "fresh" });
expect(observed).toEqual(["idle", "refreshing", "refreshing", "done", "done"]);
expect(runtime.driver.trace).toEqual(["rpc:models.refresh", "rpc:models.select"]);
unsubscribe();
client.close();
runtime.close();
});
it("keeps command and TUI renderer plugins independently removable", async () => {
const complete = createCodingAgentApp(async () => []);
const commandless = new TestAppRuntime(
defineApp({
...complete,
plugins: complete.plugins.filter((plugin) => plugin.id !== "@pi/model-selection"),
}),
it("keeps the client model feature removable without changing the session", async () => {
const complete = createCodingAgentPlugins(async () => []);
const runtime = new SessionRuntime(complete);
await runtime.start();
const client = new SessionClient(new LoopbackTransport(runtime.driver, "headless"));
await client.ready;
const terminal = new VirtualTerminal(80, 24);
const app = new MinimalCodingAgentTui(
terminal,
client,
complete.filter((plugin) => plugin.id !== "@pi/model-selection"),
);
await commandless.start();
const commandlessClient = new SessionClient(new LoopbackTransport(commandless.driver, "commandless"));
await commandlessClient.ready;
expect(commandlessClient.store.app.commands).toEqual([]);
await expect(commandlessClient.submit("/model")).rejects.toThrow("Unknown command: model");
commandlessClient.close();
commandless.close();
const headless = new TestAppRuntime(complete);
await headless.start();
const headlessClient = new SessionClient(new LoopbackTransport(headless.driver, "headless"));
await headlessClient.ready;
const invocation = headlessClient.submit("/model");
expect(modelView(headlessClient).component).toBe("model-selector");
const renderers = new Map<string, ViewRenderer>();
expect(renderers.has("model-selector")).toBe(false);
modelSelectorTui.setup(renderers);
expect(renderers.has("model-selector")).toBe(true);
headless.close();
await invocation;
headlessClient.close();
app.start();
app.submit("/model");
await vi.waitFor(async () => {
await terminal.waitForRender();
expect(terminal.getViewport().join("\n")).toContain("Unknown command: model");
});
expect(client.use(Models).state.value.catalog.availableModels).toHaveLength(2);
app.stop();
runtime.close();
});
it("keeps the cached catalogue when refresh fails", async () => {
const runtime = new TestAppRuntime(
createCodingAgentApp(async () => {
const runtime = new SessionRuntime(
createCodingAgentPlugins(async () => {
throw new Error("catalogue offline");
}),
);
await runtime.start();
const client = new SessionClient(new LoopbackTransport(runtime.driver, "failure"));
await client.ready;
const invocation = client.submit("/model");
await vi.waitFor(() =>
expect(modelView(client).state.refresh).toEqual({
status: "warning",
errors: { "@pi/providers-catalog:remote": "catalogue offline" },
}),
);
expect(client.store.app.providers.availableModels.map((model) => model.modelId)).toEqual(["base", "cached"]);
await client.sendView(modelView(client).id, { type: "cancel" });
await invocation;
const models = client.use(Models);
await models.refresh();
expect(models.state.value.refresh).toEqual({
status: "warning",
errors: { "@pi/providers-catalog:remote": "catalogue offline" },
});
expect(models.state.value.catalog.availableModels.map((model) => model.modelId)).toEqual(["base", "cached"]);
client.close();
runtime.close();
});

View file

@ -1,12 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts";
import { SessionClient } from "./fixtures/plugin-app/client.ts";
import { TestAppRuntime } from "./fixtures/plugin-app/kernel.ts";
import { createCodingAgentApp } from "./fixtures/plugin-app/plugins.ts";
import type { ModelSelectorState, ModelSpec, WireView } from "./fixtures/plugin-app/protocol.ts";
import { createCodingAgentPlugins } from "./fixtures/plugin-app/plugins.ts";
import { type ModelSpec, Models } from "./fixtures/plugin-app/protocol.ts";
import { SessionRuntime } from "./fixtures/plugin-app/session.ts";
import { LoopbackTransport, SessionTcpServer, TcpClientTransport } from "./fixtures/plugin-app/transport.ts";
import { MinimalCodingAgentTui } from "./fixtures/plugin-app/tui/app.ts";
import { modelSelectorTui } from "./fixtures/plugin-app/tui/model-selector.ts";
interface Deferred<T> {
promise: Promise<T>;
@ -21,30 +20,26 @@ function createDeferred<T>(): Deferred<T> {
return { promise, resolve: resolvePromise };
}
function modelView(client: SessionClient): WireView & { state: ModelSelectorState } {
const view = client.store.views.find((candidate) => candidate.component === "model-selector");
if (!view) throw new Error("Model selector view is not open");
return view as WireView & { state: ModelSelectorState };
}
async function viewportText(terminal: VirtualTerminal): Promise<string> {
await terminal.waitForRender();
return terminal.getViewport().join("\n");
}
describe("remote plugin app TUI", () => {
it("renders and controls a session over a real TCP transport", async () => {
it("renders and controls typed session services over real TCP", async () => {
const catalogue = createDeferred<readonly ModelSpec[]>();
const runtime = new TestAppRuntime(createCodingAgentApp(() => catalogue.promise));
const plugins = createCodingAgentPlugins(() => catalogue.promise);
const runtime = new SessionRuntime(plugins);
await runtime.start();
const server = new SessionTcpServer(runtime.driver, { host: "127.0.0.1", port: 0 });
const address = await server.start();
const transport = await TcpClientTransport.connect({ ...address, clientId: "tui" });
const client = new SessionClient(transport);
await client.ready;
const models = client.use(Models);
const closeClient = vi.spyOn(client, "close");
const terminal = new VirtualTerminal(80, 24);
const app = new MinimalCodingAgentTui(terminal, client, [modelSelectorTui]);
const app = new MinimalCodingAgentTui(terminal, client, plugins);
try {
app.start();
@ -52,11 +47,11 @@ describe("remote plugin app TUI", () => {
const initial = await viewportText(terminal);
expect(initial).toContain("pi plugin application");
expect(initial).toContain("Remote session connected");
expect(initial).toContain("events 0 · catalogue r1");
expect(initial).toContain("updates 0 · catalogue r1");
expect(initial).toContain("no-model");
terminal.sendInput("\x1b[Z");
await vi.waitFor(() => expect(client.store.app.configuration.thinkingLevel).toBe("off"));
await vi.waitFor(() => expect(models.state.value.configuration.thinkingLevel).toBe("off"));
expect(await viewportText(terminal)).toContain("Thinking level: off");
terminal.sendInput("/");
@ -69,26 +64,34 @@ describe("remote plugin app TUI", () => {
terminal.sendInput("\t");
await vi.waitFor(async () => expect(await viewportText(terminal)).toContain("/model "));
terminal.sendInput("\r");
await vi.waitFor(() => expect(modelView(client).state.refresh.status).toBe("refreshing"));
await vi.waitFor(() => expect(models.state.value.refresh.status).toBe("refreshing"));
const refreshing = await viewportText(terminal);
expect(refreshing).toContain("Only showing models from configured providers");
expect(refreshing).toContain("base [acme]");
expect(refreshing).toContain("Refreshing model catalogs");
catalogue.resolve([{ provider: "acme", modelId: "fresh", name: "Fresh", reasoning: false }]);
await vi.waitFor(() => expect(modelView(client).state.refresh.status).toBe("done"));
await vi.waitFor(() => expect(models.state.value.refresh.status).toBe("done"));
const refreshed = await viewportText(terminal);
expect(refreshed).toContain("events 2 · catalogue r2");
expect(refreshed).toContain("fresh [acme]");
expect(refreshed).toContain("Model catalogs refreshed");
await client.sendView(modelView(client).id, { type: "select", provider: "acme", modelId: "fresh" });
await vi.waitFor(() => expect(client.store.views).toEqual([]));
terminal.sendInput("fresh");
await vi.waitFor(async () => expect(await viewportText(terminal)).toContain("Fresh"));
terminal.sendInput("\r");
await vi.waitFor(() =>
expect(models.state.value.configuration.model).toEqual({ provider: "acme", modelId: "fresh" }),
);
const selected = await viewportText(terminal);
expect(selected).toContain("Model: fresh");
expect(selected).toContain("events 3 · catalogue r2");
expect(selected).toContain("(acme) fresh");
app.submit("/model acme/base");
await vi.waitFor(() =>
expect(models.state.value.configuration.model).toEqual({ provider: "acme", modelId: "base" }),
);
expect(await viewportText(terminal)).toContain("Model: base");
terminal.sendInput("\x03");
expect(closeClient).not.toHaveBeenCalled();
terminal.sendInput("\x03");
@ -101,28 +104,66 @@ describe("remote plugin app TUI", () => {
}
});
it("applies events buffered after the connection snapshot", async () => {
const runtime = new TestAppRuntime(createCodingAgentApp(async () => []));
it("cancels the session refresh when the model dialog is dismissed", async () => {
let markAborted!: () => void;
const aborted = new Promise<void>((resolve) => {
markAborted = resolve;
});
const plugins = createCodingAgentPlugins(
(signal) =>
new Promise<readonly ModelSpec[]>((_resolve, reject) => {
signal.addEventListener(
"abort",
() => {
markAborted();
reject(signal.reason);
},
{ once: true },
);
}),
);
const runtime = new SessionRuntime(plugins);
await runtime.start();
const server = new SessionTcpServer(runtime.driver, { host: "127.0.0.1", port: 0 });
const address = await server.start();
const client = new SessionClient(await TcpClientTransport.connect({ ...address, clientId: "cancel" }));
await client.ready;
const models = client.use(Models);
const terminal = new VirtualTerminal(80, 24);
const app = new MinimalCodingAgentTui(terminal, client, plugins);
try {
app.start();
app.submit("/model");
await vi.waitFor(() => expect(models.state.value.refresh.status).toBe("refreshing"));
terminal.sendInput("\x1b");
await aborted;
await vi.waitFor(() => expect(models.state.value.refresh.status).toBe("idle"));
expect(await viewportText(terminal)).not.toContain("Refreshing model catalogs");
} finally {
app.stop();
await server.close();
runtime.close();
}
});
it("applies state updates buffered after the connection snapshot", async () => {
const runtime = new SessionRuntime(createCodingAgentPlugins(async () => []));
await runtime.start();
const transport = new LoopbackTransport(runtime.driver, "buffered");
await runtime.driver.submit("buffered", "/model acme/base");
await runtime.driver.use(Models).select({ provider: "acme", modelId: "base" });
const client = new SessionClient(transport);
await client.ready;
const models = client.use(Models);
expect(client.store.app.configuration).toEqual({
expect(models.state.value.configuration).toEqual({
model: { provider: "acme", modelId: "base" },
thinkingLevel: "high",
});
expect(client.store.events).toEqual([
{
type: "config_update",
previous: { model: undefined, thinkingLevel: "high" },
value: {
model: { provider: "acme", modelId: "base" },
thinkingLevel: "high",
},
},
]);
expect(client.store.updates).toBe(1);
const observed: string[] = [];
models.state.subscribe((state) => observed.push(state.configuration.model?.modelId ?? "none"));
expect(observed).toEqual(["base"]);
client.close();
runtime.close();
});