mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(coding-agent): simplify remote plugin services
This commit is contained in:
parent
d458bfc073
commit
4cb81e2bd9
15 changed files with 991 additions and 1042 deletions
|
|
@ -1,69 +1,79 @@
|
|||
import type { AppKeybinding } from "../../../src/core/keybindings.ts";
|
||||
import type {
|
||||
ClientSnapshot,
|
||||
CodingAgentEvent,
|
||||
CodingAgentSnapshot,
|
||||
ServerWireMessage,
|
||||
SessionRequest,
|
||||
WireView,
|
||||
} from "./protocol.ts";
|
||||
import { isRpcOptions, type RemoteState, type Service } from "./kernel.ts";
|
||||
import type { ServerWireMessage, SessionRequest, StateSnapshot } from "./protocol.ts";
|
||||
|
||||
export interface ClientTransport {
|
||||
close(): void;
|
||||
request(request: SessionRequest): Promise<void>;
|
||||
request(request: SessionRequest, signal?: AbortSignal): Promise<unknown>;
|
||||
start(listener: (message: ServerWireMessage) => void): void;
|
||||
}
|
||||
|
||||
/** Replicated client state: one app snapshot plus active targeted views. */
|
||||
export class CodingAgentClientStore {
|
||||
readonly events: CodingAgentEvent[] = [];
|
||||
class ClientRemoteState<T> implements RemoteState<T> {
|
||||
private readonly listeners = new Set<(value: T) => void>();
|
||||
private current: T;
|
||||
|
||||
constructor(initial: T) {
|
||||
this.current = structuredClone(initial);
|
||||
}
|
||||
|
||||
get value(): T {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
set(value: T): void {
|
||||
this.current = structuredClone(value);
|
||||
for (const listener of this.listeners) listener(this.current);
|
||||
}
|
||||
|
||||
subscribe(listener: (value: T) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.current);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
}
|
||||
|
||||
export class ClientStateStore {
|
||||
updates = 0;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private readonly viewMap = new Map<string, WireView>();
|
||||
private appState: CodingAgentSnapshot | undefined;
|
||||
|
||||
get app(): CodingAgentSnapshot {
|
||||
if (!this.appState) throw new Error("Session snapshot has not arrived");
|
||||
return this.appState;
|
||||
}
|
||||
|
||||
get views(): readonly WireView[] {
|
||||
return [...this.viewMap.values()];
|
||||
}
|
||||
private readonly states = new Map<string, ClientRemoteState<unknown>>();
|
||||
|
||||
apply(message: ServerWireMessage): void {
|
||||
if (message.type === "response") return;
|
||||
if (message.type === "snapshot") {
|
||||
this.replace(message.snapshot);
|
||||
} else if (message.type === "event") {
|
||||
this.events.push(structuredClone(message.event));
|
||||
if (message.event.type === "config_update") {
|
||||
this.appState = { ...this.app, configuration: structuredClone(message.event.value) };
|
||||
} else {
|
||||
this.appState = { ...this.app, providers: structuredClone(message.event.providers) };
|
||||
}
|
||||
} else if (message.type === "view_updated") {
|
||||
this.viewMap.set(message.view.id, structuredClone(message.view));
|
||||
} else {
|
||||
this.viewMap.delete(message.viewId);
|
||||
}
|
||||
if (message.type === "snapshot") this.replace(message.states);
|
||||
else this.update(message.service, message.property, message.value);
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
|
||||
get<T>(service: string, property: string): RemoteState<T> {
|
||||
const state = this.states.get(`${service}.${property}`);
|
||||
if (!state) throw new Error(`Remote state not provided: ${service}.${property}`);
|
||||
return state as RemoteState<T>;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener();
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private replace(snapshot: ClientSnapshot): void {
|
||||
this.appState = structuredClone(snapshot.app);
|
||||
this.viewMap.clear();
|
||||
for (const view of snapshot.views) this.viewMap.set(view.id, structuredClone(view));
|
||||
private replace(snapshot: StateSnapshot): void {
|
||||
for (const [service, properties] of Object.entries(snapshot)) {
|
||||
for (const [property, value] of Object.entries(properties)) this.update(service, property, value, false);
|
||||
}
|
||||
}
|
||||
|
||||
private update(service: string, property: string, value: unknown, count = true): void {
|
||||
const key = `${service}.${property}`;
|
||||
const current = this.states.get(key);
|
||||
if (current) current.set(value);
|
||||
else this.states.set(key, new ClientRemoteState(value));
|
||||
if (count) this.updates++;
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionClient {
|
||||
readonly ready: Promise<void>;
|
||||
readonly store = new CodingAgentClientStore();
|
||||
readonly store = new ClientStateStore();
|
||||
private readonly proxies = new Map<string, object>();
|
||||
private readonly resolveReady: () => void;
|
||||
private readonly transport: ClientTransport;
|
||||
private receivedSnapshot = false;
|
||||
|
|
@ -88,15 +98,39 @@ export class SessionClient {
|
|||
this.transport.close();
|
||||
}
|
||||
|
||||
invokeAction(id: AppKeybinding): Promise<void> {
|
||||
return this.transport.request({ type: "invoke_action", id });
|
||||
}
|
||||
|
||||
sendView(viewId: string, message: unknown): Promise<void> {
|
||||
return this.transport.request({ type: "view_message", viewId, message });
|
||||
}
|
||||
|
||||
submit(input: string): Promise<void> {
|
||||
return this.transport.request({ type: "submit", input });
|
||||
use<T>(service: Service<T>): T {
|
||||
let proxy = this.proxies.get(service.id);
|
||||
if (!proxy) {
|
||||
proxy = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, property) => {
|
||||
if (typeof property !== "string") return undefined;
|
||||
try {
|
||||
return this.store.get(service.id, property);
|
||||
} catch {
|
||||
return (...args: unknown[]) => {
|
||||
const options = args.at(-1);
|
||||
if (!isRpcOptions(options)) {
|
||||
return this.transport.request({ type: "rpc", service: service.id, method: property, args });
|
||||
}
|
||||
return this.transport.request(
|
||||
{
|
||||
type: "rpc",
|
||||
service: service.id,
|
||||
method: property,
|
||||
args: args.slice(0, -1),
|
||||
rpcOptions: true,
|
||||
},
|
||||
options.signal,
|
||||
);
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
this.proxies.set(service.id, proxy);
|
||||
}
|
||||
return proxy as T;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +1,50 @@
|
|||
/** Minimal session-side plugin lifecycle. The app owns every service and registry. */
|
||||
/**
|
||||
* Shared tokens and two-sided plugin shape.
|
||||
* Remote state values and ordinary remote method arguments/results must be JSON-serializable.
|
||||
* RpcOptions are recognized by the proxy and sent through the transport control plane.
|
||||
*/
|
||||
|
||||
export interface AppPlugin<Api> {
|
||||
export interface Service<T> {
|
||||
readonly id: string;
|
||||
readonly _type?: T;
|
||||
}
|
||||
|
||||
export function defineService<T>(id: string): Service<T> {
|
||||
return { id };
|
||||
}
|
||||
|
||||
const rpcOptionsMarker = Symbol("rpc-options");
|
||||
|
||||
export interface RpcOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly [rpcOptionsMarker]: true;
|
||||
}
|
||||
|
||||
export function rpcOptions(options: { signal?: AbortSignal } = {}): RpcOptions {
|
||||
return { ...options, [rpcOptionsMarker]: true };
|
||||
}
|
||||
|
||||
export function isRpcOptions(value: unknown): value is RpcOptions {
|
||||
return typeof value === "object" && value !== null && rpcOptionsMarker in value;
|
||||
}
|
||||
|
||||
export interface RemoteState<T> {
|
||||
readonly value: T;
|
||||
subscribe(listener: (value: T) => void): () => void;
|
||||
}
|
||||
|
||||
export interface MutableRemoteState<T> extends RemoteState<T> {
|
||||
set(value: T): void;
|
||||
}
|
||||
|
||||
export interface AppPlugin<SessionContext, ClientContext> {
|
||||
id: string;
|
||||
setup(api: Api): void | Promise<void>;
|
||||
session?(context: SessionContext): void | Promise<void>;
|
||||
client?(context: ClientContext): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface AppInstance<Api, Driver> {
|
||||
readonly driver: Driver;
|
||||
activate(): void | Promise<void>;
|
||||
api(owner: string): Api;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface AppDefinition<Api, Driver> {
|
||||
id: string;
|
||||
create(): AppInstance<Api, Driver>;
|
||||
plugins: readonly AppPlugin<Api>[];
|
||||
}
|
||||
|
||||
export function defineApp<Api, Driver>(definition: AppDefinition<Api, Driver>): AppDefinition<Api, Driver> {
|
||||
return definition;
|
||||
}
|
||||
|
||||
export function definePlugin<Api>(plugin: AppPlugin<Api>): AppPlugin<Api> {
|
||||
export function definePlugin<SessionContext, ClientContext>(
|
||||
plugin: AppPlugin<SessionContext, ClientContext>,
|
||||
): AppPlugin<SessionContext, ClientContext> {
|
||||
return plugin;
|
||||
}
|
||||
|
||||
export class TestAppRuntime<Api, Driver> {
|
||||
readonly definition: AppDefinition<Api, Driver>;
|
||||
readonly driver: Driver;
|
||||
private readonly instance: AppInstance<Api, Driver>;
|
||||
|
||||
constructor(definition: AppDefinition<Api, Driver>) {
|
||||
this.definition = definition;
|
||||
this.instance = definition.create();
|
||||
this.driver = this.instance.driver;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
for (const plugin of this.definition.plugins) await plugin.setup(this.instance.api(plugin.id));
|
||||
await this.instance.activate();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.instance.close();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,96 @@
|
|||
import { defineApp, definePlugin } from "./kernel.ts";
|
||||
import type { ModelSelectorState, ModelSpec, ThinkingLevel } from "./protocol.ts";
|
||||
import { type CodingAgentApi, type CodingAgentApp, type CodingAgentPlugin, createSessionInstance } from "./session.ts";
|
||||
import { type AppPlugin, definePlugin, defineService } from "./kernel.ts";
|
||||
import { type ModelSpec, Models, type ModelsService, type ModelsState, type ThinkingLevel } from "./protocol.ts";
|
||||
import { ProviderRegistry } from "./providers.ts";
|
||||
import type { SessionContext } from "./session.ts";
|
||||
import { registerModelTui } from "./tui/model-selector.ts";
|
||||
import type { TuiContext } from "./tui/shared.ts";
|
||||
|
||||
export const providersBuiltin = definePlugin<CodingAgentApi>({
|
||||
export type CodingAgentPlugin = AppPlugin<SessionContext, TuiContext>;
|
||||
|
||||
const Providers = defineService<ProviderRegistry>("providers");
|
||||
export const Settings = defineService<{
|
||||
get(key: string): unknown;
|
||||
set(key: string, value: unknown): Promise<void>;
|
||||
}>("settings");
|
||||
|
||||
export const providersBuiltin = definePlugin<SessionContext, TuiContext>({
|
||||
id: "@pi/providers-builtin",
|
||||
setup(pi) {
|
||||
pi.providers.add((draft) => {
|
||||
session(context) {
|
||||
const providers = new ProviderRegistry();
|
||||
const values = new Map<string, unknown>();
|
||||
const settings = {
|
||||
get: (key: string) => values.get(key),
|
||||
async set(key: string, value: unknown) {
|
||||
values.set(key, structuredClone(value));
|
||||
},
|
||||
};
|
||||
const state = context.state<ModelsState>({
|
||||
catalog: providers.snapshot(),
|
||||
configuration: { model: undefined, thinkingLevel: "high" },
|
||||
refresh: { status: "idle" },
|
||||
});
|
||||
const sessionController = new AbortController();
|
||||
const stop = providers.subscribe((catalog) => state.set({ ...state.value, catalog }));
|
||||
const models: ModelsService = {
|
||||
state,
|
||||
async cycleThinking() {
|
||||
const current = state.value.configuration;
|
||||
const selected = current.model
|
||||
? state.value.catalog.availableModels.find(
|
||||
(model) => model.provider === current.model?.provider && model.modelId === current.model.modelId,
|
||||
)
|
||||
: undefined;
|
||||
if (selected && !selected.reasoning) throw new Error("Current model does not support thinking");
|
||||
const levels: readonly ThinkingLevel[] = ["off", "low", "high"];
|
||||
const thinkingLevel = levels[(levels.indexOf(current.thinkingLevel) + 1) % levels.length] ?? "off";
|
||||
state.set({ ...state.value, configuration: { ...current, thinkingLevel } });
|
||||
},
|
||||
async refresh(options) {
|
||||
const previousRefresh = state.value.refresh;
|
||||
state.set({ ...state.value, refresh: { status: "refreshing" } });
|
||||
const signal = options?.signal
|
||||
? AbortSignal.any([sessionController.signal, options.signal])
|
||||
: sessionController.signal;
|
||||
try {
|
||||
const errors = await providers.refresh(signal);
|
||||
signal.throwIfAborted();
|
||||
state.set({
|
||||
...state.value,
|
||||
refresh:
|
||||
errors.size === 0 ? { status: "done" } : { status: "warning", errors: Object.fromEntries(errors) },
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal.aborted) state.set({ ...state.value, refresh: previousRefresh });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async select(model) {
|
||||
const selected = state.value.catalog.availableModels.find(
|
||||
(candidate) => candidate.provider === model.provider && candidate.modelId === model.modelId,
|
||||
);
|
||||
if (!selected) throw new Error(`Unknown model: ${model.provider}/${model.modelId}`);
|
||||
const configuration = {
|
||||
model: { provider: selected.provider, modelId: selected.modelId },
|
||||
thinkingLevel: selected.reasoning ? state.value.configuration.thinkingLevel : ("off" as const),
|
||||
};
|
||||
state.set({ ...state.value, configuration });
|
||||
await settings.set("defaultModel", configuration.model);
|
||||
},
|
||||
};
|
||||
context.provide(Providers, providers);
|
||||
context.provide(Settings, settings);
|
||||
context.provide(Models, models);
|
||||
providers.add("@pi/providers-builtin", (draft) => {
|
||||
draft.native("acme", [{ provider: "acme", modelId: "base", name: "Base", reasoning: true }]);
|
||||
draft.native("unconfigured", [
|
||||
{ provider: "unconfigured", modelId: "hidden", name: "Hidden", reasoning: true },
|
||||
]);
|
||||
});
|
||||
context.onActivate(() => providers.rebuild());
|
||||
context.onClose(() => {
|
||||
stop();
|
||||
sessionController.abort();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -20,8 +100,8 @@ export function providersCatalog(
|
|||
let catalog: readonly ModelSpec[] = [{ provider: "acme", modelId: "cached", name: "Cached", reasoning: true }];
|
||||
return definePlugin({
|
||||
id: "@pi/providers-catalog",
|
||||
setup(pi: CodingAgentApi) {
|
||||
pi.providers.add((draft) => {
|
||||
session(context: SessionContext) {
|
||||
context.use(Providers).add("@pi/providers-catalog", (draft) => {
|
||||
draft.models("acme", (models) => [...models, ...catalog]);
|
||||
draft.refresh("acme", "remote", async (signal) => {
|
||||
const next = await refreshCatalog(signal);
|
||||
|
|
@ -33,10 +113,10 @@ export function providersCatalog(
|
|||
});
|
||||
}
|
||||
|
||||
export const providersModelsJson = definePlugin<CodingAgentApi>({
|
||||
export const providersModelsJson: CodingAgentPlugin = definePlugin({
|
||||
id: "@pi/providers-models-json",
|
||||
setup(pi) {
|
||||
pi.providers.add((draft) => {
|
||||
session(context) {
|
||||
context.use(Providers).add("@pi/providers-models-json", (draft) => {
|
||||
draft.models("acme", (models) =>
|
||||
models.map((model) => (model.modelId === "base" ? { ...model, name: "Base (models.json)" } : model)),
|
||||
);
|
||||
|
|
@ -44,155 +124,36 @@ export const providersModelsJson = definePlugin<CodingAgentApi>({
|
|||
},
|
||||
});
|
||||
|
||||
export const auth = definePlugin<CodingAgentApi>({
|
||||
export const auth: CodingAgentPlugin = definePlugin({
|
||||
id: "@pi/auth",
|
||||
setup(pi) {
|
||||
pi.providers.add((draft) => draft.configured("acme"));
|
||||
session(context) {
|
||||
context.use(Providers).add("@pi/auth", (draft) => draft.configured("acme"));
|
||||
},
|
||||
});
|
||||
|
||||
export const thinkingControl = definePlugin<CodingAgentApi>({
|
||||
export const thinkingControl: CodingAgentPlugin = definePlugin({
|
||||
id: "@pi/thinking-control",
|
||||
setup(pi) {
|
||||
pi.actions.add((draft) => {
|
||||
draft.set("app.thinking.cycle", {
|
||||
description: "Cycle thinking level",
|
||||
async handler() {
|
||||
const current = pi.session.getConfiguration();
|
||||
const selected = current.model
|
||||
? pi.providers
|
||||
.snapshot()
|
||||
.availableModels.find(
|
||||
(model) =>
|
||||
model.provider === current.model?.provider && model.modelId === current.model.modelId,
|
||||
)
|
||||
: undefined;
|
||||
if (selected && !selected.reasoning) throw new Error("Current model does not support thinking");
|
||||
const levels: readonly ThinkingLevel[] = ["off", "low", "high"];
|
||||
const next = levels[(levels.indexOf(current.thinkingLevel) + 1) % levels.length] ?? "off";
|
||||
await pi.session.configure({ ...current, thinkingLevel: next });
|
||||
},
|
||||
});
|
||||
});
|
||||
client(context) {
|
||||
context.actions.register("app.thinking.cycle", () => context.use(Models).cycleThinking());
|
||||
},
|
||||
});
|
||||
|
||||
type ModelSelectorMessage = { type: "cancel" } | { type: "select"; provider: string; modelId: string };
|
||||
|
||||
function isModelSelectorMessage(value: unknown): value is ModelSelectorMessage {
|
||||
if (typeof value !== "object" || value === null || !("type" in value)) return false;
|
||||
const candidate = value as { type?: unknown; provider?: unknown; modelId?: unknown };
|
||||
return (
|
||||
candidate.type === "cancel" ||
|
||||
(candidate.type === "select" && typeof candidate.provider === "string" && typeof candidate.modelId === "string")
|
||||
);
|
||||
}
|
||||
|
||||
export const modelSelection = definePlugin<CodingAgentApi>({
|
||||
export const modelSelection: CodingAgentPlugin = definePlugin({
|
||||
id: "@pi/model-selection",
|
||||
setup(pi) {
|
||||
pi.commands.add((draft) => {
|
||||
draft.set("model", {
|
||||
argumentHint: "[provider/]model",
|
||||
description: "Select model",
|
||||
async handler(args, context) {
|
||||
const resolveExact = (query: string): ModelSpec | undefined => {
|
||||
const models = pi.providers.snapshot().availableModels;
|
||||
const canonical = models.find((model) => `${model.provider}/${model.modelId}` === query);
|
||||
if (canonical) return canonical;
|
||||
const bare = models.filter((model) => model.modelId === query);
|
||||
return bare.length === 1 ? bare[0] : undefined;
|
||||
};
|
||||
const select = async (model: ModelSpec): Promise<void> => {
|
||||
const current = pi.session.getConfiguration();
|
||||
await pi.session.configure({
|
||||
model: { provider: model.provider, modelId: model.modelId },
|
||||
thinkingLevel: model.reasoning ? current.thinkingLevel : "off",
|
||||
});
|
||||
await pi.settings.set("defaultModel", { provider: model.provider, modelId: model.modelId });
|
||||
};
|
||||
|
||||
if (args) {
|
||||
let exact = resolveExact(args);
|
||||
if (!exact) {
|
||||
await pi.providers.refresh(context.signal);
|
||||
exact = resolveExact(args);
|
||||
}
|
||||
if (exact) {
|
||||
await select(exact);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let refresh: ModelSelectorState["refresh"] = { status: "refreshing" };
|
||||
const createState = (): ModelSelectorState => ({
|
||||
schema: 1,
|
||||
query: args,
|
||||
catalogRevision: pi.providers.snapshot().revision,
|
||||
refresh,
|
||||
});
|
||||
const view = pi.view("model-selector", createState(), { to: [context.originatorClientId] });
|
||||
const stopRebuild = pi.providers.onRebuild(() => {
|
||||
view.state = createState();
|
||||
});
|
||||
view.on(async (message) => {
|
||||
if (!isModelSelectorMessage(message)) return;
|
||||
if (message.type === "cancel") {
|
||||
view.close();
|
||||
return;
|
||||
}
|
||||
const selected = pi.providers
|
||||
.snapshot()
|
||||
.availableModels.find(
|
||||
(model) => model.provider === message.provider && model.modelId === message.modelId,
|
||||
);
|
||||
if (!selected) return;
|
||||
await select(selected);
|
||||
view.close();
|
||||
});
|
||||
|
||||
const refreshController = new AbortController();
|
||||
const refreshSignal = AbortSignal.any([context.signal, refreshController.signal]);
|
||||
void pi.providers
|
||||
.refresh(refreshSignal)
|
||||
.then((result) => {
|
||||
refresh =
|
||||
result.errors.size === 0
|
||||
? { status: "done" }
|
||||
: { status: "warning", errors: Object.fromEntries(result.errors) };
|
||||
view.state = createState();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (refreshSignal.aborted) return;
|
||||
refresh = {
|
||||
status: "warning",
|
||||
errors: { refresh: error instanceof Error ? error.message : String(error) },
|
||||
};
|
||||
view.state = createState();
|
||||
});
|
||||
|
||||
await view.done;
|
||||
refreshController.abort();
|
||||
stopRebuild();
|
||||
},
|
||||
});
|
||||
});
|
||||
client(context) {
|
||||
registerModelTui(context, context.use(Models));
|
||||
},
|
||||
});
|
||||
|
||||
export function createCodingAgentApp(
|
||||
export function createCodingAgentPlugins(
|
||||
refreshCatalog: (signal: AbortSignal) => Promise<readonly ModelSpec[]>,
|
||||
): CodingAgentApp {
|
||||
return defineApp({
|
||||
id: "coding-agent",
|
||||
create: createSessionInstance,
|
||||
plugins: [
|
||||
providersBuiltin,
|
||||
providersCatalog(refreshCatalog),
|
||||
providersModelsJson,
|
||||
auth,
|
||||
thinkingControl,
|
||||
modelSelection,
|
||||
],
|
||||
});
|
||||
): readonly CodingAgentPlugin[] {
|
||||
return [
|
||||
providersBuiltin,
|
||||
providersCatalog(refreshCatalog),
|
||||
providersModelsJson,
|
||||
auth,
|
||||
thinkingControl,
|
||||
modelSelection,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { AppKeybinding } from "../../../src/core/keybindings.ts";
|
||||
import { defineService, type RemoteState, type RpcOptions } from "./kernel.ts";
|
||||
|
||||
export type ThinkingLevel = "off" | "low" | "high";
|
||||
|
||||
|
|
@ -23,50 +23,43 @@ export interface ProviderSnapshot {
|
|||
models: readonly ModelSpec[];
|
||||
}
|
||||
|
||||
export interface CodingAgentSnapshot {
|
||||
actions: ReadonlyArray<{ id: AppKeybinding; description: string }>;
|
||||
commands: ReadonlyArray<{ name: string; description: string; argumentHint?: string }>;
|
||||
export type RefreshState =
|
||||
| { status: "idle" }
|
||||
| { status: "refreshing" }
|
||||
| { status: "done" }
|
||||
| { status: "warning"; errors: Readonly<Record<string, string>> };
|
||||
|
||||
export interface ModelsState {
|
||||
catalog: ProviderSnapshot;
|
||||
configuration: LaneConfiguration;
|
||||
providers: ProviderSnapshot;
|
||||
refresh: RefreshState;
|
||||
}
|
||||
|
||||
export type CodingAgentEvent =
|
||||
| { type: "config_update"; previous: LaneConfiguration; value: LaneConfiguration }
|
||||
| { type: "providers_changed"; providers: ProviderSnapshot };
|
||||
|
||||
export interface ModelSelectorState {
|
||||
schema: 1;
|
||||
query: string;
|
||||
catalogRevision: number;
|
||||
refresh:
|
||||
| { status: "refreshing" }
|
||||
| { status: "done" }
|
||||
| { status: "warning"; errors: Readonly<Record<string, string>> };
|
||||
export interface ModelsService {
|
||||
state: RemoteState<ModelsState>;
|
||||
cycleThinking(): Promise<void>;
|
||||
refresh(options?: RpcOptions): Promise<void>;
|
||||
select(model: ModelRef): Promise<void>;
|
||||
}
|
||||
|
||||
export interface WireView {
|
||||
id: string;
|
||||
component: string;
|
||||
state: unknown;
|
||||
}
|
||||
export const Models = defineService<ModelsService>("models");
|
||||
|
||||
export interface ClientSnapshot {
|
||||
app: CodingAgentSnapshot;
|
||||
views: readonly WireView[];
|
||||
}
|
||||
export type StateSnapshot = Record<string, Record<string, unknown>>;
|
||||
|
||||
export type SessionRequest =
|
||||
| { type: "submit"; input: string }
|
||||
| { type: "invoke_action"; id: AppKeybinding }
|
||||
| { type: "view_message"; viewId: string; message: unknown };
|
||||
export type SessionRequest = {
|
||||
type: "rpc";
|
||||
service: string;
|
||||
method: string;
|
||||
args: unknown[];
|
||||
rpcOptions?: true;
|
||||
};
|
||||
|
||||
export type ClientWireMessage =
|
||||
| { type: "hello"; clientId: string }
|
||||
| { type: "request"; id: number; request: SessionRequest };
|
||||
| { type: "request"; id: number; request: SessionRequest }
|
||||
| { type: "cancel"; id: number };
|
||||
|
||||
export type ServerWireMessage =
|
||||
| { type: "snapshot"; snapshot: ClientSnapshot }
|
||||
| { type: "event"; event: CodingAgentEvent }
|
||||
| { type: "view_updated"; view: WireView }
|
||||
| { type: "view_closed"; viewId: string }
|
||||
| { type: "response"; id: number; error?: string };
|
||||
| { type: "snapshot"; states: StateSnapshot }
|
||||
| { type: "state_update"; service: string; property: string; value: unknown }
|
||||
| { type: "response"; id: number; result?: unknown; error?: string };
|
||||
|
|
|
|||
152
packages/coding-agent/test/fixtures/plugin-app/providers.ts
vendored
Normal file
152
packages/coding-agent/test/fixtures/plugin-app/providers.ts
vendored
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import type { ModelSpec, ProviderSnapshot } from "./protocol.ts";
|
||||
|
||||
type Contribution<D> = (draft: D, owner: string) => void;
|
||||
|
||||
class ReplayRegistry<D, S> {
|
||||
private readonly build: (draft: D) => S;
|
||||
private readonly contributions: Array<{ owner: string; run: Contribution<D> }> = [];
|
||||
private readonly createDraft: () => D;
|
||||
private state: S;
|
||||
|
||||
constructor(createDraft: () => D, build: (draft: D) => S) {
|
||||
this.build = build;
|
||||
this.createDraft = createDraft;
|
||||
this.state = build(createDraft());
|
||||
}
|
||||
|
||||
add(owner: string, run: Contribution<D>): void {
|
||||
this.contributions.push({ owner, run });
|
||||
}
|
||||
|
||||
rebuild(): S {
|
||||
const draft = this.createDraft();
|
||||
for (const { owner, run } of this.contributions) run(draft, owner);
|
||||
this.state = this.build(draft);
|
||||
return this.state;
|
||||
}
|
||||
|
||||
current(): S {
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
|
||||
interface ProviderEntry {
|
||||
configured: boolean;
|
||||
models: ModelSpec[];
|
||||
refreshers: Array<{ id: string; refresh(signal: AbortSignal): Promise<void> }>;
|
||||
}
|
||||
|
||||
class ProviderDraftState {
|
||||
readonly entries = new Map<string, ProviderEntry>();
|
||||
|
||||
entry(id: string): ProviderEntry {
|
||||
let entry = this.entries.get(id);
|
||||
if (!entry) {
|
||||
entry = { configured: false, models: [], refreshers: [] };
|
||||
this.entries.set(id, entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
export class ProviderDraft {
|
||||
private readonly owner: string;
|
||||
private readonly state: ProviderDraftState;
|
||||
|
||||
constructor(owner: string, state: ProviderDraftState) {
|
||||
this.owner = owner;
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
native(id: string, models: readonly ModelSpec[]): void {
|
||||
this.state.entry(id).models = models.map((model) => ({ ...model, provider: id }));
|
||||
}
|
||||
|
||||
models(id: string, transform: (models: readonly ModelSpec[]) => readonly ModelSpec[]): void {
|
||||
const entry = this.state.entry(id);
|
||||
entry.models = transform(entry.models.map((model) => ({ ...model }))).map((model) => ({
|
||||
...model,
|
||||
provider: id,
|
||||
}));
|
||||
}
|
||||
|
||||
configured(id: string): void {
|
||||
this.state.entry(id).configured = true;
|
||||
}
|
||||
|
||||
refresh(id: string, localId: string, refresh: (signal: AbortSignal) => Promise<void>): void {
|
||||
this.state.entry(id).refreshers.push({ id: `${this.owner}:${localId}`, refresh });
|
||||
}
|
||||
}
|
||||
|
||||
interface ProviderState {
|
||||
availableModels: readonly ModelSpec[];
|
||||
models: readonly ModelSpec[];
|
||||
refreshers: ReadonlyArray<{ id: string; refresh(signal: AbortSignal): Promise<void> }>;
|
||||
}
|
||||
|
||||
export class ProviderRegistry {
|
||||
private readonly listeners = new Set<(snapshot: ProviderSnapshot) => void>();
|
||||
private readonly registry = new ReplayRegistry(
|
||||
() => new ProviderDraftState(),
|
||||
(draft): ProviderState => {
|
||||
const models: ModelSpec[] = [];
|
||||
const availableModels: ModelSpec[] = [];
|
||||
const refreshers: Array<{ id: string; refresh(signal: AbortSignal): Promise<void> }> = [];
|
||||
for (const entry of draft.entries.values()) {
|
||||
models.push(...entry.models.map((model) => ({ ...model })));
|
||||
if (entry.configured) availableModels.push(...entry.models.map((model) => ({ ...model })));
|
||||
refreshers.push(...entry.refreshers);
|
||||
}
|
||||
const byId = (left: ModelSpec, right: ModelSpec) =>
|
||||
`${left.provider}/${left.modelId}`.localeCompare(`${right.provider}/${right.modelId}`);
|
||||
return { availableModels: availableModels.sort(byId), models: models.sort(byId), refreshers };
|
||||
},
|
||||
);
|
||||
private revision = 0;
|
||||
private serialized = "";
|
||||
|
||||
add(owner: string, contribution: (draft: ProviderDraft) => void): void {
|
||||
this.registry.add(owner, (draft) => contribution(new ProviderDraft(owner, draft)));
|
||||
}
|
||||
|
||||
rebuild(): void {
|
||||
const state = this.registry.rebuild();
|
||||
const serialized = JSON.stringify({ availableModels: state.availableModels, models: state.models });
|
||||
if (serialized === this.serialized) return;
|
||||
this.serialized = serialized;
|
||||
this.revision++;
|
||||
for (const listener of this.listeners) listener(this.snapshot());
|
||||
}
|
||||
|
||||
snapshot(): ProviderSnapshot {
|
||||
const state = this.registry.current();
|
||||
return {
|
||||
revision: this.revision,
|
||||
availableModels: state.availableModels.map((model) => ({ ...model })),
|
||||
models: state.models.map((model) => ({ ...model })),
|
||||
};
|
||||
}
|
||||
|
||||
subscribe(listener: (snapshot: ProviderSnapshot) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.snapshot());
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
async refresh(signal: AbortSignal): Promise<ReadonlyMap<string, string>> {
|
||||
signal.throwIfAborted();
|
||||
const errors = new Map<string, string>();
|
||||
await Promise.all(
|
||||
this.registry.current().refreshers.map(async ({ id, refresh }) => {
|
||||
try {
|
||||
await refresh(signal);
|
||||
} catch (error) {
|
||||
if (!signal.aborted) errors.set(id, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (!signal.aborted) this.rebuild();
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +1,35 @@
|
|||
import { TestAppRuntime } from "./kernel.ts";
|
||||
import { createCodingAgentApp } from "./plugins.ts";
|
||||
import { createCodingAgentPlugins } from "./plugins.ts";
|
||||
import { SessionRuntime } from "./session.ts";
|
||||
import { SessionTcpServer } from "./transport.ts";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const host = process.argv[2] ?? "127.0.0.1";
|
||||
const port = Number(process.argv[3] ?? "7777");
|
||||
const runtime = new TestAppRuntime(
|
||||
createCodingAgentApp(async (signal) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(resolve, 750);
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
reject(signal.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
return [
|
||||
{ provider: "acme", modelId: "fresh", name: "Fresh from catalogue", reasoning: false },
|
||||
{ provider: "acme", modelId: "reasoning", name: "Reasoning model", reasoning: true },
|
||||
];
|
||||
}),
|
||||
);
|
||||
const plugins = createCodingAgentPlugins(async (signal) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(resolve, 750);
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
reject(signal.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
return [
|
||||
{ provider: "acme", modelId: "fresh", name: "Fresh from catalogue", reasoning: false },
|
||||
{ provider: "acme", modelId: "reasoning", name: "Reasoning model", reasoning: true },
|
||||
];
|
||||
});
|
||||
const runtime = new SessionRuntime(plugins);
|
||||
await runtime.start();
|
||||
const server = new SessionTcpServer(runtime.driver, { host, port });
|
||||
const address = await server.start();
|
||||
process.stdout.write(`Session listening on ${address.host}:${address.port}\n`);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const stop = () => resolve();
|
||||
process.once("SIGINT", stop);
|
||||
process.once("SIGTERM", stop);
|
||||
process.once("SIGINT", resolve);
|
||||
process.once("SIGTERM", resolve);
|
||||
});
|
||||
await server.close();
|
||||
runtime.close();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { ProcessTerminal } from "@earendil-works/pi-tui";
|
||||
import { SessionClient } from "./client.ts";
|
||||
import { createCodingAgentPlugins } from "./plugins.ts";
|
||||
import { TcpClientTransport } from "./transport.ts";
|
||||
import { MinimalCodingAgentTui } from "./tui/app.ts";
|
||||
import { modelSelectorTui } from "./tui/model-selector.ts";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const host = process.argv[2] ?? "127.0.0.1";
|
||||
|
|
@ -10,7 +10,11 @@ async function main(): Promise<void> {
|
|||
const transport = await TcpClientTransport.connect({ host, port, clientId: "tui" });
|
||||
const client = new SessionClient(transport);
|
||||
await client.ready;
|
||||
const app = new MinimalCodingAgentTui(new ProcessTerminal(), client, [modelSelectorTui]);
|
||||
const app = new MinimalCodingAgentTui(
|
||||
new ProcessTerminal(),
|
||||
client,
|
||||
createCodingAgentPlugins(async () => []),
|
||||
);
|
||||
const stop = () => app.stop();
|
||||
process.once("SIGINT", stop);
|
||||
process.once("SIGTERM", stop);
|
||||
|
|
|
|||
|
|
@ -1,76 +1,34 @@
|
|||
import { type AppKeybinding, KEYBINDINGS } from "../../../src/core/keybindings.ts";
|
||||
import type { AppDefinition, AppInstance, AppPlugin } from "./kernel.ts";
|
||||
import type {
|
||||
ClientSnapshot,
|
||||
CodingAgentEvent,
|
||||
CodingAgentSnapshot,
|
||||
LaneConfiguration,
|
||||
ModelSpec,
|
||||
ProviderSnapshot,
|
||||
ServerWireMessage,
|
||||
SessionRequest,
|
||||
WireView,
|
||||
} from "./protocol.ts";
|
||||
import { type AppPlugin, type MutableRemoteState, rpcOptions, type Service } from "./kernel.ts";
|
||||
import type { ServerWireMessage, SessionRequest, StateSnapshot } from "./protocol.ts";
|
||||
|
||||
type Contribution<D> = (draft: D, owner: string) => void;
|
||||
class SessionRemoteState<T> implements MutableRemoteState<T> {
|
||||
private readonly listeners = new Set<(value: T) => void>();
|
||||
private current: T;
|
||||
|
||||
class ReplayRegistry<D, S> {
|
||||
private readonly build: (draft: D) => S;
|
||||
private readonly contributions: Array<{ owner: string; run: Contribution<D> }> = [];
|
||||
private readonly createDraft: () => D;
|
||||
private state: S;
|
||||
|
||||
constructor(createDraft: () => D, build: (draft: D) => S) {
|
||||
this.build = build;
|
||||
this.createDraft = createDraft;
|
||||
this.state = build(createDraft());
|
||||
constructor(initial: T) {
|
||||
this.current = structuredClone(initial);
|
||||
}
|
||||
|
||||
add(owner: string, run: Contribution<D>): void {
|
||||
this.contributions.push({ owner, run });
|
||||
get value(): T {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
rebuild(): S {
|
||||
const draft = this.createDraft();
|
||||
for (const { owner, run } of this.contributions) run(draft, owner);
|
||||
this.state = this.build(draft);
|
||||
return this.state;
|
||||
set(value: T): void {
|
||||
this.current = structuredClone(value);
|
||||
for (const listener of this.listeners) listener(this.current);
|
||||
}
|
||||
|
||||
current(): S {
|
||||
return this.state;
|
||||
subscribe(listener: (value: T) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.current);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
}
|
||||
|
||||
export interface InvocationContext {
|
||||
originatorClientId: string;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
interface ActionDefinition {
|
||||
description: string;
|
||||
handler(context: InvocationContext): Promise<void>;
|
||||
}
|
||||
|
||||
interface CommandDefinition {
|
||||
argumentHint?: string;
|
||||
description: string;
|
||||
handler(args: string, context: InvocationContext): Promise<void>;
|
||||
}
|
||||
|
||||
type ViewMessageHandler = (message: unknown, fromClientId: string) => void | Promise<void>;
|
||||
|
||||
interface ViewRecord extends WireView {
|
||||
closed: boolean;
|
||||
handler: ViewMessageHandler | undefined;
|
||||
resolveDone(): void;
|
||||
to: readonly string[];
|
||||
}
|
||||
|
||||
class ClientHub {
|
||||
private readonly connections = new Map<string, (message: ServerWireMessage) => void>();
|
||||
|
||||
connect(clientId: string, snapshot: () => ClientSnapshot, send: (message: ServerWireMessage) => void): () => void {
|
||||
connect(clientId: string, snapshot: () => StateSnapshot, send: (message: ServerWireMessage) => void): () => void {
|
||||
const pending: ServerWireMessage[] = [];
|
||||
let ready = false;
|
||||
const connection = (message: ServerWireMessage) => {
|
||||
|
|
@ -78,7 +36,7 @@ class ClientHub {
|
|||
else pending.push(message);
|
||||
};
|
||||
this.connections.set(clientId, connection);
|
||||
send({ type: "snapshot", snapshot: structuredClone(snapshot()) });
|
||||
send({ type: "snapshot", states: structuredClone(snapshot()) });
|
||||
ready = true;
|
||||
for (const message of pending) send(message);
|
||||
return () => {
|
||||
|
|
@ -89,388 +47,96 @@ class ClientHub {
|
|||
broadcast(message: ServerWireMessage): void {
|
||||
for (const send of this.connections.values()) send(structuredClone(message));
|
||||
}
|
||||
|
||||
send(clientIds: readonly string[], message: ServerWireMessage): void {
|
||||
for (const clientId of clientIds) this.connections.get(clientId)?.(structuredClone(message));
|
||||
}
|
||||
}
|
||||
|
||||
interface ViewController {
|
||||
close(id: string): void;
|
||||
setHandler(id: string, handler: ViewMessageHandler): void;
|
||||
update(id: string, state: unknown): void;
|
||||
}
|
||||
|
||||
export class ViewHandle<S> {
|
||||
readonly done: Promise<void>;
|
||||
readonly id: string;
|
||||
private readonly controller: ViewController;
|
||||
|
||||
constructor(id: string, done: Promise<void>, controller: ViewController) {
|
||||
this.controller = controller;
|
||||
this.done = done;
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
set state(state: S) {
|
||||
this.controller.update(this.id, state);
|
||||
}
|
||||
|
||||
on(handler: ViewMessageHandler): void {
|
||||
this.controller.setHandler(this.id, handler);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.controller.close(this.id);
|
||||
}
|
||||
}
|
||||
|
||||
class ViewService implements ViewController {
|
||||
private readonly hub: ClientHub;
|
||||
private readonly records = new Map<string, ViewRecord>();
|
||||
private nextId = 1;
|
||||
openedCount = 0;
|
||||
|
||||
constructor(hub: ClientHub) {
|
||||
this.hub = hub;
|
||||
}
|
||||
|
||||
open<S>(component: string, state: S, to: readonly string[]): ViewHandle<S> {
|
||||
const id = `view-${this.nextId++}`;
|
||||
let resolveDone!: () => void;
|
||||
const done = new Promise<void>((resolve) => {
|
||||
resolveDone = resolve;
|
||||
});
|
||||
const record: ViewRecord = {
|
||||
closed: false,
|
||||
component,
|
||||
handler: undefined,
|
||||
id,
|
||||
resolveDone,
|
||||
state: structuredClone(state),
|
||||
to: [...to],
|
||||
};
|
||||
this.records.set(id, record);
|
||||
this.openedCount++;
|
||||
this.hub.send(record.to, { type: "view_updated", view: this.toWire(record) });
|
||||
return new ViewHandle(id, done, this);
|
||||
}
|
||||
|
||||
forClient(clientId: string): WireView[] {
|
||||
return [...this.records.values()]
|
||||
.filter((record) => !record.closed && record.to.includes(clientId))
|
||||
.map((record) => this.toWire(record));
|
||||
}
|
||||
|
||||
async send(id: string, clientId: string, message: unknown): Promise<void> {
|
||||
const record = this.records.get(id);
|
||||
if (!record || record.closed || !record.to.includes(clientId)) return;
|
||||
await record.handler?.(structuredClone(message), clientId);
|
||||
}
|
||||
|
||||
update(id: string, state: unknown): void {
|
||||
const record = this.records.get(id);
|
||||
if (!record || record.closed) return;
|
||||
record.state = structuredClone(state);
|
||||
this.hub.send(record.to, { type: "view_updated", view: this.toWire(record) });
|
||||
}
|
||||
|
||||
setHandler(id: string, handler: ViewMessageHandler): void {
|
||||
const record = this.records.get(id);
|
||||
if (record && !record.closed) record.handler = handler;
|
||||
}
|
||||
|
||||
close(id: string): void {
|
||||
const record = this.records.get(id);
|
||||
if (!record || record.closed) return;
|
||||
record.closed = true;
|
||||
this.hub.send(record.to, { type: "view_closed", viewId: id });
|
||||
record.resolveDone();
|
||||
}
|
||||
|
||||
closeAll(): void {
|
||||
for (const id of this.records.keys()) this.close(id);
|
||||
}
|
||||
|
||||
private toWire(record: ViewRecord): WireView {
|
||||
return { id: record.id, component: record.component, state: structuredClone(record.state) };
|
||||
}
|
||||
}
|
||||
|
||||
interface ProviderEntry {
|
||||
configured: boolean;
|
||||
models: ModelSpec[];
|
||||
refreshers: Array<{ id: string; refresh(signal: AbortSignal): Promise<void> }>;
|
||||
}
|
||||
|
||||
class ProviderDraftState {
|
||||
readonly entries = new Map<string, ProviderEntry>();
|
||||
|
||||
entry(id: string): ProviderEntry {
|
||||
let entry = this.entries.get(id);
|
||||
if (!entry) {
|
||||
entry = { configured: false, models: [], refreshers: [] };
|
||||
this.entries.set(id, entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
export class ProviderDraft {
|
||||
private readonly owner: string;
|
||||
private readonly state: ProviderDraftState;
|
||||
|
||||
constructor(owner: string, state: ProviderDraftState) {
|
||||
this.owner = owner;
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
native(id: string, models: readonly ModelSpec[]): void {
|
||||
this.state.entry(id).models = models.map((model) => ({ ...model, provider: id }));
|
||||
}
|
||||
|
||||
models(id: string, transform: (models: readonly ModelSpec[]) => readonly ModelSpec[]): void {
|
||||
const entry = this.state.entry(id);
|
||||
entry.models = transform(entry.models.map((model) => ({ ...model }))).map((model) => ({
|
||||
...model,
|
||||
provider: id,
|
||||
}));
|
||||
}
|
||||
|
||||
configured(id: string): void {
|
||||
this.state.entry(id).configured = true;
|
||||
}
|
||||
|
||||
refresh(id: string, localId: string, refresh: (signal: AbortSignal) => Promise<void>): void {
|
||||
this.state.entry(id).refreshers.push({ id: `${this.owner}:${localId}`, refresh });
|
||||
}
|
||||
}
|
||||
|
||||
interface ProviderState {
|
||||
availableModels: readonly ModelSpec[];
|
||||
models: readonly ModelSpec[];
|
||||
refreshers: ReadonlyArray<{ id: string; refresh(signal: AbortSignal): Promise<void> }>;
|
||||
}
|
||||
|
||||
export class ProviderRegistry {
|
||||
private readonly listeners = new Set<(snapshot: ProviderSnapshot) => void>();
|
||||
private readonly registry = new ReplayRegistry(
|
||||
() => new ProviderDraftState(),
|
||||
(draft): ProviderState => {
|
||||
const models: ModelSpec[] = [];
|
||||
const availableModels: ModelSpec[] = [];
|
||||
const refreshers: Array<{ id: string; refresh(signal: AbortSignal): Promise<void> }> = [];
|
||||
for (const entry of draft.entries.values()) {
|
||||
models.push(...entry.models.map((model) => ({ ...model })));
|
||||
if (entry.configured) availableModels.push(...entry.models.map((model) => ({ ...model })));
|
||||
refreshers.push(...entry.refreshers);
|
||||
}
|
||||
const byId = (left: ModelSpec, right: ModelSpec) =>
|
||||
`${left.provider}/${left.modelId}`.localeCompare(`${right.provider}/${right.modelId}`);
|
||||
return { availableModels: availableModels.sort(byId), models: models.sort(byId), refreshers };
|
||||
},
|
||||
);
|
||||
private revision = 0;
|
||||
private serialized = "";
|
||||
|
||||
add(owner: string, contribution: (draft: ProviderDraft) => void): void {
|
||||
this.registry.add(owner, (draft) => contribution(new ProviderDraft(owner, draft)));
|
||||
}
|
||||
|
||||
rebuild(): ProviderSnapshot {
|
||||
const state = this.registry.rebuild();
|
||||
const serialized = JSON.stringify({ availableModels: state.availableModels, models: state.models });
|
||||
if (serialized !== this.serialized) {
|
||||
this.serialized = serialized;
|
||||
this.revision++;
|
||||
for (const listener of this.listeners) listener(this.snapshot());
|
||||
}
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
snapshot(): ProviderSnapshot {
|
||||
const state = this.registry.current();
|
||||
return {
|
||||
revision: this.revision,
|
||||
availableModels: state.availableModels.map((model) => ({ ...model })),
|
||||
models: state.models.map((model) => ({ ...model })),
|
||||
};
|
||||
}
|
||||
|
||||
onRebuild(listener: (snapshot: ProviderSnapshot) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
async refresh(signal: AbortSignal): Promise<{ aborted: boolean; errors: ReadonlyMap<string, string> }> {
|
||||
signal.throwIfAborted();
|
||||
const errors = new Map<string, string>();
|
||||
await Promise.all(
|
||||
this.registry.current().refreshers.map(async ({ id, refresh }) => {
|
||||
try {
|
||||
await refresh(signal);
|
||||
} catch (error) {
|
||||
if (!signal.aborted) errors.set(id, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (!signal.aborted) this.rebuild();
|
||||
return { aborted: signal.aborted, errors };
|
||||
}
|
||||
}
|
||||
|
||||
export interface CodingAgentApi {
|
||||
actions: { add(contribution: (draft: Map<string, ActionDefinition>) => void): void };
|
||||
commands: { add(contribution: (draft: Map<string, CommandDefinition>) => void): void };
|
||||
providers: {
|
||||
add(contribution: (draft: ProviderDraft) => void): void;
|
||||
onRebuild(listener: (snapshot: ProviderSnapshot) => void): () => void;
|
||||
refresh(signal: AbortSignal): Promise<{ aborted: boolean; errors: ReadonlyMap<string, string> }>;
|
||||
snapshot(): ProviderSnapshot;
|
||||
};
|
||||
session: {
|
||||
configure(configuration: LaneConfiguration): Promise<void>;
|
||||
getConfiguration(): LaneConfiguration;
|
||||
};
|
||||
settings: { set(key: string, value: unknown): Promise<void> };
|
||||
view<S>(component: string, state: S, options: { to: readonly string[] }): ViewHandle<S>;
|
||||
export interface SessionContext {
|
||||
onActivate(callback: () => void | Promise<void>): void;
|
||||
onClose(callback: () => void): void;
|
||||
provide<T>(service: Service<T>, implementation: T): void;
|
||||
state<T>(initial: T): MutableRemoteState<T>;
|
||||
use<T>(service: Service<T>): T;
|
||||
}
|
||||
|
||||
export interface SessionDriver {
|
||||
readonly openedViewCount: number;
|
||||
readonly trace: readonly string[];
|
||||
connect(clientId: string, send: (message: ServerWireMessage) => void): () => void;
|
||||
getConfiguration(): LaneConfiguration;
|
||||
invokeAction(clientId: string, id: AppKeybinding): Promise<void>;
|
||||
providers: ProviderRegistry;
|
||||
request(clientId: string, request: SessionRequest): Promise<void>;
|
||||
settings: ReadonlyMap<string, unknown>;
|
||||
submit(clientId: string, input: string): Promise<void>;
|
||||
request(clientId: string, request: SessionRequest, signal: AbortSignal): Promise<unknown>;
|
||||
use<T>(service: Service<T>): T;
|
||||
}
|
||||
|
||||
export type CodingAgentPlugin = AppPlugin<CodingAgentApi>;
|
||||
export type CodingAgentApp = AppDefinition<CodingAgentApi, SessionDriver>;
|
||||
export class SessionRuntime<ClientContext> {
|
||||
readonly driver: SessionDriver;
|
||||
private readonly activateCallbacks: Array<() => void | Promise<void>> = [];
|
||||
private readonly closeCallbacks: Array<() => void> = [];
|
||||
private readonly hub = new ClientHub();
|
||||
private readonly plugins: readonly AppPlugin<SessionContext, ClientContext>[];
|
||||
private readonly services = new Map<string, object>();
|
||||
private readonly trace: string[] = [];
|
||||
|
||||
export function createSessionInstance(): AppInstance<CodingAgentApi, SessionDriver> {
|
||||
const actions = new ReplayRegistry<Map<string, ActionDefinition>, ReadonlyMap<string, ActionDefinition>>(
|
||||
() => new Map(),
|
||||
(draft) => new Map(draft),
|
||||
);
|
||||
const commands = new ReplayRegistry<Map<string, CommandDefinition>, ReadonlyMap<string, CommandDefinition>>(
|
||||
() => new Map(),
|
||||
(draft) => new Map(draft),
|
||||
);
|
||||
const providers = new ProviderRegistry();
|
||||
const settings = new Map<string, unknown>();
|
||||
const trace: string[] = [];
|
||||
const activeInvocations = new Set<AbortController>();
|
||||
const hub = new ClientHub();
|
||||
const views = new ViewService(hub);
|
||||
let configuration: LaneConfiguration = { model: undefined, thinkingLevel: "high" };
|
||||
constructor(plugins: readonly AppPlugin<SessionContext, ClientContext>[]) {
|
||||
this.plugins = plugins;
|
||||
this.driver = {
|
||||
trace: this.trace,
|
||||
connect: (clientId, send) => this.hub.connect(clientId, () => this.snapshot(), send),
|
||||
request: async (_clientId, request, signal) => {
|
||||
const service = this.services.get(request.service) as Record<string, unknown> | undefined;
|
||||
const method = service?.[request.method];
|
||||
if (typeof method !== "function") throw new Error(`Unknown RPC: ${request.service}.${request.method}`);
|
||||
this.trace.push(`rpc:${request.service}.${request.method}`);
|
||||
const args = request.rpcOptions ? [...request.args, rpcOptions({ signal })] : request.args;
|
||||
return Reflect.apply(method, service, args);
|
||||
},
|
||||
use: (service) => this.use(service),
|
||||
};
|
||||
}
|
||||
|
||||
const appSnapshot = (): CodingAgentSnapshot => ({
|
||||
actions: [...actions.current()]
|
||||
.filter((entry): entry is [AppKeybinding, ActionDefinition] => Object.hasOwn(KEYBINDINGS, entry[0]))
|
||||
.map(([id, action]) => ({ id, description: action.description })),
|
||||
commands: [...commands.current()].map(([name, command]) => ({
|
||||
name,
|
||||
description: command.description,
|
||||
...(command.argumentHint ? { argumentHint: command.argumentHint } : {}),
|
||||
})),
|
||||
configuration: structuredClone(configuration),
|
||||
providers: providers.snapshot(),
|
||||
});
|
||||
providers.onRebuild((snapshot) =>
|
||||
hub.broadcast({ type: "event", event: { type: "providers_changed", providers: snapshot } }),
|
||||
);
|
||||
async start(): Promise<void> {
|
||||
const context: SessionContext = {
|
||||
onActivate: (callback) => this.activateCallbacks.push(callback),
|
||||
onClose: (callback) => this.closeCallbacks.push(callback),
|
||||
provide: (service, implementation) => this.provide(service, implementation),
|
||||
state: (initial) => new SessionRemoteState(initial),
|
||||
use: (service) => this.use(service),
|
||||
};
|
||||
for (const plugin of this.plugins) await plugin.session?.(context);
|
||||
for (const activate of this.activateCallbacks) await activate();
|
||||
}
|
||||
|
||||
const invoke = async (clientId: string, run: (context: InvocationContext) => Promise<void>): Promise<void> => {
|
||||
const controller = new AbortController();
|
||||
activeInvocations.add(controller);
|
||||
try {
|
||||
await run({ originatorClientId: clientId, signal: controller.signal });
|
||||
} finally {
|
||||
activeInvocations.delete(controller);
|
||||
close(): void {
|
||||
for (const close of this.closeCallbacks.splice(0).reverse()) close();
|
||||
}
|
||||
|
||||
private provide<T>(service: Service<T>, implementation: T): void {
|
||||
if (this.services.has(service.id)) throw new Error(`Service already provided: ${service.id}`);
|
||||
this.services.set(service.id, implementation as object);
|
||||
for (const [property, value] of Object.entries(implementation as object)) {
|
||||
if (!(value instanceof SessionRemoteState)) continue;
|
||||
value.subscribe((next) => {
|
||||
this.hub.broadcast({
|
||||
type: "state_update",
|
||||
service: service.id,
|
||||
property,
|
||||
value: structuredClone(next),
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const submit = async (clientId: string, input: string): Promise<void> => {
|
||||
if (!input.startsWith("/")) throw new Error("Only slash commands are supported by this test app");
|
||||
const separator = input.indexOf(" ");
|
||||
const name = input.slice(1, separator < 0 ? undefined : separator);
|
||||
const args = separator < 0 ? "" : input.slice(separator + 1).trim();
|
||||
const command = commands.current().get(name);
|
||||
if (!command) throw new Error(`Unknown command: ${name}`);
|
||||
trace.push(`command:${name}`);
|
||||
await invoke(clientId, (context) => command.handler(args, context));
|
||||
};
|
||||
private use<T>(service: Service<T>): T {
|
||||
const implementation = this.services.get(service.id);
|
||||
if (!implementation) throw new Error(`Service not provided: ${service.id}`);
|
||||
return implementation as T;
|
||||
}
|
||||
|
||||
const driver: SessionDriver = {
|
||||
get openedViewCount() {
|
||||
return views.openedCount;
|
||||
},
|
||||
trace,
|
||||
connect: (clientId, send) =>
|
||||
hub.connect(clientId, () => ({ app: appSnapshot(), views: views.forClient(clientId) }), send),
|
||||
getConfiguration: () => structuredClone(configuration),
|
||||
invokeAction: async (clientId, id) => {
|
||||
const action = actions.current().get(id);
|
||||
if (!action) throw new Error(`Unknown action: ${id}`);
|
||||
trace.push(`action:${id}`);
|
||||
await invoke(clientId, action.handler);
|
||||
},
|
||||
providers,
|
||||
request: async (clientId, request) => {
|
||||
if (request.type === "submit") await submit(clientId, request.input);
|
||||
else if (request.type === "invoke_action") await driver.invokeAction(clientId, request.id);
|
||||
else await views.send(request.viewId, clientId, request.message);
|
||||
},
|
||||
settings,
|
||||
submit,
|
||||
};
|
||||
|
||||
return {
|
||||
driver,
|
||||
activate() {
|
||||
actions.rebuild();
|
||||
commands.rebuild();
|
||||
providers.rebuild();
|
||||
},
|
||||
api(owner) {
|
||||
return {
|
||||
actions: { add: (contribution) => actions.add(owner, (draft) => contribution(draft)) },
|
||||
commands: { add: (contribution) => commands.add(owner, (draft) => contribution(draft)) },
|
||||
providers: {
|
||||
add: (contribution) => providers.add(owner, contribution),
|
||||
onRebuild: (listener) => providers.onRebuild(listener),
|
||||
refresh: (signal) => providers.refresh(signal),
|
||||
snapshot: () => providers.snapshot(),
|
||||
},
|
||||
session: {
|
||||
configure: async (next) => {
|
||||
const previous = structuredClone(configuration);
|
||||
configuration = structuredClone(next);
|
||||
trace.push(`config:${next.model?.provider}/${next.model?.modelId}`);
|
||||
const event: CodingAgentEvent = { type: "config_update", previous, value: structuredClone(next) };
|
||||
hub.broadcast({ type: "event", event });
|
||||
},
|
||||
getConfiguration: () => structuredClone(configuration),
|
||||
},
|
||||
settings: {
|
||||
set: async (key, value) => {
|
||||
settings.set(key, structuredClone(value));
|
||||
trace.push(`setting:${key}`);
|
||||
},
|
||||
},
|
||||
view: (component, state, options) => {
|
||||
trace.push(`view:open:${component}`);
|
||||
return views.open(component, state, options.to);
|
||||
},
|
||||
};
|
||||
},
|
||||
close() {
|
||||
for (const controller of activeInvocations) controller.abort();
|
||||
views.closeAll();
|
||||
},
|
||||
};
|
||||
private snapshot(): StateSnapshot {
|
||||
const snapshot: StateSnapshot = {};
|
||||
for (const [serviceId, implementation] of this.services) {
|
||||
const states: Record<string, unknown> = {};
|
||||
for (const [property, value] of Object.entries(implementation)) {
|
||||
if (value instanceof SessionRemoteState) states[property] = structuredClone(value.value);
|
||||
}
|
||||
if (Object.keys(states).length > 0) snapshot[serviceId] = states;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export class SessionTcpServer {
|
|||
private accept(socket: Socket): void {
|
||||
this.sockets.add(socket);
|
||||
const peer = new JsonLineSocket<ClientWireMessage, ServerWireMessage>(socket);
|
||||
const requests = new Map<number, AbortController>();
|
||||
let clientId: string | undefined;
|
||||
let disconnect: (() => void) | undefined;
|
||||
peer.onMessage((message) => {
|
||||
|
|
@ -89,17 +90,28 @@ export class SessionTcpServer {
|
|||
peer.send({ type: "response", id: message.id, error: "Client has not sent hello" });
|
||||
return;
|
||||
}
|
||||
void this.driver.request(clientId, message.request).then(
|
||||
() => peer.send({ type: "response", id: message.id }),
|
||||
(error: unknown) =>
|
||||
peer.send({
|
||||
type: "response",
|
||||
id: message.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
if (message.type === "cancel") {
|
||||
requests.get(message.id)?.abort();
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
requests.set(message.id, controller);
|
||||
void this.driver
|
||||
.request(clientId, message.request, controller.signal)
|
||||
.then(
|
||||
(result) => peer.send({ type: "response", id: message.id, result }),
|
||||
(error: unknown) =>
|
||||
peer.send({
|
||||
type: "response",
|
||||
id: message.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
)
|
||||
.finally(() => requests.delete(message.id));
|
||||
});
|
||||
socket.once("close", () => {
|
||||
for (const controller of requests.values()) controller.abort();
|
||||
requests.clear();
|
||||
disconnect?.();
|
||||
this.sockets.delete(socket);
|
||||
});
|
||||
|
|
@ -109,7 +121,10 @@ export class SessionTcpServer {
|
|||
export class TcpClientTransport implements ClientTransport {
|
||||
private readonly buffered: ServerWireMessage[] = [];
|
||||
private readonly peer: JsonLineSocket<ServerWireMessage, ClientWireMessage>;
|
||||
private readonly pending = new Map<number, { resolve(): void; reject(error: Error): void }>();
|
||||
private readonly pending = new Map<
|
||||
number,
|
||||
{ resolve(value: unknown): void; reject(error: unknown): void; stopAbort(): void }
|
||||
>();
|
||||
private readonly socket: Socket;
|
||||
private listener: ((message: ServerWireMessage) => void) | undefined;
|
||||
private nextRequestId = 1;
|
||||
|
|
@ -122,8 +137,9 @@ export class TcpClientTransport implements ClientTransport {
|
|||
const pending = this.pending.get(message.id);
|
||||
if (!pending) return;
|
||||
this.pending.delete(message.id);
|
||||
pending.stopAbort();
|
||||
if (message.error) pending.reject(new Error(message.error));
|
||||
else pending.resolve();
|
||||
else pending.resolve(message.result);
|
||||
} else if (this.listener) {
|
||||
this.listener(message);
|
||||
} else {
|
||||
|
|
@ -131,7 +147,10 @@ export class TcpClientTransport implements ClientTransport {
|
|||
}
|
||||
});
|
||||
socket.once("close", () => {
|
||||
for (const pending of this.pending.values()) pending.reject(new Error("Session connection closed"));
|
||||
for (const pending of this.pending.values()) {
|
||||
pending.stopAbort();
|
||||
pending.reject(new Error("Session connection closed"));
|
||||
}
|
||||
this.pending.clear();
|
||||
});
|
||||
this.peer.send({ type: "hello", clientId });
|
||||
|
|
@ -151,10 +170,18 @@ export class TcpClientTransport implements ClientTransport {
|
|||
for (const message of this.buffered.splice(0)) listener(message);
|
||||
}
|
||||
|
||||
request(request: SessionRequest): Promise<void> {
|
||||
request(request: SessionRequest, signal?: AbortSignal): Promise<unknown> {
|
||||
if (signal?.aborted) return Promise.reject(signal.reason);
|
||||
const id = this.nextRequestId++;
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject });
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
const abort = () => {
|
||||
if (!this.pending.delete(id)) return;
|
||||
this.peer.send({ type: "cancel", id });
|
||||
reject(signal?.reason ?? new Error("RPC aborted"));
|
||||
};
|
||||
const stopAbort = () => signal?.removeEventListener("abort", abort);
|
||||
this.pending.set(id, { resolve, reject, stopAbort });
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
this.peer.send({ type: "request", id, request });
|
||||
});
|
||||
}
|
||||
|
|
@ -165,6 +192,7 @@ export class TcpClientTransport implements ClientTransport {
|
|||
}
|
||||
|
||||
export class LoopbackTransport implements ClientTransport {
|
||||
private readonly active = new Set<AbortController>();
|
||||
private readonly buffered: ServerWireMessage[] = [];
|
||||
private readonly disconnect: () => void;
|
||||
private readonly driver: SessionDriver;
|
||||
|
|
@ -185,11 +213,34 @@ export class LoopbackTransport implements ClientTransport {
|
|||
for (const message of this.buffered.splice(0)) listener(message);
|
||||
}
|
||||
|
||||
request(request: SessionRequest): Promise<void> {
|
||||
return this.driver.request(this.clientId, request);
|
||||
request(request: SessionRequest, signal?: AbortSignal): Promise<unknown> {
|
||||
if (signal?.aborted) return Promise.reject(signal.reason);
|
||||
const controller = new AbortController();
|
||||
this.active.add(controller);
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (settle: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal?.removeEventListener("abort", abort);
|
||||
this.active.delete(controller);
|
||||
settle();
|
||||
};
|
||||
const abort = () => {
|
||||
controller.abort(signal?.reason);
|
||||
finish(() => reject(signal?.reason ?? new Error("RPC aborted")));
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
void this.driver.request(this.clientId, request, controller.signal).then(
|
||||
(value) => finish(() => resolve(value)),
|
||||
(error: unknown) => finish(() => reject(error)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
for (const controller of this.active) controller.abort();
|
||||
this.active.clear();
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,34 +8,61 @@ import {
|
|||
TuiAltScreen,
|
||||
VStack,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import { KeybindingsManager } from "../../../../src/core/keybindings.ts";
|
||||
import { type AppKeybinding, KeybindingsManager } from "../../../../src/core/keybindings.ts";
|
||||
import { CustomEditor } from "../../../../src/modes/interactive/components/custom-editor.ts";
|
||||
import type { SessionClient } from "../client.ts";
|
||||
import type { CodingAgentEvent } from "../protocol.ts";
|
||||
import type { CodingAgentPlugin } from "../plugins.ts";
|
||||
import { type LaneConfiguration, Models, type ModelsService } from "../protocol.ts";
|
||||
import { PluginFooter } from "./footer.ts";
|
||||
import { accent, bold, border, dim, errorStyle, type TuiPlugin, type ViewRenderer } from "./shared.ts";
|
||||
import {
|
||||
accent,
|
||||
bold,
|
||||
border,
|
||||
dim,
|
||||
errorStyle,
|
||||
type TuiCommand,
|
||||
type TuiContext,
|
||||
type ViewRenderer,
|
||||
} from "./shared.ts";
|
||||
|
||||
/** Alternate-screen projection of the remote client store. */
|
||||
/** Alternate-screen client whose commands and views are supplied by plugins. */
|
||||
export class MinimalCodingAgentTui {
|
||||
readonly done: Promise<void>;
|
||||
readonly tui: TuiAltScreen;
|
||||
private readonly actions = new Map<AppKeybinding, () => void | Promise<void>>();
|
||||
private readonly client: SessionClient;
|
||||
private readonly commands = new Map<string, TuiCommand>();
|
||||
private readonly editor: CustomEditor;
|
||||
private readonly editorContainer = new Container();
|
||||
private readonly models: ModelsService;
|
||||
private readonly renderers = new Map<string, ViewRenderer>();
|
||||
private readonly resolveDone: () => void;
|
||||
private readonly root: VStack;
|
||||
private readonly statusContainer = new Container();
|
||||
private readonly transcript = new Container();
|
||||
private eventIndex = 0;
|
||||
private activeView: string | undefined;
|
||||
private lastClearTime = 0;
|
||||
private lastConfiguration: LaneConfiguration;
|
||||
private query = "";
|
||||
private stopped = false;
|
||||
private unsubscribe: (() => void) | undefined;
|
||||
|
||||
constructor(terminal: Terminal, client: SessionClient, plugins: readonly TuiPlugin[]) {
|
||||
constructor(terminal: Terminal, client: SessionClient, plugins: readonly CodingAgentPlugin[]) {
|
||||
this.client = client;
|
||||
for (const plugin of plugins) plugin.setup(this.renderers);
|
||||
this.models = client.use(Models);
|
||||
this.lastConfiguration = structuredClone(this.models.state.value.configuration);
|
||||
const context: TuiContext = {
|
||||
actions: { register: (id, handler) => this.actions.set(id, handler) },
|
||||
commands: { register: (command) => this.commands.set(command.name, command) },
|
||||
use: (service) => client.use(service),
|
||||
views: {
|
||||
close: () => this.closeView(),
|
||||
open: (component, query = "") => this.openView(component, query),
|
||||
register: (component, renderer) => this.renderers.set(component, renderer),
|
||||
},
|
||||
};
|
||||
for (const plugin of plugins) plugin.client?.(context);
|
||||
|
||||
this.tui = new TuiAltScreen(terminal);
|
||||
this.editor = new CustomEditor(
|
||||
this.tui,
|
||||
|
|
@ -52,20 +79,38 @@ export class MinimalCodingAgentTui {
|
|||
new KeybindingsManager(),
|
||||
{ paddingX: 1 },
|
||||
);
|
||||
for (const [id, handler] of this.actions) {
|
||||
this.editor.onAction(
|
||||
id,
|
||||
() => void Promise.resolve(handler()).catch((error: unknown) => this.showError(error)),
|
||||
);
|
||||
}
|
||||
this.editor.onAction("app.clear", () => this.handleClear());
|
||||
this.editor.onSubmit = (value) => {
|
||||
this.editor.addToHistory(value);
|
||||
this.submit(value);
|
||||
};
|
||||
this.editor.setAutocompleteProvider(
|
||||
new CombinedAutocompleteProvider(
|
||||
[
|
||||
...[...this.commands.values()].map(({ name, description, argumentHint }) => ({
|
||||
name,
|
||||
description,
|
||||
argumentHint,
|
||||
})),
|
||||
{ name: "quit", description: "Exit the plugin application" },
|
||||
],
|
||||
process.cwd(),
|
||||
),
|
||||
);
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.transcript.addChild(new Text(`${bold(accent("pi"))}${dim(" plugin application")}`, 1, 0));
|
||||
this.transcript.addChild(new Spacer(1));
|
||||
this.transcript.addChild(new Text("Remote session connected. Type /model to select a model.", 1, 0));
|
||||
|
||||
const dock = new VStack([
|
||||
{ component: this.statusContainer, shrink: 1, minSize: 0 },
|
||||
{ component: this.editorContainer, shrink: 1, minSize: 3 },
|
||||
{ component: new PluginFooter(client.store), basis: 2, shrink: 0 },
|
||||
{ component: new PluginFooter(this.models.state, () => client.store.updates), basis: 2, shrink: 0 },
|
||||
]);
|
||||
this.root = new VStack([
|
||||
{
|
||||
|
|
@ -82,7 +127,6 @@ export class MinimalCodingAgentTui {
|
|||
},
|
||||
{ component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
|
||||
]);
|
||||
|
||||
let settleDone!: () => void;
|
||||
this.done = new Promise<void>((resolve) => {
|
||||
settleDone = resolve;
|
||||
|
|
@ -92,19 +136,8 @@ export class MinimalCodingAgentTui {
|
|||
|
||||
start(): void {
|
||||
if (this.unsubscribe || this.stopped) return;
|
||||
for (const action of this.client.store.app.actions) {
|
||||
this.editor.onAction(action.id, () => {
|
||||
void this.client.invokeAction(action.id).catch((error: unknown) => this.showError(error));
|
||||
});
|
||||
}
|
||||
this.editor.setAutocompleteProvider(
|
||||
new CombinedAutocompleteProvider(
|
||||
[...this.client.store.app.commands, { name: "quit", description: "Exit the plugin application" }],
|
||||
process.cwd(),
|
||||
),
|
||||
);
|
||||
this.unsubscribe = this.client.store.subscribe(() => {
|
||||
this.reduceEvents();
|
||||
this.applyConfiguration();
|
||||
this.renderView();
|
||||
this.updateEditorBorder();
|
||||
this.tui.requestRender();
|
||||
|
|
@ -115,18 +148,29 @@ export class MinimalCodingAgentTui {
|
|||
}
|
||||
|
||||
submit(input: string): void {
|
||||
const command = input.trim();
|
||||
if (!command) return;
|
||||
if (command === "/quit" || command === "quit") {
|
||||
const value = input.trim();
|
||||
if (!value) return;
|
||||
if (value === "/quit" || value === "quit") {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
if (command !== "/model") {
|
||||
this.transcript.addChild(new Spacer(1));
|
||||
this.transcript.addChild(new Text(`${accent(">")} ${command}`, 1, 0));
|
||||
this.showStatus(`Running ${command}`);
|
||||
if (!value.startsWith("/")) {
|
||||
this.showError(new Error("Only slash commands are supported by this test app"));
|
||||
return;
|
||||
}
|
||||
void this.client.submit(command).catch((error: unknown) => this.showError(error));
|
||||
const separator = value.indexOf(" ");
|
||||
const name = value.slice(1, separator < 0 ? undefined : separator);
|
||||
const args = separator < 0 ? "" : value.slice(separator + 1).trim();
|
||||
const command = this.commands.get(name);
|
||||
if (!command) {
|
||||
this.showError(new Error(`Unknown command: ${name}`));
|
||||
return;
|
||||
}
|
||||
if (name !== "model") {
|
||||
this.transcript.addChild(new Spacer(1));
|
||||
this.transcript.addChild(new Text(`${accent("> ")}${value}`, 1, 0));
|
||||
}
|
||||
void Promise.resolve(command.run(args)).catch((error: unknown) => this.showError(error));
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
|
|
@ -140,47 +184,52 @@ export class MinimalCodingAgentTui {
|
|||
this.resolveDone();
|
||||
}
|
||||
|
||||
private reduceEvents(): void {
|
||||
while (this.eventIndex < this.client.store.events.length) {
|
||||
this.applyEvent(this.client.store.events[this.eventIndex++]!);
|
||||
private applyConfiguration(): void {
|
||||
const current = this.models.state.value.configuration;
|
||||
if (
|
||||
this.lastConfiguration.model?.provider !== current.model?.provider ||
|
||||
this.lastConfiguration.model?.modelId !== current.model?.modelId
|
||||
) {
|
||||
this.showStatus(current.model ? `Model: ${current.model.modelId}` : "Model cleared");
|
||||
} else if (this.lastConfiguration.thinkingLevel !== current.thinkingLevel) {
|
||||
this.showStatus(`Thinking level: ${current.thinkingLevel}`);
|
||||
}
|
||||
this.lastConfiguration = structuredClone(current);
|
||||
}
|
||||
|
||||
private applyEvent(event: CodingAgentEvent): void {
|
||||
if (event.type !== "config_update") return;
|
||||
if (
|
||||
event.previous.model?.provider !== event.value.model?.provider ||
|
||||
event.previous.model?.modelId !== event.value.model?.modelId
|
||||
) {
|
||||
this.showStatus(event.value.model ? `Model: ${event.value.model.modelId}` : "Model cleared");
|
||||
} else if (event.previous.thinkingLevel !== event.value.thinkingLevel) {
|
||||
this.showStatus(`Thinking level: ${event.value.thinkingLevel}`);
|
||||
}
|
||||
private openView(component: string, query: string): void {
|
||||
this.activeView = component;
|
||||
this.query = query;
|
||||
this.renderView();
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
private closeView(): void {
|
||||
this.activeView = undefined;
|
||||
this.query = "";
|
||||
this.renderView();
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
private renderView(): void {
|
||||
this.editorContainer.clear();
|
||||
const view = this.client.store.views[0];
|
||||
if (!view) {
|
||||
this.query = "";
|
||||
if (!this.activeView) {
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.tui.setFocus(this.editor);
|
||||
return;
|
||||
}
|
||||
const renderer = this.renderers.get(view.component);
|
||||
const renderer = this.renderers.get(this.activeView);
|
||||
if (!renderer) {
|
||||
this.editorContainer.addChild(new Text(dim(`No TUI renderer for ${view.component}`), 1, 0));
|
||||
this.editorContainer.addChild(new Text(dim(`No TUI renderer for ${this.activeView}`), 1, 0));
|
||||
this.tui.setFocus(null);
|
||||
return;
|
||||
}
|
||||
const rendered = renderer({
|
||||
app: this.client.store.app,
|
||||
close: () => this.closeView(),
|
||||
query: this.query,
|
||||
onQueryChange: (query) => {
|
||||
setQuery: (query) => {
|
||||
this.query = query;
|
||||
},
|
||||
send: (message) => this.client.sendView(view.id, message),
|
||||
view,
|
||||
});
|
||||
this.editorContainer.addChild(rendered.component);
|
||||
this.tui.setFocus(rendered.focus);
|
||||
|
|
@ -209,13 +258,13 @@ export class MinimalCodingAgentTui {
|
|||
}
|
||||
|
||||
private updateEditorBorder(): void {
|
||||
const snapshot = this.client.store.app;
|
||||
const current = snapshot.configuration.model;
|
||||
const currentSpec = current
|
||||
? snapshot.providers.availableModels.find(
|
||||
const state = this.models.state.value;
|
||||
const current = state.configuration.model;
|
||||
const selected = current
|
||||
? state.catalog.availableModels.find(
|
||||
(model) => model.provider === current.provider && model.modelId === current.modelId,
|
||||
)
|
||||
: undefined;
|
||||
this.editor.borderColor = currentSpec?.reasoning ? accent : border;
|
||||
this.editor.borderColor = selected?.reasoning ? accent : border;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,41 @@
|
|||
import { homedir } from "node:os";
|
||||
import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
||||
import type { CodingAgentClientStore } from "../client.ts";
|
||||
import type { RemoteState } from "../kernel.ts";
|
||||
import type { ModelsState } from "../protocol.ts";
|
||||
import { dim } from "./shared.ts";
|
||||
|
||||
export class PluginFooter implements Component {
|
||||
private readonly store: CodingAgentClientStore;
|
||||
private readonly models: RemoteState<ModelsState>;
|
||||
private readonly updateCount: () => number;
|
||||
|
||||
constructor(store: CodingAgentClientStore) {
|
||||
this.store = store;
|
||||
constructor(models: RemoteState<ModelsState>, updateCount: () => number) {
|
||||
this.models = models;
|
||||
this.updateCount = updateCount;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const snapshot = this.store.app;
|
||||
const state = this.models.value;
|
||||
const home = homedir();
|
||||
const cwd = process.cwd();
|
||||
const displayCwd = cwd === home ? "~" : cwd.startsWith(`${home}/`) ? `~/${cwd.slice(home.length + 1)}` : cwd;
|
||||
const current = snapshot.configuration.model;
|
||||
const current = state.configuration.model;
|
||||
const currentSpec = current
|
||||
? snapshot.providers.availableModels.find(
|
||||
? state.catalog.availableModels.find(
|
||||
(model) => model.provider === current.provider && model.modelId === current.modelId,
|
||||
)
|
||||
: undefined;
|
||||
const model = current
|
||||
? `(${current.provider}) ${current.modelId}${
|
||||
currentSpec?.reasoning
|
||||
? snapshot.configuration.thinkingLevel === "off"
|
||||
? state.configuration.thinkingLevel === "off"
|
||||
? " • thinking off"
|
||||
: ` • ${snapshot.configuration.thinkingLevel}`
|
||||
: ` • ${state.configuration.thinkingLevel}`
|
||||
: ""
|
||||
}`
|
||||
: "no-model";
|
||||
const left = `events ${this.store.events.length} · catalogue r${snapshot.providers.revision}`;
|
||||
const left = `updates ${this.updateCount()} · catalogue r${state.catalog.revision}`;
|
||||
const right = truncateToWidth(model, Math.max(0, width - visibleWidth(left) - 2), "");
|
||||
const padding = " ".repeat(Math.max(2, width - visibleWidth(left) - visibleWidth(right)));
|
||||
return [truncateToWidth(dim(displayCwd), width, dim("...")), dim(`${left}${padding}${right}`)];
|
||||
|
|
|
|||
|
|
@ -1,60 +1,91 @@
|
|||
import { type Component, Container, type Focusable, getKeybindings, Input, Spacer, Text } from "@earendil-works/pi-tui";
|
||||
import type { ModelSelectorState, ModelSpec } from "../protocol.ts";
|
||||
import { accent, border, dim, success, type TuiPlugin, warning } from "./shared.ts";
|
||||
import { rpcOptions } from "../kernel.ts";
|
||||
import type { ModelSpec, ModelsService, ModelsState } from "../protocol.ts";
|
||||
import { accent, border, dim, success, type TuiContext, warning } from "./shared.ts";
|
||||
|
||||
class Rule implements Component {
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
return [border("─".repeat(Math.max(1, width)))];
|
||||
}
|
||||
}
|
||||
|
||||
export function isModelSelectorState(value: unknown): value is ModelSelectorState {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const candidate = value as Partial<ModelSelectorState>;
|
||||
return candidate.schema === 1 && typeof candidate.catalogRevision === "number";
|
||||
export function registerModelTui(context: TuiContext, models: ModelsService): void {
|
||||
let refreshController: AbortController | undefined;
|
||||
const resolve = (query: string): ModelSpec | undefined => {
|
||||
const available = models.state.value.catalog.availableModels;
|
||||
const qualified = available.find((model) => `${model.provider}/${model.modelId}` === query);
|
||||
if (qualified) return qualified;
|
||||
const unqualified = available.filter((model) => model.modelId === query);
|
||||
return unqualified.length === 1 ? unqualified[0] : undefined;
|
||||
};
|
||||
context.commands.register({
|
||||
name: "model",
|
||||
description: "Select model",
|
||||
argumentHint: "[provider/]model",
|
||||
async run(args) {
|
||||
if (args) {
|
||||
let model = resolve(args);
|
||||
if (!model) {
|
||||
await models.refresh();
|
||||
model = resolve(args);
|
||||
}
|
||||
if (model) return models.select(model);
|
||||
}
|
||||
refreshController?.abort();
|
||||
const controller = new AbortController();
|
||||
refreshController = controller;
|
||||
context.views.open("model-selector", args);
|
||||
void models
|
||||
.refresh(rpcOptions({ signal: controller.signal }))
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (refreshController === controller) refreshController = undefined;
|
||||
});
|
||||
},
|
||||
});
|
||||
context.views.register("model-selector", (view) => {
|
||||
const close = () => {
|
||||
refreshController?.abort();
|
||||
refreshController = undefined;
|
||||
view.close();
|
||||
};
|
||||
const component = new ModelSelector({
|
||||
state: models.state.value,
|
||||
query: view.query,
|
||||
setQuery: view.setQuery,
|
||||
cancel: close,
|
||||
select: async (model) => {
|
||||
await models.select(model);
|
||||
close();
|
||||
},
|
||||
});
|
||||
return { component, focus: component };
|
||||
});
|
||||
}
|
||||
|
||||
export const modelSelectorTui: TuiPlugin = {
|
||||
id: "@pi/model-selection-tui",
|
||||
setup(renderers) {
|
||||
renderers.set("model-selector", (context) => {
|
||||
if (!isModelSelectorState(context.view.state)) throw new Error("Invalid model-selector state");
|
||||
const component = new ModelSelectorComponent({
|
||||
state: context.view.state,
|
||||
models: context.app.providers.availableModels,
|
||||
query: context.query || context.view.state.query,
|
||||
onQueryChange: context.onQueryChange,
|
||||
send: context.send,
|
||||
});
|
||||
return { component, focus: component };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export class ModelSelectorComponent extends Container implements Focusable {
|
||||
class ModelSelector extends Container implements Focusable {
|
||||
private readonly input = new Input();
|
||||
private readonly list = new Container();
|
||||
private readonly models: readonly ModelSpec[];
|
||||
private readonly onQueryChange: (query: string) => void;
|
||||
private readonly send: (message: unknown) => Promise<void>;
|
||||
private readonly state: ModelSelectorState;
|
||||
private filteredModels: readonly ModelSpec[] = [];
|
||||
private selectedIndex = 0;
|
||||
private readonly cancel: () => void;
|
||||
private readonly select: (model: ModelSpec) => Promise<void>;
|
||||
private readonly setQuery: (query: string) => void;
|
||||
private readonly state: ModelsState;
|
||||
private filtered: readonly ModelSpec[] = [];
|
||||
private selected = 0;
|
||||
private _focused = false;
|
||||
|
||||
constructor(options: {
|
||||
state: ModelSelectorState;
|
||||
models: readonly ModelSpec[];
|
||||
state: ModelsState;
|
||||
query: string;
|
||||
onQueryChange(query: string): void;
|
||||
send(message: unknown): Promise<void>;
|
||||
setQuery(query: string): void;
|
||||
cancel(): void;
|
||||
select(model: ModelSpec): Promise<void>;
|
||||
}) {
|
||||
super();
|
||||
this.models = options.models;
|
||||
this.onQueryChange = options.onQueryChange;
|
||||
this.send = options.send;
|
||||
this.cancel = options.cancel;
|
||||
this.select = options.select;
|
||||
this.setQuery = options.setQuery;
|
||||
this.state = options.state;
|
||||
this.input.setValue(options.query);
|
||||
this.addChild(new Rule());
|
||||
|
|
@ -68,61 +99,51 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
this.addChild(this.list);
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new Rule());
|
||||
this.updateList();
|
||||
this.update();
|
||||
}
|
||||
|
||||
get focused(): boolean {
|
||||
return this._focused;
|
||||
}
|
||||
|
||||
set focused(value: boolean) {
|
||||
this._focused = value;
|
||||
this.input.focused = value;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
const keybindings = getKeybindings();
|
||||
if (keybindings.matches(data, "tui.select.up")) {
|
||||
if (this.filteredModels.length > 0) {
|
||||
this.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1;
|
||||
this.updateList();
|
||||
}
|
||||
} else if (keybindings.matches(data, "tui.select.down")) {
|
||||
if (this.filteredModels.length > 0) {
|
||||
this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1;
|
||||
this.updateList();
|
||||
}
|
||||
} else if (keybindings.matches(data, "tui.select.confirm")) {
|
||||
const model = this.filteredModels[this.selectedIndex];
|
||||
if (model) void this.send({ type: "select", provider: model.provider, modelId: model.modelId });
|
||||
} else if (keybindings.matches(data, "tui.select.cancel")) {
|
||||
void this.send({ type: "cancel" });
|
||||
const keys = getKeybindings();
|
||||
if (keys.matches(data, "tui.select.up")) {
|
||||
if (this.filtered.length) this.selected = this.selected ? this.selected - 1 : this.filtered.length - 1;
|
||||
} else if (keys.matches(data, "tui.select.down")) {
|
||||
if (this.filtered.length) this.selected = this.selected === this.filtered.length - 1 ? 0 : this.selected + 1;
|
||||
} else if (keys.matches(data, "tui.select.confirm")) {
|
||||
const model = this.filtered[this.selected];
|
||||
if (model) void this.select(model);
|
||||
return;
|
||||
} else if (keys.matches(data, "tui.select.cancel")) {
|
||||
this.cancel();
|
||||
return;
|
||||
} else {
|
||||
this.input.handleInput(data);
|
||||
this.onQueryChange(this.input.getValue());
|
||||
this.selectedIndex = 0;
|
||||
this.updateList();
|
||||
this.setQuery(this.input.getValue());
|
||||
this.selected = 0;
|
||||
}
|
||||
this.update();
|
||||
}
|
||||
|
||||
private updateList(): void {
|
||||
private update(): void {
|
||||
const query = this.input.getValue().trim().toLowerCase();
|
||||
this.filteredModels = query
|
||||
? this.models.filter((model) =>
|
||||
`${model.provider} ${model.modelId} ${model.name}`.toLowerCase().includes(query),
|
||||
)
|
||||
: this.models;
|
||||
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
|
||||
const models = this.state.catalog.availableModels;
|
||||
this.filtered = query
|
||||
? models.filter((model) => `${model.provider} ${model.modelId} ${model.name}`.toLowerCase().includes(query))
|
||||
: models;
|
||||
this.selected = Math.min(this.selected, Math.max(0, this.filtered.length - 1));
|
||||
this.list.clear();
|
||||
const maxVisible = 10;
|
||||
const start = Math.max(
|
||||
0,
|
||||
Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredModels.length - maxVisible),
|
||||
);
|
||||
for (let index = start; index < Math.min(start + maxVisible, this.filteredModels.length); index++) {
|
||||
const model = this.filteredModels[index];
|
||||
const start = Math.max(0, Math.min(this.selected - 5, this.filtered.length - 10));
|
||||
for (let index = start; index < Math.min(start + 10, this.filtered.length); index++) {
|
||||
const model = this.filtered[index];
|
||||
if (!model) continue;
|
||||
const selected = index === this.selectedIndex;
|
||||
const selected = index === this.selected;
|
||||
this.list.addChild(
|
||||
new Text(
|
||||
`${selected ? accent("→ ") : " "}${selected ? accent(model.modelId) : model.modelId} ${dim(`[${model.provider}]`)}`,
|
||||
|
|
@ -131,22 +152,20 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
),
|
||||
);
|
||||
}
|
||||
if (this.filteredModels.length === 0) {
|
||||
this.list.addChild(new Text(dim(" No matching models"), 0, 0));
|
||||
} else {
|
||||
const selected = this.filteredModels[this.selectedIndex];
|
||||
if (selected) {
|
||||
if (!this.filtered.length) this.list.addChild(new Text(dim(" No matching models"), 0, 0));
|
||||
else {
|
||||
const model = this.filtered[this.selected];
|
||||
if (model) {
|
||||
this.list.addChild(new Spacer(1));
|
||||
this.list.addChild(new Text(dim(` Model Name: ${selected.name}`), 0, 0));
|
||||
this.list.addChild(new Text(dim(` Model Name: ${model.name}`), 0, 0));
|
||||
}
|
||||
}
|
||||
this.list.addChild(new Spacer(1));
|
||||
if (this.state.refresh.status === "refreshing") {
|
||||
if (this.state.refresh.status === "refreshing")
|
||||
this.list.addChild(new Text(dim(" Refreshing model catalogs…"), 0, 0));
|
||||
} else if (this.state.refresh.status === "warning") {
|
||||
else if (this.state.refresh.status === "warning")
|
||||
this.list.addChild(new Text(warning(" Could not refresh model catalogs; showing cached models."), 0, 0));
|
||||
} else {
|
||||
else if (this.state.refresh.status === "done")
|
||||
this.list.addChild(new Text(success(" Model catalogs refreshed."), 0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Component } from "@earendil-works/pi-tui";
|
||||
import type { CodingAgentSnapshot, WireView } from "../protocol.ts";
|
||||
import type { AppKeybinding } from "../../../../src/core/keybindings.ts";
|
||||
import type { Service } from "../kernel.ts";
|
||||
|
||||
const style =
|
||||
(open: number, close: number) =>
|
||||
|
|
@ -14,22 +15,33 @@ export const warning = style(33, 39);
|
|||
export const errorStyle = style(31, 39);
|
||||
export const border = (text: string): string => `\x1b[38;5;240m${text}\x1b[39m`;
|
||||
|
||||
export interface TuiCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
argumentHint?: string;
|
||||
run(args: string): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface RenderedView {
|
||||
component: Component;
|
||||
focus: Component;
|
||||
}
|
||||
|
||||
export interface ViewRendererContext {
|
||||
app: CodingAgentSnapshot;
|
||||
export interface LocalViewContext {
|
||||
close(): void;
|
||||
query: string;
|
||||
onQueryChange(query: string): void;
|
||||
send(message: unknown): Promise<void>;
|
||||
view: WireView;
|
||||
setQuery(query: string): void;
|
||||
}
|
||||
|
||||
export type ViewRenderer = (context: ViewRendererContext) => RenderedView;
|
||||
export type ViewRenderer = (context: LocalViewContext) => RenderedView;
|
||||
|
||||
export interface TuiPlugin {
|
||||
id: string;
|
||||
setup(renderers: Map<string, ViewRenderer>): void;
|
||||
export interface TuiContext {
|
||||
actions: { register(id: AppKeybinding, handler: () => void | Promise<void>): void };
|
||||
commands: { register(command: TuiCommand): void };
|
||||
use<T>(service: Service<T>): T;
|
||||
views: {
|
||||
close(): void;
|
||||
open(component: string, query?: string): void;
|
||||
register(component: string, renderer: ViewRenderer): void;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +1,40 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts";
|
||||
import { SessionClient } from "./fixtures/plugin-app/client.ts";
|
||||
import { defineApp, TestAppRuntime } from "./fixtures/plugin-app/kernel.ts";
|
||||
import { createCodingAgentApp } from "./fixtures/plugin-app/plugins.ts";
|
||||
import type { ModelSelectorState, ModelSpec, WireView } from "./fixtures/plugin-app/protocol.ts";
|
||||
import { createCodingAgentPlugins, Settings } from "./fixtures/plugin-app/plugins.ts";
|
||||
import { type ModelSpec, Models } from "./fixtures/plugin-app/protocol.ts";
|
||||
import { SessionRuntime } from "./fixtures/plugin-app/session.ts";
|
||||
import { LoopbackTransport } from "./fixtures/plugin-app/transport.ts";
|
||||
import { modelSelectorTui } from "./fixtures/plugin-app/tui/model-selector.ts";
|
||||
import type { ViewRenderer } from "./fixtures/plugin-app/tui/shared.ts";
|
||||
import { MinimalCodingAgentTui } from "./fixtures/plugin-app/tui/app.ts";
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
reject(error: unknown): void;
|
||||
resolve(value: T): void;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let rejectPromise!: (error: unknown) => void;
|
||||
let resolvePromise!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
rejectPromise = reject;
|
||||
const promise = new Promise<T>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
return { promise, reject: rejectPromise, resolve: resolvePromise };
|
||||
return { promise, resolve: resolvePromise };
|
||||
}
|
||||
|
||||
function modelView(client: SessionClient): WireView & { state: ModelSelectorState } {
|
||||
const view = client.store.views.find((candidate) => candidate.component === "model-selector");
|
||||
if (!view || typeof view.state !== "object" || view.state === null || !("schema" in view.state)) {
|
||||
throw new Error("Model selector view is not open");
|
||||
}
|
||||
return view as WireView & { state: ModelSelectorState };
|
||||
}
|
||||
|
||||
describe("app-as-plugins /model vertical slice", () => {
|
||||
it("builds the session from plugins and runs /model over a serializable client boundary", async () => {
|
||||
describe("app-as-plugins model service", () => {
|
||||
it("replicates RemoteState and invokes typed methods over a serializable boundary", async () => {
|
||||
const catalogue = createDeferred<readonly ModelSpec[]>();
|
||||
let refreshCalls = 0;
|
||||
const definition = createCodingAgentApp((_signal) => {
|
||||
const plugins = createCodingAgentPlugins(() => {
|
||||
refreshCalls++;
|
||||
return catalogue.promise;
|
||||
});
|
||||
const runtime = new TestAppRuntime(definition);
|
||||
const runtime = new SessionRuntime(plugins);
|
||||
await runtime.start();
|
||||
const client = new SessionClient(new LoopbackTransport(runtime.driver, "tui-1"));
|
||||
await client.ready;
|
||||
const models = client.use(Models);
|
||||
|
||||
expect(definition.plugins.map((plugin) => plugin.id)).toEqual([
|
||||
expect(plugins.map((plugin) => plugin.id)).toEqual([
|
||||
"@pi/providers-builtin",
|
||||
"@pi/providers-catalog",
|
||||
"@pi/providers-models-json",
|
||||
|
|
@ -52,97 +42,72 @@ describe("app-as-plugins /model vertical slice", () => {
|
|||
"@pi/thinking-control",
|
||||
"@pi/model-selection",
|
||||
]);
|
||||
expect(client.store.app.actions).toEqual([{ id: "app.thinking.cycle", description: "Cycle thinking level" }]);
|
||||
expect(client.store.app.commands).toEqual([
|
||||
{ name: "model", argumentHint: "[provider/]model", description: "Select model" },
|
||||
]);
|
||||
expect(client.store.app.providers.availableModels.map((model) => model.modelId)).toEqual(["base", "cached"]);
|
||||
expect(client.store.app.providers.models.map((model) => model.modelId)).toEqual(["base", "cached", "hidden"]);
|
||||
expect(models.state.value.catalog.availableModels.map((model) => model.modelId)).toEqual(["base", "cached"]);
|
||||
expect(models.state.value.catalog.models.map((model) => model.modelId)).toEqual(["base", "cached", "hidden"]);
|
||||
|
||||
const invocation = client.submit("/model");
|
||||
expect(modelView(client).state.refresh).toEqual({ status: "refreshing" });
|
||||
const observed: string[] = [];
|
||||
const unsubscribe = models.state.subscribe((state) => observed.push(state.refresh.status));
|
||||
expect(observed).toEqual(["idle"]);
|
||||
const refresh = models.refresh();
|
||||
await vi.waitFor(() => expect(models.state.value.refresh).toEqual({ status: "refreshing" }));
|
||||
expect(refreshCalls).toBe(1);
|
||||
|
||||
catalogue.resolve([{ provider: "ignored", modelId: "fresh", name: "Fresh", reasoning: false }]);
|
||||
await vi.waitFor(() => expect(modelView(client).state.refresh).toEqual({ status: "done" }));
|
||||
expect(client.store.app.providers.availableModels.map((model) => model.modelId)).toEqual(["base", "fresh"]);
|
||||
|
||||
await client.sendView(modelView(client).id, { type: "select", provider: "acme", modelId: "fresh" });
|
||||
await invocation;
|
||||
expect(client.store.views).toEqual([]);
|
||||
expect(runtime.driver.getConfiguration()).toEqual({
|
||||
await refresh;
|
||||
expect(models.state.value.refresh).toEqual({ status: "done" });
|
||||
expect(models.state.value.catalog.availableModels.map((model) => model.modelId)).toEqual(["base", "fresh"]);
|
||||
await models.select({ provider: "acme", modelId: "fresh" });
|
||||
expect(models.state.value.configuration).toEqual({
|
||||
model: { provider: "acme", modelId: "fresh" },
|
||||
thinkingLevel: "off",
|
||||
});
|
||||
expect(runtime.driver.settings.get("defaultModel")).toEqual({ provider: "acme", modelId: "fresh" });
|
||||
expect(client.store.events.filter((event) => event.type === "config_update")).toHaveLength(1);
|
||||
expect(runtime.driver.trace).toEqual([
|
||||
"command:model",
|
||||
"view:open:model-selector",
|
||||
"config:acme/fresh",
|
||||
"setting:defaultModel",
|
||||
]);
|
||||
|
||||
const openedViews = runtime.driver.openedViewCount;
|
||||
await client.submit("/model acme/base");
|
||||
expect(runtime.driver.openedViewCount).toBe(openedViews);
|
||||
expect(refreshCalls).toBe(1);
|
||||
expect(runtime.driver.getConfiguration().model).toEqual({ provider: "acme", modelId: "base" });
|
||||
expect(runtime.driver.use(Settings).get("defaultModel")).toEqual({ provider: "acme", modelId: "fresh" });
|
||||
expect(observed).toEqual(["idle", "refreshing", "refreshing", "done", "done"]);
|
||||
expect(runtime.driver.trace).toEqual(["rpc:models.refresh", "rpc:models.select"]);
|
||||
unsubscribe();
|
||||
client.close();
|
||||
runtime.close();
|
||||
});
|
||||
|
||||
it("keeps command and TUI renderer plugins independently removable", async () => {
|
||||
const complete = createCodingAgentApp(async () => []);
|
||||
const commandless = new TestAppRuntime(
|
||||
defineApp({
|
||||
...complete,
|
||||
plugins: complete.plugins.filter((plugin) => plugin.id !== "@pi/model-selection"),
|
||||
}),
|
||||
it("keeps the client model feature removable without changing the session", async () => {
|
||||
const complete = createCodingAgentPlugins(async () => []);
|
||||
const runtime = new SessionRuntime(complete);
|
||||
await runtime.start();
|
||||
const client = new SessionClient(new LoopbackTransport(runtime.driver, "headless"));
|
||||
await client.ready;
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const app = new MinimalCodingAgentTui(
|
||||
terminal,
|
||||
client,
|
||||
complete.filter((plugin) => plugin.id !== "@pi/model-selection"),
|
||||
);
|
||||
await commandless.start();
|
||||
const commandlessClient = new SessionClient(new LoopbackTransport(commandless.driver, "commandless"));
|
||||
await commandlessClient.ready;
|
||||
expect(commandlessClient.store.app.commands).toEqual([]);
|
||||
await expect(commandlessClient.submit("/model")).rejects.toThrow("Unknown command: model");
|
||||
commandlessClient.close();
|
||||
commandless.close();
|
||||
|
||||
const headless = new TestAppRuntime(complete);
|
||||
await headless.start();
|
||||
const headlessClient = new SessionClient(new LoopbackTransport(headless.driver, "headless"));
|
||||
await headlessClient.ready;
|
||||
const invocation = headlessClient.submit("/model");
|
||||
expect(modelView(headlessClient).component).toBe("model-selector");
|
||||
const renderers = new Map<string, ViewRenderer>();
|
||||
expect(renderers.has("model-selector")).toBe(false);
|
||||
modelSelectorTui.setup(renderers);
|
||||
expect(renderers.has("model-selector")).toBe(true);
|
||||
headless.close();
|
||||
await invocation;
|
||||
headlessClient.close();
|
||||
app.start();
|
||||
app.submit("/model");
|
||||
await vi.waitFor(async () => {
|
||||
await terminal.waitForRender();
|
||||
expect(terminal.getViewport().join("\n")).toContain("Unknown command: model");
|
||||
});
|
||||
expect(client.use(Models).state.value.catalog.availableModels).toHaveLength(2);
|
||||
app.stop();
|
||||
runtime.close();
|
||||
});
|
||||
|
||||
it("keeps the cached catalogue when refresh fails", async () => {
|
||||
const runtime = new TestAppRuntime(
|
||||
createCodingAgentApp(async () => {
|
||||
const runtime = new SessionRuntime(
|
||||
createCodingAgentPlugins(async () => {
|
||||
throw new Error("catalogue offline");
|
||||
}),
|
||||
);
|
||||
await runtime.start();
|
||||
const client = new SessionClient(new LoopbackTransport(runtime.driver, "failure"));
|
||||
await client.ready;
|
||||
|
||||
const invocation = client.submit("/model");
|
||||
await vi.waitFor(() =>
|
||||
expect(modelView(client).state.refresh).toEqual({
|
||||
status: "warning",
|
||||
errors: { "@pi/providers-catalog:remote": "catalogue offline" },
|
||||
}),
|
||||
);
|
||||
expect(client.store.app.providers.availableModels.map((model) => model.modelId)).toEqual(["base", "cached"]);
|
||||
await client.sendView(modelView(client).id, { type: "cancel" });
|
||||
await invocation;
|
||||
const models = client.use(Models);
|
||||
await models.refresh();
|
||||
expect(models.state.value.refresh).toEqual({
|
||||
status: "warning",
|
||||
errors: { "@pi/providers-catalog:remote": "catalogue offline" },
|
||||
});
|
||||
expect(models.state.value.catalog.availableModels.map((model) => model.modelId)).toEqual(["base", "cached"]);
|
||||
client.close();
|
||||
runtime.close();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts";
|
||||
import { SessionClient } from "./fixtures/plugin-app/client.ts";
|
||||
import { TestAppRuntime } from "./fixtures/plugin-app/kernel.ts";
|
||||
import { createCodingAgentApp } from "./fixtures/plugin-app/plugins.ts";
|
||||
import type { ModelSelectorState, ModelSpec, WireView } from "./fixtures/plugin-app/protocol.ts";
|
||||
import { createCodingAgentPlugins } from "./fixtures/plugin-app/plugins.ts";
|
||||
import { type ModelSpec, Models } from "./fixtures/plugin-app/protocol.ts";
|
||||
import { SessionRuntime } from "./fixtures/plugin-app/session.ts";
|
||||
import { LoopbackTransport, SessionTcpServer, TcpClientTransport } from "./fixtures/plugin-app/transport.ts";
|
||||
import { MinimalCodingAgentTui } from "./fixtures/plugin-app/tui/app.ts";
|
||||
import { modelSelectorTui } from "./fixtures/plugin-app/tui/model-selector.ts";
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
|
|
@ -21,30 +20,26 @@ function createDeferred<T>(): Deferred<T> {
|
|||
return { promise, resolve: resolvePromise };
|
||||
}
|
||||
|
||||
function modelView(client: SessionClient): WireView & { state: ModelSelectorState } {
|
||||
const view = client.store.views.find((candidate) => candidate.component === "model-selector");
|
||||
if (!view) throw new Error("Model selector view is not open");
|
||||
return view as WireView & { state: ModelSelectorState };
|
||||
}
|
||||
|
||||
async function viewportText(terminal: VirtualTerminal): Promise<string> {
|
||||
await terminal.waitForRender();
|
||||
return terminal.getViewport().join("\n");
|
||||
}
|
||||
|
||||
describe("remote plugin app TUI", () => {
|
||||
it("renders and controls a session over a real TCP transport", async () => {
|
||||
it("renders and controls typed session services over real TCP", async () => {
|
||||
const catalogue = createDeferred<readonly ModelSpec[]>();
|
||||
const runtime = new TestAppRuntime(createCodingAgentApp(() => catalogue.promise));
|
||||
const plugins = createCodingAgentPlugins(() => catalogue.promise);
|
||||
const runtime = new SessionRuntime(plugins);
|
||||
await runtime.start();
|
||||
const server = new SessionTcpServer(runtime.driver, { host: "127.0.0.1", port: 0 });
|
||||
const address = await server.start();
|
||||
const transport = await TcpClientTransport.connect({ ...address, clientId: "tui" });
|
||||
const client = new SessionClient(transport);
|
||||
await client.ready;
|
||||
const models = client.use(Models);
|
||||
const closeClient = vi.spyOn(client, "close");
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const app = new MinimalCodingAgentTui(terminal, client, [modelSelectorTui]);
|
||||
const app = new MinimalCodingAgentTui(terminal, client, plugins);
|
||||
|
||||
try {
|
||||
app.start();
|
||||
|
|
@ -52,11 +47,11 @@ describe("remote plugin app TUI", () => {
|
|||
const initial = await viewportText(terminal);
|
||||
expect(initial).toContain("pi plugin application");
|
||||
expect(initial).toContain("Remote session connected");
|
||||
expect(initial).toContain("events 0 · catalogue r1");
|
||||
expect(initial).toContain("updates 0 · catalogue r1");
|
||||
expect(initial).toContain("no-model");
|
||||
|
||||
terminal.sendInput("\x1b[Z");
|
||||
await vi.waitFor(() => expect(client.store.app.configuration.thinkingLevel).toBe("off"));
|
||||
await vi.waitFor(() => expect(models.state.value.configuration.thinkingLevel).toBe("off"));
|
||||
expect(await viewportText(terminal)).toContain("Thinking level: off");
|
||||
|
||||
terminal.sendInput("/");
|
||||
|
|
@ -69,26 +64,34 @@ describe("remote plugin app TUI", () => {
|
|||
terminal.sendInput("\t");
|
||||
await vi.waitFor(async () => expect(await viewportText(terminal)).toContain("/model "));
|
||||
terminal.sendInput("\r");
|
||||
await vi.waitFor(() => expect(modelView(client).state.refresh.status).toBe("refreshing"));
|
||||
await vi.waitFor(() => expect(models.state.value.refresh.status).toBe("refreshing"));
|
||||
const refreshing = await viewportText(terminal);
|
||||
expect(refreshing).toContain("Only showing models from configured providers");
|
||||
expect(refreshing).toContain("base [acme]");
|
||||
expect(refreshing).toContain("Refreshing model catalogs");
|
||||
|
||||
catalogue.resolve([{ provider: "acme", modelId: "fresh", name: "Fresh", reasoning: false }]);
|
||||
await vi.waitFor(() => expect(modelView(client).state.refresh.status).toBe("done"));
|
||||
await vi.waitFor(() => expect(models.state.value.refresh.status).toBe("done"));
|
||||
const refreshed = await viewportText(terminal);
|
||||
expect(refreshed).toContain("events 2 · catalogue r2");
|
||||
expect(refreshed).toContain("fresh [acme]");
|
||||
expect(refreshed).toContain("Model catalogs refreshed");
|
||||
|
||||
await client.sendView(modelView(client).id, { type: "select", provider: "acme", modelId: "fresh" });
|
||||
await vi.waitFor(() => expect(client.store.views).toEqual([]));
|
||||
terminal.sendInput("fresh");
|
||||
await vi.waitFor(async () => expect(await viewportText(terminal)).toContain("Fresh"));
|
||||
terminal.sendInput("\r");
|
||||
await vi.waitFor(() =>
|
||||
expect(models.state.value.configuration.model).toEqual({ provider: "acme", modelId: "fresh" }),
|
||||
);
|
||||
const selected = await viewportText(terminal);
|
||||
expect(selected).toContain("Model: fresh");
|
||||
expect(selected).toContain("events 3 · catalogue r2");
|
||||
expect(selected).toContain("(acme) fresh");
|
||||
|
||||
app.submit("/model acme/base");
|
||||
await vi.waitFor(() =>
|
||||
expect(models.state.value.configuration.model).toEqual({ provider: "acme", modelId: "base" }),
|
||||
);
|
||||
expect(await viewportText(terminal)).toContain("Model: base");
|
||||
|
||||
terminal.sendInput("\x03");
|
||||
expect(closeClient).not.toHaveBeenCalled();
|
||||
terminal.sendInput("\x03");
|
||||
|
|
@ -101,28 +104,66 @@ describe("remote plugin app TUI", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("applies events buffered after the connection snapshot", async () => {
|
||||
const runtime = new TestAppRuntime(createCodingAgentApp(async () => []));
|
||||
it("cancels the session refresh when the model dialog is dismissed", async () => {
|
||||
let markAborted!: () => void;
|
||||
const aborted = new Promise<void>((resolve) => {
|
||||
markAborted = resolve;
|
||||
});
|
||||
const plugins = createCodingAgentPlugins(
|
||||
(signal) =>
|
||||
new Promise<readonly ModelSpec[]>((_resolve, reject) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
markAborted();
|
||||
reject(signal.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const runtime = new SessionRuntime(plugins);
|
||||
await runtime.start();
|
||||
const server = new SessionTcpServer(runtime.driver, { host: "127.0.0.1", port: 0 });
|
||||
const address = await server.start();
|
||||
const client = new SessionClient(await TcpClientTransport.connect({ ...address, clientId: "cancel" }));
|
||||
await client.ready;
|
||||
const models = client.use(Models);
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const app = new MinimalCodingAgentTui(terminal, client, plugins);
|
||||
|
||||
try {
|
||||
app.start();
|
||||
app.submit("/model");
|
||||
await vi.waitFor(() => expect(models.state.value.refresh.status).toBe("refreshing"));
|
||||
terminal.sendInput("\x1b");
|
||||
await aborted;
|
||||
await vi.waitFor(() => expect(models.state.value.refresh.status).toBe("idle"));
|
||||
expect(await viewportText(terminal)).not.toContain("Refreshing model catalogs");
|
||||
} finally {
|
||||
app.stop();
|
||||
await server.close();
|
||||
runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies state updates buffered after the connection snapshot", async () => {
|
||||
const runtime = new SessionRuntime(createCodingAgentPlugins(async () => []));
|
||||
await runtime.start();
|
||||
const transport = new LoopbackTransport(runtime.driver, "buffered");
|
||||
await runtime.driver.submit("buffered", "/model acme/base");
|
||||
await runtime.driver.use(Models).select({ provider: "acme", modelId: "base" });
|
||||
const client = new SessionClient(transport);
|
||||
await client.ready;
|
||||
const models = client.use(Models);
|
||||
|
||||
expect(client.store.app.configuration).toEqual({
|
||||
expect(models.state.value.configuration).toEqual({
|
||||
model: { provider: "acme", modelId: "base" },
|
||||
thinkingLevel: "high",
|
||||
});
|
||||
expect(client.store.events).toEqual([
|
||||
{
|
||||
type: "config_update",
|
||||
previous: { model: undefined, thinkingLevel: "high" },
|
||||
value: {
|
||||
model: { provider: "acme", modelId: "base" },
|
||||
thinkingLevel: "high",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(client.store.updates).toBe(1);
|
||||
const observed: string[] = [];
|
||||
models.state.subscribe((state) => observed.push(state.configuration.model?.modelId ?? "none"));
|
||||
expect(observed).toEqual(["base"]);
|
||||
client.close();
|
||||
runtime.close();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue