fix(coding-agent): remove obsolete plugin app fixture

This commit is contained in:
Mario Zechner 2026-08-30 23:36:39 +02:00
parent d3697e65ca
commit 7731f1cfe2
19 changed files with 0 additions and 2013 deletions

View file

@ -1,44 +0,0 @@
# Remote plugin application prototype
Run both processes from the repository root:
```bash
packages/coding-agent/test/fixtures/plugin-app/run.sh
```
## Layout
```text
lib/ Reusable plugin and transport library
api.ts Service tokens, plugins, ReplicatedState, RPC cancellation
client.ts Typed client proxies and replicated state
session.ts Session service host and lifecycle
protocol.ts Generic JSON wire messages
transport.ts TCP and loopback transports
model-app/ Example application built on the library
services.ts Shared model service contract
plugins.ts Session and client plugin halves
providers.ts Application-specific provider composition
tui/ Client-local commands, views, and rendering
run-session.ts Session process entry point
run-tui.ts TUI process entry point
```
The library knows nothing about models, providers, commands, or TUI components. The application knows nothing about snapshots, RPC request IDs, cancellation messages, or TCP framing.
## Plugin author model
1. Define a typed service token in `model-app/services.ts`.
2. In `plugin.session()`, create replicated state and provide the service implementation.
3. In `plugin.client()`, use the typed proxy and register client-local UI.
4. Add the plugin to the application's plugin list.
Replicated state and ordinary method arguments/results must be JSON-serializable. `RpcOptions` are transport metadata and travel out of band.
## Disconnect contract
- A client disconnect removes its state subscription and aborts its in-flight RPC calls. The session continues so another client can reconnect.
- A session disconnect rejects pending and future client RPC calls. The client keeps its last snapshot as stale display data and exposes `store.connection` as disconnected.
- Reconnection creates a new transport and client and starts from a fresh authoritative snapshot. Replay and automatic reconnect are intentionally outside this prototype.
- The application decides when an unattended session should exit, for example immediately, after an idle timeout, or never.

View file

@ -1,52 +0,0 @@
/**
* Shared tokens and two-sided plugin shape.
* Replicated 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 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 {
/** Set by the session runtime. Client callers cannot override it. */
readonly clientId?: string;
readonly signal?: AbortSignal;
readonly [rpcOptionsMarker]: true;
}
export function rpcOptions(options: { clientId?: string; 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 ReplicatedState<T> {
readonly value: T;
subscribe(listener: (value: T) => void): () => void;
}
export interface MutableReplicatedState<T> extends ReplicatedState<T> {
set(value: T): void;
}
export interface AppPlugin<SessionContext, ClientContext> {
id: string;
session?(context: SessionContext): void | Promise<void>;
client?(context: ClientContext): void | Promise<void>;
}
export function definePlugin<SessionContext, ClientContext>(
plugin: AppPlugin<SessionContext, ClientContext>,
): AppPlugin<SessionContext, ClientContext> {
return plugin;
}

View file

@ -1,167 +0,0 @@
import { isRpcOptions, type ReplicatedState, type Service } from "./api.ts";
import type { ServerWireMessage, SessionRequest, StateSnapshot } from "./protocol.ts";
export interface ClientTransport {
close(): void;
request(request: SessionRequest, signal?: AbortSignal): Promise<unknown>;
start(listener: (message: ServerWireMessage) => void, onDisconnect: (error?: Error) => void): void;
}
export type ConnectionState =
| { status: "connecting" }
| { status: "connected" }
| { status: "disconnected"; reason: string };
class ClientReplicatedState<T> implements ReplicatedState<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 {
readonly connection = new ClientReplicatedState<ConnectionState>({ status: "connecting" });
updates = 0;
private readonly listeners = new Set<() => void>();
private readonly states = new Map<string, ClientReplicatedState<unknown>>();
apply(message: ServerWireMessage): void {
if (message.type === "response") return;
if (message.type === "snapshot") this.replace(message.states);
else this.update(message.service, message.property, message.value);
this.notify();
}
connected(): void {
this.connection.set({ status: "connected" });
this.notify();
}
disconnected(error?: Error): void {
this.connection.set({ status: "disconnected", reason: error?.message ?? "Connection closed" });
this.notify();
}
get<T>(service: string, property: string): ReplicatedState<T> {
const state = this.states.get(`${service}.${property}`);
if (!state) throw new Error(`Replicated state not provided: ${service}.${property}`);
return state as ReplicatedState<T>;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
listener();
return () => this.listeners.delete(listener);
}
private notify(): void {
for (const listener of this.listeners) listener();
}
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 ClientReplicatedState(value));
if (count) this.updates++;
}
}
export class SessionClient {
readonly ready: Promise<void>;
readonly store = new ClientStateStore();
private readonly proxies = new Map<string, object>();
private readonly rejectReady: (error: Error) => void;
private readonly resolveReady: () => void;
private readonly transport: ClientTransport;
private receivedSnapshot = false;
constructor(transport: ClientTransport) {
this.transport = transport;
let settleReady!: () => void;
let failReady!: (error: Error) => void;
this.ready = new Promise<void>((resolve, reject) => {
settleReady = resolve;
failReady = reject;
});
this.rejectReady = failReady;
this.resolveReady = settleReady;
transport.start(
(message) => {
this.store.apply(message);
if (message.type === "snapshot" && !this.receivedSnapshot) {
this.receivedSnapshot = true;
this.store.connected();
this.resolveReady();
}
},
(error) => {
this.store.disconnected(error);
if (!this.receivedSnapshot) this.rejectReady(error ?? new Error("Session disconnected before snapshot"));
},
);
}
close(): void {
this.transport.close();
}
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,4 +0,0 @@
export * from "./api.ts";
export * from "./client.ts";
export * from "./session.ts";
export * from "./transport.ts";

View file

@ -1,19 +0,0 @@
export type StateSnapshot = Record<string, Record<string, 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: "cancel"; id: number };
export type ServerWireMessage =
| { type: "snapshot"; states: StateSnapshot }
| { type: "state_update"; service: string; property: string; value: unknown }
| { type: "response"; id: number; result?: unknown; error?: string };

