mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(coding-agent): remove obsolete plugin app fixture
This commit is contained in:
parent
d3697e65ca
commit
7731f1cfe2
19 changed files with 0 additions and 2013 deletions
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from "./api.ts";
|
||||
export * from "./client.ts";
|
||||
export * from "./session.ts";
|
||||
export * from "./transport.ts";
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`)];
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue