feat(coding-agent): wire Chat into experimental TUI

This commit is contained in:
Armin Ronacher 2026-08-26 14:16:06 +02:00
parent a17ead3292
commit 8288bc3543
4 changed files with 138 additions and 36 deletions

View file

@ -3,6 +3,7 @@ import type { SessionSummary } from "@earendil-works/pi-protocol";
import {
type Component,
Container,
Input,
ProcessTerminal,
type SelectItem,
SelectList,
@ -14,6 +15,7 @@ import type { ClientCommand } from "../cli/experimental/commands/client.ts";
import { type OpenClientRuntimeOptions, openClientRuntime } from "./client-runtime.ts";
import { combineFacetLoaders, createStaticFacetLoader, type FacetLoader, type LoadedFacets } from "./facet-loader.ts";
import { createFacetHost, defineFacet, type FacetHost } from "./facets.ts";
import { Chat } from "./services/chat.ts";
import type { ServerServiceConnection, SessionServiceConnection } from "./services/connection.ts";
import { Models } from "./services/models.ts";
import { SessionDirectory, SessionManagement } from "./services/sessions.ts";
@ -41,6 +43,11 @@ interface ModelSelectionFeature {
readonly models: Models;
}
interface ChatFeature {
readonly serverId: string;
readonly chat: Chat;
}
type ClientTuiAction =
| { readonly kind: "attach"; readonly feature: SessionPickerFeature; readonly sessionId: string }
| { readonly kind: "create"; readonly feature: SessionPickerFeature }
@ -81,9 +88,18 @@ export const modelSelectionTuiFacet = defineFacet({
},
});
const BUILTIN_TUI_FACET_LOADER = createStaticFacetLoader([sessionPickerTuiFacet, modelSelectionTuiFacet]);
export const chatTuiFacet = defineFacet({
id: "@pi/chat-input",
setup(env) {
const tui = env.use(Tui);
const chat = env.use(Chat);
env.onActivate(() => env.own(tui.registerChat(chat)));
},
});
/** Minimal service-only presentation used before Harness execution is available. */
const BUILTIN_TUI_FACET_LOADER = createStaticFacetLoader([sessionPickerTuiFacet, modelSelectionTuiFacet, chatTuiFacet]);
/** Minimal service-only presentation for Session selection, model selection, and turns. */
export class ExperimentalClientTui implements Component {
readonly #requestRender: () => void;
readonly #finish: () => void;
@ -92,9 +108,11 @@ export class ExperimentalClientTui implements Component {
readonly #facetHosts: FacetHost[] = [];
readonly #sessionPickers = new Map<string, SessionPickerFeature>();
readonly #modelSelections = new Map<string, ModelSelectionFeature>();
readonly #chats = new Map<string, ChatFeature>();
readonly #actions = new Map<string, ClientTuiAction>();
readonly #chatInput = new Input();
#selectList: SelectList | undefined;
#screen: "sessions" | "models" = "sessions";
#screen: "sessions" | "models" | "chat" = "sessions";
#selectedServerId: string | undefined;
#status = "Select a Session or create one.";
#busy = false;
@ -104,6 +122,12 @@ export class ExperimentalClientTui implements Component {
this.#requestRender = requestRender;
this.#finish = finish;
this.#loadedFacets = loadedFacets;
this.#chatInput.onSubmit = (message) => void this.#runPrompt(message);
this.#chatInput.onEscape = () => {
this.#screen = "models";
this.#status = "Select a model.";
this.#rebuild();
};
}
static async create(options: {
@ -135,7 +159,13 @@ export class ExperimentalClientTui implements Component {
}
handleInput(data: string): void {
if (!this.#busy) this.#selectList?.handleInput(data);
if (this.#busy) return;
if (this.#screen === "chat") {
this.#chatInput.handleInput(data);
this.#requestRender();
return;
}
this.#selectList?.handleInput(data);
}
invalidate(): void {
@ -175,6 +205,11 @@ export class ExperimentalClientTui implements Component {
serverId: server.serverId,
models,
}),
registerChat: (chat) =>
this.#register(this.#chats, "Chat", {
serverId: server.serverId,
chat,
}),
refresh: () => this.#rebuild(),
setStatus: (status) => {
this.#status = status;
@ -230,32 +265,45 @@ export class ExperimentalClientTui implements Component {
#rebuild(): void {
this.#container.clear();
this.#actions.clear();
const title = this.#screen === "sessions" ? "Experimental Sessions" : "Experimental Models";
const title =
this.#screen === "sessions"
? "Experimental Sessions"
: this.#screen === "models"
? "Experimental Models"
: "Experimental Chat";
this.#container.addChild(new Text(chalk.bold(title), 1, 1));
this.#container.addChild(new Text(this.#status, 1, 0));
const items = this.#screen === "sessions" ? this.#sessionItems() : this.#modelItems();
this.#selectList = new SelectList(items, Math.min(Math.max(items.length, 1), 12), selectTheme);
this.#selectList.onSelect = (item) => {
const action = this.#actions.get(item.value);
if (action !== undefined) void this.#runAction(action);
};
this.#selectList.onCancel = () => {
if (this.#screen === "models") {
this.#screen = "sessions";
this.#status = "Select a Session or create one.";
this.#rebuild();
} else {
this.#finish();
}
};
this.#container.addChild(this.#selectList);
this.#container.addChild(
new Text(
chalk.dim(this.#screen === "sessions" ? "enter select · esc quit" : "enter select · esc sessions"),
1,
1,
),
);
if (this.#screen === "chat") {
this.#selectList = undefined;
this.#chatInput.focused = !this.#busy;
this.#container.addChild(this.#chatInput);
this.#container.addChild(new Text(chalk.dim(this.#busy ? "running turn…" : "enter send · esc models"), 1, 1));
} else {
this.#chatInput.focused = false;
const items = this.#screen === "sessions" ? this.#sessionItems() : this.#modelItems();
this.#selectList = new SelectList(items, Math.min(Math.max(items.length, 1), 12), selectTheme);
this.#selectList.onSelect = (item) => {
const action = this.#actions.get(item.value);
if (action !== undefined) void this.#runAction(action);
};
this.#selectList.onCancel = () => {
if (this.#screen === "models") {
this.#screen = "sessions";
this.#status = "Select a Session or create one.";
this.#rebuild();
} else {
this.#finish();
}
};
this.#container.addChild(this.#selectList);
this.#container.addChild(
new Text(
chalk.dim(this.#screen === "sessions" ? "enter select · esc quit" : "enter select · esc sessions"),
1,
1,
),
);
}
this.#requestRender();
}
@ -326,7 +374,8 @@ export class ExperimentalClientTui implements Component {
this.#selectedServerId === undefined ? undefined : this.#modelSelections.get(this.#selectedServerId);
if (feature === undefined) throw new Error("No Session model selection is available");
await feature.models.select({ provider: action.provider, modelId: action.modelId }, BACKGROUND_CONTEXT);
this.#status = `Selected ${action.provider}/${action.modelId}`;
this.#screen = "chat";
this.#status = `Selected ${action.provider}/${action.modelId}. Enter a prompt.`;
return;
}
const summary =
@ -353,6 +402,35 @@ export class ExperimentalClientTui implements Component {
this.#rebuild();
}
}
async #runPrompt(message: string): Promise<void> {
if (this.#busy) return;
const prompt = message.trim();
if (prompt.length === 0) {
this.#status = "Enter a prompt.";
this.#rebuild();
return;
}
this.#busy = true;
this.#chatInput.setValue("");
this.#status = "Running turn…";
this.#rebuild();
try {
const feature = this.#selectedServerId === undefined ? undefined : this.#chats.get(this.#selectedServerId);
if (feature === undefined) throw new Error("No Session Chat service is available");
const response = await feature.chat.prompt({ message: prompt, images: null }, BACKGROUND_CONTEXT);
this.#status = response.accepted
? response.error === null
? `Turn ${response.operationId} completed.`
: `Turn failed: ${response.error.message}`
: `Prompt rejected: ${response.error.message}`;
} catch (error) {
this.#status = `Error: ${error instanceof Error ? error.message : String(error)}`;
} finally {
this.#busy = false;
this.#rebuild();
}
}
}
export async function runClientTui(command: ClientCommand, options: RunClientTuiOptions = {}): Promise<void> {

View file

@ -7,14 +7,14 @@ Facet setup generates each host's RPC service catalogue from its provided RPC-ca
| server | `SessionDirectory` | replicated state implemented | add authenticated per-client projection when identity lands |
| server | `SessionManagement` | create, remove, attach, detach implemented | add authenticated workspace authorization |
| session | `Models` | state, selection, thinking, refresh implemented | move provider/auth composition behind plugin facets |
| session | `Chat` | prompt and durable `requestAbort` implemented; queue, resume, compaction, and navigation contracts declared | replace each explicit throwing provider member with its Harness adapter slice |
| session | `Chat` | prompt and durable `requestAbort` route to `AgentLane`; queue, resume, compaction, and navigation contracts declared | implement the corresponding `AgentLane` execution slices |
| session | `Accounts` | state declared; mutations throw | connect the local credential service to the presentation-safe facade |
| session | `Transcript` | authoritative snapshot declared but throws | implement snapshot projection and remove the compatibility lane watch |
`ServerServiceConnection.connection` and `SessionServiceConnection.attachment` are implemented local control states.
With `PI_EXPERIMENTAL=1`, an interactive `pi client` opens the service-only TUI. It lists the repository-backed `SessionDirectory`, creates or switches Sessions through `SessionManagement`, and selects from the attached worker's `Models` state without invoking Harness drive.
With `PI_EXPERIMENTAL=1`, an interactive `pi client` opens the service-only TUI. It lists the repository-backed `SessionDirectory`, creates or switches Sessions through `SessionManagement`, selects from the attached worker's `Models` state, and submits single-line prompts through `Chat`. Completion and errors are shown as status text; successful turns remain blocked on the `AgentLane.prompt` execution slice, and rendering the conversation waits for the `Transcript` slice.
The Session worker loads all built-in and configured Session facets through one `FacetLoader`, then creates one active `FacetHost`. The service-only TUI likewise loads all TUI facets once and creates one host that combines its local `Tui` service with connected server and selected-Session services. A host may reload selected facets when their declared service shape is unchanged; retained consumers keep the same local and RPC service facades while replacement implementations and singleton snapshots are installed behind them. Runtime facets provide host-created services such as `Harness`, `ModelsRuntime`, and `Tui` through the same `env.provide()` operation used by every other facet. Synchronous setup-time `env.provide()`, `env.provideMany()`, `env.use()`, and `env.observe()` calls produce the internal dependency graph; setup does not repeat a declarative dependency list, and service handles remain disconnected until the complete graph validates. Providers activate before consumers, observations connect with their consuming facet, and facet replacement or host shutdown disposes affected lifecycles in reverse dependency order. Services are RPC-capable by default; process-local tokens such as `Harness`, `ModelsRuntime`, and `Tui` declare `{ rpc: false }`. The host automatically publishes every provided RPC-capable service.
The Session worker loads all built-in and configured Session facets through one `FacetLoader`, then creates one active `FacetHost`. The service-only TUI likewise loads all TUI facets once and creates one host that combines its local `Tui` service with connected server and selected-Session services. A host may reload selected facets when their declared service shape is unchanged; retained consumers keep the same local and RPC service facades while replacement implementations and singleton snapshots are installed behind them. Runtime facets provide host-created services such as `Harness`, `Lane`, `ModelsRuntime`, and `Tui` through the same `env.provide()` operation used by every other facet. Synchronous setup-time `env.provide()`, `env.provideMany()`, `env.use()`, and `env.observe()` calls produce the internal dependency graph; setup does not repeat a declarative dependency list, and service handles remain disconnected until the complete graph validates. Providers activate before consumers, observations connect with their consuming facet, and facet replacement or host shutdown disposes affected lifecycles in reverse dependency order. Services are RPC-capable by default; process-local tokens such as `Harness`, `Lane`, `ModelsRuntime`, and `Tui` declare `{ rpc: false }`. The host automatically publishes every provided RPC-capable service.
A facet always calls unqualified `env.use()` or `env.observe()`. The host resolves each token across facet-provided and connected services. Transport namespaces remain internal binding machinery rather than part of the facet environment. TUI facets own subscriptions to their replicated state; `ExperimentalClientTui` retains only terminal rendering, navigation, and action dispatch. The server provider remains directly assembled until its complete host environment exists. The question dialog, diff review, Git, indexing-job, and canvas examples in `packages/agent/docs/plugins.md` are extension patterns, not built-in coding-agent services. Private references, trace carriers, and flow control remain protocol/host infrastructure slices rather than presentation service tokens.

View file

@ -1,10 +1,12 @@
import { defineService } from "@earendil-works/pi-agent-core";
import type { Chat } from "./chat.ts";
import type { Models } from "./models.ts";
import type { SessionDirectory, SessionManagement } from "./sessions.ts";
export interface Tui {
registerSessionPicker(directory: SessionDirectory, management: SessionManagement): () => void;
registerModelSelection(models: Models): () => void;
registerChat(chat: Chat): () => void;
refresh(): void;
setStatus(status: string): void;
}

View file

@ -1,4 +1,5 @@
import {
type AgentLane,
BACKGROUND_CONTEXT,
createLoopbackServiceConnection,
RemoteServiceNamespace,
@ -10,6 +11,8 @@ import { describe, expect, test, vi } from "vitest";
import { type ClientTuiServer, ExperimentalClientTui } from "../src/experimental/client-tui.ts";
import type { FacetLoader } from "../src/experimental/facet-loader.ts";
import { defineFacet } from "../src/experimental/facets.ts";
import { Chat } from "../src/experimental/services/chat.ts";
import { createChatService } from "../src/experimental/services/chat-provider.ts";
import type {
ServerConnectionState,
ServerServiceConnection,
@ -31,7 +34,7 @@ function session(sessionId: string, createdAt: number): SessionSummary {
}
describe("experimental client TUI", () => {
test("creates and switches Sessions, then selects a model through facet service handles", async () => {
test("creates a Session, selects a model, and runs a turn through facet service handles", async () => {
const directoryState = replicatedState<SessionDirectoryState>({ revision: 1, sessions: [session("one", 1)] });
const attachment = replicatedState<SessionAttachmentState>({ status: "detached" });
const modelsState = replicatedState<ModelsState>({
@ -56,6 +59,15 @@ describe("experimental client TUI", () => {
BACKGROUND_CONTEXT,
);
});
const prompt = vi.fn(async () => ({
ok: true as const,
value: {
operation: "run" as const,
kind: "completed" as const,
runId: "run-1",
tipId: "entry-1",
},
}));
const serverProvider = new RemoteServiceProvider([SessionDirectory, SessionManagement]);
serverProvider.provide(SessionDirectory, { state: directoryState });
@ -69,13 +81,14 @@ describe("experimental client TUI", () => {
attachment.set({ status: "detached" }, BACKGROUND_CONTEXT);
},
});
const sessionProvider = new RemoteServiceProvider([Models]);
const sessionProvider = new RemoteServiceProvider([Models, Chat]);
sessionProvider.provide(Models, {
state: modelsState,
async cycleThinking() {},
async refresh() {},
select,
});
sessionProvider.provide(Chat, createChatService({ prompt } as unknown as AgentLane));
const serverNamespace = new RemoteServiceNamespace({
services: [SessionDirectory, SessionManagement],
@ -97,7 +110,7 @@ describe("experimental client TUI", () => {
},
});
const sessionNamespace = new RemoteServiceNamespace({
services: [Models],
services: [Models, Chat],
connection: createLoopbackServiceConnection(sessionProvider),
bound: false,
});
@ -166,8 +179,17 @@ describe("experimental client TUI", () => {
expect(select).toHaveBeenCalledWith({ provider: "test", modelId: "two" }, expect.anything()),
);
expect(modelsState.value.configuration.model).toEqual({ provider: "test", modelId: "two" });
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Selected test/two"));
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Experimental Chat"));
expect(component.render(80).join("\n")).toContain("Selected test/two");
component.handleInput("hello");
expect(component.render(80).join("\n")).toContain("hello");
component.handleInput("\r");
await vi.waitFor(() => expect(prompt).toHaveBeenCalledWith("hello", undefined, expect.anything()));
await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Turn run-1 completed."));
component.handleInput("\u001b");
expect(component.render(80).join("\n")).toContain("Experimental Models");
component.handleInput("\u001b");
expect(component.render(80).join("\n")).toContain("Experimental Sessions");
component.handleInput("\u001b");