Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
805 lines
27 KiB
TypeScript
805 lines
27 KiB
TypeScript
import { resolve } from "node:path";
|
|
import {
|
|
combineFacetLoaders,
|
|
createFacetHost,
|
|
defineFacet,
|
|
type FacetHost,
|
|
type FacetLoader,
|
|
type JsonValue,
|
|
type LoadedFacets,
|
|
} from "@earendil-works/chord";
|
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
import {
|
|
CombinedAutocompleteProvider,
|
|
type Component,
|
|
Container,
|
|
type SelectItem,
|
|
SelectList,
|
|
setKeybindings,
|
|
Text,
|
|
type TUI,
|
|
} from "@earendil-works/pi-tui";
|
|
import type { ClientCommand } from "../cli/experimental/commands/client.ts";
|
|
import { getAgentDir } from "../config.ts";
|
|
import { KeybindingsManager } from "../core/keybindings.ts";
|
|
import { DefaultResourceLoader } from "../core/resource-loader.ts";
|
|
import { SettingsManager } from "../core/settings-manager.ts";
|
|
import { createChatViewport } from "../modes/interactive/chat-viewport.ts";
|
|
import { CustomEditor } from "../modes/interactive/components/custom-editor.ts";
|
|
import { getEditorTheme, setRegisteredThemes, stopThemeWatcher, theme } from "../modes/interactive/theme/theme.ts";
|
|
import { InteractiveThemeController } from "../modes/interactive/theme/theme-controller.ts";
|
|
import { createInteractiveTui } from "../modes/interactive/tui-renderer.ts";
|
|
import { type OpenClientRuntimeOptions, openClientRuntime } from "./client-runtime.ts";
|
|
import { ExperimentalChatView } from "./client-tui-chat.ts";
|
|
import { createPresentationFacetLoaders } from "./plugins/bundled.ts";
|
|
import { AgentController, type AgentOperationResponse, type AgentQueueResponse } from "./services/agent-controller.ts";
|
|
import type {
|
|
ServerConnectionState,
|
|
ServerServiceSource,
|
|
SessionAttachmentState,
|
|
SessionServiceSource,
|
|
} from "./services/connection.ts";
|
|
import { PresentationPlugins } from "./services/plugins.ts";
|
|
import { PresentationUI } from "./services/presentation-ui.ts";
|
|
import { SessionDirectory, SessionManagement, type SessionSummary } from "./services/sessions.ts";
|
|
import { SlashCommands } from "./services/slash-commands.ts";
|
|
import {
|
|
createBuiltInSlashCommandsFacet,
|
|
createSlashCommandsRuntimeFacet,
|
|
} from "./services/slash-commands-provider.ts";
|
|
import { Transcript, type Transcript as TranscriptService } from "./services/transcript.ts";
|
|
|
|
export interface RunClientTuiOptions extends OpenClientRuntimeOptions {
|
|
readonly facetLoader?: FacetLoader;
|
|
}
|
|
|
|
export interface ClientTuiServer {
|
|
readonly serverId: string;
|
|
readonly radius: boolean;
|
|
readonly server: ServerServiceSource;
|
|
readonly session: SessionServiceSource;
|
|
}
|
|
|
|
interface SessionFeature {
|
|
readonly serverId: string;
|
|
readonly session: SessionServiceSource;
|
|
readonly transcript: TranscriptService;
|
|
}
|
|
|
|
interface PreparedClientSession {
|
|
readonly server: ClientTuiServer;
|
|
readonly summary: SessionSummary;
|
|
readonly presentationPlugins: JsonValue;
|
|
}
|
|
|
|
interface PendingSelection {
|
|
readonly title: string;
|
|
readonly items: readonly SelectItem[];
|
|
readonly selectedValue?: string;
|
|
resolve(value: string | undefined): void;
|
|
}
|
|
|
|
const selectTheme = {
|
|
selectedPrefix: (text: string) => theme.fg("accent", text),
|
|
selectedText: (text: string) => theme.fg("accent", text),
|
|
description: (text: string) => theme.fg("muted", text),
|
|
scrollInfo: (text: string) => theme.fg("dim", text),
|
|
noMatch: (text: string) => theme.fg("warning", text),
|
|
};
|
|
|
|
/** Service-only presentation driven by a replicated main-lane snapshot. */
|
|
export class ExperimentalClientTui implements Component {
|
|
readonly #ui: TUI;
|
|
readonly #requestRender: () => void;
|
|
readonly #finish: () => void;
|
|
readonly #documentContainer = new Container();
|
|
readonly #sessionHeading = new Text("", 1, 0);
|
|
readonly #pendingMessagesContainer = new Container();
|
|
readonly #statusContainer = new Container();
|
|
readonly #editorContainer = new Container();
|
|
readonly #footerComponent = new Text("", 1, 0);
|
|
readonly #layoutRoot: Component;
|
|
readonly #sharedFacets: LoadedFacets;
|
|
readonly #keybindings = KeybindingsManager.create();
|
|
#presentationFacets: LoadedFacets | undefined;
|
|
#facetHost: FacetHost | undefined;
|
|
#facetReloadTail = Promise.resolve();
|
|
#session: SessionFeature | undefined;
|
|
#slashCommands: SlashCommands | undefined;
|
|
#controller: AgentController | undefined;
|
|
readonly #chatInput: CustomEditor;
|
|
#selectList: SelectList | undefined;
|
|
#selection: PendingSelection | undefined;
|
|
#screen: "select" | "chat" = "chat";
|
|
#selectedServerId: string | undefined;
|
|
#sessionId: string | undefined;
|
|
#status = "Starting Session…";
|
|
#busy = false;
|
|
#closed = false;
|
|
#closePromise: Promise<void> | undefined;
|
|
#recoveryTransition: Promise<void> = Promise.resolve();
|
|
#laneUnsubscribe: (() => void) | undefined;
|
|
#chatView: ExperimentalChatView | undefined;
|
|
|
|
private constructor(ui: TUI, requestRender: () => void, finish: () => void, loadedFacets: LoadedFacets) {
|
|
this.#ui = ui;
|
|
this.#requestRender = requestRender;
|
|
this.#finish = finish;
|
|
this.#sharedFacets = loadedFacets;
|
|
setKeybindings(this.#keybindings);
|
|
this.#chatInput = new CustomEditor(ui, getEditorTheme(), this.#keybindings, { paddingX: 1 });
|
|
this.#chatInput.onSubmit = (message) => void this.#runPrompt(message);
|
|
this.#chatInput.onEscape = () => this.#interrupt();
|
|
this.#chatInput.onCtrlD = finish;
|
|
this.#chatInput.onAction("app.clear", finish);
|
|
this.#chatInput.onAction("app.model.select", () => void this.#executeSlashCommand("model", ""));
|
|
this.#chatInput.onAction("app.message.followUp", () => {
|
|
const text = this.#chatInput.getText().trim();
|
|
if (text.length === 0) return;
|
|
this.#chatInput.setText("");
|
|
void this.#queueFollowUp(text);
|
|
});
|
|
this.#editorContainer.addChild(this.#chatInput);
|
|
this.#layoutRoot = createChatViewport({
|
|
document: this.#documentContainer,
|
|
pendingMessages: this.#pendingMessagesContainer,
|
|
status: this.#statusContainer,
|
|
editor: this.#editorContainer,
|
|
footer: this.#footerComponent,
|
|
scrollbarTrackStyle: (text) => theme.fg("scrollbarTrack", text),
|
|
scrollbarThumbStyle: (text) => theme.fg("scrollbarThumb", text),
|
|
}).root;
|
|
this.#rebuild();
|
|
}
|
|
|
|
static async create(options: {
|
|
readonly command: ClientCommand;
|
|
readonly ui: TUI;
|
|
readonly servers: readonly ClientTuiServer[];
|
|
readonly facetLoader?: FacetLoader;
|
|
requestRender(): void;
|
|
finish(): void;
|
|
}): Promise<ExperimentalClientTui> {
|
|
const prepared = await prepareClientSession(options.command, options.servers);
|
|
const loadedFacets = await combineFacetLoaders(
|
|
options.facetLoader === undefined ? [] : [options.facetLoader],
|
|
).load();
|
|
const component = new ExperimentalClientTui(options.ui, options.requestRender, options.finish, loadedFacets);
|
|
try {
|
|
await component.#start(prepared);
|
|
await component.#openPreparedSession(prepared);
|
|
return component;
|
|
} catch (error) {
|
|
try {
|
|
await component.close();
|
|
} catch (cleanupError) {
|
|
throw new AggregateError([error, cleanupError], "Experimental TUI startup and cleanup failed");
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
get layoutRoot(): Component {
|
|
return this.#layoutRoot;
|
|
}
|
|
|
|
render(width: number): string[] {
|
|
return [
|
|
...this.#documentContainer.render(width),
|
|
...this.#pendingMessagesContainer.render(width),
|
|
...this.#statusContainer.render(width),
|
|
...this.#editorContainer.render(width),
|
|
...this.#footerComponent.render(width),
|
|
];
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
if (this.#busy) {
|
|
if (
|
|
this.#keybindings.matches(data, "app.clear") ||
|
|
(this.#chatInput.getText().length === 0 && this.#keybindings.matches(data, "app.exit"))
|
|
) {
|
|
this.#finish();
|
|
}
|
|
return;
|
|
}
|
|
if (this.#screen === "chat") {
|
|
this.#chatInput.handleInput(data);
|
|
this.#requestRender();
|
|
return;
|
|
}
|
|
this.#selectList?.handleInput(data);
|
|
}
|
|
|
|
invalidate(): void {
|
|
this.#layoutRoot.invalidate();
|
|
}
|
|
|
|
dispose(): void {
|
|
void this.close().catch(() => {});
|
|
}
|
|
|
|
refreshTheme(): void {
|
|
const snapshot = this.#laneSnapshot();
|
|
if (snapshot !== undefined) this.#chatView?.refreshTheme(snapshot);
|
|
this.#rebuild();
|
|
}
|
|
|
|
showError(error: string): void {
|
|
this.#status = `Error: ${error}`;
|
|
this.#rebuild();
|
|
}
|
|
|
|
close(): Promise<void> {
|
|
this.#closePromise ??= this.#close();
|
|
return this.#closePromise;
|
|
}
|
|
|
|
async #start(prepared: PreparedClientSession): Promise<void> {
|
|
const server = prepared.server;
|
|
let presentationFacets = await combineFacetLoaders(
|
|
createPresentationFacetLoaders(prepared.presentationPlugins),
|
|
).load();
|
|
this.#presentationFacets = presentationFacets;
|
|
let facetHost!: FacetHost;
|
|
const reloadPresentationPlugins = (data: JsonValue): Promise<void> => {
|
|
const operation = this.#facetReloadTail.then(async () => {
|
|
const candidate = await combineFacetLoaders(createPresentationFacetLoaders(data)).load();
|
|
try {
|
|
await facetHost.reload(candidate.facets);
|
|
} catch (error) {
|
|
try {
|
|
await candidate.dispose();
|
|
} catch (cleanupError) {
|
|
throw new AggregateError([error, cleanupError], "TUI plugin reload and cleanup failed");
|
|
}
|
|
throw error;
|
|
}
|
|
const retired = presentationFacets;
|
|
presentationFacets = candidate;
|
|
this.#presentationFacets = candidate;
|
|
await retired.dispose();
|
|
});
|
|
this.#facetReloadTail = operation.catch(() => {});
|
|
return operation;
|
|
};
|
|
const presentationBridgeFacet = defineFacet({
|
|
id: "@pi/presentation-bridge",
|
|
setup: (env) => {
|
|
env.provide(PresentationUI, {
|
|
select: (title, items, selectedValue) =>
|
|
this.#select(
|
|
title,
|
|
items.map((item) => ({ ...item })),
|
|
selectedValue,
|
|
),
|
|
showStatus: (status) => {
|
|
this.#status = status;
|
|
this.#rebuild();
|
|
},
|
|
});
|
|
const commands = env.use(SlashCommands);
|
|
const controller = env.use(AgentController);
|
|
const transcript = env.use(Transcript);
|
|
const sessionFeature: SessionFeature = {
|
|
serverId: server.serverId,
|
|
session: server.session,
|
|
transcript,
|
|
};
|
|
env.onActivate(() => {
|
|
if (this.#session !== undefined || this.#slashCommands !== undefined || this.#controller !== undefined) {
|
|
throw new Error("Presentation services are already active");
|
|
}
|
|
this.#session = sessionFeature;
|
|
this.#slashCommands = commands;
|
|
this.#controller = controller;
|
|
env.own(() => {
|
|
if (this.#session === sessionFeature) this.#session = undefined;
|
|
if (this.#slashCommands === commands) this.#slashCommands = undefined;
|
|
if (this.#controller === controller) this.#controller = undefined;
|
|
});
|
|
env.own(commands.subscribe(() => this.#updateAutocomplete()));
|
|
if (server.radius) {
|
|
env.own(
|
|
server.server.connection.subscribe((state) => this.#handleConnectionState(server.serverId, state)),
|
|
);
|
|
env.own(
|
|
server.session.attachment.subscribe((state) => this.#handleAttachmentState(sessionFeature, state)),
|
|
);
|
|
}
|
|
});
|
|
},
|
|
});
|
|
facetHost = await createFacetHost({
|
|
facets: [
|
|
createSlashCommandsRuntimeFacet(),
|
|
presentationBridgeFacet,
|
|
createBuiltInSlashCommandsFacet({ reloadPresentationPlugins }),
|
|
...this.#sharedFacets.facets,
|
|
...presentationFacets.facets,
|
|
],
|
|
serviceSources: [server.server, server.session],
|
|
});
|
|
this.#facetHost = facetHost;
|
|
}
|
|
|
|
async #openPreparedSession(prepared: PreparedClientSession): Promise<void> {
|
|
const feature = this.#session;
|
|
if (feature === undefined) throw new Error(`No Session service is available for ${prepared.server.serverId}`);
|
|
await feature.session.whenAttached(prepared.summary.sessionId, BACKGROUND_CONTEXT);
|
|
this.#selectedServerId = feature.serverId;
|
|
this.#sessionId = prepared.summary.sessionId;
|
|
this.#updateAutocomplete();
|
|
await this.#openLane(feature);
|
|
this.#screen = "chat";
|
|
this.#status = "";
|
|
this.#rebuild();
|
|
}
|
|
|
|
async #close(): Promise<void> {
|
|
this.#closed = true;
|
|
this.#completeSelection(undefined);
|
|
const errors: unknown[] = [];
|
|
try {
|
|
await this.#recoveryTransition;
|
|
await this.#closeLane();
|
|
await this.#facetReloadTail;
|
|
} catch (error) {
|
|
errors.push(error);
|
|
}
|
|
if (this.#facetHost !== undefined) {
|
|
try {
|
|
await this.#facetHost.dispose();
|
|
} catch (error) {
|
|
errors.push(error);
|
|
}
|
|
this.#facetHost = undefined;
|
|
}
|
|
const generations = [this.#presentationFacets, this.#sharedFacets].filter(
|
|
(generation): generation is LoadedFacets => generation !== undefined,
|
|
);
|
|
this.#presentationFacets = undefined;
|
|
const results = await Promise.allSettled(generations.map((generation) => generation.dispose()));
|
|
errors.push(...results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])));
|
|
if (errors.length === 1) throw errors[0];
|
|
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose experimental TUI facets");
|
|
}
|
|
|
|
#rebuild(): void {
|
|
this.#sessionHeading.setText(
|
|
this.#sessionId === undefined || this.#selectedServerId === undefined
|
|
? ""
|
|
: theme.fg("dim", `Server: ${this.#selectedServerId}\nSession: ${this.#sessionId}`),
|
|
);
|
|
this.#statusContainer.clear();
|
|
if (this.#status.length > 0) {
|
|
this.#statusContainer.addChild(new Text(theme.fg("dim", this.#status), 1, 0));
|
|
}
|
|
if (this.#chatView !== undefined) this.#statusContainer.addChild(this.#chatView.status);
|
|
this.#footerComponent.setText(theme.fg("dim", this.#footer()));
|
|
this.#editorContainer.clear();
|
|
if (this.#screen === "select" && this.#selection !== undefined) {
|
|
this.#chatInput.focused = false;
|
|
const selector = new Container();
|
|
selector.addChild(new Text(theme.bold(this.#selection.title), 1, 1));
|
|
const items = [...this.#selection.items];
|
|
this.#selectList = new SelectList(items, Math.min(Math.max(items.length, 1), 12), selectTheme);
|
|
const selectedIndex = items.findIndex((item) => item.value === this.#selection?.selectedValue);
|
|
if (selectedIndex >= 0) this.#selectList.setSelectedIndex(selectedIndex);
|
|
this.#selectList.onSelect = (item) => this.#completeSelection(item.value);
|
|
this.#selectList.onCancel = () => this.#completeSelection(undefined);
|
|
selector.addChild(this.#selectList);
|
|
this.#editorContainer.addChild(selector);
|
|
} else {
|
|
this.#selectList = undefined;
|
|
this.#chatInput.focused = !this.#busy;
|
|
this.#editorContainer.addChild(this.#chatInput);
|
|
}
|
|
this.#layoutRoot.invalidate();
|
|
this.#requestRender();
|
|
}
|
|
|
|
#select(title: string, items: readonly SelectItem[], selectedValue?: string): Promise<string | undefined> {
|
|
if (this.#selection !== undefined) throw new Error("A slash command selector is already active");
|
|
return new Promise((resolve) => {
|
|
this.#selection = { title, items, ...(selectedValue === undefined ? {} : { selectedValue }), resolve };
|
|
this.#screen = "select";
|
|
this.#rebuild();
|
|
});
|
|
}
|
|
|
|
#completeSelection(value: string | undefined): void {
|
|
const selection = this.#selection;
|
|
if (selection === undefined) return;
|
|
this.#selection = undefined;
|
|
this.#screen = "chat";
|
|
selection.resolve(value);
|
|
if (!this.#closed) this.#rebuild();
|
|
}
|
|
|
|
#updateAutocomplete(): void {
|
|
const commands = this.#selectedSlashCommands()?.list() ?? [];
|
|
this.#chatInput.setAutocompleteProvider(
|
|
new CombinedAutocompleteProvider(
|
|
commands.map((command) => ({
|
|
name: command.name,
|
|
description: command.description,
|
|
...(command.argumentHint === undefined ? {} : { argumentHint: command.argumentHint }),
|
|
...(command.getArgumentCompletions === undefined
|
|
? {}
|
|
: {
|
|
getArgumentCompletions: async (prefix: string) => {
|
|
const items = await command.getArgumentCompletions!(prefix);
|
|
return items === null ? null : [...items];
|
|
},
|
|
}),
|
|
})),
|
|
process.cwd(),
|
|
),
|
|
);
|
|
this.#requestRender();
|
|
}
|
|
|
|
#selectedSlashCommands(): SlashCommands | undefined {
|
|
return this.#slashCommands;
|
|
}
|
|
|
|
#handleConnectionState(serverId: string, state: ServerConnectionState): void {
|
|
if (this.#closed || this.#selectedServerId !== serverId) return;
|
|
if (state.status === "connected") {
|
|
if (this.#laneUnsubscribe === undefined) {
|
|
this.#busy = true;
|
|
this.#status = "Reattaching Session…";
|
|
this.#rebuild();
|
|
}
|
|
return;
|
|
}
|
|
this.#busy = true;
|
|
this.#status = state.status === "connecting" ? "Reconnecting to Radius…" : "Radius disconnected; retrying…";
|
|
this.#queueRecovery(() => this.#closeLane());
|
|
this.#rebuild();
|
|
}
|
|
|
|
#handleAttachmentState(feature: SessionFeature, state: SessionAttachmentState): void {
|
|
if (this.#closed || this.#selectedServerId !== feature.serverId || this.#sessionId === undefined) return;
|
|
if (state.status === "attached" && state.sessionId === this.#sessionId) {
|
|
this.#queueRecovery(async () => {
|
|
if (this.#laneUnsubscribe === undefined) await this.#openLane(feature);
|
|
this.#busy = false;
|
|
this.#status = "";
|
|
this.#rebuild();
|
|
});
|
|
return;
|
|
}
|
|
if (state.status === "attaching" && state.sessionId === this.#sessionId) {
|
|
this.#busy = true;
|
|
this.#status = "Reattaching Session…";
|
|
this.#rebuild();
|
|
}
|
|
}
|
|
|
|
#queueRecovery(operation: () => Promise<void>): void {
|
|
this.#recoveryTransition = this.#recoveryTransition
|
|
.then(async () => {
|
|
if (!this.#closed) await operation();
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (this.#closed) return;
|
|
this.#busy = true;
|
|
this.#status = `Reconnect error: ${message(error)}`;
|
|
this.#rebuild();
|
|
});
|
|
}
|
|
|
|
async #openLane(feature: SessionFeature): Promise<void> {
|
|
await this.#closeLane();
|
|
const view = new ExperimentalChatView(this.#ui, process.cwd());
|
|
this.#chatView = view;
|
|
this.#documentContainer.addChild(this.#sessionHeading);
|
|
this.#documentContainer.addChild(view.transcript);
|
|
this.#pendingMessagesContainer.addChild(view.pendingMessages);
|
|
this.#laneUnsubscribe = feature.transcript.state.subscribe((value) => {
|
|
if (value.snapshot === null) return;
|
|
view.apply(value.snapshot);
|
|
this.#rebuild();
|
|
});
|
|
if (feature.transcript.state.value?.snapshot === null || feature.transcript.state.value?.snapshot === undefined) {
|
|
await this.#closeLane();
|
|
throw new Error("Transcript has no initialized snapshot");
|
|
}
|
|
}
|
|
|
|
async #closeLane(): Promise<void> {
|
|
this.#laneUnsubscribe?.();
|
|
this.#laneUnsubscribe = undefined;
|
|
this.#chatView?.dispose();
|
|
this.#chatView = undefined;
|
|
this.#documentContainer.clear();
|
|
this.#pendingMessagesContainer.clear();
|
|
this.#statusContainer.clear();
|
|
}
|
|
|
|
async #runPrompt(messageText: string): Promise<void> {
|
|
const prompt = messageText.trim();
|
|
if (prompt.length === 0) return;
|
|
if (prompt.startsWith("/")) {
|
|
const separator = prompt.indexOf(" ");
|
|
const name = prompt.slice(1, separator === -1 ? undefined : separator);
|
|
const args = separator === -1 ? "" : prompt.slice(separator + 1).trim();
|
|
await this.#executeSlashCommand(name, args);
|
|
return;
|
|
}
|
|
this.#chatInput.setText("");
|
|
try {
|
|
await this.#submitPrompt(prompt);
|
|
} catch (error) {
|
|
this.#status = `Error: ${message(error)}`;
|
|
this.#rebuild();
|
|
}
|
|
}
|
|
|
|
async #executeSlashCommand(name: string, args: string): Promise<void> {
|
|
const command = this.#selectedSlashCommands()
|
|
?.list()
|
|
.find((candidate) => candidate.name === name);
|
|
this.#chatInput.setText("");
|
|
if (command === undefined) {
|
|
this.#status = `Unknown slash command: /${name}`;
|
|
this.#rebuild();
|
|
return;
|
|
}
|
|
try {
|
|
const result = await command.run(args, BACKGROUND_CONTEXT);
|
|
if (result !== undefined) {
|
|
if ("entryId" in result) this.#reportQueue(result);
|
|
else this.#reportOperation(result);
|
|
}
|
|
} catch (error) {
|
|
this.#status = `Error: ${message(error)}`;
|
|
this.#rebuild();
|
|
}
|
|
}
|
|
|
|
async #submitPrompt(prompt: string): Promise<void> {
|
|
const controller = this.#selectedController();
|
|
if (controller === undefined) throw new Error("No Session AgentController service is available");
|
|
const operation = this.#laneSnapshot()?.operation;
|
|
const running = operation !== null && operation !== undefined;
|
|
this.#status = running ? "Queueing steering message…" : "Running turn…";
|
|
this.#rebuild();
|
|
if (running) this.#reportQueue(await controller.steer({ message: prompt, images: null }, BACKGROUND_CONTEXT));
|
|
else this.#reportOperation(await controller.prompt({ message: prompt, images: null }, BACKGROUND_CONTEXT));
|
|
}
|
|
|
|
async #queueFollowUp(text: string): Promise<void> {
|
|
const controller = this.#selectedController();
|
|
if (controller === undefined) return;
|
|
try {
|
|
this.#status = "Queueing follow-up…";
|
|
this.#rebuild();
|
|
this.#reportQueue(await controller.followUp({ message: text, images: null }, BACKGROUND_CONTEXT));
|
|
} catch (error) {
|
|
this.#status = `Error: ${message(error)}`;
|
|
this.#rebuild();
|
|
}
|
|
}
|
|
|
|
#reportOperation(response: AgentOperationResponse): void {
|
|
this.#status = response.accepted
|
|
? response.error === null
|
|
? ""
|
|
: `Operation failed: ${response.error.message}`
|
|
: `Operation rejected: ${response.error.message}`;
|
|
this.#rebuild();
|
|
}
|
|
|
|
#reportQueue(response: AgentQueueResponse): void {
|
|
this.#status = response.accepted ? `Queued ${response.entryId}.` : `Message rejected: ${response.error.message}`;
|
|
this.#rebuild();
|
|
}
|
|
|
|
#interrupt(): void {
|
|
const operation = this.#laneSnapshot()?.operation;
|
|
const controller = this.#selectedController();
|
|
if (operation === null || operation === undefined || controller === undefined) return;
|
|
this.#status = `Aborting ${operation.id}…`;
|
|
this.#rebuild();
|
|
void controller.requestAbort(operation.id, BACKGROUND_CONTEXT).catch((error: unknown) => {
|
|
this.#status = `Error: ${message(error)}`;
|
|
this.#rebuild();
|
|
});
|
|
}
|
|
|
|
#selectedController(): AgentController | undefined {
|
|
return this.#controller;
|
|
}
|
|
|
|
#laneSnapshot() {
|
|
const snapshot = this.#session?.transcript.state.value?.snapshot;
|
|
return snapshot === null ? undefined : snapshot;
|
|
}
|
|
|
|
#footer(): string {
|
|
const snapshot = this.#laneSnapshot();
|
|
if (!snapshot) return "/model · /thinking · /compact · /reload";
|
|
return `${snapshot.configuration.model.provider}/${snapshot.configuration.model.modelId} · thinking:${snapshot.configuration.thinkingLevel} · ${snapshot.stats.messageCount} messages · /model · /thinking · /compact · /reload`;
|
|
}
|
|
}
|
|
|
|
async function prepareClientSession(
|
|
command: ClientCommand,
|
|
servers: readonly ClientTuiServer[],
|
|
): Promise<PreparedClientSession> {
|
|
const opened = servers.map((server) => ({
|
|
server,
|
|
services: server.server.open({
|
|
services: [SessionDirectory, SessionManagement, PresentationPlugins],
|
|
assertAccess() {},
|
|
onError() {},
|
|
}),
|
|
}));
|
|
try {
|
|
const features = opened.map(({ server, services }) => ({
|
|
server,
|
|
directory: services.use(SessionDirectory),
|
|
management: services.use(SessionManagement),
|
|
plugins: services.use(PresentationPlugins),
|
|
}));
|
|
await Promise.all(opened.map(({ services }) => services.ready(BACKGROUND_CONTEXT)));
|
|
let selected:
|
|
| {
|
|
readonly server: ClientTuiServer;
|
|
readonly management: SessionManagement;
|
|
readonly plugins: PresentationPlugins;
|
|
readonly summary: SessionSummary;
|
|
}
|
|
| undefined;
|
|
if (command.sessionId !== undefined) {
|
|
const matches = features.flatMap((feature) =>
|
|
(feature.directory.state.value?.sessions ?? [])
|
|
.filter((session) => session.sessionId === command.sessionId)
|
|
.map((summary) => ({
|
|
server: feature.server,
|
|
management: feature.management,
|
|
plugins: feature.plugins,
|
|
summary,
|
|
})),
|
|
);
|
|
if (matches.length > 1) throw new Error(`Session ${command.sessionId} is available from more than one server`);
|
|
selected = matches[0];
|
|
if (selected === undefined) {
|
|
if (command.connect?.transport === "radius") {
|
|
throw new Error(`Remote server does not contain Session ${command.sessionId}`);
|
|
}
|
|
const feature = requireSingleServer(features);
|
|
selected = {
|
|
server: feature.server,
|
|
management: feature.management,
|
|
plugins: feature.plugins,
|
|
summary: await feature.management.create({ id: command.sessionId }, BACKGROUND_CONTEXT),
|
|
};
|
|
}
|
|
} else if (command.continue === true || command.resume === true) {
|
|
selected = features
|
|
.flatMap((feature) =>
|
|
(feature.directory.state.value?.sessions ?? []).map((summary) => ({
|
|
server: feature.server,
|
|
management: feature.management,
|
|
plugins: feature.plugins,
|
|
summary,
|
|
})),
|
|
)
|
|
.sort(
|
|
(left, right) =>
|
|
right.summary.createdAt - left.summary.createdAt ||
|
|
left.summary.serverId.localeCompare(right.summary.serverId) ||
|
|
left.summary.sessionId.localeCompare(right.summary.sessionId),
|
|
)[0];
|
|
}
|
|
if (selected === undefined) {
|
|
const feature = requireSingleServer(features);
|
|
selected = {
|
|
server: feature.server,
|
|
management: feature.management,
|
|
plugins: feature.plugins,
|
|
summary: await feature.management.create({}, BACKGROUND_CONTEXT),
|
|
};
|
|
}
|
|
const presentationPlugins = await selected.plugins.prepareSession(
|
|
{
|
|
sessionId: selected.summary.sessionId,
|
|
packagePaths: command.pluginPackages?.map((packagePath) => resolve(packagePath)) ?? null,
|
|
},
|
|
BACKGROUND_CONTEXT,
|
|
);
|
|
await selected.management.attach(selected.summary.sessionId, BACKGROUND_CONTEXT);
|
|
await selected.server.session.whenAttached(selected.summary.sessionId, BACKGROUND_CONTEXT);
|
|
return {
|
|
server: selected.server,
|
|
summary: selected.summary,
|
|
presentationPlugins,
|
|
};
|
|
} finally {
|
|
await Promise.allSettled(opened.map(({ services }) => services.dispose(BACKGROUND_CONTEXT)));
|
|
}
|
|
}
|
|
|
|
export async function runClientTui(command: ClientCommand, options: RunClientTuiOptions = {}): Promise<void> {
|
|
const cwd = process.cwd();
|
|
const agentDir = getAgentDir();
|
|
const settingsManager = SettingsManager.create(cwd, agentDir);
|
|
const resourceLoader = new DefaultResourceLoader({
|
|
cwd,
|
|
agentDir,
|
|
settingsManager,
|
|
noExtensions: true,
|
|
noSkills: true,
|
|
noPromptTemplates: true,
|
|
noContextFiles: true,
|
|
});
|
|
await resourceLoader.reload();
|
|
setRegisteredThemes(resourceLoader.getThemes().themes);
|
|
const runtime = await openClientRuntime(command, options);
|
|
const tui = createInteractiveTui({
|
|
tuiMode: "fullscreen",
|
|
showHardwareCursor: settingsManager.getShowHardwareCursor(),
|
|
logDirectory: agentDir,
|
|
});
|
|
tui.setClearOnShrink(settingsManager.getClearOnShrink());
|
|
let component: ExperimentalClientTui | undefined;
|
|
let tuiStarted = false;
|
|
const themeController = new InteractiveThemeController(tui, {
|
|
getSettingsManager: () => settingsManager,
|
|
showError: (error) => component?.showError(error),
|
|
onChanged: () => component?.refreshTheme(),
|
|
});
|
|
try {
|
|
let finish!: () => void;
|
|
const finished = new Promise<void>((resolve) => {
|
|
finish = () => {
|
|
themeController.disableAutoSync();
|
|
if (tuiStarted) {
|
|
tui.stop();
|
|
tuiStarted = false;
|
|
}
|
|
resolve();
|
|
};
|
|
});
|
|
component = await ExperimentalClientTui.create({
|
|
command,
|
|
ui: tui,
|
|
servers: runtime.servers.map((server) => ({
|
|
serverId: server.route.serverId,
|
|
radius: server.route.transport === "radius",
|
|
server: server.server,
|
|
session: server.session,
|
|
})),
|
|
facetLoader: options.facetLoader,
|
|
requestRender: () => tui.requestRender(),
|
|
finish,
|
|
});
|
|
tui.addChild(component);
|
|
tui.setLayoutRoot(component.layoutRoot);
|
|
tui.setFocus(component);
|
|
tuiStarted = true;
|
|
tui.start();
|
|
await themeController.applyFromSettings();
|
|
await finished;
|
|
} finally {
|
|
themeController.dispose();
|
|
stopThemeWatcher();
|
|
if (tuiStarted) tui.stop();
|
|
await component?.close();
|
|
await runtime.dispose();
|
|
}
|
|
}
|
|
|
|
function requireSingleServer<T>(features: readonly T[]): T {
|
|
if (features.length !== 1) throw new Error("Starting a Session requires exactly one server");
|
|
return features[0]!;
|
|
}
|
|
|
|
function message(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|