View file

@ -1,158 +0,0 @@
import { type AppPlugin, type MutableReplicatedState, rpcOptions, type Service } from "./api.ts";
import type { ServerWireMessage, SessionRequest, StateSnapshot } from "./protocol.ts";
class SessionReplicatedState<T> implements MutableReplicatedState<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);
}
}
class ClientHub {
private readonly connections = new Map<string, (message: ServerWireMessage) => void>();
connect(clientId: string, snapshot: () => StateSnapshot, send: (message: ServerWireMessage) => void): () => void {
const pending: ServerWireMessage[] = [];
let ready = false;
const connection = (message: ServerWireMessage) => {
if (ready) send(message);
else pending.push(message);
};
this.connections.set(clientId, connection);
send({ type: "snapshot", states: structuredClone(snapshot()) });
ready = true;
for (const message of pending) send(message);
return () => {
if (this.connections.get(clientId) === connection) this.connections.delete(clientId);
};
}
broadcast(message: ServerWireMessage): void {
for (const send of this.connections.values()) send(structuredClone(message));
}
}
export interface SessionContext {
onActivate(callback: () => void | Promise<void>): void;
onClientConnect(callback: (clientId: string) => void): void;
onClientDisconnect(callback: (clientId: string) => void): void;
onClose(callback: () => void): void;
provide<T>(service: Service<T>, implementation: T): void;
replicatedState<T>(initial: T): MutableReplicatedState<T>;
use<T>(service: Service<T>): T;
}
export interface SessionDriver {
readonly trace: readonly string[];
connect(clientId: string, send: (message: ServerWireMessage) => void): () => void;
request(clientId: string, request: SessionRequest, signal: AbortSignal): Promise<unknown>;
use<T>(service: Service<T>): T;
}
export class SessionRuntime<ClientContext> {
readonly driver: SessionDriver;
private readonly activateCallbacks: Array<() => void | Promise<void>> = [];
private readonly clientConnectCallbacks: Array<(clientId: string) => void> = [];
private readonly clientDisconnectCallbacks: Array<(clientId: string) => 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[] = [];
constructor(plugins: readonly AppPlugin<SessionContext, ClientContext>[]) {
this.plugins = plugins;
this.driver = {
trace: this.trace,
connect: (clientId, send) => {
const disconnect = this.hub.connect(clientId, () => this.snapshot(), send);
for (const callback of this.clientConnectCallbacks) callback(clientId);
let connected = true;
return () => {
if (!connected) return;
connected = false;
disconnect();
for (const callback of this.clientDisconnectCallbacks) callback(clientId);
};
},
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({ clientId, signal })] : request.args;
return Reflect.apply(method, service, args);
},
use: (service) => this.use(service),
};
}
async start(): Promise<void> {
const context: SessionContext = {
onActivate: (callback) => this.activateCallbacks.push(callback),
onClientConnect: (callback) => this.clientConnectCallbacks.push(callback),
onClientDisconnect: (callback) => this.clientDisconnectCallbacks.push(callback),
onClose: (callback) => this.closeCallbacks.push(callback),
provide: (service, implementation) => this.provide(service, implementation),
replicatedState: (initial) => new SessionReplicatedState(initial),
use: (service) => this.use(service),
};
for (const plugin of this.plugins) await plugin.session?.(context);
for (const activate of this.activateCallbacks) await activate();
}
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 SessionReplicatedState)) continue;
value.subscribe((next) => {
this.hub.broadcast({
type: "state_update",
service: service.id,
property,
value: structuredClone(next),
});
});
}
}
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;
}
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 SessionReplicatedState) states[property] = structuredClone(value.value);
}
if (Object.keys(states).length > 0) snapshot[serviceId] = states;
}
return snapshot;
}
}

View file

@ -1,266 +0,0 @@
import { createConnection, createServer, type Server, type Socket } from "node:net";
import type { ClientTransport } from "./client.ts";
import type { ClientWireMessage, ServerWireMessage, SessionRequest } from "./protocol.ts";
import type { SessionDriver } from "./session.ts";
class JsonLineSocket<Incoming, Outgoing> {
private buffer = "";
private readonly listeners = new Set<(message: Incoming) => void>();
private readonly socket: Socket;
constructor(socket: Socket) {
this.socket = socket;
socket.setEncoding("utf8");
socket.on("data", (chunk: string) => {
this.buffer += chunk;
for (;;) {
const newline = this.buffer.indexOf("\n");
if (newline < 0) break;
const line = this.buffer.slice(0, newline);
this.buffer = this.buffer.slice(newline + 1);
if (!line) continue;
const message = JSON.parse(line) as Incoming;
for (const listener of this.listeners) listener(message);
}
});
}
onMessage(listener: (message: Incoming) => void): void {
this.listeners.add(listener);
}
send(message: Outgoing): void {
this.socket.write(`${JSON.stringify(message)}\n`);
}
}
export class SessionTcpServer {
private readonly driver: SessionDriver;
private readonly host: string;
private readonly port: number;
private readonly sockets = new Set<Socket>();
private server: Server | undefined;
constructor(driver: SessionDriver, options: { host: string; port: number }) {
this.driver = driver;
this.host = options.host;
this.port = options.port;
}
async start(): Promise<{ host: string; port: number }> {
if (this.server) throw new Error("Session server already started");
this.server = createServer((socket) => this.accept(socket));
await new Promise<void>((resolve, reject) => {
this.server!.once("error", reject);
this.server!.listen(this.port, this.host, () => {
this.server!.removeListener("error", reject);
resolve();
});
});
const address = this.server.address();
if (!address || typeof address === "string") throw new Error("Session server has no TCP address");
return { host: this.host, port: address.port };
}
async close(): Promise<void> {
for (const socket of this.sockets) socket.destroy();
this.sockets.clear();
const server = this.server;
this.server = undefined;
if (!server) return;
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
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) => {
if (message.type === "hello") {
disconnect?.();
clientId = message.clientId;
disconnect = this.driver.connect(clientId, (outgoing) => peer.send(outgoing));
return;
}
if (!clientId) {
peer.send({ type: "response", id: message.id, error: "Client has not sent hello" });
return;
}
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);
});
}
}
export class TcpClientTransport implements ClientTransport {
private readonly buffered: ServerWireMessage[] = [];
private readonly peer: JsonLineSocket<ServerWireMessage, ClientWireMessage>;
private readonly pending = new Map<
number,
{ resolve(value: unknown): void; reject(error: unknown): void; stopAbort(): void }
>();
private readonly socket: Socket;
private closed = false;
private disconnectError: Error | undefined;
private disconnectListener: ((error?: Error) => void) | undefined;
private listener: ((message: ServerWireMessage) => void) | undefined;
private nextRequestId = 1;
private constructor(socket: Socket, clientId: string) {
this.socket = socket;
this.peer = new JsonLineSocket(socket);
this.peer.onMessage((message) => {
if (message.type === "response") {
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(message.result);
} else if (this.listener) {
this.listener(message);
} else {
this.buffered.push(message);
}
});
socket.on("error", (error) => {
this.disconnectError = error;
});
socket.once("close", () => {
this.closed = true;
const error = this.disconnectError ?? new Error("Session connection closed");
for (const pending of this.pending.values()) {
pending.stopAbort();
pending.reject(error);
}
this.pending.clear();
this.disconnectListener?.(error);
});
this.peer.send({ type: "hello", clientId });
}
static async connect(options: { host: string; port: number; clientId: string }): Promise<TcpClientTransport> {
const socket = createConnection({ host: options.host, port: options.port });
await new Promise<void>((resolve, reject) => {
socket.once("connect", resolve);
socket.once("error", reject);
});
return new TcpClientTransport(socket, options.clientId);
}
start(listener: (message: ServerWireMessage) => void, onDisconnect: (error?: Error) => void): void {
this.listener = listener;
this.disconnectListener = onDisconnect;
for (const message of this.buffered.splice(0)) listener(message);
if (this.closed) onDisconnect(this.disconnectError ?? new Error("Session connection closed"));
}
request(request: SessionRequest, signal?: AbortSignal): Promise<unknown> {
if (this.closed) return Promise.reject(this.disconnectError ?? new Error("Session connection closed"));
if (signal?.aborted) return Promise.reject(signal.reason);
const id = this.nextRequestId++;
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 });
});
}
close(): void {
if (!this.closed) this.socket.destroy();
}
}
export class LoopbackTransport implements ClientTransport {
private readonly active = new Set<AbortController>();
private readonly buffered: ServerWireMessage[] = [];
private readonly disconnect: () => void;
private readonly driver: SessionDriver;
private readonly clientId: string;
private closed = false;
private disconnectListener: ((error?: Error) => void) | undefined;
private listener: ((message: ServerWireMessage) => void) | undefined;
constructor(driver: SessionDriver, clientId: string) {
this.clientId = clientId;
this.driver = driver;
this.disconnect = driver.connect(clientId, (message) => {
if (this.listener) this.listener(message);
else this.buffered.push(message);
});
}
start(listener: (message: ServerWireMessage) => void, onDisconnect: (error?: Error) => void): void {
this.listener = listener;
this.disconnectListener = onDisconnect;
for (const message of this.buffered.splice(0)) listener(message);
if (this.closed) onDisconnect(new Error("Session connection closed"));
}
request(request: SessionRequest, signal?: AbortSignal): Promise<unknown> {
if (this.closed) return Promise.reject(new Error("Session connection closed"));
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 {
if (this.closed) return;
this.closed = true;
for (const controller of this.active) controller.abort();
this.active.clear();
this.disconnect();
this.disconnectListener?.(new Error("Session connection closed"));
}
}

View file

@ -1,158 +0,0 @@
import { type AppPlugin, definePlugin, defineService, type SessionContext } from "../lib/index.ts";
import { ProviderRegistry } from "./providers.ts";
import { type ModelSpec, Models, type ModelsState, type ThinkingLevel } from "./services.ts";
import { registerModelTui } from "./tui/model-selector.ts";
import type { TuiContext } from "./tui/shared.ts";
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",
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.replicatedState<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: Models = {
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();
});
},
});
export function providersCatalog(
refreshCatalog: (signal: AbortSignal) => Promise<readonly ModelSpec[]>,
): CodingAgentPlugin {
let catalog: readonly ModelSpec[] = [{ provider: "acme", modelId: "cached", name: "Cached", reasoning: true }];
return definePlugin({
id: "@pi/providers-catalog",
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);
signal.throwIfAborted();
catalog = next.map((model) => ({ ...model, provider: "acme" }));
});
});
},
});
}
export const providersModelsJson: CodingAgentPlugin = definePlugin({
id: "@pi/providers-models-json",
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)),
);
});
},
});
export const auth: CodingAgentPlugin = definePlugin({
id: "@pi/auth",
session(context) {
context.use(Providers).add("@pi/auth", (draft) => draft.configured("acme"));
},
});
export const thinkingControl: CodingAgentPlugin = definePlugin({
id: "@pi/thinking-control",
client(context) {
context.actions.register("app.thinking.cycle", () => context.use(Models).cycleThinking());
},
});
export const modelSelection: CodingAgentPlugin = definePlugin({
id: "@pi/model-selection",
client(context) {
registerModelTui(context, context.use(Models));
},
});
export function createCodingAgentPlugins(
refreshCatalog: (signal: AbortSignal) => Promise<readonly ModelSpec[]>,
): readonly CodingAgentPlugin[] {
return [
providersBuiltin,
providersCatalog(refreshCatalog),
providersModelsJson,
auth,
thinkingControl,
modelSelection,
];
}

View file

@ -1,152 +0,0 @@
import type { ModelSpec, ProviderSnapshot } from "./services.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,40 +0,0 @@
import { SessionRuntime, SessionTcpServer } from "../lib/index.ts";
import { createCodingAgentPlugins } from "./plugins.ts";
async function main(): Promise<void> {
const host = process.argv[2] ?? "127.0.0.1";
const port = Number(process.argv[3] ?? "7777");
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) => {
process.once("SIGINT", resolve);
process.once("SIGTERM", resolve);
});
await server.close();
runtime.close();
}
main().catch((error: unknown) => {
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
process.exitCode = 1;
});

View file

@ -1,29 +0,0 @@
import { ProcessTerminal } from "@earendil-works/pi-tui";
import { SessionClient, TcpClientTransport } from "../lib/index.ts";
import { createCodingAgentPlugins } from "./plugins.ts";
import { MinimalCodingAgentTui } from "./tui/app.ts";
async function main(): Promise<void> {
const host = process.argv[2] ?? "127.0.0.1";
const port = Number(process.argv[3] ?? "7777");
const transport = await TcpClientTransport.connect({ host, port, clientId: "tui" });
const client = new SessionClient(transport);
await client.ready;
const app = new MinimalCodingAgentTui(
new ProcessTerminal(),
client,
createCodingAgentPlugins(async () => []),
);
const stop = () => app.stop();
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
app.start();
await app.done;
process.removeListener("SIGINT", stop);
process.removeListener("SIGTERM", stop);
}
main().catch((error: unknown) => {
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
process.exitCode = 1;
});

View file

@ -1,45 +0,0 @@
import { defineService, type ReplicatedState, type RpcOptions } from "../lib/index.ts";
export type ThinkingLevel = "off" | "low" | "high";
export interface ModelRef {
provider: string;
modelId: string;
}
export interface ModelSpec extends ModelRef {
name: string;
reasoning: boolean;
}
export interface LaneConfiguration {
model: ModelRef | undefined;
thinkingLevel: ThinkingLevel;
}
export interface ProviderSnapshot {
revision: number;
availableModels: readonly ModelSpec[];
models: readonly ModelSpec[];
}
export type RefreshState =
| { status: "idle" }
| { status: "refreshing" }
| { status: "done" }
| { status: "warning"; errors: Readonly<Record<string, string>> };
export interface ModelsState {
catalog: ProviderSnapshot;
configuration: LaneConfiguration;
refresh: RefreshState;
}
export interface Models {
state: ReplicatedState<ModelsState>;
cycleThinking(): Promise<void>;
refresh(options?: RpcOptions): Promise<void>;
select(model: ModelRef): Promise<void>;
}
export const Models = defineService<Models>("pi.models");

View file

@ -1,272 +0,0 @@
import {
CombinedAutocompleteProvider,
Container,
ScrollView,
Spacer,
type Terminal,
Text,
TuiAltScreen,
VStack,
} from "@earendil-works/pi-tui";
import { type AppKeybinding, KeybindingsManager } from "../../../../../src/core/keybindings.ts";
import { CustomEditor } from "../../../../../src/modes/interactive/components/custom-editor.ts";
import type { SessionClient } from "../../lib/index.ts";
import type { CodingAgentPlugin } from "../plugins.ts";
import { type LaneConfiguration, Models } from "../services.ts";
import { PluginFooter } from "./footer.ts";
import {
accent,
bold,
border,
dim,
errorStyle,
type TuiCommand,
type TuiContext,
type ViewRenderer,
} from "./shared.ts";
/** 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: Models;
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 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 CodingAgentPlugin[]) {
this.client = client;
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,
{
borderColor: border,
selectList: {
description: dim,
noMatch: dim,
scrollInfo: dim,
selectedPrefix: accent,
selectedText: accent,
},
},
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(this.models.state, () => client.store.updates), basis: 2, shrink: 0 },
]);
this.root = new VStack([
{
component: new ScrollView(this.transcript, {
follow: "end",
primary: true,
overscroll: "chain",
scrollbar: "auto",
}),
basis: 0,
grow: 1,
shrink: 1,
minSize: 1,
},
{ component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
]);
let settleDone!: () => void;
this.done = new Promise<void>((resolve) => {
settleDone = resolve;
});
this.resolveDone = settleDone;
}
start(): void {
if (this.unsubscribe || this.stopped) return;
this.unsubscribe = this.client.store.subscribe(() => {
const connection = this.client.store.connection.value;
if (connection.status === "disconnected") this.showStatus(`Disconnected: ${connection.reason}`, true);
this.applyConfiguration();
this.renderView();
this.updateEditorBorder();
this.tui.requestRender();
});
this.renderView();
this.tui.setLayoutRoot(this.root);
this.tui.start();
}
submit(input: string): void {
const value = input.trim();
if (!value) return;
if (value === "/quit" || value === "quit") {
this.stop();
return;
}
if (!value.startsWith("/")) {
this.showError(new Error("Only slash commands are supported by this test app"));
return;
}
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();
}
stop(): void {
if (this.stopped) return;
this.stopped = true;
this.unsubscribe?.();
this.unsubscribe = undefined;
this.client.close();
this.tui.stop({ preserveScreen: true });
this.resolveDone();
}
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 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();
if (!this.activeView) {
this.editorContainer.addChild(this.editor);
this.tui.setFocus(this.editor);
return;
}
const renderer = this.renderers.get(this.activeView);
if (!renderer) {
this.editorContainer.addChild(new Text(dim(`No TUI renderer for ${this.activeView}`), 1, 0));
this.tui.setFocus(null);
return;
}
const rendered = renderer({
close: () => this.closeView(),
query: this.query,
setQuery: (query) => {
this.query = query;
},
});
this.editorContainer.addChild(rendered.component);
this.tui.setFocus(rendered.focus);
}
private handleClear(): void {
const now = Date.now();
if (now - this.lastClearTime < 500) {
this.stop();
return;
}
this.editor.setText("");
this.showStatus(undefined);
this.lastClearTime = now;
this.tui.requestRender();
}
private showError(error: unknown): void {
this.showStatus(error instanceof Error ? error.message : String(error), true);
this.tui.requestRender();
}
private showStatus(message: string | undefined, isError = false): void {
this.statusContainer.clear();
if (message) this.statusContainer.addChild(new Text(isError ? errorStyle(message) : accent(message), 1, 0));
}
private updateEditorBorder(): void {
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 = selected?.reasoning ? accent : border;
}
}

View file

@ -1,43 +0,0 @@
import { homedir } from "node:os";
import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import type { ReplicatedState } from "../../lib/index.ts";
import type { ModelsState } from "../services.ts";
import { dim } from "./shared.ts";
export class PluginFooter implements Component {
private readonly models: ReplicatedState<ModelsState>;
private readonly updateCount: () => number;
constructor(models: ReplicatedState<ModelsState>, updateCount: () => number) {
this.models = models;
this.updateCount = updateCount;
}
invalidate(): void {}
render(width: number): string[] {
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 = state.configuration.model;
const currentSpec = current
? state.catalog.availableModels.find(
(model) => model.provider === current.provider && model.modelId === current.modelId,
)
: undefined;
const model = current
? `(${current.provider}) ${current.modelId}${
currentSpec?.reasoning
? state.configuration.thinkingLevel === "off"
? " • thinking off"
: ` • ${state.configuration.thinkingLevel}`
: ""
}`
: "no-model";
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,171 +0,0 @@
import { type Component, Container, type Focusable, getKeybindings, Input, Spacer, Text } from "@earendil-works/pi-tui";
import { rpcOptions } from "../../lib/index.ts";
import type { ModelSpec, Models, ModelsState } from "../services.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 registerModelTui(context: TuiContext, models: Models): 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 };
});
}
class ModelSelector extends Container implements Focusable {
private readonly input = new Input();
private readonly list = new Container();
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: ModelsState;
query: string;
setQuery(query: string): void;
cancel(): void;
select(model: ModelSpec): Promise<void>;
}) {
super();
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());
this.addChild(new Spacer(1));
this.addChild(
new Text(warning("Only showing models from configured providers. Use provider plugins to add more."), 0, 0),
);
this.addChild(new Spacer(1));
this.addChild(this.input);
this.addChild(new Spacer(1));
this.addChild(this.list);
this.addChild(new Spacer(1));
this.addChild(new Rule());
this.update();
}
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.input.focused = value;
}
handleInput(data: string): void {
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.setQuery(this.input.getValue());
this.selected = 0;
}
this.update();
}
private update(): void {
const query = this.input.getValue().trim().toLowerCase();
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 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.selected;
this.list.addChild(
new Text(
`${selected ? accent("→ ") : " "}${selected ? accent(model.modelId) : model.modelId} ${dim(`[${model.provider}]`)}`,
0,
0,
),
);
}
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: ${model.name}`), 0, 0));
}
}
this.list.addChild(new Spacer(1));
if (this.state.refresh.status === "refreshing")
this.list.addChild(new Text(dim(" Refreshing model catalogs…"), 0, 0));
else if (this.state.refresh.status === "warning")
this.list.addChild(new Text(warning(" Could not refresh model catalogs; showing cached models."), 0, 0));
else if (this.state.refresh.status === "done")
this.list.addChild(new Text(success(" Model catalogs refreshed."), 0, 0));
}
}

View file

@ -1,47 +0,0 @@
import type { Component } from "@earendil-works/pi-tui";
import type { AppKeybinding } from "../../../../../src/core/keybindings.ts";
import type { Service } from "../../lib/index.ts";
const style =
(open: number, close: number) =>
(text: string): string =>
`\x1b[${open}m${text}\x1b[${close}m`;
export const bold = style(1, 22);
export const dim = style(2, 22);
export const accent = style(36, 39);
export const success = style(32, 39);
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 LocalViewContext {
close(): void;
query: string;
setQuery(query: string): void;
}
export type ViewRenderer = (context: LocalViewContext) => RenderedView;
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,33 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
app_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
host="${1:-127.0.0.1}"
port="${2:-7777}"
log_file="$(mktemp)"
node "$app_dir/model-app/run-session.ts" "$host" "$port" > >(tee "$log_file") &
server_pid=$!
cleanup() {
trap - EXIT INT TERM
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
rm -f "$log_file"
}
trap cleanup EXIT INT TERM
for ((attempt = 0; attempt < 100; attempt++)); do
if grep -q "^Session listening on " "$log_file"; then
node "$app_dir/model-app/run-tui.ts" "$host" "$port"
exit
fi
if ! kill -0 "$server_pid" 2>/dev/null; then
wait "$server_pid"
exit $?
fi
sleep 0.05
done
echo "Timed out waiting for session server" >&2
exit 1

View file

@ -1,112 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts";
import { LoopbackTransport, SessionClient, SessionRuntime } from "./fixtures/plugin-app/lib/index.ts";
import { createCodingAgentPlugins, Settings } from "./fixtures/plugin-app/model-app/plugins.ts";
import { type ModelSpec, Models } from "./fixtures/plugin-app/model-app/services.ts";
import { MinimalCodingAgentTui } from "./fixtures/plugin-app/model-app/tui/app.ts";
interface Deferred<T> {
promise: Promise<T>;
resolve(value: T): void;
}
function createDeferred<T>(): Deferred<T> {
let resolvePromise!: (value: T) => void;
const promise = new Promise<T>((resolve) => {
resolvePromise = resolve;
});
return { promise, resolve: resolvePromise };
}
describe("app-as-plugins model service", () => {
it("replicates ReplicatedState and invokes typed methods over a serializable boundary", async () => {
const catalogue = createDeferred<readonly ModelSpec[]>();
let refreshCalls = 0;
const plugins = createCodingAgentPlugins(() => {
refreshCalls++;
return catalogue.promise;
});
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(plugins.map((plugin) => plugin.id)).toEqual([
"@pi/providers-builtin",
"@pi/providers-catalog",
"@pi/providers-models-json",
"@pi/auth",
"@pi/thinking-control",
"@pi/model-selection",
]);
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 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 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.use(Settings).get("defaultModel")).toEqual({ provider: "acme", modelId: "fresh" });
expect(observed).toEqual(["idle", "refreshing", "refreshing", "done", "done"]);
expect(runtime.driver.trace).toEqual(["rpc:pi.models.refresh", "rpc:pi.models.select"]);
unsubscribe();
client.close();
runtime.close();
});
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"),
);
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 SessionRuntime(
createCodingAgentPlugins(async () => {
throw new Error("catalogue offline");
}),
);
await runtime.start();
const client = new SessionClient(new LoopbackTransport(runtime.driver, "failure"));
await client.ready;
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,201 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts";
import {
definePlugin,
LoopbackTransport,
SessionClient,
SessionRuntime,
SessionTcpServer,
TcpClientTransport,
} from "./fixtures/plugin-app/lib/index.ts";
import { type CodingAgentPlugin, createCodingAgentPlugins } from "./fixtures/plugin-app/model-app/plugins.ts";
import { type ModelSpec, Models } from "./fixtures/plugin-app/model-app/services.ts";
import { MinimalCodingAgentTui } from "./fixtures/plugin-app/model-app/tui/app.ts";
interface Deferred<T> {
promise: Promise<T>;
resolve(value: T): void;
}
function createDeferred<T>(): Deferred<T> {
let resolvePromise!: (value: T) => void;
const promise = new Promise<T>((resolve) => {
resolvePromise = resolve;
});
return { promise, resolve: resolvePromise };
}
async function viewportText(terminal: VirtualTerminal): Promise<string> {
await terminal.waitForRender();
return terminal.getViewport().join("\n");
}
describe("remote plugin app TUI", () => {
it("renders and controls typed session services over real TCP", async () => {
const catalogue = createDeferred<readonly ModelSpec[]>();
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, plugins);
try {
app.start();
expect(app.tui.mode).toBe("fullscreen");
const initial = await viewportText(terminal);
expect(initial).toContain("pi plugin application");
expect(initial).toContain("Remote session connected");
expect(initial).toContain("updates 0 · catalogue r1");
expect(initial).toContain("no-model");
terminal.sendInput("\x1b[Z");
await vi.waitFor(() => expect(models.state.value.configuration.thinkingLevel).toBe("off"));
expect(await viewportText(terminal)).toContain("Thinking level: off");
terminal.sendInput("/");
await vi.waitFor(async () => {
const autocomplete = await viewportText(terminal);
expect(autocomplete).toContain("model");
expect(autocomplete).toContain("[provider/]model — Select model");
expect(autocomplete).toContain("quit");
});
terminal.sendInput("\t");
await vi.waitFor(async () => expect(await viewportText(terminal)).toContain("/model "));
terminal.sendInput("\r");
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(models.state.value.refresh.status).toBe("done"));
const refreshed = await viewportText(terminal);
expect(refreshed).toContain("fresh [acme]");
expect(refreshed).toContain("Model catalogs refreshed");
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("(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");
await app.done;
expect(closeClient).toHaveBeenCalledOnce();
} finally {
app.stop();
await server.close();
runtime.close();
}
});
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("reports session disconnects to both sides", async () => {
let disconnectedClient: string | undefined;
const lifecycle: CodingAgentPlugin = definePlugin({
id: "lifecycle-test",
session(context) {
context.onClientDisconnect((clientId) => {
disconnectedClient = clientId;
});
},
});
const runtime = new SessionRuntime([...createCodingAgentPlugins(async () => []), lifecycle]);
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: "disconnect-test" }));
await client.ready;
const models = client.use(Models);
await server.close();
await vi.waitFor(() => expect(client.store.connection.value.status).toBe("disconnected"));
await vi.waitFor(() => expect(disconnectedClient).toBe("disconnect-test"));
await expect(models.cycleThinking()).rejects.toThrow("Session connection closed");
client.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.use(Models).select({ provider: "acme", modelId: "base" });
const client = new SessionClient(transport);
await client.ready;
const models = client.use(Models);
expect(models.state.value.configuration).toEqual({
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();
});
});