chore: import upstream snapshot with attribution
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:43:05 +08:00
commit 426e9eeabd
41828 changed files with 9656266 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
/**
* ACP reply publication is tested at the transport boundary with a
* deterministic publisher, keeping raw planner envelopes out of captured text.
*/
import { describe, expect, it } from "bun:test";
import { type AcpTextUpdate, publishParsedReply } from "./acp-response.js";
describe("ACP parsed reply publication (#15814)", () => {
it("publishes exactly one authoritative user-facing chunk", async () => {
const updates: AcpTextUpdate[] = [];
const parsedReply = "Created the requested application.";
await publishParsedReply("session-1", parsedReply, async (update) => {
updates.push(update);
});
expect(updates).toEqual([
{
sessionId: "session-1",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: parsedReply },
},
},
]);
expect(JSON.stringify(updates)).not.toContain('```json {"response"');
});
it("does not fabricate a chunk for an empty parsed reply", async () => {
const updates: AcpTextUpdate[] = [];
await publishParsedReply("session-1", " ", async (update) => {
updates.push(update);
});
expect(updates).toEqual([]);
});
it("propagates transport failures to the ACP request boundary", async () => {
const failure = new Error("transport closed");
await expect(
publishParsedReply("session-1", "done", async () => {
throw failure;
}),
).rejects.toBe(failure);
});
});
@@ -0,0 +1,29 @@
/**
* Publishes the completed, parsed Eliza reply at the ACP transport boundary.
* The inner runtime's streaming chunks are planner protocol data, so callers
* supply only the authoritative response returned after the turn completes.
*/
export interface AcpTextUpdate {
sessionId: string;
update: {
sessionUpdate: "agent_message_chunk";
content: { type: "text"; text: string };
};
}
export async function publishParsedReply(
sessionId: string,
response: string,
publish: (update: AcpTextUpdate) => Promise<unknown>,
): Promise<void> {
if (!response.trim()) return;
await publish({
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: response },
},
});
}
+352
View File
@@ -0,0 +1,352 @@
#!/usr/bin/env node
/**
* eliza-code ACP server — lets eliza-code run AS a coding sub-agent that the
* elizaOS orchestrator (plugin-agent-orchestrator) can spawn over the Agent
* Client Protocol, exactly like the opencode / codex / claude ACP agents.
*
* The orchestrator resolves the `elizaos` agent type to the command in
* `ELIZA_ELIZAOS_ACP_COMMAND` and spawns it as a long-lived ACP JSON-RPC server
* on stdio (initialize → session/new → session/prompt → session/cancel). This
* entrypoint backs those methods onto eliza-code's EXISTING runtime + agent
* client (the same `initializeAgent()` / `getAgentClient().sendMessage(onDelta)`
* loop the TUI uses), so a spawned eliza-code sub-agent builds with the same
* runtime, plugins, and configured model provider (e.g. Cerebras via
* `@elizaos/plugin-openai`).
*
* Recursion guard: the runtime is built WITHOUT `@elizaos/plugin-agent-orchestrator`
* (`includeOrchestrator: false`) so a sub-agent cannot spawn its own sub-agents.
*
* Run directly (the orchestrator does this):
* bun packages/examples/code/dist/acp.js
* or via acpx for an isolated test:
* acpx --agent "bun .../dist/acp.js" --cwd <workspace> "<build task>"
*
* @module example-code/acp
*/
import { randomUUID } from "node:crypto";
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
import type { AgentRuntime } from "@elizaos/core";
import {
SandboxService,
SessionCwdService,
} from "@elizaos/plugin-coding-tools";
import { publishParsedReply } from "./acp-response.js";
import { initializeAgent } from "./lib/agent.js";
import { getAgentClient } from "./lib/agent-client.js";
import {
ensureSessionIdentity,
getMainRoomElizaId,
type SessionIdentity,
} from "./lib/identity.js";
import { applyOpencodeProviderEnv } from "./lib/model-provider.js";
import type { ChatRoom } from "./types.js";
/** A `console.error` logger (stdout is the ACP JSON-RPC channel — never log there). */
function log(message: string, extra?: unknown): void {
if (extra !== undefined) {
process.stderr.write(
`[eliza-code-acp] ${message} ${JSON.stringify(extra)}\n`,
);
} else {
process.stderr.write(`[eliza-code-acp] ${message}\n`);
}
}
// Lazily-initialized shared runtime (one per ACP server process).
let runtimePromise: Promise<AgentRuntime> | null = null;
let identity: SessionIdentity | null = null;
async function ensureRuntime(cwd?: string): Promise<AgentRuntime> {
if (!runtimePromise) {
// The coding tools + shell sandbox to the workspace via these env vars — set
// them from the ACP session cwd. We deliberately do NOT process.chdir(): the
// process must stay in the monorepo so bun resolves the workspace @elizaos/*
// packages (a different cwd resolves stale/broken builds from the bun cache).
// The build target is conveyed purely through the workspace-root env.
if (cwd) {
// The sub-agent's own task workspace is always reachable. ALSO grant
// access to the published-apps directory (when the host configured one)
// so the agent can read/edit/republish an EXISTING deployed app — not just
// build new ones into a throwaway workspace. Without this, "edit the
// coinflip app" lands in an empty workspace, the app dir is sandbox-blocked,
// and nothing happens. Roots are comma-separated; the agent picks the path
// (its workspace for scripts, the apps dir for web apps) by the task.
const appsDir = process.env.ELIZA_APP_DEPLOY_CUSTOM_APPS_DIR?.trim();
const roots = appsDir && appsDir !== cwd ? `${cwd},${appsDir}` : cwd;
process.env.CODING_TOOLS_WORKSPACE_ROOTS ??= roots;
process.env.SHELL_ALLOWED_DIRECTORY ??= roots;
}
// Drop-in for the opencode coding sub-agent: when the host configured
// opencode (ELIZA_OPENCODE_* — e.g. a Cerebras key/url/models) but no
// explicit OPENAI_*, inherit that provider config so eliza-code runs on the
// same backend with zero extra setup. The orchestrator forwards the parent
// env to this spawned process.
applyOpencodeProviderEnv(process.env);
// Isolated, ephemeral database for this coding sub-agent. PGlite is
// single-process: the parent bot (and any other concurrently-spawned
// eliza-code sub-agent) holds the PGlite dir under ELIZA_STATE_DIR, so a
// spawned eliza-code that inherits the same dir crashes on init with
// "this.adapter is undefined" → the orchestrator reports state_lost and the
// respawn fails (observed live: intermittent app-build failures under
// concurrent/overlapping requests). A coding agent works on the filesystem,
// not the DB, so force an in-memory DB and don't inherit the parent's
// Postgres/PGlite connection. (Force-set: this must win over inherited env.)
process.env.PGLITE_DATA_DIR = ":memory:";
process.env.DATABASE_URL = "";
process.env.POSTGRES_URL = "";
runtimePromise = (async () => {
// Resolve the session identity FIRST and mark its user as the runtime OWNER
// — the coding tools are role-gated (FILE=ADMIN, SHELL=OWNER), so without
// this the sub-agent runs as GUEST and every tool is denied ("I don't have
// permission… role (GUEST)"). A spawned coding sub-agent IS the operator in
// its sandbox, so it gets full rights. Must be set before initializeAgent so
// the role resolver sees the owner at boot.
identity = ensureSessionIdentity();
process.env.ELIZA_ADMIN_ENTITY_ID ??= identity.userId;
// A coding sub-agent has a small, all-relevant tool set (FILE/SHELL/READ/
// EDIT/…); expose them ALL as native tools (full surface, no chat-style
// tiering) so the model can actually CALL them instead of only seeing them
// described in the prompt and narrating.
process.env.ELIZA_PLANNER_FULL_ACTION_SURFACE ??= "1";
// Headless coding sub-agent: only sql + provider + shell + coding-tools.
// codingOnly drops mcp/goals AND the orchestrator (recursion guard).
const runtime = await initializeAgent({ codingOnly: true });
// Mark the session user as OWNER via the RUNTIME SETTING the role resolver
// actually reads (getConfiguredOwnerEntityIds → runtime.getSetting), not just
// process.env — otherwise the sender stays GUEST and FILE/SHELL are gated off.
const rt = runtime as unknown as {
setSetting?: (k: string, v: unknown) => void;
};
rt.setSetting?.("ELIZA_ADMIN_ENTITY_ID", identity.userId);
getAgentClient().setRuntime(runtime);
log("runtime initialized", { owner: identity.userId });
return runtime;
})();
}
return runtimePromise;
}
/** Extract plain text from an ACP prompt's content blocks. */
function promptToText(prompt: unknown): string {
if (!Array.isArray(prompt)) return "";
const parts: string[] = [];
for (const block of prompt) {
if (
block &&
typeof block === "object" &&
(block as { type?: unknown }).type === "text" &&
typeof (block as { text?: unknown }).text === "string"
) {
parts.push((block as { text: string }).text);
}
}
return parts.join("\n").trim();
}
// Per-session state: the chat room, the workspace cwd, and whether the
// orchestrator's scaffolded operating manual has been injected yet.
interface AcpSession {
room: ChatRoom;
cwd?: string;
manualInjected: boolean;
}
const sessions = new Map<string, AcpSession>();
/**
* Read the operating manual the orchestrator scaffolds into a spawned sub-agent's
* workspace (`AGENTS.md` / `CLAUDE.md` — "what Eliza is, you are a non-interactive
* coding sub-agent, the relay contract"). claude/codex/opencode auto-read these
* from their cwd; eliza-code runs from the monorepo for dep resolution, so it must
* read them explicitly from the build workspace and inject them so the sub-agent
* gets the same orientation as the other backends.
*/
async function readWorkspaceManual(cwd?: string): Promise<string> {
if (!cwd) return "";
const { readFile } = await import("node:fs/promises");
const { join } = await import("node:path");
for (const name of ["AGENTS.md", "CLAUDE.md"]) {
try {
const text = await readFile(join(cwd, name), "utf8");
if (text.trim()) return text.trim();
} catch (error) {
// error-policy:J4 A missing optional manual is an expected unavailable
// state; read and permission failures must still stop session setup.
if (
!(error instanceof Error) ||
!("code" in error) ||
error.code !== "ENOENT"
) {
throw error;
}
}
}
return "";
}
// stdout = the ACP JSON-RPC output; stdin = the input. (ndJsonStream(output, input).)
const output = new WritableStream<Uint8Array>({
write(chunk) {
return new Promise<void>((resolve, reject) => {
process.stdout.write(chunk, (err) => (err ? reject(err) : resolve()));
});
},
});
const input = new ReadableStream<Uint8Array>({
start(controller) {
process.stdin.on("data", (chunk: Buffer) =>
controller.enqueue(new Uint8Array(chunk)),
);
process.stdin.on("end", () => controller.close());
process.stdin.on("error", (err) => controller.error(err));
},
});
const stream = ndJsonStream(output, input);
const _connection = new AgentSideConnection(
(conn) => ({
async initialize() {
return {
protocolVersion: 1,
agentCapabilities: {
loadSession: false,
promptCapabilities: {
image: false,
audio: false,
embeddedContext: true,
},
},
authMethods: [],
};
},
async authenticate() {
return {};
},
async newSession(params: { cwd?: string }) {
const runtime = await ensureRuntime(params.cwd);
const id = randomUUID();
const session = identity as SessionIdentity;
const room: ChatRoom = {
id,
name: "acp",
messages: [],
createdAt: new Date(),
taskIds: [],
elizaRoomId: getMainRoomElizaId(session),
};
sessions.set(id, { room, cwd: params.cwd, manualInjected: false });
// Point the coding tools' per-conversation working directory at the ACP
// workspace. We can't process.chdir() (it would break bun's workspace
// @elizaos/* resolution, so the process stays in the monorepo), and
// SessionCwdService otherwise defaults the conversation cwd to
// process.cwd() — the monorepo — making `pwd`, relative-path resolution,
// and the sandbox roots all point at the wrong directory. Set it
// explicitly to the build workspace so FILE/SHELL/LS operate there.
// conversationId == message.roomId == room.elizaRoomId (see agent-client).
if (params.cwd) {
const conversationId = String(room.elizaRoomId);
const cwdSvc = runtime.getService<SessionCwdService>(
SessionCwdService.serviceType,
);
cwdSvc?.setCwd(conversationId, params.cwd);
const sandbox = runtime.getService<SandboxService>(
SandboxService.serviceType,
);
sandbox?.addRoot(conversationId, params.cwd);
}
log("session created", { id, cwd: params.cwd });
return { sessionId: id };
},
async prompt(params: { sessionId: string; prompt: unknown }) {
const session = sessions.get(params.sessionId);
if (!session || !identity) {
throw new Error(`[eliza-code-acp] unknown session ${params.sessionId}`);
}
const { room } = session;
let text = promptToText(params.prompt);
if (!text) return { stopReason: "end_turn" };
// Inject the orchestrator's scaffolded operating manual on the first prompt
// of the session so eliza-code gets the same "you are a non-interactive Eliza
// coding sub-agent + relay contract" orientation as claude/codex/opencode.
if (!session.manualInjected) {
session.manualInjected = true;
const preamble: string[] = [];
const manual = await readWorkspaceManual(session.cwd);
if (manual) preamble.push(manual);
// Execution contract: weaker coding models (e.g. Cerebras glm-4.7) tend
// to NARRATE a plan ("I'll create the app...") and end the turn instead
// of emitting the FILE/SHELL action, especially on larger tasks — which
// leaves nothing on disk. Make the act-don't-describe requirement
// explicit so a build actually happens before the agent reports done.
preamble.push(
"Execution contract: DO the work by calling tools — use the FILE " +
"action to actually write/edit each file and the SHELL action to run " +
"commands. Do NOT reply with a description of what you are about to " +
'do; a turn that only says "I\'ll create..." or "Creating the app ' +
'now" without an accompanying FILE/SHELL tool call is a failure. For ' +
"a multi-file or large build, write the full content of each file " +
"with a FILE action first, then verify, and only then report what you " +
"did. Never claim a file exists unless you wrote it this session.",
);
if (session.cwd) {
// The coding tools (FILE/EDIT) require ABSOLUTE paths. Tell the agent
// its workspace root up front so it writes absolute paths directly
// instead of emitting a relative path, having it rejected, and
// round-tripping through `pwd` to rediscover the directory.
preamble.push(
`Your workspace directory is: ${session.cwd}\n` +
`All file paths MUST be absolute — create and edit files under ` +
`this directory (e.g. ${session.cwd}/<filename>) and run shell ` +
`commands from here.`,
);
}
if (preamble.length > 0) {
text = `${preamble.join("\n\n---\n\n")}\n\n---\n\nTask:\n${text}`;
log("injected workspace preamble", {
manual: manual.length,
cwd: session.cwd ?? null,
});
}
}
log("prompt", { sessionId: params.sessionId, chars: text.length });
// Do NOT forward raw stream deltas as agent_message_chunk. The inner
// runtime's stream is the model's RAW output — for a structured planner
// (response-grammar JSON/XML) that is the unparsed envelope, and the
// orchestrator concatenates chunks into the session's captured finalText,
// which the parent then relays to the user verbatim. Streaming raw here
// is how a Discord user ends up seeing ```json {"response":...} instead
// of the answer. The parsed user-facing reply only exists once the turn
// completes, so emit exactly one authoritative chunk with it.
const response = await getAgentClient().sendMessage({
room,
text,
identity,
source: "acp",
});
await publishParsedReply(params.sessionId, response, (update) =>
conn.sessionUpdate(update),
);
log("prompt done", { response: response.length });
return { stopReason: "end_turn" };
},
async cancel() {
// Best-effort: the runtime turn isn't externally cancellable here; the next
// prompt simply starts a new turn. (Hook into runtime abort when available.)
},
// The elizaOS orchestrator's native ACP transport sends `session/close` on
// teardown. It IS a standard ACP method (schema.AGENT_METHODS.session_close),
// so the SDK only routes it when the agent implements `closeSession`;
// otherwise it returns JSON-RPC "Method not found" (-32601), which the
// orchestrator surfaces to the user as a failed task ("Couldn't finish —
// Method not found: session/close"). Drop the session entry and ack.
async closeSession(params: { sessionId?: string }) {
const sessionId = params?.sessionId;
if (sessionId) sessions.delete(sessionId);
log("session closed", { sessionId });
return {};
},
}),
stream,
);
log("ACP server listening on stdio");
@@ -0,0 +1,30 @@
// @vitest-environment node
import { describe, expect, it } from "bun:test";
import { readFileSync } from "node:fs";
import { main } from "./cli.js";
function packageVersion(): string {
const pkg = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
) as { version?: string };
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
}
describe("eliza-code CLI version (#11294)", () => {
it("prints the package.json version for --version", async () => {
const lines: string[] = [];
const originalLog = console.log;
console.log = (...args: unknown[]) => {
lines.push(args.join(" "));
};
try {
const exitCode = await main(["--version"]);
expect(exitCode).toBe(0);
expect(lines).toEqual([`eliza-code v${packageVersion()}`]);
} finally {
console.log = originalLog;
}
});
});
+94
View File
@@ -0,0 +1,94 @@
/** Exercises argument, output, and early-exit CLI behavior without starting an agent runtime. */
import { afterEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { formatOutput, getMessage, main, parseArgs, runCLI } from "./cli.js";
const originalProvider = process.env.ELIZA_CODE_PROVIDER;
const originalOpenAiKey = process.env.OPENAI_API_KEY;
afterEach(() => {
if (originalProvider === undefined) delete process.env.ELIZA_CODE_PROVIDER;
else process.env.ELIZA_CODE_PROVIDER = originalProvider;
if (originalOpenAiKey === undefined) delete process.env.OPENAI_API_KEY;
else process.env.OPENAI_API_KEY = originalOpenAiKey;
});
describe("CLI boundaries", () => {
it("parses flags, paths, and a multi-word message", () => {
expect(
parseArgs(["--json", "--stream", "--cwd", "/tmp", "review", "this"]),
).toMatchObject({
json: true,
stream: true,
cwd: "/tmp",
message: "review this",
});
expect(() => parseArgs(["--file"])).toThrow(
"--file requires a path argument",
);
expect(() => parseArgs(["--unknown"])).toThrow("Unknown option");
});
it("reads direct and file-backed messages", async () => {
const directory = await mkdtemp(join(tmpdir(), "eliza-code-cli-"));
const file = join(directory, "prompt.txt");
await writeFile(file, " from disk \n");
try {
expect(await getMessage(parseArgs(["direct message"]))).toBe(
"direct message",
);
expect(await getMessage(parseArgs(["--file", file]))).toBe("from disk");
} finally {
await rm(directory, { recursive: true });
}
});
it("formats plain and JSON results", () => {
const logs: string[] = [];
const errors: string[] = [];
const originalLog = console.log;
const originalError = console.error;
console.log = (value?: unknown) => logs.push(String(value));
console.error = (value?: unknown) => errors.push(String(value));
try {
formatOutput({ success: true, response: "done" }, parseArgs([]));
formatOutput({ success: false, error: "broken" }, parseArgs([]));
formatOutput({ success: true, response: "json" }, parseArgs(["--json"]));
expect(logs[0]).toBe("done");
expect(logs[1]).toContain('"response": "json"');
expect(errors).toEqual(["Error: broken"]);
} finally {
console.log = originalLog;
console.error = originalError;
}
});
it("handles help, interactive mode, and missing provider credentials", async () => {
const originalLog = console.log;
const originalError = console.error;
console.log = () => undefined;
console.error = () => undefined;
try {
expect(await main(["--help"])).toBe(0);
expect(await main(["--interactive"])).toBe(-1);
process.env.ELIZA_CODE_PROVIDER = "openai";
delete process.env.OPENAI_API_KEY;
expect(await main(["hello"])).toBe(1);
process.env.ELIZA_CODE_PROVIDER = "invalid";
expect(await main(["hello"])).toBe(1);
} finally {
console.log = originalLog;
console.error = originalError;
}
});
it("rejects an invalid working directory before runtime initialization", async () => {
const result = await runCLI(
parseArgs(["--cwd", "/definitely/missing/eliza-code", "hello"]),
);
expect(result.success).toBe(false);
expect(result.error).toContain("Failed to set working directory");
});
});
+482
View File
@@ -0,0 +1,482 @@
#!/usr/bin/env node
/**
* Non-interactive CLI mode for Eliza Code
*
* Usage:
* eliza-code --help Show help
* eliza-code --version Show version
* eliza-code "message" Send a message and get response
* eliza-code --file <path> Read message from file
* echo "message" | eliza-code Read message from stdin
* eliza-code --json "message" Output response as JSON
* eliza-code --stream "message" Stream response as it's generated
*/
import { readFileSync } from "node:fs";
import * as fs from "node:fs/promises";
import * as readline from "node:readline";
import type { AgentRuntime } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { getAgentClient } from "./lib/agent-client.js";
import { getCwd, setCwd } from "./lib/cwd.js";
import { ensureSessionIdentity, getMainRoomElizaId } from "./lib/identity.js";
import { loadEnv } from "./lib/load-env.js";
import { resolveModelProvider } from "./lib/model-provider.js";
import { loadSession, type SessionState, saveSession } from "./lib/session.js";
import type { ChatRoom, Message, MessageRole } from "./types.js";
// ============================================================================
// Types
// ============================================================================
interface CLIOptions {
help: boolean;
version: boolean;
json: boolean;
stream: boolean;
file: string | null;
cwd: string | null;
message: string | null;
interactive: boolean;
}
interface CLIResult {
success: boolean;
response?: string;
error?: string;
timing?: {
startedAt: number;
completedAt: number;
durationMs: number;
};
}
// ============================================================================
// Constants
// ============================================================================
/**
* Read the real version from package.json rather than a hardcoded literal
* (which drifted to "1.0.0" while the package is on 2.0.x). `../package.json`
* resolves correctly whether running from source (`src/cli.ts`) or the built
* bundle (`dist/index.js`) — both are one level under the package root.
*/
function readVersion(): string {
const pkgUrl = new URL("../package.json", import.meta.url);
const pkg: unknown = JSON.parse(readFileSync(pkgUrl, "utf8"));
if (
!pkg ||
typeof pkg !== "object" ||
!("version" in pkg) ||
typeof pkg.version !== "string"
) {
throw new Error("eliza-code package.json is missing a valid version");
}
return pkg.version;
}
const VERSION = readVersion();
const HELP_TEXT = `
Eliza Code - Async Coding Agent CLI
Usage:
eliza-code [options] [message]
Options:
-h, --help Show this help message
-v, --version Show version number
-j, --json Output response as JSON
-s, --stream Stream response as it's generated
-f, --file <path> Read message from file
-c, --cwd <path> Set working directory
-i, --interactive Force interactive mode (TUI)
Examples:
eliza-code "What files are in the current directory?"
eliza-code --json "Review the code in src/index.ts"
eliza-code --file prompt.txt
eliza-code --cwd /path/to/project "Run the tests"
echo "Explain this code" | eliza-code
Environment Variables:
OPENAI_API_KEY Required (if using OpenAI). Your OpenAI API key
ANTHROPIC_API_KEY Required (if using Anthropic). Your Anthropic API key
ELIZA_CODE_PROVIDER Optional. Force provider: openai|anthropic
LOG_LEVEL Log level (default: fatal for CLI)
`;
// ============================================================================
// Argument Parsing
// ============================================================================
function parseArgs(args: string[]): CLIOptions {
const options: CLIOptions = {
help: false,
version: false,
json: false,
stream: false,
file: null,
cwd: null,
message: null,
interactive: false,
};
let i = 0;
const positionalArgs: string[] = [];
while (i < args.length) {
const arg = args[i];
switch (arg) {
case "-h":
case "--help":
options.help = true;
break;
case "-v":
case "--version":
options.version = true;
break;
case "-j":
case "--json":
options.json = true;
break;
case "-s":
case "--stream":
options.stream = true;
break;
case "-i":
case "--interactive":
options.interactive = true;
break;
case "-f":
case "--file":
i++;
if (i >= args.length) {
throw new Error("--file requires a path argument");
}
options.file = args[i];
break;
case "-c":
case "--cwd":
i++;
if (i >= args.length) {
throw new Error("--cwd requires a path argument");
}
options.cwd = args[i];
break;
default:
if (arg.startsWith("-")) {
throw new Error(`Unknown option: ${arg}`);
}
positionalArgs.push(arg);
break;
}
i++;
}
// Join positional args as the message
if (positionalArgs.length > 0) {
options.message = positionalArgs.join(" ");
}
return options;
}
// ============================================================================
// Input Handling
// ============================================================================
async function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = "";
const rl = readline.createInterface({
input: process.stdin,
terminal: false,
});
rl.on("line", (line) => {
data += `${line}\n`;
});
rl.on("close", () => {
resolve(data.trim());
});
rl.on("error", reject);
// Timeout after 100ms if no data (means no piped input)
setTimeout(() => {
if (data === "") {
rl.close();
}
}, 100);
});
}
async function getMessage(options: CLIOptions): Promise<string | null> {
// Message from arguments
if (options.message) {
return options.message;
}
// Message from file
if (options.file) {
const content = await fs.readFile(options.file, "utf-8");
return content.trim();
}
// Message from stdin (only if not a TTY)
if (!process.stdin.isTTY) {
const stdinContent = await readStdin();
if (stdinContent) {
return stdinContent;
}
}
return null;
}
function createDefaultSessionState(): SessionState {
const identity = ensureSessionIdentity();
const room: ChatRoom = {
id: "default-main-room",
name: "Main",
messages: [],
createdAt: new Date(),
taskIds: [],
elizaRoomId: getMainRoomElizaId(identity),
};
return {
rooms: [room],
currentRoomId: room.id,
currentTaskId: null,
cwd: getCwd(),
identity,
};
}
function getCurrentRoomFromSession(session: SessionState): ChatRoom {
const byId = session.rooms.find((r) => r.id === session.currentRoomId);
if (byId) return byId;
const fallback = session.rooms[0];
if (!fallback) {
// Should never happen (we always create at least one room).
throw new Error("Session has no rooms");
}
session.currentRoomId = fallback.id;
return fallback;
}
function appendSessionMessage(
session: SessionState,
room: ChatRoom,
role: MessageRole,
content: string,
): void {
const message: Message = {
id: uuidv4(),
role,
content,
timestamp: new Date(),
roomId: room.id,
};
// Mutate in place (session is not shared across callers).
room.messages.push(message);
// Ensure the room in the session references the updated messages array.
session.rooms = session.rooms.map((r) => (r.id === room.id ? room : r));
}
// ============================================================================
// CLI Execution
// ============================================================================
async function runCLI(options: CLIOptions): Promise<CLIResult> {
const startedAt = Date.now();
// Set working directory if specified
if (options.cwd) {
const result = await setCwd(options.cwd);
if (!result.success) {
return {
success: false,
error: `Failed to set working directory: ${result.error}`,
};
}
// In CLI mode, treat --cwd as the project root for session persistence.
process.chdir(result.path);
}
// Get message
const message = await getMessage(options);
if (!message) {
return {
success: false,
error: "No message provided. Use --help for usage information.",
};
}
// Initialize agent
let runtime: AgentRuntime | undefined;
let shutdownAgent: ((runtime: AgentRuntime) => Promise<void>) | undefined;
try {
const agentModule = await import("./lib/agent.js");
shutdownAgent = agentModule.shutdownAgent;
const { initializeAgent } = agentModule;
runtime = await initializeAgent();
const agentClient = getAgentClient();
agentClient.setRuntime(runtime);
const session = (await loadSession()) ?? createDefaultSessionState();
const room = getCurrentRoomFromSession(session);
appendSessionMessage(session, room, "user", message);
const shouldStream = options.stream && !options.json;
let didPrintStreaming = false;
// Send message and get response
const response = await agentClient.sendMessage({
room,
text: message,
identity: session.identity,
onDelta: shouldStream
? (delta) => {
// Write deltas directly for real-time streaming.
process.stdout.write(delta);
didPrintStreaming = true;
}
: undefined,
});
if (didPrintStreaming) {
process.stdout.write("\n");
}
appendSessionMessage(session, room, "assistant", response);
// The response is not complete until its durable session history is saved.
session.cwd = getCwd();
await saveSession(session);
const completedAt = Date.now();
return {
success: true,
response,
timing: {
startedAt,
completedAt,
durationMs: completedAt - startedAt,
},
};
} finally {
if (runtime && shutdownAgent) {
await shutdownAgent(runtime);
}
}
}
// ============================================================================
// Output Formatting
// ============================================================================
function formatOutput(result: CLIResult, options: CLIOptions): void {
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
if (result.success) {
console.log(result.response);
} else {
console.error(`Error: ${result.error}`);
}
}
// ============================================================================
// Main Entry Point
// ============================================================================
export async function main(
args: string[] = process.argv.slice(2),
): Promise<number> {
loadEnv();
// Suppress logs in CLI mode
process.env.LOG_LEVEL = "fatal";
const options = parseArgs(args);
// Handle help
if (options.help) {
console.log(HELP_TEXT);
return 0;
}
// Handle version
if (options.version) {
console.log(`eliza-code v${VERSION}`);
return 0;
}
// If interactive mode is requested, return special code to indicate TUI should run
if (options.interactive) {
return -1; // Special code: run TUI
}
// Check for API key
try {
const provider = resolveModelProvider(process.env);
if (provider === "anthropic" && !process.env.ANTHROPIC_API_KEY?.trim()) {
console.error(
"Error: ANTHROPIC_API_KEY environment variable is required (ELIZA_CODE_PROVIDER=anthropic)",
);
console.error("Set it in your environment or in a .env file");
return 1;
}
if (provider === "openai" && !process.env.OPENAI_API_KEY?.trim()) {
console.error(
"Error: OPENAI_API_KEY environment variable is required (ELIZA_CODE_PROVIDER=openai)",
);
console.error("Set it in your environment or in a .env file");
return 1;
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: ${message}`);
return 1;
}
// Run CLI
const result = await runCLI(options);
formatOutput(result, options);
return result.success ? 0 : 1;
}
// ============================================================================
// Exports for Testing
// ============================================================================
export {
type CLIOptions,
type CLIResult,
formatOutput,
getMessage,
parseArgs,
runCLI,
};
@@ -0,0 +1,512 @@
// Renders a reusable UI component for the Code example.
import {
type AutocompleteProvider,
CURSOR_MARKER,
Editor,
type Focusable,
Loader,
Markdown,
matchesKey,
type TUI,
truncateToWidth,
visibleWidth,
} from "@elizaos/tui";
import chalk from "chalk";
import { createEditorTheme } from "../lib/editor-theme.js";
import { createChatMarkdownTheme } from "../lib/markdown-theme.js";
import { useStore } from "../lib/store.js";
import type { Message } from "../types.js";
// Rendering markdown below ~40 cols is cramped (code fences + gutters eat the
// line); fall back to the plain wrapper there. This also keeps the #11043
// narrow-terminal guarantee simple on the cockpit's ~43-col phone xterm.
const MARKDOWN_MIN_WIDTH = 40;
const COMPOSER_MAX_LINES = 6;
const COMPOSER_PROMPT = "> ";
const ANSI_SGR_PATTERN = new RegExp(
`${String.fromCharCode(27)}\\[[0-9;]*m`,
"g",
);
// One shared theme instance (chalk style fns; cheap, but no need to rebuild).
const chatMarkdownTheme = createChatMarkdownTheme();
interface ChatPaneProps {
onSubmit: (text: string) => Promise<void>;
autocompleteProvider?: AutocompleteProvider;
tui: TUI;
}
interface RenderLine {
text: string;
color?: string;
dim?: boolean;
italic?: boolean;
bold?: boolean;
/** `text` is already ANSI-styled (e.g. Markdown output) — render verbatim,
* do not re-apply chalk. */
raw?: boolean;
}
function formatTime(timestamp: Date | number | string | undefined): string {
if (!timestamp) return "";
try {
let date: Date;
if (timestamp instanceof Date) {
date = timestamp;
} else if (typeof timestamp === "number") {
date = new Date(timestamp);
} else if (typeof timestamp === "string") {
date = new Date(timestamp);
} else {
return "";
}
if (Number.isNaN(date.getTime())) {
return "";
}
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
} catch {
return "";
}
}
function wrapText(text: string, maxWidth: number): string[] {
if (maxWidth <= 0) return [text];
const lines: string[] = [];
const paragraphs = text.split("\n");
for (const paragraph of paragraphs) {
if (paragraph.length <= maxWidth) {
lines.push(paragraph);
continue;
}
const words = paragraph.split(" ");
let currentLine = "";
for (const word of words) {
if (currentLine.length + word.length + 1 <= maxWidth) {
currentLine += (currentLine ? " " : "") + word;
} else {
if (currentLine) {
lines.push(currentLine);
}
if (word.length > maxWidth) {
let remaining = word;
while (remaining.length > maxWidth) {
lines.push(remaining.substring(0, maxWidth));
remaining = remaining.substring(maxWidth);
}
currentLine = remaining;
} else {
currentLine = word;
}
}
}
if (currentLine) {
lines.push(currentLine);
}
}
return lines.length > 0 ? lines : [""];
}
function padToVisibleWidth(text: string, maxWidth: number): string {
const clipped = truncateToWidth(text, maxWidth, "");
const padding = Math.max(0, maxWidth - visibleWidth(clipped));
return `${clipped}${" ".repeat(padding)}`;
}
function isEditorRule(line: string, width: number): boolean {
if (visibleWidth(line) !== width) return false;
const plain = line
.replaceAll(CURSOR_MARKER, "")
.replace(ANSI_SGR_PATTERN, "");
return plain.length > 0 && [...plain].every((char) => char === "─");
}
function windowAroundCursor(lines: string[], maxLines: number): string[] {
if (lines.length <= maxLines) return lines;
const cursorIndex = lines.findIndex(
(line) => line.includes(CURSOR_MARKER) || line.includes("\x1b[7m"),
);
if (cursorIndex === -1) {
return lines.slice(-maxLines);
}
const start = Math.min(
Math.max(0, cursorIndex - maxLines + 1),
Math.max(0, lines.length - maxLines),
);
return lines.slice(start, start + maxLines);
}
function toRenderLines(messages: Message[], maxWidth: number): RenderLine[] {
const lines: RenderLine[] = [];
for (const msg of messages) {
const timeStr = formatTime(msg.timestamp);
if (msg.kind === "tool") {
const wrapped = wrapText(msg.content, maxWidth);
for (const line of wrapped) {
lines.push({ text: line, dim: true });
}
continue;
}
if (msg.role === "system") {
const wrapped = wrapText(msg.content, maxWidth);
for (const line of wrapped) {
lines.push({ text: line, dim: true, italic: true });
}
continue;
}
const speaker = msg.role === "user" ? "You" : "Eliza";
const color = msg.role === "user" ? "cyan" : "green";
const header = `${speaker}${timeStr ? ` ${timeStr}` : ""}`;
lines.push({ text: header, color, bold: true });
const indent = " ";
const contentWidth = Math.max(1, maxWidth - indent.length);
if (contentWidth >= MARKDOWN_MIN_WIDTH) {
// Render the body as markdown (headings, lists, fenced code, inline
// styles) into pre-styled lines. Markdown wraps to contentWidth, so
// `indent + line` never exceeds maxWidth; MainScreen's truncateToWidth is
// the final overflow backstop.
const md = new Markdown(msg.content, 0, 0, chatMarkdownTheme).render(
contentWidth,
);
const body = md.length > 0 ? md : [""];
for (const line of body) {
lines.push({ text: indent + line, raw: true });
}
} else {
// Narrow terminal (e.g. the ~43-col cockpit xterm): plain wrap.
const wrapped = wrapText(msg.content, contentWidth);
for (const line of wrapped) {
lines.push({ text: indent + line });
}
}
}
return lines;
}
export class ChatPane implements Focusable {
focused = false;
private props: ChatPaneProps;
private editor: Editor;
private typingLoader: Loader | null = null;
private typingLoaderRunning = false;
private scrollOffset = 0;
private lastMessageAreaHeight = 1;
private lastMaxScroll = 0;
private lastRenderedLineCount = 0;
private lastRenderedRoomId: string | null = null;
constructor(props: ChatPaneProps) {
this.props = props;
const theme = createEditorTheme();
this.editor = new Editor(props.tui, theme, { paddingX: 0 });
if (props.autocompleteProvider) {
this.editor.setAutocompleteProvider(props.autocompleteProvider);
}
this.editor.onSubmit = async (text: string) => {
const trimmed = text.trim();
if (trimmed.length > 0) {
// Record the prompt so ↑/↓ recalls it — the Editor implements history
// browsing but never had anything added to it, so up-arrow did nothing.
this.editor.addToHistory(trimmed);
this.editor.setText("");
this.scrollOffset = 0;
await props.onSubmit(trimmed);
}
};
this.editor.onChange = (text: string) => {
useStore.getState().setInputValue(text);
};
}
dispose(): void {
this.stopTypingLoader();
}
syncFocus(isFocused: boolean): void {
this.focused = isFocused;
this.editor.focused = isFocused;
}
invalidate(): void {
this.editor.invalidate();
}
handleInput(char: string): void {
if (!this.focused) return;
// Transcript scrollback. Page keys always control history; Home/End do so
// when the composer is empty or the user is already reading scrollback.
if (matchesKey(char, "ctrl+up")) {
this.scrollBy(1);
return;
}
if (matchesKey(char, "ctrl+down")) {
this.scrollBy(-1);
return;
}
if (matchesKey(char, "pageUp")) {
this.scrollBy(this.getPageScrollAmount());
return;
}
if (matchesKey(char, "pageDown")) {
this.scrollBy(-this.getPageScrollAmount());
return;
}
const shouldRouteHomeEndToScroll =
this.editor.getText().length === 0 || this.scrollOffset > 0;
if (shouldRouteHomeEndToScroll && matchesKey(char, "home")) {
this.scrollToTop();
return;
}
if (shouldRouteHomeEndToScroll && matchesKey(char, "end")) {
this.scrollToBottom();
return;
}
// Escape to clear input
if (char === "\x1b") {
useStore.getState().setInputValue("");
this.editor.setText("");
this.props.tui.requestRender();
return;
}
this.editor.handleInput(char);
const inputValue = this.editor.getText();
useStore.getState().setInputValue(inputValue);
this.props.tui.requestRender();
}
private getPageScrollAmount(): number {
return Math.max(1, this.lastMessageAreaHeight - 1);
}
private scrollBy(deltaLines: number): void {
this.scrollOffset = Math.max(
0,
Math.min(this.scrollOffset + deltaLines, this.lastMaxScroll),
);
this.props.tui.requestRender();
}
private scrollToTop(): void {
this.scrollOffset = this.lastMaxScroll;
this.props.tui.requestRender();
}
private scrollToBottom(): void {
this.scrollOffset = 0;
this.props.tui.requestRender();
}
private ensureTypingLoader(): Loader {
if (!this.typingLoader) {
this.typingLoader = new Loader(
this.props.tui,
chalk.green,
chalk.dim,
"Processing (Esc/Ctrl+C abort)",
);
this.typingLoaderRunning = true;
return this.typingLoader;
}
if (!this.typingLoaderRunning) {
this.typingLoader.start();
this.typingLoaderRunning = true;
}
return this.typingLoader;
}
private stopTypingLoader(): void {
if (!this.typingLoaderRunning) return;
this.typingLoader?.stop();
this.typingLoaderRunning = false;
}
private renderTypingLoader(width: number): RenderLine[] {
return this.ensureTypingLoader()
.render(width)
.filter((line) => line.trim().length > 0)
.map((line) => ({ text: line, raw: true }));
}
private renderComposerLines(editorWidth: number, maxLines: number): string[] {
const editorLines = this.editor.render(editorWidth);
const bodyLines = editorLines.filter(
(line) => !isEditorRule(line, editorWidth),
);
const visibleBody = bodyLines.length > 0 ? bodyLines : [""];
return windowAroundCursor(visibleBody, maxLines);
}
/** Region body for the chat column (messages + input chrome). */
renderContent(width: number, height: number): string[] {
this.editor.setPaddingX(1);
const state = useStore.getState();
const room = state.rooms.find((r) => r.id === state.currentRoomId);
const messages = room?.messages ?? [];
const isAgentTyping = state.isAgentTyping;
const innerWidth = Math.max(1, width - 4);
const paddingX = 1;
const headerHeight = 1;
const helpHeight = 1;
const inputChromeHeight = 2;
const editorWidth = Math.max(1, innerWidth - 3);
const composerMaxLines = Math.max(
1,
Math.min(
COMPOSER_MAX_LINES,
height - headerHeight - helpHeight - inputChromeHeight - 1,
),
);
// While a turn is running the input row shows a loading notice — unless
// the user is typing ahead (queue-and-send), in which case the composer
// stays visible so they can see what they're about to queue.
const composerVisible =
!state.isLoading || this.editor.getText().length > 0;
const composerLines = composerVisible
? this.renderComposerLines(editorWidth, composerMaxLines)
: [];
const inputBodyHeight = composerVisible ? composerLines.length : 1;
const inputHeight = inputChromeHeight + inputBodyHeight;
const messageAreaHeight = Math.max(
1,
height - headerHeight - inputHeight - helpHeight,
);
const allLines = toRenderLines(messages, innerWidth);
if (isAgentTyping) {
allLines.push(...this.renderTypingLoader(innerWidth));
} else {
this.stopTypingLoader();
}
if (this.lastRenderedRoomId !== state.currentRoomId) {
this.scrollOffset = 0;
} else if (
this.scrollOffset > 0 &&
allLines.length > this.lastRenderedLineCount
) {
this.scrollOffset += allLines.length - this.lastRenderedLineCount;
}
const maxScroll = Math.max(0, allLines.length - messageAreaHeight);
const clampedScroll = Math.min(this.scrollOffset, maxScroll);
this.scrollOffset = clampedScroll;
this.lastMaxScroll = maxScroll;
this.lastMessageAreaHeight = messageAreaHeight;
this.lastRenderedLineCount = allLines.length;
this.lastRenderedRoomId = state.currentRoomId;
const startIndex = Math.max(
0,
allLines.length - messageAreaHeight - clampedScroll,
);
const endIndex = Math.max(0, allLines.length - clampedScroll);
const visibleLines = allLines.slice(startIndex, endIndex);
const output: string[] = [];
const headerColor = this.focused ? chalk.bold.cyan : chalk.white;
const scrollIndicator =
clampedScroll > 0 ? chalk.dim(` [↑ ${clampedScroll}]`) : "";
const header = `${headerColor(`Chat: ${room?.name ?? "Unknown"}`)} ${chalk.dim(`(${messages.length})`)}${scrollIndicator}`;
output.push(" ".repeat(paddingX) + header);
if (visibleLines.length === 0) {
output.push(" ".repeat(paddingX) + chalk.dim.italic("No messages."));
for (let i = 1; i < messageAreaHeight; i++) {
output.push("");
}
} else {
for (const line of visibleLines) {
if (line.raw) {
// Already ANSI-styled (Markdown output) — don't re-chalk.
output.push(" ".repeat(paddingX) + line.text);
continue;
}
let styled = line.text;
if (line.bold) styled = chalk.bold(styled);
if (line.italic) styled = chalk.italic(styled);
if (line.dim) styled = chalk.dim(styled);
if (line.color === "cyan") styled = chalk.cyan(styled);
else if (line.color === "green") styled = chalk.green(styled);
output.push(" ".repeat(paddingX) + styled);
}
const remaining = messageAreaHeight - visibleLines.length;
for (let i = 0; i < remaining; i++) {
output.push("");
}
}
const borderColor = this.focused ? chalk.cyan : chalk.gray;
const topBorder = borderColor(`${"─".repeat(innerWidth)}`);
const bottomBorder = borderColor(`${"─".repeat(innerWidth)}`);
output.push(topBorder);
if (!composerVisible) {
const queuedCount = state.pendingSubmissions.length;
const queuedSuffix = queuedCount > 0 ? `${queuedCount} queued` : "";
const loadingText = `Processing... Esc/Ctrl+C abort${queuedSuffix}`;
const available = Math.max(1, innerWidth - 1);
const visibleText =
loadingText.length > available
? loadingText.slice(0, available)
: loadingText;
output.push(
`${borderColor("│")} ${chalk.dim(visibleText)}${" ".repeat(Math.max(0, innerWidth - visibleText.length - 1))}${borderColor("│")}`,
);
} else {
for (let i = 0; i < composerLines.length; i++) {
const prompt =
i === 0
? chalk.cyan(COMPOSER_PROMPT)
: " ".repeat(COMPOSER_PROMPT.length);
const content = ` ${prompt}${composerLines[i] ?? ""}`;
output.push(
`${borderColor("│")}${padToVisibleWidth(content, innerWidth)}${borderColor("│")}`,
);
}
}
output.push(bottomBorder);
const helpText = !this.focused
? "Tab: focus"
: state.isLoading
? "Enter: queue • Esc/Ctrl+C: abort • PgUp/PgDn: scroll"
: state.inputValue.startsWith("/")
? "Enter: run • Tab: complete • Esc: clear • ?: help"
: "Enter: send • PgUp/PgDn: scroll • Esc: clear • ?: help";
output.push(truncateToWidth(chalk.dim(helpText), width));
return output;
}
}
@@ -0,0 +1,98 @@
// Renders a reusable UI component for the Code example.
import type { Component, Terminal } from "@elizaos/tui";
import chalk from "chalk";
export class HelpOverlay implements Component {
constructor(private readonly terminal: Terminal) {}
invalidate(): void {}
render(width: number): string[] {
const height = Math.max(1, this.terminal.rows);
const output: string[] = [];
const innerWidth = Math.max(1, width - 4);
const borderColor = chalk.cyan;
const topBorder = ` ${borderColor(`${"─".repeat(innerWidth)}`)}`;
const bottomBorder = ` ${borderColor(`${"─".repeat(innerWidth)}`)}`;
const emptyLine = ` ${borderColor("│")}${" ".repeat(innerWidth)}${borderColor("│")}`;
output.push(topBorder);
output.push(emptyLine);
output.push(
` ${borderColor("│")} ${chalk.bold.cyan("Help")}${" ".repeat(innerWidth - 5)}${borderColor("│")}`,
);
output.push(emptyLine);
output.push(
` ${borderColor("│")} ${chalk.bold("Navigation")}${" ".repeat(innerWidth - 11)}${borderColor("│")}`,
);
output.push(
` ${borderColor("│")} ${chalk.dim("Tab: switch panes (except while typing /command)")}${" ".repeat(Math.max(0, innerWidth - 49))}${borderColor("│")}`,
);
output.push(
` ${borderColor("│")} ${chalk.dim("Ctrl+< / Ctrl+> (or Ctrl+, / Ctrl+.): resize tasks pane")}${" ".repeat(Math.max(0, innerWidth - 56))}${borderColor("│")}`,
);
output.push(
` ${borderColor("│")} ${chalk.dim("?: toggle help")}${" ".repeat(Math.max(0, innerWidth - 15))}${borderColor("│")}`,
);
output.push(
` ${borderColor("│")} ${chalk.dim("Ctrl+N: new conversation")}${" ".repeat(Math.max(0, innerWidth - 25))}${borderColor("│")}`,
);
output.push(
` ${borderColor("│")} ${chalk.dim("Ctrl+C / Ctrl+Q: quit")}${" ".repeat(Math.max(0, innerWidth - 22))}${borderColor("│")}`,
);
output.push(emptyLine);
output.push(
` ${borderColor("│")} ${chalk.bold("Chat")}${" ".repeat(innerWidth - 5)}${borderColor("│")}`,
);
output.push(
` ${borderColor("│")} ${chalk.dim("Enter: send | PgUp/PgDn/Home/End: scroll | Esc: clear")}${" ".repeat(Math.max(0, innerWidth - 54))}${borderColor("│")}`,
);
output.push(
` ${borderColor("│")} ${chalk.dim("/help: show commands")}${" ".repeat(Math.max(0, innerWidth - 21))}${borderColor("│")}`,
);
output.push(emptyLine);
output.push(
` ${borderColor("│")} ${chalk.bold("Tasks")}${" ".repeat(innerWidth - 6)}${borderColor("│")}`,
);
output.push(
` ${borderColor("│")} ${chalk.dim("↑↓ select | Enter switch | d done/open | f finished | e edit mode")}${" ".repeat(Math.max(0, innerWidth - 66))}${borderColor("│")}`,
);
output.push(
` ${borderColor("│")} ${chalk.dim("Edit mode: r rename | p pause/resume | c cancel | x delete (y/n confirm)")}${" ".repeat(Math.max(0, innerWidth - 72))}${borderColor("│")}`,
);
output.push(
` ${borderColor("│")} ${chalk.dim("/task pane show|hide|auto|toggle")}${" ".repeat(Math.max(0, innerWidth - 34))}${borderColor("│")}`,
);
output.push(emptyLine);
output.push(
` ${borderColor("│")} ${chalk.bold("Commands")}${" ".repeat(innerWidth - 9)}${borderColor("│")}`,
);
output.push(
` ${borderColor("│")} ${chalk.dim("/new, /switch, /rename, /delete, /reset")}${" ".repeat(Math.max(0, innerWidth - 40))}${borderColor("│")}`,
);
output.push(
` ${borderColor("│")} ${chalk.dim("/task, /tasks, /cd, /pwd, /clear")}${" ".repeat(Math.max(0, innerWidth - 33))}${borderColor("│")}`,
);
output.push(emptyLine);
const usedLines = output.length;
const remainingLines = height - usedLines - 2;
for (let i = 0; i < remainingLines; i++) {
output.push(emptyLine);
}
output.push(
` ${borderColor("│")} ${chalk.dim("Press ? or Esc to close")}${" ".repeat(Math.max(0, innerWidth - 24))}${borderColor("│")}`,
);
output.push(bottomBorder);
return output;
}
}
@@ -0,0 +1,80 @@
// Renders a reusable UI component for the Code example.
import type { Component, Focusable, Terminal } from "@elizaos/tui";
import { truncateToWidth } from "@elizaos/tui";
import { useStore } from "../lib/store.js";
import type { ChatPane } from "./ChatPane.js";
import type { StatusBar } from "./StatusBar.js";
import type { TaskPane } from "./TaskPane.js";
/**
* Composes status bar + chat / task split into one root {@link Component} for the TUI.
*/
export class MainScreen implements Component, Focusable {
focused = true;
constructor(
private readonly terminal: Terminal,
private readonly statusBar: StatusBar,
private readonly chatPane: ChatPane,
private readonly taskPane: TaskPane,
) {}
invalidate(): void {
this.statusBar.invalidate();
this.chatPane.invalidate();
this.taskPane.invalidate();
}
handleInput(data: string): void {
const pane = useStore.getState().focusedPane;
if (pane === "tasks") {
this.taskPane.handleInput(data);
} else {
this.chatPane.handleInput(data);
}
}
render(width: number): string[] {
const height = Math.max(1, this.terminal.rows);
const state = useStore.getState();
const showTasks = state.isTaskPaneVisible();
this.chatPane.syncFocus(state.focusedPane === "chat");
this.taskPane.syncFocus(state.focusedPane === "tasks");
const statusLines = this.statusBar.render(width);
const contentHeight = Math.max(1, height - statusLines.length);
// Clip every assembled line to the terminal width. Child components are
// responsible for their own layout, but any visible-width overflow that
// slips through (fixed-width footers, unpadded chrome on narrow
// terminals) is a fatal render abort in the TUI — clip here at the one
// choke point instead of crashing.
const clip = (lines: string[]): string[] =>
lines.map((line) => truncateToWidth(line, width));
if (!showTasks) {
return clip([
...statusLines,
...this.chatPane.renderContent(width, contentHeight),
]);
}
const taskW = Math.max(
18,
Math.min(Math.floor(width * state.taskPaneWidthFraction), width - 22),
);
const chatW = Math.max(18, width - taskW - 1);
const chatLines = this.chatPane.renderContent(chatW, contentHeight);
const taskLines = this.taskPane.renderContent(taskW, contentHeight);
const maxRows = Math.max(chatLines.length, taskLines.length);
const sep = "│";
const body: string[] = [];
for (let r = 0; r < maxRows; r++) {
const left = chatLines[r] ?? "";
const right = taskLines[r] ?? "";
body.push(`${left}${sep}${right}`);
}
return clip([...statusLines, ...body]);
}
}
@@ -0,0 +1,129 @@
// Renders a reusable UI component for the Code example.
import type { Component } from "@elizaos/tui";
import chalk from "chalk";
import { getCwd } from "../lib/cwd.js";
import { describeActiveModel } from "../lib/model-provider.js";
import { useStore } from "../lib/store.js";
/** Longest model label shown in the status bar before eliding. */
const MODEL_LABEL_MAX = 22;
export class StatusBar implements Component {
private cwd = getCwd();
private lastCwdCheck = Date.now();
invalidate(): void {}
render(width: number): string[] {
// Periodically update CWD
const now = Date.now();
if (now - this.lastCwdCheck > 500) {
this.cwd = getCwd();
this.lastCwdCheck = now;
}
const state = useStore.getState();
const isLoading = state.isLoading;
const tasks = state.tasks;
const rooms = state.rooms;
const currentRoomId = state.currentRoomId;
const currentRoom = rooms.find((r) => r.id === currentRoomId);
const roomIndex = rooms.findIndex((r) => r.id === currentRoomId) + 1;
const taskCounts = {
running: tasks.filter((t) => t.metadata?.status === "running").length,
completed: tasks.filter((t) => t.metadata?.status === "completed").length,
failed: tasks.filter((t) => t.metadata?.status === "failed").length,
cancelled: tasks.filter((t) => t.metadata?.status === "cancelled").length,
};
const showFullRight = width >= 80;
const showMediumRight = width >= 60;
// Room segment first — its real width feeds the layout math below (a fixed
// slack constant here overflowed the bar with long room names).
const maxRoomNameLen = 20;
const roomName = currentRoom?.name ?? "Chat";
const shortRoomName =
roomName.length > maxRoomNameLen
? `${roomName.slice(0, maxRoomNameLen - 1)}`
: roomName;
const roomDecor = ` (${roomIndex}/${rooms.length}) | `;
// Borders + surrounding spaces: "│ " … " │".
const frameOverhead = 4;
const leftFixedLen =
shortRoomName.length + roomDecor.length + frameOverhead;
// Active model/provider — the "which model am I talking to" indicator every
// comparable coding TUI shows. Only at full width (the bar is already busy
// below 80), elided to a sane length, and omitted entirely when no provider
// is configured (describeActiveModel returns null rather than throwing).
const modelLabelRaw = showFullRight ? describeActiveModel() : null;
const modelLabel =
modelLabelRaw && modelLabelRaw.length > MODEL_LABEL_MAX
? `${modelLabelRaw.slice(0, MODEL_LABEL_MAX - 1)}`
: modelLabelRaw;
const rightBasePlain = showFullRight
? `Tasks r${taskCounts.running} c${taskCounts.completed} f${taskCounts.failed} x${taskCounts.cancelled}${isLoading ? " …" : ""} | ?`
: showMediumRight
? `Tasks r${taskCounts.running} f${taskCounts.failed}${isLoading ? " …" : ""} | ?`
: `Tasks r${taskCounts.running}${isLoading ? " …" : ""} | ?`;
// Include the model indicator only when it fits without pushing the cwd
// below its 10-char floor — prefer dropping the indicator over overflowing
// the bar (which the screen would clip, cutting off the right border).
const modelPrefixCandidate = modelLabel ? `${modelLabel} | ` : "";
const modelPrefix =
modelPrefixCandidate.length > 0 &&
width -
(rightBasePlain.length + modelPrefixCandidate.length) -
leftFixedLen >=
10
? modelPrefixCandidate
: "";
const rightTextPlain = `${modelPrefix}${rightBasePlain}`;
const maxCwdLen = Math.max(
10,
width - rightTextPlain.length - leftFixedLen,
);
const shortCwd =
this.cwd.length > maxCwdLen
? `...${this.cwd.slice(-(maxCwdLen - 3))}`
: this.cwd;
// Build the status bar
const innerWidth = Math.max(1, width - 4);
// Last resort at narrow widths: with the cwd already at its floor, elide
// the room name down to what actually fits rather than overflowing (the
// screen would clip the line, cutting off the right border and help hint).
const overflowBy =
shortRoomName.length +
roomDecor.length +
shortCwd.length +
rightTextPlain.length -
innerWidth;
const finalRoomName =
overflowBy > 0
? `${shortRoomName.slice(0, Math.max(1, shortRoomName.length - overflowBy - 1))}`
: shortRoomName;
const leftText = `${chalk.bold.magenta(finalRoomName)} ${chalk.dim(`(${roomIndex}/${rooms.length})`)} ${chalk.dim("|")} ${chalk.cyan(shortCwd)}`;
const rightText = chalk.dim(rightTextPlain);
// Calculate padding to right-align the right text
const leftLen = finalRoomName.length + roomDecor.length + shortCwd.length;
const rightLen = rightTextPlain.length;
const padding = Math.max(0, innerWidth - leftLen - rightLen);
const borderColor = chalk.gray;
const topBorder = borderColor(`${"─".repeat(innerWidth)}`);
const bottomBorder = borderColor(`${"─".repeat(innerWidth)}`);
const content = `${borderColor("│")} ${leftText}${" ".repeat(padding)}${rightText} ${borderColor("│")}`;
return [topBorder, content, bottomBorder];
}
}
@@ -0,0 +1,619 @@
// Renders a reusable UI component for the Code example.
import type { AgentRuntime } from "@elizaos/core";
import { Editor, type Focusable, type TUI } from "@elizaos/tui";
import chalk from "chalk";
import { createEditorTheme } from "../lib/editor-theme.js";
import { getCodeTaskService } from "../lib/get-code-task-service.js";
import { useStore } from "../lib/store.js";
import { padEndVisible } from "../lib/text-width.js";
import type {
CodeTaskService,
SubAgentType,
TaskStatus,
TaskTraceEvent,
TaskUserStatus,
} from "../types.js";
const SUB_AGENT_TYPES: SubAgentType[] = [
"eliza",
"claude-code",
"codex",
"opencode",
"elizaos-native",
];
interface TaskPaneProps {
runtime: AgentRuntime;
tui: TUI;
}
function getStatusIcon(status: TaskStatus): string {
switch (status) {
case "pending":
return "⏳";
case "running":
return "🔄";
case "completed":
return "✅";
case "failed":
return "❌";
case "paused":
return "⏸️";
case "cancelled":
return "🛑";
default:
return "❓";
}
}
function getStatusColor(status: TaskStatus): (text: string) => string {
switch (status) {
case "pending":
return chalk.gray;
case "running":
return chalk.yellow;
case "completed":
return chalk.green;
case "failed":
return chalk.red;
case "paused":
return chalk.blue;
case "cancelled":
return chalk.red;
default:
return chalk.white;
}
}
function getTaskUserStatus(
userStatus: TaskUserStatus | undefined,
): TaskUserStatus {
return userStatus ?? "open";
}
function reportTaskServiceError(
taskService: CodeTaskService,
taskId: string,
action: string,
err: Error,
): void {
const msg = err.message;
const line = `UI error (${action}): ${msg}`;
taskService.appendOutput(taskId, line).catch((appendError: Error) => {
// error-policy:J7 The primary failure is reported below; a failed diagnostic write must remain observable without recursively calling the same service.
process.stderr.write(
`[TaskPane] Failed to append diagnostic output: ${appendError.message}\n`,
);
});
process.stderr.write(`[TaskPane] ${line}\n`);
}
function formatTraceLines(events: TaskTraceEvent[]): string[] {
const lines: string[] = [];
for (const event of events) {
const head = `#${event.seq}`;
switch (event.kind) {
case "status":
lines.push(
`${head} ⏸️ ${event.status}${event.message ? `${event.message}` : ""}`,
);
break;
case "note": {
const icon =
event.level === "error"
? "❌"
: event.level === "warning"
? "⚠️"
: "️";
lines.push(`${head} ${icon} ${event.message}`);
break;
}
case "llm":
lines.push(
`${head} 🤖 LLM iter ${event.iteration} (${event.modelType})`,
);
lines.push(` ${event.responsePreview}`);
break;
case "tool_call":
lines.push(`${head} 🔧 TOOL: ${event.name}`);
break;
case "tool_result":
lines.push(
`${head} 🔧 RESULT: ${event.name} ${event.success ? "✓" : "✗"}`,
);
lines.push(` ${event.outputPreview}`);
break;
}
}
return lines;
}
export class TaskPane implements Focusable {
focused = false;
private props: TaskPaneProps;
private selectedIndex = 0;
private detailView: "output" | "trace" = "output";
private detailScrollOffset = 0;
private editMode = false;
private isRenaming = false;
private renameEditor: Editor | null = null;
private confirm: { type: "cancel" | "delete"; taskId: string } | null = null;
constructor(props: TaskPaneProps) {
this.props = props;
}
resize(_width: number, _height: number): void {}
syncFocus(focused: boolean): void {
this.focused = focused;
if (!focused) {
this.editMode = false;
this.isRenaming = false;
this.renameEditor = null;
this.confirm = null;
}
}
isFocused(): boolean {
return this.focused;
}
invalidate(): void {
if (this.renameEditor) {
this.renameEditor.invalidate();
}
}
private getTaskService(): CodeTaskService | null {
return getCodeTaskService(this.props.runtime);
}
handleInput(char: string): void {
if (!this.focused) return;
const state = useStore.getState();
const tasks = state.tasks;
const currentTaskId = state.currentTaskId;
const showFinished = state.showFinishedTasks;
const visibleTasks = showFinished
? tasks
: tasks.filter(
(t) =>
getTaskUserStatus(t.metadata?.userStatus) !== "done" ||
t.id === currentTaskId,
);
const currentTask = tasks.find((t) => t.id === currentTaskId);
const taskService = this.getTaskService();
// Handle rename mode
if (this.isRenaming && this.renameEditor) {
if (char === "\x1b") {
// Escape
this.isRenaming = false;
this.renameEditor = null;
this.props.tui.requestRender();
return;
}
if (char === "\r") {
// Enter
const next = this.renameEditor.getText().trim();
const taskId = currentTask?.id ?? null;
if (next.length > 0 && taskId && taskService) {
taskService.renameTask(taskId, next).catch((err: Error) => {
reportTaskServiceError(taskService, taskId, "renameTask", err);
});
}
this.isRenaming = false;
this.renameEditor = null;
this.props.tui.requestRender();
return;
}
this.renameEditor.handleInput(char);
this.props.tui.requestRender();
return;
}
// Handle confirmation dialog
if (this.confirm) {
const confirmSnapshot = this.confirm;
if (char === "y" || char === "Y") {
if (taskService) {
if (confirmSnapshot.type === "cancel") {
taskService
.cancelTask(confirmSnapshot.taskId)
.catch((err: Error) => {
reportTaskServiceError(
taskService,
confirmSnapshot.taskId,
"cancelTask",
err,
);
});
} else {
taskService
.deleteTask(confirmSnapshot.taskId)
.catch((err: Error) => {
reportTaskServiceError(
taskService,
confirmSnapshot.taskId,
"deleteTask",
err,
);
});
}
}
this.confirm = null;
this.props.tui.requestRender();
return;
}
if (char === "n" || char === "N" || char === "\x1b") {
this.confirm = null;
this.props.tui.requestRender();
return;
}
return;
}
// Navigation
if (char === "\x1b[A") {
// Up arrow
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.props.tui.requestRender();
return;
}
if (char === "\x1b[B") {
// Down arrow
this.selectedIndex = Math.min(
visibleTasks.length - 1,
this.selectedIndex + 1,
);
this.props.tui.requestRender();
return;
}
// Select task with Enter
if (char === "\r" && visibleTasks.length > 0) {
const safeIndex = Math.min(
Math.max(0, this.selectedIndex),
Math.max(0, visibleTasks.length - 1),
);
const id = visibleTasks[safeIndex]?.id ?? null;
if (id) {
state.setCurrentTaskId(id);
taskService?.setCurrentTask(id);
this.detailScrollOffset = 0;
this.props.tui.requestRender();
}
return;
}
// Ctrl+Up/Down for detail scroll
if (char === "\x1b[1;5A") {
this.detailScrollOffset++;
this.props.tui.requestRender();
return;
}
if (char === "\x1b[1;5B") {
this.detailScrollOffset = Math.max(0, this.detailScrollOffset - 1);
this.props.tui.requestRender();
return;
}
// Toggle finished tasks
if (char === "f") {
state.toggleShowFinishedTasks();
this.props.tui.requestRender();
return;
}
// Toggle edit mode
if (char === "e") {
this.editMode = !this.editMode;
this.props.tui.requestRender();
return;
}
// Toggle trace view
if (char === "t") {
this.detailView = this.detailView === "output" ? "trace" : "output";
this.detailScrollOffset = 0;
this.props.tui.requestRender();
return;
}
// Mark/unmark as done
if (char === "d" && taskService && currentTask) {
const taskId = currentTask.id;
if (taskId) {
const currentUserStatus = getTaskUserStatus(
currentTask.metadata?.userStatus,
);
const nextStatus: TaskUserStatus =
currentUserStatus === "done" ? "open" : "done";
taskService.setUserStatus(taskId, nextStatus).catch((err: Error) => {
reportTaskServiceError(taskService, taskId, "setUserStatus", err);
});
this.props.tui.requestRender();
}
return;
}
// Edit mode commands
if (!this.editMode || !taskService || !currentTask) return;
const taskId = currentTask.id;
if (!taskId) return;
// Cycle sub-agent
if (char === "a") {
const rawType = currentTask.metadata?.subAgentType;
const current = SUB_AGENT_TYPES.includes(rawType as SubAgentType)
? (rawType as SubAgentType)
: "eliza";
const idx = Math.max(0, SUB_AGENT_TYPES.indexOf(current));
const next = SUB_AGENT_TYPES[(idx + 1) % SUB_AGENT_TYPES.length];
taskService.setTaskSubAgentType(taskId, next).catch((err: Error) => {
reportTaskServiceError(taskService, taskId, "setTaskSubAgentType", err);
});
this.props.tui.requestRender();
return;
}
// Rename
if (char === "r") {
const ed = new Editor(this.props.tui, createEditorTheme(), {
paddingX: 0,
});
ed.setText(currentTask.name);
ed.focused = true;
this.renameEditor = ed;
this.isRenaming = true;
this.props.tui.requestRender();
return;
}
// Cancel (with confirmation)
if (char === "c") {
this.confirm = { type: "cancel", taskId };
this.props.tui.requestRender();
return;
}
// Delete (with confirmation)
if (char === "x") {
this.confirm = { type: "delete", taskId };
this.props.tui.requestRender();
return;
}
// Pause/resume
if (char === "p") {
const status = currentTask.metadata?.status ?? "pending";
if (status === "running") {
taskService.pauseTask(taskId).catch((err: Error) => {
reportTaskServiceError(taskService, taskId, "pauseTask", err);
});
} else if (status === "paused" || status === "pending") {
taskService.resumeTask(taskId).then(
() =>
taskService.startTaskExecution(taskId).catch((err: Error) => {
reportTaskServiceError(
taskService,
taskId,
"startTaskExecution",
err,
);
}),
(err: Error) => {
reportTaskServiceError(taskService, taskId, "resumeTask", err);
},
);
}
this.props.tui.requestRender();
}
}
renderContent(width: number, height: number): string[] {
const state = useStore.getState();
const tasks = state.tasks;
const currentTaskId = state.currentTaskId;
const showFinished = state.showFinishedTasks;
const visibleTasks = showFinished
? tasks
: tasks.filter(
(t) =>
getTaskUserStatus(t.metadata?.userStatus) !== "done" ||
t.id === currentTaskId,
);
const currentTask = tasks.find((t) => t.id === currentTaskId);
const innerWidth = Math.max(1, width - 2);
const output: string[] = [];
// Header
const headerColor = this.focused ? chalk.bold.cyan : chalk.white;
const editIndicator = this.editMode ? " [edit]" : "";
const header = `${headerColor(`Tasks${editIndicator}`)} ${chalk.dim(`(${visibleTasks.length}/${tasks.length})`)}${showFinished ? chalk.dim(" (all)") : ""}`;
output.push(` ${header}`);
// Task list
const taskListHeight = Math.min(8, Math.max(3, height - 12));
const maxTaskNameChars = Math.max(12, Math.min(60, width - 16));
if (visibleTasks.length === 0) {
output.push(
` ${chalk.dim.italic(tasks.length === 0 ? "No tasks." : "No open tasks.")}`,
);
for (let i = 1; i < taskListHeight; i++) {
output.push("");
}
} else {
const validSelectedIndex = Math.max(
0,
Math.min(this.selectedIndex, visibleTasks.length - 1),
);
for (let i = 0; i < Math.min(visibleTasks.length, taskListHeight); i++) {
const task = visibleTasks[i];
const isSelected = i === validSelectedIndex && this.focused;
const isCurrent = task.id === currentTaskId;
const status = task.metadata?.status ?? "pending";
const progress = task.metadata?.progress ?? 0;
const userStatus = getTaskUserStatus(task.metadata?.userStatus);
const displayName = task.name.substring(0, maxTaskNameChars);
const clipped = task.name.length > maxTaskNameChars ? "..." : "";
let lineText = `${isSelected ? "▶ " : " "}${getStatusIcon(status)} ${displayName}${clipped}`;
lineText += chalk.dim(` (${progress}%)`);
if (userStatus === "done") lineText += chalk.dim(" ✓");
if (isSelected) {
lineText = chalk.cyan.inverse(lineText);
} else if (isCurrent) {
lineText = chalk.yellow.bold(lineText);
}
output.push(` ${lineText}`);
}
// Fill remaining space
const remaining =
taskListHeight - Math.min(visibleTasks.length, taskListHeight);
for (let i = 0; i < remaining; i++) {
output.push("");
}
}
// Current task details
if (currentTask) {
const progressBarWidth = Math.max(8, Math.min(20, width - 22));
const maxOutputLines = Math.max(6, height - 12);
const maxOutputChars = Math.max(12, width - 4);
const outputLines = currentTask.metadata?.output ?? [];
const traceLines = formatTraceLines(currentTask.metadata?.trace ?? []);
const detailLines =
this.detailView === "output" ? outputLines : traceLines;
// Border
const borderColor = this.focused ? chalk.cyan : chalk.gray;
output.push(borderColor(`${"─".repeat(innerWidth)}`));
// Task header
const statusColor = getStatusColor(
currentTask.metadata?.status ?? "pending",
);
const taskHeader = `${getStatusIcon(currentTask.metadata?.status ?? "pending")} ${currentTask.name}`;
output.push(
`${borderColor("│")} ${padEndVisible(statusColor(chalk.bold(taskHeader)), innerWidth - 1)}${borderColor("│")}`,
);
// Progress bar
const progress = currentTask.metadata?.progress ?? 0;
const filled = Math.round((progress / 100) * progressBarWidth);
const empty = progressBarWidth - filled;
const progressBar =
chalk.green("█".repeat(filled)) + chalk.dim("░".repeat(empty));
output.push(
`${borderColor("│")} ${chalk.dim("Progress: ")}${progressBar}${` ${progress}%`.padEnd(innerWidth - progressBarWidth - 12)}${borderColor("│")}`,
);
// Sub-agent
output.push(
`${borderColor("│")} ${padEndVisible(chalk.dim(`Sub-agent: ${currentTask.metadata?.subAgentType ?? "eliza"}`), innerWidth - 1)}${borderColor("│")}`,
);
// Detail view header
const detailHeader = this.detailView === "output" ? "Output" : "Trace";
const liveIndicator =
currentTask.metadata?.status === "running" ? " (live)" : "";
output.push(
`${borderColor("│")} ${padEndVisible(chalk.dim.bold(`${detailHeader}${liveIndicator}`), innerWidth - 1)}${borderColor("│")}`,
);
// Detail lines
const detailStart = Math.max(
0,
detailLines.length - maxOutputLines - this.detailScrollOffset,
);
const detailEnd = detailLines.length - this.detailScrollOffset;
const visibleDetail = detailLines.slice(detailStart, detailEnd);
if (visibleDetail.length === 0) {
output.push(
`${borderColor("│")} ${padEndVisible(chalk.dim.italic(this.detailView === "output" ? "No output yet." : "No trace yet."), innerWidth - 1)}${borderColor("│")}`,
);
} else {
for (const line of visibleDetail.slice(0, maxOutputLines)) {
const isError =
line.startsWith("❌") ||
line.startsWith("⚠️") ||
line.startsWith("Error:");
const isSuccess = line.startsWith("🎉") || line.startsWith("✅");
const isTool = line.startsWith("🔧") || line.startsWith("[");
const isAgent =
line.startsWith("🤖") ||
line.startsWith("🧠") ||
line.startsWith("#");
let color = chalk.dim;
if (isError) color = chalk.red;
else if (isSuccess) color = chalk.green;
else if (isTool) color = chalk.yellow;
else if (isAgent) color = chalk.cyan;
const clipped =
line.length > maxOutputChars
? `${line.slice(0, Math.max(0, maxOutputChars - 1))}`
: line;
output.push(
`${borderColor("│")} ${padEndVisible(color(clipped), innerWidth - 1)}${borderColor("│")}`,
);
}
}
if (this.detailScrollOffset > 0) {
output.push(
`${borderColor("│")} ${padEndVisible(chalk.dim(`[↓ ${this.detailScrollOffset} newer lines]`), innerWidth - 1)}${borderColor("│")}`,
);
}
// Error display
if (currentTask.metadata?.error) {
output.push(
`${borderColor("│")} ${chalk.red.bold("Error: ")}${padEndVisible(chalk.red(currentTask.metadata.error.substring(0, innerWidth - 10)), innerWidth - 8)}${borderColor("│")}`,
);
}
output.push(borderColor(`${"─".repeat(innerWidth)}`));
}
// Rename input
if (this.isRenaming && this.renameEditor) {
const borderColor = chalk.cyan;
output.push(borderColor(`${"─".repeat(innerWidth)}`));
const editorLines = this.renameEditor.render(Math.max(1, innerWidth - 2));
output.push(
`${borderColor("│")} ${chalk.dim("Rename: ")}${padEndVisible(editorLines[0] || "", innerWidth - 10)}${borderColor("│")}`,
);
output.push(borderColor(`${"─".repeat(innerWidth)}`));
}
// Help text
const helpText = !this.focused
? "Tab: focus tasks"
: this.confirm
? `Confirm ${this.confirm.type}? (y/n)`
: this.editMode
? "Edit: a agent • r rename • p pause/resume • c cancel • x delete • t trace • e exit • f finished"
: "↑↓ select • Enter switch • e edit • t trace • d done/open • f finished";
output.push(chalk.dim(helpText));
return output;
}
}
@@ -0,0 +1,100 @@
// @vitest-environment node
//
// #11294: assistant replies render as real markdown (bold, fenced code) via
// @elizaos/tui's Markdown component at usable widths, and fall back to plain
// wrapped text on narrow terminals (the ~43-col cockpit xterm) so the #11043
// overflow guarantee holds. Uses the same VirtualTerminal harness as
// narrow-terminal.test.ts.
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import type { AgentRuntime } from "@elizaos/core";
import { TUI, visibleWidth } from "@elizaos/tui";
import chalk from "chalk";
import { useStore } from "../lib/store.js";
import { VirtualTerminal } from "../testing/virtual-terminal.test.js";
import { ChatPane } from "./ChatPane.js";
import { MainScreen } from "./MainScreen.js";
import { StatusBar } from "./StatusBar.js";
import { TaskPane } from "./TaskPane.js";
function makeScreen(cols: number) {
const terminal = new VirtualTerminal(cols, 40);
const tui = new TUI(terminal);
const chatPane = new ChatPane({ onSubmit: async () => {}, tui });
const statusBar = new StatusBar();
const taskPane = new TaskPane({
runtime: {} as unknown as AgentRuntime,
tui,
});
const mainScreen = new MainScreen(terminal, statusBar, chatPane, taskPane);
return { chatPane, mainScreen };
}
const prevChalkLevel = chalk.level;
beforeEach(() => {
// Pin color OFF: this suite asserts markdown MARKER consumption (# / ** /
// backticks stripped), which is color-independent. Forcing level 0 makes the
// substring assertions ("const x = 1;") stable regardless of any chalk.level
// another test file leaked (syntax highlighting inserts SGR mid-token).
chalk.level = 0;
// The store is a cross-file singleton; a sibling test file may have left it
// with rooms:[] (dangling currentRoomId). Establish our own fresh room so
// addMessage(currentRoomId, …) actually lands.
useStore.setState({ rooms: [] });
const room = useStore.getState().createRoom("Main");
useStore.getState().switchRoom(room.id);
});
afterEach(() => {
chalk.level = prevChalkLevel;
useStore.setState({ rooms: [] });
useStore.getState().setInputValue("");
});
describe("eliza-code chat markdown rendering (#11294)", () => {
const MD_BODY =
"# Title\n\nHere is **bold** and `inline code`:\n\n```ts\nconst x = 1;\n```";
test("renders assistant replies through the markdown component at a usable width", () => {
const { chatPane, mainScreen } = makeScreen(100);
chatPane.syncFocus(true);
const state = useStore.getState();
state.addMessage(state.currentRoomId, "assistant", MD_BODY);
const lines = mainScreen.render(100);
const joined = lines.join("\n");
// Content survives.
expect(joined).toContain("const x = 1;");
// Inline markdown markers are consumed (was flat raw text before): the
// heading "#", the bold "**", and the inline-code backticks are stripped
// — in a real TTY the runs are also colored via the chalk theme (color is
// disabled in this non-TTY test env, so we assert the marker-stripping,
// which is environment-independent).
expect(joined).toContain("Title");
expect(joined).not.toContain("# Title");
expect(joined).toContain("bold");
expect(joined).not.toContain("**bold**");
expect(joined).toContain("inline code");
expect(joined).not.toContain("`inline code`");
// Overflow invariant still holds.
for (const line of lines) {
expect(visibleWidth(line)).toBeLessThanOrEqual(100);
}
});
test("falls back to plain wrap on a narrow (cockpit phone) terminal", () => {
const { chatPane, mainScreen } = makeScreen(43);
chatPane.syncFocus(true);
const state = useStore.getState();
state.addMessage(state.currentRoomId, "assistant", MD_BODY);
const lines = mainScreen.render(43);
// No crash + width invariant (the #11043 guarantee) — the whole point of
// the narrow-width fallback.
expect(lines.length).toBeGreaterThan(0);
for (const line of lines) {
expect(visibleWidth(line)).toBeLessThanOrEqual(43);
}
});
});
@@ -0,0 +1,68 @@
// @vitest-environment node
//
// Polish batch (#11294 Med/Low): composer border stays aligned when the prompt
// is ANSI-colored (padEndVisible, not padEnd), and submitted prompts are
// recallable with the up-arrow (Editor history). Uses the VirtualTerminal
// harness; color is forced on so the ANSI-padding bug can actually manifest
// (chalk disables color off a TTY otherwise).
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import type { AgentRuntime } from "@elizaos/core";
import { TUI } from "@elizaos/tui";
import chalk from "chalk";
import { useStore } from "../lib/store.js";
import { VirtualTerminal } from "../testing/virtual-terminal.test.js";
import { ChatPane } from "./ChatPane.js";
import { MainScreen } from "./MainScreen.js";
import { StatusBar } from "./StatusBar.js";
import { TaskPane } from "./TaskPane.js";
const prevChalkLevel = chalk.level;
function makeScreen(cols: number) {
const terminal = new VirtualTerminal(cols, 24);
const tui = new TUI(terminal);
const chatPane = new ChatPane({ onSubmit: async () => {}, tui });
const statusBar = new StatusBar();
const taskPane = new TaskPane({
runtime: {} as unknown as AgentRuntime,
tui,
});
const mainScreen = new MainScreen(terminal, statusBar, chatPane, taskPane);
return { chatPane, mainScreen };
}
beforeEach(() => {
// Force color so chalk.cyan("> ") actually emits SGR — the padEnd-over-ANSI
// bug only exists when the composer prompt carries invisible escape bytes.
chalk.level = 3;
useStore.setState({ rooms: [] });
useStore.getState().setInputValue("");
});
afterEach(() => {
chalk.level = prevChalkLevel;
});
describe("chat input-history recall (#11294)", () => {
test("a submitted prompt is recalled by the up-arrow", async () => {
const { chatPane } = makeScreen(80);
chatPane.syncFocus(true);
// Type a prompt and submit it (Enter). onSubmit records it to history +
// clears the editor.
for (const ch of "hello world") chatPane.handleInput(ch);
chatPane.handleInput("\r");
// Give the async onSubmit microtask a tick to run addToHistory/setText.
await Promise.resolve();
// Editor is cleared after submit…
const editor = (chatPane as unknown as { editor: { getText(): string } })
.editor;
expect(editor.getText()).toBe("");
// …and up-arrow recalls the last prompt.
chatPane.handleInput("\x1b[A");
expect(editor.getText()).toBe("hello world");
});
});
@@ -0,0 +1,100 @@
// @vitest-environment node
//
// #11294: scrollback paging. Before, the transcript only scrolled ±1 line
// (Ctrl+Up/Down). Add PgUp/PgDn (page) and Home/End (jump to oldest/newest)
// so long conversations are navigable. Asserts on the actually-rendered visible
// message text via renderContent (the real behavior), driven by real keystrokes.
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { TUI } from "@elizaos/tui";
import chalk from "chalk";
import { useStore } from "../lib/store.js";
import { VirtualTerminal } from "../testing/virtual-terminal.test.js";
import { ChatPane } from "./ChatPane.js";
const prevChalkLevel = chalk.level;
function makeChatPane() {
const terminal = new VirtualTerminal(80, 12);
const tui = new TUI(terminal);
return new ChatPane({ onSubmit: async () => {}, tui });
}
// A fresh room with N single-line system messages "MSG-000".."MSG-(N-1)".
function seedMessages(n: number): void {
useStore.setState({ rooms: [] });
const room = useStore.getState().createRoom("Main");
useStore.getState().switchRoom(room.id);
for (let i = 0; i < n; i++) {
useStore
.getState()
.addMessage(room.id, "system", `MSG-${String(i).padStart(3, "0")}`);
}
}
beforeEach(() => {
chalk.level = 0; // deterministic plain text for substring assertions
seedMessages(30);
});
afterEach(() => {
chalk.level = prevChalkLevel;
});
describe("chat scrollback paging (#11294)", () => {
const WIDTH = 80;
const HEIGHT = 12; // messageAreaHeight = 12 - 6 = 6 visible rows
test("starts pinned to the newest messages", () => {
const cp = makeChatPane();
cp.syncFocus(true);
const out = cp.renderContent(WIDTH, HEIGHT).join("\n");
expect(out).toContain("MSG-029");
expect(out).not.toContain("MSG-000");
});
test("Home jumps to the oldest, End back to the newest", () => {
const cp = makeChatPane();
cp.syncFocus(true);
cp.renderContent(WIDTH, HEIGHT); // set dims
cp.handleInput("\x1b[H"); // Home
let out = cp.renderContent(WIDTH, HEIGHT).join("\n");
expect(out).toContain("MSG-000");
expect(out).not.toContain("MSG-029");
cp.handleInput("\x1b[F"); // End
out = cp.renderContent(WIDTH, HEIGHT).join("\n");
expect(out).toContain("MSG-029");
expect(out).not.toContain("MSG-000");
});
test("PgUp scrolls a page toward older; PgDn returns", () => {
const cp = makeChatPane();
cp.syncFocus(true);
cp.renderContent(WIDTH, HEIGHT);
cp.handleInput("\x1b[5~"); // PgUp
const afterPgUp = cp.renderContent(WIDTH, HEIGHT).join("\n");
// Moved off the newest (a page = height-6-1 = 5 lines up).
expect(afterPgUp).not.toContain("MSG-029");
// Header shows a scroll indicator once scrolled.
expect(afterPgUp).toContain("[↑");
cp.handleInput("\x1b[6~"); // PgDn
const afterPgDn = cp.renderContent(WIDTH, HEIGHT).join("\n");
expect(afterPgDn).toContain("MSG-029");
});
test("scrolling is clamped — cannot page past the oldest line", () => {
const cp = makeChatPane();
cp.syncFocus(true);
cp.renderContent(WIDTH, HEIGHT);
// Many PgUps well beyond the top.
for (let i = 0; i < 50; i++) cp.handleInput("\x1b[5~");
const out = cp.renderContent(WIDTH, HEIGHT).join("\n");
// The very first line is visible and stays put (no blank overscroll).
expect(out).toContain("MSG-000");
});
});
@@ -0,0 +1,321 @@
// Exercises the Code example behavior that this module protects.
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { stripVTControlCharacters } from "node:util";
import { type AgentRuntime, stringToUuid } from "@elizaos/core";
import { TUI, visibleWidth } from "@elizaos/tui";
import { useStore } from "../lib/store.js";
import { VirtualTerminal } from "../testing/virtual-terminal.test.js";
import { ChatPane } from "./ChatPane.js";
import { MainScreen } from "./MainScreen.js";
import { StatusBar } from "./StatusBar.js";
import { TaskPane } from "./TaskPane.js";
// Regression for #11040 / #10830: the cockpit xterm is ~43 cols. At that
// width the eliza-code TUI used to abort every frame — the composer row
// exceeded the terminal width (editor rendered at innerWidth, then wrapped in
// "│ > … │" chrome → width + 1) and fixed footer chrome overflowed too.
// The TUI's final render guard throws when any line's visible width exceeds
// the terminal width (see packages/tui/src/tui.ts: `visibleWidth(line) >
// width`), so an overflow is a hard crash, not a cosmetic glitch.
//
// The narrow-width guarantee has two seams and this suite bites both:
// * ChatPane renders the editor at innerWidth - 3 so the composer row fits.
// * MainScreen clips every assembled line via truncateToWidth so fixed-width
// chrome can never overflow.
const PHONE_COLS = 43;
function makeScreen(cols: number, rows = 24) {
const terminal = new VirtualTerminal(cols, rows);
const tui = new TUI(terminal);
const chatPane = new ChatPane({ onSubmit: async () => {}, tui });
const statusBar = new StatusBar();
// TaskPane is not rendered on the default (chat-focused) path; it only needs
// to satisfy MainScreen's `syncFocus` call, so a runtime is never touched.
const taskPane = new TaskPane({
runtime: {} as unknown as AgentRuntime,
tui,
});
const mainScreen = new MainScreen(terminal, statusBar, chatPane, taskPane);
return { chatPane, mainScreen };
}
function resetChatStore(): void {
useStore.setState({
rooms: [
{
id: "test-room",
name: "Main",
messages: [],
createdAt: new Date(0),
taskIds: [],
elizaRoomId: stringToUuid("eliza-code-narrow-terminal-test-room"),
},
],
currentRoomId: "test-room",
focusedPane: "chat",
taskPaneVisibility: "hidden",
inputValue: "",
isLoading: false,
isAgentTyping: false,
pendingSubmissions: [],
});
}
function plainText(lines: string[]): string {
return lines.map((line) => stripVTControlCharacters(line)).join("\n");
}
function inputFrameLines(lines: string[]): string[] {
const topBorderIdx = lines.findIndex((line) => line.includes("┌"));
expect(topBorderIdx).toBeGreaterThanOrEqual(0);
const bottomBorderIdx = lines.findIndex(
(line, index) => index > topBorderIdx && line.includes("└"),
);
expect(bottomBorderIdx).toBeGreaterThan(topBorderIdx);
return lines.slice(topBorderIdx + 1, bottomBorderIdx);
}
function typeIntoChat(chatPane: ChatPane, text: string): void {
for (const char of text) {
chatPane.handleInput(char);
}
}
function addTranscriptMessages(count: number): void {
const state = useStore.getState();
for (let i = 1; i <= count; i++) {
state.addMessage(
state.currentRoomId,
"assistant",
`transcript line ${i.toString().padStart(2, "0")}`,
);
}
}
function firstVisibleTranscriptLine(rendered: string): string | undefined {
return rendered.match(/transcript line \d{2}/)?.[0];
}
// Keep the shared zustand store deterministic across tests.
beforeEach(() => {
resetChatStore();
});
afterEach(() => {
resetChatStore();
});
describe("eliza-code TUI at cockpit phone width", () => {
test("MainScreen never emits a line wider than the terminal (would crash the TUI)", () => {
const { chatPane, mainScreen } = makeScreen(PHONE_COLS);
// Chat focused → the help footer renders along with real message content,
// so both child layout and MainScreen clipping are exercised.
chatPane.syncFocus(true);
const state = useStore.getState();
state.addMessage(
state.currentRoomId,
"system",
"Booting eliza-code interactive session on Eliza Cloud and attaching to the cockpit terminal.",
);
state.setInputValue(
"please refactor the extremely long identifier names in this module now",
);
let lines: string[] = [];
expect(() => {
lines = mainScreen.render(PHONE_COLS);
}).not.toThrow();
expect(lines.length).toBeGreaterThan(0);
for (const line of lines) {
// Exactly the invariant the TUI render guard enforces before it throws.
expect(visibleWidth(line)).toBeLessThanOrEqual(PHONE_COLS);
}
});
test.each([
39, 43, 47, 60,
])("MainScreen output fits within %i columns", (cols: number) => {
const { chatPane, mainScreen } = makeScreen(cols);
chatPane.syncFocus(true);
const lines = mainScreen.render(cols);
const widest = Math.max(...lines.map(visibleWidth));
expect(widest).toBeLessThanOrEqual(cols);
});
test("ChatPane composer row fits inside the terminal width", () => {
const { chatPane } = makeScreen(PHONE_COLS);
chatPane.syncFocus(true);
const lines = chatPane.renderContent(PHONE_COLS, 24);
// The composer row is the line directly under the editor's top border.
// Match glyphs directly (they survive the surrounding SGR color codes) so
// no control-character ANSI-stripping regex is needed.
const topBorderIdx = lines.findIndex((l) => l.includes("┌"));
expect(topBorderIdx).toBeGreaterThanOrEqual(0);
const composer = lines[topBorderIdx + 1];
expect(composer).toContain(">");
// Without the innerWidth - 3 fix this row is width + 1 (44) and the TUI
// aborts; with the fix it is width - 2 (41).
expect(visibleWidth(composer)).toBeLessThanOrEqual(PHONE_COLS);
});
test("ChatPane renders multiple visible composer rows without overflowing", () => {
const { chatPane } = makeScreen(PHONE_COLS);
chatPane.syncFocus(true);
typeIntoChat(chatPane, "first line\nsecond line\nthird line");
const lines = chatPane.renderContent(PHONE_COLS, 24);
const composerLines = inputFrameLines(lines);
const composerText = plainText(composerLines);
expect(composerLines.length).toBeGreaterThanOrEqual(3);
expect(composerText).toContain("first line");
expect(composerText).toContain("second line");
expect(composerText).toContain("third line");
for (const line of lines) {
expect(visibleWidth(line)).toBeLessThanOrEqual(PHONE_COLS);
}
});
test("ChatPane supports page and edge scrollback keys", () => {
const { chatPane } = makeScreen(80, 18);
chatPane.syncFocus(true);
addTranscriptMessages(28);
const bottom = plainText(chatPane.renderContent(80, 18));
expect(bottom).toContain("transcript line 28");
chatPane.handleInput("\x1b[5~"); // PageUp
const pageUp = plainText(chatPane.renderContent(80, 18));
expect(pageUp).toContain("[↑ ");
expect(firstVisibleTranscriptLine(pageUp)).not.toBe(
firstVisibleTranscriptLine(bottom),
);
chatPane.handleInput("\x1b[H"); // Home
const top = plainText(chatPane.renderContent(80, 18));
expect(top).toContain("transcript line 01");
chatPane.handleInput("\x1b[F"); // End
const backAtBottom = plainText(chatPane.renderContent(80, 18));
expect(backAtBottom).toContain("transcript line 28");
expect(backAtBottom).not.toContain("[↑ ");
});
test("ChatPane keeps the visible scrollback viewport pinned while new messages arrive", () => {
const { chatPane } = makeScreen(80, 18);
chatPane.syncFocus(true);
addTranscriptMessages(28);
chatPane.renderContent(80, 18);
chatPane.handleInput("\x1b[5~"); // PageUp
const beforeAppend = plainText(chatPane.renderContent(80, 18));
const firstBeforeAppend = firstVisibleTranscriptLine(beforeAppend);
expect(firstBeforeAppend).toBeDefined();
const state = useStore.getState();
state.addMessage(state.currentRoomId, "assistant", "transcript line 29");
state.addMessage(state.currentRoomId, "assistant", "transcript line 30");
const afterAppend = plainText(chatPane.renderContent(80, 18));
expect(firstVisibleTranscriptLine(afterAppend)).toBe(firstBeforeAppend);
expect(afterAppend).not.toContain("transcript line 30");
expect(afterAppend).toContain("[↑ ");
});
test("ChatPane loading row advertises abort without overflowing narrow terminals", () => {
const { chatPane } = makeScreen(PHONE_COLS);
chatPane.syncFocus(true);
useStore.getState().setLoading(true);
const phoneLines = chatPane.renderContent(PHONE_COLS, 24);
const phoneLoading = phoneLines.find((line) => line.includes("Processing"));
expect(phoneLoading).toBeDefined();
expect(visibleWidth(phoneLoading ?? "")).toBeLessThanOrEqual(PHONE_COLS);
const wideLines = chatPane.renderContent(80, 24);
const wideLoading = wideLines.find((line) => line.includes("Processing"));
expect(wideLoading).toContain("Esc/Ctrl+C abort");
expect(visibleWidth(wideLoading ?? "")).toBeLessThanOrEqual(80);
});
test("ChatPane keeps the type-ahead composer visible while a turn is running", () => {
const { chatPane } = makeScreen(PHONE_COLS);
chatPane.syncFocus(true);
useStore.getState().setLoading(true);
// Typing during the turn (queue-and-send) must be visible, not swallowed
// behind the "Processing" row.
typeIntoChat(chatPane, "queued follow-up");
const lines = chatPane.renderContent(PHONE_COLS, 24);
const rendered = plainText(lines);
expect(plainText(inputFrameLines(lines))).toContain("queued follow-up");
expect(rendered).not.toContain("Processing...");
expect(rendered).toContain("Enter: queue");
for (const line of lines) {
expect(visibleWidth(line)).toBeLessThanOrEqual(PHONE_COLS);
}
});
test("ChatPane loading row shows the queued-submission count", () => {
const { chatPane } = makeScreen(80);
chatPane.syncFocus(true);
useStore.getState().setLoading(true);
useStore.setState({ pendingSubmissions: ["next thing", "and another"] });
const wideLines = chatPane.renderContent(80, 24);
const wideLoading = wideLines.find((line) => line.includes("Processing"));
expect(wideLoading).toContain("2 queued");
// Narrow terminals clip the suffix instead of overflowing (#11043 guard).
const phoneLines = chatPane.renderContent(PHONE_COLS, 24);
for (const line of phoneLines) {
expect(visibleWidth(line)).toBeLessThanOrEqual(PHONE_COLS);
}
});
test("ChatPane uses the TUI loader while the assistant is typing", () => {
const { chatPane } = makeScreen(PHONE_COLS);
chatPane.syncFocus(true);
useStore.getState().setAgentTyping(true);
try {
const lines = chatPane.renderContent(PHONE_COLS, 24);
const loaderLine = lines.find((line) =>
line.includes("Processing (Esc/Ctrl+C abort)"),
);
expect(loaderLine).toBeDefined();
expect(lines.join("\n")).not.toContain("Eliza typing");
expect(visibleWidth(loaderLine ?? "")).toBeLessThanOrEqual(PHONE_COLS);
} finally {
useStore.getState().setAgentTyping(false);
chatPane.renderContent(PHONE_COLS, 24);
chatPane.dispose();
}
});
test("ChatPane renders tool transcript lines without overflowing narrow terminals", () => {
const { chatPane } = makeScreen(PHONE_COLS);
chatPane.syncFocus(true);
useStore
.getState()
.addMessage(
useStore.getState().currentRoomId,
"system",
"edit src/foo.ts +12/-3",
undefined,
"tool",
);
const lines = chatPane.renderContent(PHONE_COLS, 24);
const toolLine = lines.find((line) => line.includes("edit src/foo.ts"));
expect(toolLine).toBeDefined();
expect(toolLine).not.toContain("Eliza");
expect(visibleWidth(toolLine ?? "")).toBeLessThanOrEqual(PHONE_COLS);
});
});
@@ -0,0 +1,112 @@
// @vitest-environment node
//
// #11294: the status bar surfaces the active model/provider ("which model am I
// talking to") at full width, elided sanely, omitted when unconfigured, and
// never overflowing / crashing. Drives the real StatusBar.render with env-driven
// model config.
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { visibleWidth } from "@elizaos/tui";
import chalk from "chalk";
import { useStore } from "../lib/store.js";
import { StatusBar } from "./StatusBar.js";
const MODEL_ENV_KEYS = [
"ELIZA_CODE_PROVIDER",
"OPENAI_API_KEY",
"OPENAI_LARGE_MODEL",
"OPENAI_MODEL",
"OPENAI_SMALL_MODEL",
"ANTHROPIC_API_KEY",
"ANTHROPIC_LARGE_MODEL",
] as const;
const saved: Record<string, string | undefined> = {};
const prevChalk = chalk.level;
beforeEach(() => {
chalk.level = 0; // deterministic plain text
for (const k of MODEL_ENV_KEYS) {
saved[k] = process.env[k];
delete process.env[k];
}
// Isolate room state per test (some cases set a max-length room name).
useStore.setState({ rooms: [] });
const room = useStore.getState().createRoom("Main");
useStore.getState().switchRoom(room.id);
});
afterEach(() => {
chalk.level = prevChalk;
for (const k of MODEL_ENV_KEYS) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
});
describe("status bar model indicator (#11294)", () => {
test("shows the configured model name at full width", () => {
process.env.ELIZA_CODE_PROVIDER = "openai";
process.env.OPENAI_API_KEY = "sk-test";
process.env.OPENAI_LARGE_MODEL = "llama-3.3-70b";
const lines = new StatusBar().render(100);
const joined = lines.join("\n");
expect(joined).toContain("llama-3.3-70b");
for (const l of lines) expect(visibleWidth(l)).toBeLessThanOrEqual(100);
});
test("falls back to the bare provider when no model name is set", () => {
process.env.ELIZA_CODE_PROVIDER = "anthropic";
process.env.ANTHROPIC_API_KEY = "sk-ant";
const joined = new StatusBar().render(100).join("\n");
expect(joined).toContain("anthropic");
});
test("elides an overlong model name", () => {
process.env.ELIZA_CODE_PROVIDER = "openai";
process.env.OPENAI_API_KEY = "sk-test";
process.env.OPENAI_LARGE_MODEL =
"some-absurdly-long-model-identifier-that-exceeds-the-cap";
const joined = new StatusBar().render(120).join("\n");
expect(joined).toContain("…"); // elided
expect(joined).not.toContain("exceeds-the-cap");
});
test("never overflows with a max-length room name + long cwd (50/80/100/120)", () => {
process.env.ELIZA_CODE_PROVIDER = "openai";
process.env.OPENAI_API_KEY = "sk-test";
// Elides to the 22-char cap — the widest the indicator can get.
process.env.OPENAI_LARGE_MODEL =
"some-absurdly-long-model-identifier-that-exceeds-the-cap";
useStore.setState({ rooms: [] });
const room = useStore.getState().createRoom("a".repeat(20));
useStore.getState().switchRoom(room.id);
const bar = new StatusBar();
// Pin a long cwd (the ctor already stamped lastCwdCheck, so render()
// won't refresh it away within this test).
(bar as unknown as { cwd: string }).cwd =
`/Users/someone/${"deeply/nested/".repeat(4)}project`;
for (const width of [50, 80, 100, 120] as const) {
for (const l of bar.render(width)) {
expect(visibleWidth(l)).toBeLessThanOrEqual(width);
}
}
});
test("omits the model at narrow width and never crashes unconfigured", () => {
// No provider env at all → describeActiveModel returns null.
const narrow = new StatusBar().render(50);
expect(narrow.length).toBeGreaterThan(0);
for (const l of narrow) expect(visibleWidth(l)).toBeLessThanOrEqual(50);
// Even at full width with no keys, it renders (no model, no throw).
const full = new StatusBar().render(100);
for (const l of full) expect(visibleWidth(l)).toBeLessThanOrEqual(100);
});
});
@@ -0,0 +1,118 @@
/** Exercises task-list rendering and keyboard state transitions through the real TUI host. */
import { beforeEach, describe, expect, it } from "bun:test";
import { type AgentRuntime, stringToUuid } from "@elizaos/core";
import { TUI } from "@elizaos/tui";
import { useStore } from "../lib/store.js";
import { VirtualTerminal } from "../testing/virtual-terminal.test.js";
import type { CodeTask } from "../types.js";
import { TaskPane } from "./TaskPane.js";
function codeTask(): CodeTask {
return {
id: stringToUuid("task-pane:test"),
name: "Build feature",
metadata: {
status: "running",
progress: 50,
output: ["🔧 running tool", "✅ complete"],
steps: [],
workingDirectory: "/tmp",
createdAt: 1,
subAgentType: "codex",
trace: [
{ kind: "note", level: "warning", message: "check", ts: 1, seq: 1 },
{
kind: "llm",
iteration: 1,
modelType: "text",
response: "answer",
responsePreview: "answer",
ts: 2,
seq: 2,
},
{
kind: "tool_call",
iteration: 1,
name: "shell",
args: {},
ts: 3,
seq: 3,
},
{
kind: "tool_result",
iteration: 1,
name: "shell",
success: true,
output: "ok",
outputPreview: "ok",
ts: 4,
seq: 4,
},
{ kind: "status", status: "paused", message: "waiting", ts: 5, seq: 5 },
],
},
};
}
function pane(): TaskPane {
const terminal = new VirtualTerminal();
const tui = new TUI(terminal);
const runtime = { getService: () => null } as unknown as AgentRuntime;
return new TaskPane({ runtime, tui });
}
beforeEach(() => {
process.env.ELIZA_CODE_DISABLE_SESSION_PERSISTENCE = "1";
useStore.setState({
tasks: [],
currentTaskId: null,
focusedPane: "chat",
showFinishedTasks: false,
taskPaneVisibility: "shown",
pendingSubmissions: [],
});
});
describe("TaskPane", () => {
it("distinguishes empty, output, and trace views", () => {
const component = pane();
expect(component.renderContent(80, 24).join("\n")).toContain("No tasks.");
const task = codeTask();
useStore.getState().setTasks([task]);
useStore.getState().setCurrentTaskId(task.id ?? null);
component.syncFocus(true);
const output = component.renderContent(80, 24).join("\n");
expect(output).toContain("Build feature");
expect(output).toContain("running tool");
expect(output).toContain("50%");
component.handleInput("t");
const trace = component.renderContent(80, 24).join("\n");
expect(trace).toContain("LLM iter 1");
expect(trace).toContain("RESULT: shell");
});
it("handles navigation and local display controls", () => {
const component = pane();
const task = codeTask();
useStore
.getState()
.setTasks([
task,
{ ...task, id: stringToUuid("task-pane:second"), name: "Second" },
]);
component.syncFocus(true);
component.handleInput("\x1b[B");
component.handleInput("\r");
expect(useStore.getState().getCurrentTask()?.name).toBe("Second");
component.handleInput("f");
component.handleInput("e");
component.handleInput("\x1b[1;5A");
component.handleInput("\x1b[1;5B");
expect(useStore.getState().showFinishedTasks).toBe(true);
expect(component.renderContent(60, 18).join("\n")).toContain("[edit]");
component.syncFocus(false);
expect(component.isFocused()).toBe(false);
});
});
@@ -0,0 +1,84 @@
// @vitest-environment node
//
// #11294: /copy copies the last assistant reply to the clipboard (OSC 52) and
// reports it; with no assistant reply it says so. Drives the real
// App.handleSlashCommand (constructor is synchronous; terminal.write emits the
// OSC-52 to stdout harmlessly in a test).
import { beforeEach, describe, expect, it } from "bun:test";
import type { AgentRuntime } from "@elizaos/core";
import { App } from "./App.js";
import { osc52 } from "./lib/clipboard.js";
import { useStore } from "./lib/store.js";
function makeApp() {
const runtime = {
agentId: "test",
character: { name: "Eliza" },
getService: () => null,
} as unknown as AgentRuntime;
const app = new App(runtime);
const run = (cmd: string, args: string): Promise<boolean> =>
(
app as unknown as {
handleSlashCommand(c: string, a: string): Promise<boolean>;
}
).handleSlashCommand(cmd, args);
// Capture terminal output so tests can assert the OSC-52 sequence is
// actually emitted, not just that the chat message says it was.
const written: string[] = [];
const terminal = (
app as unknown as { terminal: { write(data: string): void } }
).terminal;
terminal.write = (data: string) => {
written.push(data);
};
return { run, written };
}
function freshRoom() {
useStore.setState({ rooms: [] });
const room = useStore.getState().createRoom("Main");
useStore.getState().switchRoom(room.id);
return room.id;
}
function systemMessages(roomId: string): string[] {
const room = useStore.getState().rooms.find((r) => r.id === roomId);
return (room?.messages ?? [])
.filter((m) => m.role === "system")
.map((m) => m.content);
}
describe("/copy command (#11294)", () => {
beforeEach(() => {
freshRoom();
});
it("emits the OSC-52 clipboard sequence for the last assistant reply", async () => {
const { run, written } = makeApp();
const roomId = useStore.getState().currentRoomId;
useStore.getState().addMessage(roomId, "user", "hi");
useStore.getState().addMessage(roomId, "assistant", "the answer is 42");
const handled = await run("copy", "");
expect(handled).toBe(true);
expect(systemMessages(roomId).some((m) => m.includes("Copied"))).toBe(true);
// The core effect: the exact OSC-52 escape for the reply reached the
// terminal (deleting the emission must red this test).
expect(written).toContain(osc52("the answer is 42"));
});
it("says there is nothing to copy when no assistant reply exists", async () => {
const { run, written } = makeApp();
const roomId = useStore.getState().currentRoomId;
useStore.getState().addMessage(roomId, "user", "hi"); // user only
const handled = await run("copy", "");
expect(handled).toBe(true);
expect(
systemMessages(roomId).some((m) => m.includes("Nothing to copy")),
).toBe(true);
expect(written.filter((d) => d.includes("\x1b]52;"))).toHaveLength(0);
});
});
@@ -0,0 +1,554 @@
#!/usr/bin/env bun
/**
* E2E Tests for Game Generation
*
* This script tests that each of the 4 primary agent types can successfully
* create working games:
*
* 1. TypeScript Guessing Game (tested with eliza or codex)
* 2. Rust Blackjack Game (tested with claude-code)
* 3. Python Adventure Game (tested with eliza)
*
* Requirements:
* - ANTHROPIC_API_KEY or OPENAI_API_KEY must be set
* - The script creates an isolated git worktree for each test
* - Files created during tests are cleaned up after
*/
import { execFile, spawn } from "node:child_process";
import * as crypto from "node:crypto";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { promisify } from "node:util";
import { initializeAgent, shutdownAgent } from "../lib/agent.js";
import { setCwd } from "../lib/cwd.js";
import { getCodeTaskService } from "../lib/get-code-task-service.js";
import type { CodeTaskService, SubAgentType } from "../types.js";
const execFileAsync = promisify(execFile);
// ============================================================================
// Types
// ============================================================================
interface TestResult {
name: string;
agent: SubAgentType;
language: string;
passed: boolean;
error?: string;
filesCreated: string[];
executionTime: number;
}
interface GameTest {
name: string;
description: string;
language: "typescript" | "rust" | "python";
preferredAgents: SubAgentType[];
expectedFiles: string[];
verifyFn: (workdir: string) => Promise<{ ok: boolean; error?: string }>;
}
// ============================================================================
// Utilities
// ============================================================================
function log(msg: string): void {
console.log(`[${new Date().toISOString()}] ${msg}`);
}
async function git(
args: string[],
opts?: { cwd?: string },
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
try {
const { stdout, stderr } = await execFileAsync("git", args, {
cwd: opts?.cwd,
});
return { stdout: String(stdout), stderr: String(stderr), exitCode: 0 };
} catch (err) {
const e = err instanceof Error ? err : new Error(String(err));
return { stdout: "", stderr: e.message, exitCode: 1 };
}
}
async function shell(
cmd: string,
args: string[],
opts?: { cwd?: string; timeout?: number },
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
return new Promise((resolve) => {
const timeout = opts?.timeout ?? 30000;
let stdout = "";
let stderr = "";
let killed = false;
const proc = spawn(cmd, args, {
cwd: opts?.cwd,
stdio: ["pipe", "pipe", "pipe"],
shell: false,
});
const timer = setTimeout(() => {
killed = true;
proc.kill("SIGTERM");
}, timeout);
proc.stdout?.on("data", (d) => (stdout += d.toString()));
proc.stderr?.on("data", (d) => (stderr += d.toString()));
proc.on("close", (code) => {
clearTimeout(timer);
if (killed) {
resolve({ stdout, stderr: "timeout", exitCode: 124 });
} else {
resolve({ stdout, stderr, exitCode: code ?? 1 });
}
});
proc.on("error", (err) => {
clearTimeout(timer);
resolve({ stdout, stderr: err.message, exitCode: 1 });
});
});
}
async function getRepoRoot(startDir: string): Promise<string> {
const r = await git(["rev-parse", "--show-toplevel"], { cwd: startDir });
if (r.exitCode !== 0 || !r.stdout.trim()) {
throw new Error(`Not a git repository: ${r.stderr || startDir}`);
}
return r.stdout.trim();
}
async function createDetachedWorktree(repoRoot: string): Promise<string> {
const rand = crypto.randomBytes(6).toString("hex");
const dir = path.join(
repoRoot,
".eliza",
"game-e2e",
`${Date.now()}-${rand}`,
);
const add = await git(["worktree", "add", "--detach", dir, "HEAD"], {
cwd: repoRoot,
});
if (add.exitCode !== 0) {
throw new Error(`git worktree add failed: ${add.stderr}`);
}
return dir;
}
async function removeWorktree(repoRoot: string, dir: string): Promise<void> {
await git(["worktree", "remove", "--force", dir], { cwd: repoRoot });
await git(["worktree", "prune"], { cwd: repoRoot });
}
async function fileExists(filepath: string): Promise<boolean> {
try {
await fs.stat(filepath);
return true;
} catch {
return false;
}
}
// ============================================================================
// Game Definitions
// ============================================================================
const TYPESCRIPT_GUESSING_GAME: GameTest = {
name: "TypeScript Guessing Game",
description: `Create a simple number guessing game in TypeScript.
Requirements:
- Create a file called \`guessing-game.ts\` in the current directory
- The game should:
1. Generate a random number between 1 and 100
2. Have a function \`guess(n: number): string\` that returns "too low", "too high", or "correct!"
3. Export the guess function and a \`secretNumber\` variable
- Keep it simple, no external dependencies
- Use TypeScript syntax (type annotations)`,
language: "typescript",
preferredAgents: ["eliza"],
expectedFiles: ["guessing-game.ts"],
verifyFn: async (workdir: string) => {
const filepath = path.join(workdir, "guessing-game.ts");
if (!(await fileExists(filepath))) {
return { ok: false, error: "guessing-game.ts not created" };
}
const content = await fs.readFile(filepath, "utf-8");
// Check for required elements
if (
!content.includes("function guess") &&
!content.includes("const guess")
) {
return { ok: false, error: "Missing guess function" };
}
if (!content.includes("secretNumber") && !content.includes("secret")) {
return { ok: false, error: "Missing secret number" };
}
if (!content.includes("number")) {
return { ok: false, error: "Missing type annotations" };
}
// Try to compile
const tsc = await shell("bunx", ["tsc", "--noEmit", filepath], {
cwd: workdir,
timeout: 15000,
});
if (tsc.exitCode !== 0) {
return {
ok: false,
error: `TypeScript compilation failed: ${tsc.stderr}`,
};
}
return { ok: true };
},
};
const RUST_BLACKJACK_GAME: GameTest = {
name: "Rust Blackjack Game",
description: `Create a simple blackjack game module in Rust.
Requirements:
- Create a file called \`blackjack.rs\` in the current directory
- The module should include:
1. A Card struct with suit and value fields
2. A function \`card_value(card: &Card) -> u8\` that returns the card's point value
3. A function \`is_blackjack(hand: &[Card]) -> bool\` that checks for natural blackjack (21 with 2 cards)
4. Ace = 1 or 11, Face cards = 10
- Keep it simple, no external dependencies
- Use proper Rust idioms`,
language: "rust",
preferredAgents: ["eliza"],
expectedFiles: ["blackjack.rs"],
verifyFn: async (workdir: string) => {
const filepath = path.join(workdir, "blackjack.rs");
if (!(await fileExists(filepath))) {
return { ok: false, error: "blackjack.rs not created" };
}
const content = await fs.readFile(filepath, "utf-8");
// Check for required elements
if (!content.includes("struct Card")) {
return { ok: false, error: "Missing Card struct" };
}
if (!content.includes("card_value") && !content.includes("fn value")) {
return { ok: false, error: "Missing card_value function" };
}
if (!content.includes("blackjack") && !content.includes("is_21")) {
return { ok: false, error: "Missing blackjack check function" };
}
// Try to check syntax with rustc
const rustc = await shell(
"rustc",
["--edition", "2021", "--emit=metadata", "-o", "/dev/null", filepath],
{
cwd: workdir,
timeout: 30000,
},
);
// Allow warnings, only fail on errors
if (rustc.exitCode !== 0 && !rustc.stderr.includes("warning")) {
return { ok: false, error: `Rust compilation failed: ${rustc.stderr}` };
}
return { ok: true };
},
};
const PYTHON_ADVENTURE_GAME: GameTest = {
name: "Python Adventure Game",
description: `Create a simple text adventure game engine in Python.
Requirements:
- Create a file called \`adventure.py\` in the current directory
- The module should include:
1. A Room class with name, description, and exits (dict of direction -> room_name)
2. A Player class with current_room and inventory (list)
3. A \`move(player, direction)\` function that moves the player
4. A \`look(player, rooms)\` function that describes the current room
- Keep it simple, no external dependencies
- Use type hints`,
language: "python",
preferredAgents: ["eliza"],
expectedFiles: ["adventure.py"],
verifyFn: async (workdir: string) => {
const filepath = path.join(workdir, "adventure.py");
if (!(await fileExists(filepath))) {
return { ok: false, error: "adventure.py not created" };
}
const content = await fs.readFile(filepath, "utf-8");
// Check for required elements
if (!content.includes("class Room")) {
return { ok: false, error: "Missing Room class" };
}
if (!content.includes("class Player")) {
return { ok: false, error: "Missing Player class" };
}
if (!content.includes("def move")) {
return { ok: false, error: "Missing move function" };
}
if (!content.includes("def look")) {
return { ok: false, error: "Missing look function" };
}
// Check Python syntax
const python = await shell("python3", ["-m", "py_compile", filepath], {
cwd: workdir,
timeout: 10000,
});
if (python.exitCode !== 0) {
return { ok: false, error: `Python syntax error: ${python.stderr}` };
}
return { ok: true };
},
};
const GAME_TESTS: GameTest[] = [
TYPESCRIPT_GUESSING_GAME,
RUST_BLACKJACK_GAME,
PYTHON_ADVENTURE_GAME,
];
// ============================================================================
// Test Runner
// ============================================================================
function getAvailableAgents(): SubAgentType[] {
const openai = process.env.OPENAI_API_KEY?.trim();
const anthropic = process.env.ANTHROPIC_API_KEY?.trim();
const _provider = (process.env.ELIZA_CODE_PROVIDER ?? "")
.trim()
.toLowerCase();
const agents: SubAgentType[] = [];
// Eliza works with any provider
if (openai || anthropic) {
agents.push("eliza");
}
// Codex requires OpenAI
if (openai) {
agents.push("codex");
}
// Claude Code requires Anthropic
if (anthropic) {
agents.push("claude-code");
}
return agents;
}
function selectAgent(
preferred: SubAgentType[],
available: SubAgentType[],
): SubAgentType | null {
for (const agent of preferred) {
if (available.includes(agent)) {
return agent;
}
}
// Fallback to any available
return available[0] ?? null;
}
async function runGameTest(
service: CodeTaskService,
test: GameTest,
agent: SubAgentType,
workdir: string,
): Promise<TestResult> {
const startTime = Date.now();
log(`Running: ${test.name} with ${agent}`);
try {
// Create task
const task = await service.createTask(
test.name,
test.description,
undefined,
agent,
);
const taskId = task.id ?? "";
if (!taskId) {
return {
name: test.name,
agent,
language: test.language,
passed: false,
error: "Failed to create task",
filesCreated: [],
executionTime: Date.now() - startTime,
};
}
log(` Task created: ${taskId}`);
// Execute task
await service.startTaskExecution(taskId);
const finished = await service.getTask(taskId);
const result = finished?.metadata.result;
const status = finished?.metadata.status;
log(` Status: ${status}`);
if (status !== "completed" || !result?.success) {
return {
name: test.name,
agent,
language: test.language,
passed: false,
error:
result?.error ??
result?.summary ??
"Task did not complete successfully",
filesCreated: result?.filesCreated ?? [],
executionTime: Date.now() - startTime,
};
}
// Verify the game was created correctly
const verify = await test.verifyFn(workdir);
return {
name: test.name,
agent,
language: test.language,
passed: verify.ok,
error: verify.error,
filesCreated: result.filesCreated,
executionTime: Date.now() - startTime,
};
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
return {
name: test.name,
agent,
language: test.language,
passed: false,
error,
filesCreated: [],
executionTime: Date.now() - startTime,
};
}
}
// ============================================================================
// Main
// ============================================================================
async function main(): Promise<void> {
log("=== Game Generation E2E Tests ===");
log("");
const available = getAvailableAgents();
if (available.length === 0) {
console.error(
"ERROR: No agents available. Set OPENAI_API_KEY or ANTHROPIC_API_KEY.",
);
process.exit(1);
}
log(`Available agents: ${available.join(", ")}`);
log("");
const repoRoot = await getRepoRoot(process.cwd());
const results: TestResult[] = [];
for (const test of GAME_TESTS) {
const agent = selectAgent(test.preferredAgents, available);
if (!agent) {
log(`SKIP: ${test.name} - no suitable agent available`);
results.push({
name: test.name,
agent: "eliza",
language: test.language,
passed: false,
error: "No suitable agent available",
filesCreated: [],
executionTime: 0,
});
continue;
}
// Create isolated worktree for this test
const worktree = await createDetachedWorktree(repoRoot);
log(`Using worktree: ${worktree}`);
try {
const cwdResult = await setCwd(worktree);
if (!cwdResult.success) {
throw new Error(
`Failed to set CWD: ${cwdResult.error ?? cwdResult.path}`,
);
}
process.chdir(cwdResult.path);
// Initialize agent for this test
const runtime = await initializeAgent();
const service = getCodeTaskService(runtime);
if (!service) {
throw new Error("CodeTaskService not available");
}
const result = await runGameTest(service, test, agent, worktree);
results.push(result);
await shutdownAgent(runtime);
} finally {
await removeWorktree(repoRoot, worktree);
}
log("");
}
// Print results
log("=== Results ===");
log("");
let passed = 0;
let failed = 0;
for (const r of results) {
const status = r.passed ? "✓ PASS" : "✗ FAIL";
const time = `${(r.executionTime / 1000).toFixed(1)}s`;
log(`${status} | ${r.name} (${r.agent}) [${r.language}] - ${time}`);
if (!r.passed && r.error) {
log(` Error: ${r.error}`);
}
if (r.filesCreated.length > 0) {
log(` Files: ${r.filesCreated.join(", ")}`);
}
if (r.passed) passed++;
else failed++;
}
log("");
log(`Total: ${passed} passed, ${failed} failed`);
if (failed > 0) {
process.exit(1);
}
}
// Run if executed directly
await main();
+237
View File
@@ -0,0 +1,237 @@
// Drives end-to-end coverage for the Code example.
import { execFile } from "node:child_process";
import * as crypto from "node:crypto";
import * as path from "node:path";
import { promisify } from "node:util";
import { initializeAgent, shutdownAgent } from "../lib/agent.js";
import { setCwd } from "../lib/cwd.js";
import { getCodeTaskService } from "../lib/get-code-task-service.js";
import type { CodeTaskService, SubAgentType } from "../types.js";
type RunResult = {
type: SubAgentType;
taskId: string;
status: "completed" | "failed" | "cancelled";
summary: string;
};
const execFileAsync = promisify(execFile);
function nowIso(): string {
return new Date().toISOString();
}
function logLine(line: string): void {
process.stdout.write(`${line}\n`);
}
async function git(
args: string[],
opts?: { cwd?: string },
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
try {
const { stdout, stderr } = await execFileAsync("git", args, {
cwd: opts?.cwd,
});
return { stdout: String(stdout), stderr: String(stderr), exitCode: 0 };
} catch (err) {
const e = err instanceof Error ? err : new Error(String(err));
return { stdout: "", stderr: e.message, exitCode: 1 };
}
}
async function getRepoRoot(startDir: string): Promise<string> {
const r = await git(["rev-parse", "--show-toplevel"], { cwd: startDir });
if (r.exitCode !== 0 || !r.stdout.trim()) {
throw new Error(`Not a git repository: ${r.stderr || startDir}`);
}
return r.stdout.trim();
}
async function createDetachedWorktree(repoRoot: string): Promise<string> {
const rand = crypto.randomBytes(6).toString("hex");
const dir = path.join(
repoRoot,
".eliza",
"e2e-worktrees",
`${Date.now()}-${rand}`,
);
const add = await git(["worktree", "add", "--detach", dir, "HEAD"], {
cwd: repoRoot,
});
if (add.exitCode !== 0) {
throw new Error(`git worktree add failed: ${add.stderr}`);
}
return dir;
}
async function removeWorktree(repoRoot: string, dir: string): Promise<void> {
// Use --force so task-created untracked files do not survive teardown.
await git(["worktree", "remove", "--force", dir], { cwd: repoRoot });
await git(["worktree", "prune"], { cwd: repoRoot });
}
async function gitStatusPorcelain(cwd: string): Promise<string> {
const r = await git(["status", "--porcelain"], { cwd });
if (r.exitCode !== 0) return "(git status failed)";
return r.stdout.trim();
}
async function runOne(
service: CodeTaskService,
type: SubAgentType,
): Promise<RunResult> {
const task = await service.createCodeTask(
`E2E: ${type}`,
[
`E2E sanity check for sub-agent "${type}".`,
"",
"Requirements:",
"- Run a simple, safe command to confirm the environment (e.g. `pwd` and `git status --porcelain`).",
"- Do NOT modify files.",
"- Return DONE with a short summary of what you verified.",
].join("\n"),
undefined,
type,
);
const taskId = task.id ?? "";
if (!taskId) {
throw new Error(`Failed to create task for ${type}`);
}
logLine(`[${nowIso()}] starting ${type} task ${taskId}`);
await service.startTaskExecution(taskId);
const finished = await service.getTask(taskId);
const status = finished?.metadata.status;
const result = finished?.metadata.result;
if (!finished || !status || !result) {
return {
type,
taskId,
status: "failed",
summary: "Missing task metadata after execution",
};
}
if (status === "completed") {
return { type, taskId, status, summary: result.summary };
}
if (status === "cancelled") {
return { type, taskId, status, summary: result.summary };
}
return {
type,
taskId,
status: "failed",
summary: result.error ?? result.summary,
};
}
function getRunnableTypes(): SubAgentType[] {
// SDK workers require provider-specific API keys; still include them, but skip
// when keys are missing so this script can run in a local environment without
// configuring every provider.
const types: SubAgentType[] = [
"eliza",
"elizaos-native",
"opencode",
"codex",
"claude-code",
];
const openai = process.env.OPENAI_API_KEY?.trim();
const anthropic = process.env.ANTHROPIC_API_KEY?.trim();
const provider = (process.env.ELIZA_CODE_PROVIDER ?? "").trim().toLowerCase();
return types.filter((t) => {
if (t === "codex") return Boolean(openai);
if (t === "claude-code") return Boolean(anthropic);
// If a provider is explicitly selected, require that key for runtime-based workers too.
if (provider === "openai") return Boolean(openai);
if (provider === "anthropic") return Boolean(anthropic);
// Otherwise, allow if either key is present (runtime will choose).
return Boolean(openai || anthropic);
});
}
async function main(): Promise<void> {
const repoRoot = await getRepoRoot(process.cwd());
const worktree = await createDetachedWorktree(repoRoot);
const cwdResult = await setCwd(worktree);
if (!cwdResult.success) {
await removeWorktree(repoRoot, worktree);
throw new Error(`Failed to set CWD: ${cwdResult.error ?? cwdResult.path}`);
}
process.chdir(cwdResult.path);
const runtime = await initializeAgent();
try {
const service = getCodeTaskService(runtime);
if (!service) {
throw new Error("CodeTaskService not available");
}
const runnable = getRunnableTypes();
if (runnable.length === 0) {
throw new Error(
"No runnable sub-agents (set OPENAI_API_KEY and/or ANTHROPIC_API_KEY).",
);
}
logLine(
`[${nowIso()}] running e2e sub-agent checks: ${runnable.join(", ")}`,
);
const before = await gitStatusPorcelain(worktree);
if (before.length > 0) {
logLine(
`[${nowIso()}] warning: worktree is not clean before run:\n${before}`,
);
}
const results: RunResult[] = [];
for (const type of runnable) {
results.push(await runOne(service, type));
}
const after = await gitStatusPorcelain(worktree);
const repoDirtyAfter = after.length > 0 ? after : null;
logLine("");
logLine("=== Results ===");
for (const r of results) {
logLine(`- ${r.type}: ${r.status} (${r.taskId}) — ${r.summary}`);
}
if (repoDirtyAfter) {
logLine("");
logLine("=== Worktree status (dirty) ===");
logLine(repoDirtyAfter);
}
const failed = results.filter((r) => r.status !== "completed");
if (failed.length > 0 || repoDirtyAfter) {
process.exitCode = 1;
logLine("");
const parts: string[] = [];
if (failed.length > 0)
parts.push(`${failed.length} sub-agent(s) did not complete`);
if (repoDirtyAfter) parts.push("repository changed during run");
logLine(`FAILED: ${parts.join("; ")}`);
} else {
logLine("");
logLine("OK: all sub-agents completed");
}
} finally {
await shutdownAgent(runtime);
await removeWorktree(repoRoot, worktree);
}
}
await main();
+6
View File
@@ -0,0 +1,6 @@
// Type declarations for modules without types
declare module "@elizaos/plugin-sql" {
import type { Plugin } from "@elizaos/core";
export const plugin: Plugin;
}
@@ -0,0 +1,237 @@
// @vitest-environment node
//
// Regression tests for the eliza-code TUI "front door": global-shortcut input
// routing (#11266). App's constructor is synchronous and only needs a runtime,
// and FilteringTerminal routes stdin through App.consumeGlobalInput before the
// focused component sees it — so we construct a real App with a minimal runtime
// stub and drive the real interceptor, asserting which keys it consumes.
import { beforeEach, describe, expect, it } from "bun:test";
import type { AgentRuntime } from "@elizaos/core";
import { App } from "./App.js";
import { getAgentClient, resetAgentClient } from "./lib/agent-client.js";
import { useStore } from "./lib/store.js";
// App's constructor builds terminal/tui/panes synchronously; nothing touches
// stdin (FilteringTerminal is inert until start()) or the network at construct
// time, and TaskPane just stores its props. A bare object satisfies the fields
// the construction path reads.
function makeApp(): {
consume: (data: string) => boolean;
} {
const runtime = {
agentId: "test",
character: { name: "Eliza" },
getService: () => null,
} as unknown as AgentRuntime;
const app = new App(runtime);
const consume = (data: string): boolean =>
(
app as unknown as { consumeGlobalInput(d: string): boolean }
).consumeGlobalInput(data);
return { consume };
}
function makeAbortError(): Error {
const err = new Error("aborted");
err.name = "AbortError";
return err;
}
function makeAbortableApp(): {
consume: (data: string) => boolean;
send: (text: string) => Promise<void>;
started: Promise<void>;
seenSignal: () => AbortSignal | undefined;
} {
let resolveStarted: (() => void) | null = null;
let seenSignal: AbortSignal | undefined;
const started = new Promise<void>((resolve) => {
resolveStarted = resolve;
});
const runtime = Object.assign(Object.create(null) as AgentRuntime, {
agentId: "test",
character: { name: "Eliza" },
getService: () => null,
ensureConnection: async () => {},
messageService: {
handleMessage: async (
_runtime: unknown,
_message: unknown,
_callback: unknown,
options?: { abortSignal?: AbortSignal },
) => {
seenSignal = options?.abortSignal;
resolveStarted?.();
if (!seenSignal) {
throw new Error("missing abort signal");
}
await new Promise<void>((_resolve, reject) => {
if (seenSignal?.aborted) {
reject(makeAbortError());
return;
}
seenSignal?.addEventListener(
"abort",
() => reject(makeAbortError()),
{ once: true },
);
});
return { didRespond: false, responseMessages: [] };
},
},
});
getAgentClient().setRuntime(runtime);
const app = new App(runtime);
return {
// biome-ignore lint/complexity/useLiteralKeys: private test hook
consume: (data: string): boolean => app["consumeGlobalInput"](data),
// biome-ignore lint/complexity/useLiteralKeys: private test hook
send: (text: string): Promise<void> => app["handleSendMessage"](text),
started,
seenSignal: () => seenSignal,
};
}
function makeSendCapturingApp(): {
send: (text: string) => Promise<void>;
sentTexts: () => string[];
} {
const sentTexts: string[] = [];
const runtime = Object.assign(Object.create(null) as AgentRuntime, {
agentId: "test",
character: { name: "Eliza" },
getService: () => null,
ensureConnection: async () => {},
messageService: {
handleMessage: async (
_runtime: unknown,
message: { content?: { text?: string } },
callback: (content: { text: string }) => Promise<unknown>,
) => {
if (typeof message.content?.text === "string") {
sentTexts.push(message.content.text);
}
await callback({ text: "ok" });
return { didRespond: true, responseMessages: [] };
},
},
});
getAgentClient().setRuntime(runtime);
const app = new App(runtime);
return {
// biome-ignore lint/complexity/useLiteralKeys: private test hook
send: (text: string): Promise<void> => app["handleSendMessage"](text),
sentTexts: () => sentTexts,
};
}
describe("eliza-code global-input routing (#11266)", () => {
beforeEach(() => {
// Fresh, chat-focused, empty composer — the normal typing state.
useStore.setState({ focusedPane: "chat", inputValue: "", rooms: [] });
resetAgentClient();
});
it("does NOT consume punctuation while typing in the chat composer", () => {
const { consume } = makeApp();
useStore.setState({ focusedPane: "chat", inputValue: "Fix App.ts" });
// These reach the editor now (previously hijacked as resize/help).
expect(consume(",")).toBe(false);
expect(consume(".")).toBe(false);
expect(consume("?")).toBe(false);
});
it("opens help on '?' only when the composer is empty (or chat unfocused)", () => {
const { consume } = makeApp();
useStore.setState({ focusedPane: "chat", inputValue: "" });
expect(consume("?")).toBe(true); // empty buffer → help
});
it("treats bare ','/'.' as pane resize only when the task pane is focused", () => {
const { consume } = makeApp();
useStore.setState({ focusedPane: "tasks", inputValue: "" });
expect(consume(",")).toBe(true);
expect(consume(".")).toBe(true);
});
it("always honors the Ctrl+←/→ resize sequences regardless of focus", () => {
const { consume } = makeApp();
useStore.setState({ focusedPane: "chat", inputValue: "typing" });
expect(consume("\x1b[1;5D")).toBe(true);
expect(consume("\x1b[1;5C")).toBe(true);
});
for (const [label, key] of [
["Ctrl+C", "\x03"],
["Esc", "\x1b"],
] as const) {
it(`aborts an in-flight turn on ${label} instead of leaving a blank assistant placeholder`, async () => {
const { consume, send, started, seenSignal } = makeAbortableApp();
const state = useStore.getState();
const room = state.createRoom("Abort test");
const turn = send("think for a long time");
await started;
expect(seenSignal()).toBeDefined();
expect(seenSignal()?.aborted).toBe(false);
expect(useStore.getState().isLoading).toBe(true);
expect(consume(key)).toBe(true);
await turn;
expect(seenSignal()?.aborted).toBe(true);
const after = useStore
.getState()
.rooms.find((candidate) => candidate.id === room.id);
expect(after?.messages.map((message) => message.role)).toEqual([
"user",
"system",
]);
expect(after?.messages.at(-1)?.content).toBe("Turn aborted.");
expect(useStore.getState().isLoading).toBe(false);
expect(useStore.getState().isAgentTyping).toBe(false);
});
}
});
describe("eliza-code slash command routing (#11294)", () => {
beforeEach(() => {
useStore.setState({ focusedPane: "chat", inputValue: "", rooms: [] });
resetAgentClient();
});
it("reports an unknown slash command without sending it to the LLM", async () => {
const { send, sentTexts } = makeSendCapturingApp();
const state = useStore.getState();
const room = state.createRoom("Slash test");
await send("/comand");
expect(sentTexts()).toEqual([]);
const after = useStore
.getState()
.rooms.find((candidate) => candidate.id === room.id);
expect(after?.messages.map((message) => message.role)).toEqual(["system"]);
expect(after?.messages[0]?.content).toBe(
"Unknown command: /comand — type /help for the list.",
);
});
it("preserves the double-slash escape hatch for literal slash-prefixed text", async () => {
const { send, sentTexts } = makeSendCapturingApp();
useStore.getState().createRoom("Slash escape test");
await send("//literal");
expect(sentTexts()).toEqual(["//literal"]);
const messages = useStore.getState().rooms[0]?.messages ?? [];
expect(
messages.some((message) => message.content.includes("Unknown command")),
).toBe(false);
});
});
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env node
// Suppress elizaOS logs before any imports
process.env.LOG_LEVEL = "fatal";
import type { AgentRuntime } from "@elizaos/core";
import { main as cliMain } from "./cli.js";
import { loadEnv } from "./lib/load-env.js";
loadEnv();
// ============================================================================
// Environment Detection
// ============================================================================
/**
* Determine if we should run in interactive (TUI) mode.
* Interactive mode requires:
* - stdin and stdout both be TTYs
* - No message argument provided (unless --interactive flag)
*/
function shouldRunInteractive(): boolean {
const args = process.argv.slice(2);
// Explicit interactive flag
if (args.includes("-i") || args.includes("--interactive")) {
return true;
}
// Help/version should use CLI mode
if (
args.includes("-h") ||
args.includes("--help") ||
args.includes("-v") ||
args.includes("--version")
) {
return false;
}
// If there are any arguments (message, file, etc.), use CLI mode
if (args.length > 0) {
return false;
}
// Check if TTY is available
// Bun/watch can sometimes leave `isTTY` undefined even in a real terminal.
// Only treat it as non-interactive if it is explicitly `false`.
return process.stdin.isTTY !== false && process.stdout.isTTY !== false;
}
function shouldRunCodingOnly(): boolean {
const args = process.argv.slice(2);
const env = process.env.ELIZA_CODE_CODING_ONLY?.trim().toLowerCase();
return (
env === "1" ||
env === "true" ||
args.includes("--coding-only") ||
args.includes("--no-orchestrator")
);
}
// ============================================================================
// Interactive Mode (TUI)
// ============================================================================
let isShuttingDown = false;
// Module-scoped handle to the live TUI app so the fatal handlers below can
// restore the terminal (raw mode / bracketed paste / cursor) before exiting —
// that teardown only runs via app.stop(), so a bare process.exit(1) on an
// unhandled error used to leave the user's shell wedged.
let activeApp: { stop: () => void } | undefined;
/** Best-effort terminal restore before a fatal exit. Never throws. */
function restoreTerminalBestEffort(): void {
try {
activeApp?.stop();
} catch (error) {
// error-policy:J6 Terminal restoration continues through every teardown
// step, while stderr preserves each failed step for diagnosis.
process.stderr.write(
`[eliza-code] app teardown failed: ${error instanceof Error ? error.message : String(error)}\n`,
);
}
try {
if (process.stdout.isTTY) {
// Disable bracketed paste + Kitty keyboard protocol, show the cursor.
process.stdout.write("\x1b[?2004l\x1b[<u\x1b[?25h");
}
if (process.stdin.isTTY && process.stdin.setRawMode) {
process.stdin.setRawMode(false);
}
} catch (error) {
// error-policy:J6 This is the last terminal teardown path before exit.
process.stderr.write(
`[eliza-code] terminal restore failed: ${error instanceof Error ? error.message : String(error)}\n`,
);
}
}
async function cleanup(runtime: AgentRuntime): Promise<void> {
if (isShuttingDown) return;
isShuttingDown = true;
try {
const [{ shutdownAgent }, { resetAgentClient }, { useStore }] =
await Promise.all([
import("./lib/agent.js"),
import("./lib/agent-client.js"),
import("./lib/store.js"),
]);
// Save session before shutdown
await useStore.getState().saveSessionState();
if (runtime) {
await shutdownAgent(runtime);
}
resetAgentClient();
} catch (error) {
// error-policy:J6 Process shutdown must reach exit even if persistence or
// service teardown fails, but the failure remains observable on stderr.
process.stderr.write(
`[eliza-code] shutdown failed: ${error instanceof Error ? error.message : String(error)}\n`,
);
}
process.exit(0);
}
async function runInteractive(): Promise<void> {
// Validate TTY
if (process.stdin.isTTY === false || process.stdout.isTTY === false) {
console.error("❌ Interactive mode requires a terminal.");
console.error(
" Use CLI mode for non-interactive usage: eliza-code --help",
);
process.exit(1);
}
const [{ App }, { initializeAgent }] = await Promise.all([
import("./App.js"),
import("./lib/agent.js"),
]);
let runtime: AgentRuntime | undefined;
let app: InstanceType<typeof App> | undefined;
// Initialize the agent
runtime = await initializeAgent({ codingOnly: shouldRunCodingOnly() });
// Handle SIGINT (Ctrl+C) and SIGTERM
const handleSignal = () => {
if (app) {
app.stop();
}
if (runtime) {
cleanup(runtime);
}
};
process.on("SIGINT", handleSignal);
process.on("SIGTERM", handleSignal);
// Clear the screen before rendering TUI
console.clear();
// Create and run the app
app = new App(runtime);
activeApp = app;
await app.run();
activeApp = undefined;
// App exited normally (e.g., Ctrl+Q)
await cleanup(runtime);
}
// ============================================================================
// Main Entry Point
// ============================================================================
async function main(): Promise<void> {
if (shouldRunInteractive()) {
await runInteractive();
} else {
const exitCode = await cliMain();
// Special code -1 means: force interactive mode
if (exitCode === -1) {
await runInteractive();
} else {
process.exit(exitCode);
}
}
}
// Handle uncaught errors
process.on("uncaughtException", (error) => {
restoreTerminalBestEffort();
console.error("Uncaught exception:", error);
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
restoreTerminalBestEffort();
console.error("Unhandled rejection:", reason);
process.exit(1);
});
// Run the app
main().catch((error) => {
restoreTerminalBestEffort();
console.error("Fatal error:", error);
process.exit(1);
});
// ============================================================================
// Exports for Testing
// ============================================================================
export { runInteractive, shouldRunInteractive };
@@ -0,0 +1,143 @@
// Exercises the Code example behavior that this module protects.
import { beforeEach, describe, expect, it } from "bun:test";
import {
type HandlerCallback,
type IAgentRuntime,
type Memory,
type StreamChunkCallback,
stringToUuid,
} from "@elizaos/core";
import type { ChatRoom } from "../types.js";
import { getAgentClient, resetAgentClient } from "./agent-client.js";
import type { SessionIdentity } from "./identity.js";
interface HandleMessageOptions {
abortSignal?: AbortSignal;
onStreamChunk?: StreamChunkCallback;
}
function makeIdentity(): SessionIdentity {
const projectId = stringToUuid("agent-client-streaming-test-project");
return {
projectId,
userId: stringToUuid("agent-client-streaming-test-user"),
worldId: stringToUuid("agent-client-streaming-test-world"),
messageServerId: stringToUuid("agent-client-streaming-test-server"),
};
}
function makeRoom(): ChatRoom {
return {
id: "streaming-test-room",
name: "Streaming test",
messages: [],
createdAt: new Date("2026-01-01T00:00:00.000Z"),
taskIds: [],
elizaRoomId: stringToUuid("agent-client-streaming-test-room"),
};
}
function makeRuntime(
handleMessage: (
runtime: IAgentRuntime,
message: Memory,
callback?: HandlerCallback,
options?: HandleMessageOptions,
) => Promise<{ didRespond: boolean; responseMessages: Memory[] }>,
): IAgentRuntime {
return {
ensureConnection: async () => {},
messageService: {
handleMessage,
},
} as unknown as IAgentRuntime;
}
describe("AgentClient streaming", () => {
beforeEach(() => {
resetAgentClient();
});
it("passes onStreamChunk through and does not duplicate the final callback text", async () => {
const deltas: string[] = [];
const abortController = new AbortController();
let seenOptions: HandleMessageOptions | undefined;
const runtime = makeRuntime(
async (_runtime, _message, callback, options) => {
seenOptions = options;
await options?.onStreamChunk?.("hel", "response-id", "hel");
await options?.onStreamChunk?.("lo", "response-id", "hello");
await callback?.({ text: "hello" });
return { didRespond: true, responseMessages: [] };
},
);
getAgentClient().setRuntime(runtime);
const response = await getAgentClient().sendMessage({
room: makeRoom(),
text: "say hello",
identity: makeIdentity(),
abortSignal: abortController.signal,
onDelta: (delta) => deltas.push(delta),
});
expect(response).toBe("hello");
expect(deltas).toEqual(["hel", "lo"]);
expect(seenOptions?.abortSignal).toBe(abortController.signal);
expect(typeof seenOptions?.onStreamChunk).toBe("function");
});
it("falls back to callback text when no text chunks stream", async () => {
const deltas: string[] = [];
const runtime = makeRuntime(
async (_runtime, _message, callback, options) => {
await options?.onStreamChunk?.(
JSON.stringify({ type: "tool_call", name: "SHELL" }),
"response-id",
);
await callback?.({ text: "done" });
return { didRespond: true, responseMessages: [] };
},
);
getAgentClient().setRuntime(runtime);
const response = await getAgentClient().sendMessage({
room: makeRoom(),
text: "run a tool",
identity: makeIdentity(),
onDelta: (delta) => deltas.push(delta),
});
expect(response).toBe("done");
expect(deltas).toEqual(["done"]);
});
it("appends only the missing final suffix after streamed text", async () => {
const deltas: string[] = [];
const runtime = makeRuntime(
async (_runtime, _message, callback, options) => {
await options?.onStreamChunk?.(
"The answer",
"response-id",
"The answer",
);
await callback?.({ text: "The answer is 42." });
return { didRespond: true, responseMessages: [] };
},
);
getAgentClient().setRuntime(runtime);
const response = await getAgentClient().sendMessage({
room: makeRoom(),
text: "answer",
identity: makeIdentity(),
onDelta: (delta) => deltas.push(delta),
});
expect(response).toBe("The answer is 42.");
expect(deltas).toEqual(["The answer", " is 42."]);
});
});
@@ -0,0 +1,209 @@
// Provides shared support logic for the Code example.
import {
ChannelType,
type Content,
createMessageMemory,
type IAgentRuntime,
type Memory,
type StreamChunkCallback,
type UUID,
} from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import type { ChatRoom } from "../types.js";
import type { SessionIdentity } from "./identity.js";
const STREAM_EVENT_TYPES = new Set([
"tool_call",
"tool_result",
"evaluation",
"context_event",
]);
interface SendMessageParams {
room: ChatRoom;
text: string;
identity: SessionIdentity;
userName?: string;
source?: string;
channelType?: ChannelType;
/**
* Optional streaming callback. Called with each incremental text chunk
* produced by the runtime.
*/
onDelta?: (delta: string) => void;
/** Optional caller-controlled cancellation signal for in-flight turns. */
abortSignal?: AbortSignal;
}
function hasStreamingEventType(value: unknown): value is { type: string } {
return (
typeof value === "object" &&
value !== null &&
"type" in value &&
typeof value.type === "string"
);
}
function isStructuredStreamEvent(chunk: string): boolean {
const trimmed = chunk.trimStart();
if (!trimmed.startsWith("{")) return false;
try {
const parsed: unknown = JSON.parse(trimmed);
return hasStreamingEventType(parsed) && STREAM_EVENT_TYPES.has(parsed.type);
} catch {
return false;
}
}
/**
* Stateless runtime adapter: converts a UI "room + text" into a core message and
* returns the agent response. All conversation state is owned by the runtime DB
* (and optionally mirrored in the UI store).
*/
class AgentClient {
private runtime: IAgentRuntime | null = null;
setRuntime(runtime: IAgentRuntime): void {
this.runtime = runtime;
}
async sendMessage(params: SendMessageParams): Promise<string> {
if (!this.runtime) {
throw new Error("Runtime not initialized");
}
const runtime = this.runtime;
const { room, text, identity } = params;
const source = params.source ?? "eliza-code";
const channelType = params.channelType ?? ChannelType.DM;
const userName = params.userName ?? "User";
const onDelta = params.onDelta;
await runtime.ensureConnection({
entityId: identity.userId,
roomId: room.elizaRoomId,
worldId: identity.worldId,
userName,
source,
type: channelType,
channelId: room.id,
messageServerId: identity.messageServerId,
});
const messageMemory = createMessageMemory({
id: uuidv4() as UUID,
entityId: identity.userId,
roomId: room.elizaRoomId,
content: {
text,
source,
channelType,
},
});
let response = "";
let streamedText = "";
let didStreamText = false;
const emitStreamDelta = (delta: string): void => {
if (!delta) return;
didStreamText = true;
onDelta?.(delta);
};
const handleStreamChunk: StreamChunkCallback = (
chunk,
_messageId,
accumulated,
) => {
if (typeof accumulated === "string") {
if (accumulated === streamedText) return;
let delta = "";
if (accumulated.startsWith(streamedText)) {
delta = accumulated.slice(streamedText.length);
streamedText = accumulated;
} else {
delta = chunk;
streamedText += chunk;
}
response = accumulated;
emitStreamDelta(delta);
return;
}
if (isStructuredStreamEvent(chunk)) return;
streamedText += chunk;
response = streamedText;
emitStreamDelta(chunk);
};
const callback = async (content: Content): Promise<Memory[]> => {
if (content && typeof content === "object" && "text" in content) {
const maybeText = content.text;
if (typeof maybeText === "string") {
response = maybeText;
if (!didStreamText) {
streamedText = maybeText;
emitStreamDelta(maybeText);
} else if (maybeText.startsWith(streamedText)) {
const finalDelta = maybeText.slice(streamedText.length);
streamedText = maybeText;
emitStreamDelta(finalDelta);
} else {
streamedText = maybeText;
}
}
}
return [];
};
if (!runtime.messageService) {
throw new Error("Runtime message service not available");
}
const options =
params.abortSignal || onDelta
? {
...(params.abortSignal ? { abortSignal: params.abortSignal } : {}),
...(onDelta ? { onStreamChunk: handleStreamChunk } : {}),
}
: undefined;
await runtime.messageService.handleMessage(
runtime,
messageMemory,
callback,
options,
);
return response;
}
async clearConversation(room: ChatRoom): Promise<void> {
if (!this.runtime) return;
const runtime = this.runtime;
if (!runtime.messageService) return;
await runtime.messageService.clearChannel(
runtime,
room.elizaRoomId,
room.id,
);
}
}
let agentClientInstance: AgentClient | null = null;
export function getAgentClient(): AgentClient {
if (!agentClientInstance) {
agentClientInstance = new AgentClient();
}
return agentClientInstance;
}
export function resetAgentClient(): void {
agentClientInstance = null;
}
+160
View File
@@ -0,0 +1,160 @@
// Provides shared support logic for the Code example.
import "dotenv/config";
import { AgentRuntime, type Character, type Plugin } from "@elizaos/core";
import {
applyOpencodeProviderEnv,
resolveModelProvider,
} from "./model-provider.js";
import { CODE_ASSISTANT_SYSTEM_PROMPT } from "./prompts.js";
/**
* Eliza Code Character Configuration (Direct Code Agent)
*/
const elizaCodeCharacter: Character = {
name: "Eliza",
bio: [
"A coding assistant that directly helps users with implementation tasks.",
"Capable of reading, writing, and editing files directly.",
"Executes shell commands to run tests, linters, and other tools.",
],
system: `${CODE_ASSISTANT_SYSTEM_PROMPT}
You are a direct coding agent. You have tools to READ, WRITE, and EDIT files directly.
You also have tools to execute SHELL commands.
When the user asks for code changes, CALL the provided tools to implement them
immediately — do NOT just describe what you would do. Take the action: emit the
tool call (FILE/WRITE/EDIT/SHELL), don't narrate "I'll create the file" and stop.
You do NOT need to create sub-agents or delegate tasks. You are the worker.
After making changes, verify them if possible (e.g. run a test), then give a one
line summary of what you did.
The current working directory is dynamically provided.`,
topics: [
"coding",
"programming",
"software development",
"debugging",
"testing",
"refactoring",
"file operations",
"shell commands",
"git",
"TypeScript",
"JavaScript",
"Python",
"Rust",
],
style: {
all: [
"Be thorough but concise",
"Explain your reasoning and actions",
"Proactively identify potential issues",
"Use code blocks for all code examples",
],
chat: [
"Engage naturally in conversation",
"Provide updates on actions taken",
],
},
settings: {
secrets: {},
},
};
/**
* Initialize the Eliza runtime with coding capabilities
*/
export interface InitializeAgentOptions {
/**
* Load `@elizaos/plugin-agent-orchestrator` (default true). Set false when
* eliza-code itself runs AS a coding sub-agent (e.g. the ACP server) so it
* cannot recursively spawn its own sub-agents.
*/
includeOrchestrator?: boolean;
/**
* Load only the plugins a headless coding sub-agent needs: sql + provider +
* shell + coding-tools. Drops mcp, goals, and the orchestrator. (default false)
* Used by the ACP server variant to avoid goal/mcp surface a sub-agent doesn't
* use.
*/
codingOnly?: boolean;
}
export async function initializeAgent(
options: InitializeAgentOptions = {},
): Promise<AgentRuntime> {
const includeOrchestrator = options.includeOrchestrator !== false;
applyOpencodeProviderEnv(process.env);
const provider = resolveModelProvider(process.env);
if (provider === "anthropic" && !process.env.ANTHROPIC_API_KEY) {
throw new Error(
"ANTHROPIC_API_KEY is required (ELIZA_CODE_PROVIDER=anthropic).",
);
}
if (provider === "openai" && !process.env.OPENAI_API_KEY) {
throw new Error("OPENAI_API_KEY is required (ELIZA_CODE_PROVIDER=openai).");
}
const providerPlugin =
provider === "anthropic"
? (await import("@elizaos/plugin-anthropic")).default
: (await import("@elizaos/plugin-openai")).default;
if (!process.env.CODING_TOOLS_WORKSPACE_ROOTS) {
process.env.CODING_TOOLS_WORKSPACE_ROOTS = process.cwd();
}
if (!process.env.SHELL_ALLOWED_DIRECTORY) {
process.env.SHELL_ALLOWED_DIRECTORY = process.cwd();
}
const codingOnly = options.codingOnly === true;
const [
{ plugin: sqlPlugin },
{ shellPlugin },
{ default: codingToolsPlugin },
] = await Promise.all([
import("@elizaos/plugin-sql"),
import("@elizaos/plugin-shell"),
import("@elizaos/plugin-coding-tools"),
]);
const plugins: Plugin[] = [
sqlPlugin,
providerPlugin,
shellPlugin,
codingToolsPlugin,
];
// The full agent also loads mcp + goals + (optionally) the orchestrator. A
// headless coding sub-agent (codingOnly) skips them — it just reads/writes/runs.
if (!codingOnly) {
const [{ default: mcpPlugin }, { default: goalsPlugin }] =
await Promise.all([
import("@elizaos/plugin-mcp"),
import("@elizaos/plugin-goals"),
]);
plugins.push(mcpPlugin, goalsPlugin);
if (includeOrchestrator) {
const { agentOrchestratorPlugin } = await import(
"@elizaos/plugin-agent-orchestrator"
);
plugins.push(agentOrchestratorPlugin);
}
}
const runtime = new AgentRuntime({
character: elizaCodeCharacter,
plugins,
});
await runtime.initialize();
return runtime;
}
export async function shutdownAgent(runtime: AgentRuntime): Promise<void> {
await runtime.stop();
}
@@ -0,0 +1,23 @@
// @vitest-environment node
//
// #11294: OSC 52 clipboard escape for /copy.
import { describe, expect, it } from "bun:test";
import { osc52 } from "./clipboard.js";
describe("osc52 (#11294)", () => {
it("wraps base64-encoded UTF-8 in the OSC 52 clipboard sequence", () => {
const seq = osc52("hello");
expect(seq.startsWith("\x1b]52;c;")).toBe(true);
expect(seq.endsWith("\x07")).toBe(true);
const b64 = seq.slice("\x1b]52;c;".length, -1);
expect(Buffer.from(b64, "base64").toString("utf8")).toBe("hello");
});
it("round-trips multi-line and unicode content", () => {
const text = "line one\nline two — ✅ 你好";
const seq = osc52(text);
const b64 = seq.slice("\x1b]52;c;".length, -1);
expect(Buffer.from(b64, "base64").toString("utf8")).toBe(text);
});
});
@@ -0,0 +1,13 @@
/**
* Build an OSC 52 terminal escape that copies `text` to the system clipboard.
*
* OSC 52 lets a program set the clipboard through the terminal itself, so it
* works over SSH / inside the cockpit PTY where there's no direct clipboard
* access. Format: `ESC ] 52 ; c ; <base64> BEL` — `c` is the clipboard
* selection, the payload is base64-encoded UTF-8. Terminals that don't support
* OSC 52 (or have it disabled) simply ignore the sequence.
*/
export function osc52(text: string): string {
const b64 = Buffer.from(text, "utf8").toString("base64");
return `\x1b]52;c;${b64}\x07`;
}
@@ -0,0 +1,41 @@
/** Exercises working-directory changes against the real process boundary. */
import { afterAll, describe, expect, it } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getCwd, setCwd } from "./cwd.js";
const originalCwd = process.cwd();
const temporaryDirectories: string[] = [];
afterAll(async () => {
process.chdir(originalCwd);
await Promise.all(
temporaryDirectories.map((directory) => rm(directory, { recursive: true })),
);
});
describe("working directory", () => {
it("resolves absolute and relative directory changes", async () => {
const parent = await mkdtemp(join(tmpdir(), "eliza-code-cwd-"));
temporaryDirectories.push(parent);
const child = await mkdtemp(join(parent, "child-"));
expect(await setCwd(parent)).toEqual({ success: true, path: parent });
expect(await setCwd(child.slice(parent.length + 1))).toEqual({
success: true,
path: child,
});
expect(getCwd()).toBe(child);
});
it("returns a failure without changing the tracked directory", async () => {
const before = getCwd();
const result = await setCwd("missing-directory");
expect(result.success).toBe(false);
expect(result.path).toBe(join(before, "missing-directory"));
expect(result.error).toBeString();
expect(getCwd()).toBe(before);
});
});
+23
View File
@@ -0,0 +1,23 @@
// Provides shared support logic for the Code example.
import * as path from "node:path";
let currentWorkingDirectory = process.cwd();
export function getCwd(): string {
return currentWorkingDirectory;
}
export async function setCwd(
nextPath: string,
): Promise<{ success: boolean; path: string; error?: string }> {
const resolved = path.resolve(currentWorkingDirectory, nextPath);
try {
process.chdir(resolved);
currentWorkingDirectory = resolved;
return { success: true, path: resolved };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { success: false, path: resolved, error: msg };
}
}
@@ -0,0 +1,17 @@
// Provides shared support logic for the Code example.
import type { EditorTheme } from "@elizaos/tui";
import { ansi, darkTheme } from "@elizaos/tui";
/** Theme bundle for {@link import("@elizaos/tui").Editor} using the built-in dark palette. */
export function createEditorTheme(): EditorTheme {
return {
borderColor: darkTheme.colors.border,
selectList: {
selectedPrefix: (text: string) => ansi.brightCyan(text),
selectedText: (text: string) => ansi.white(text),
description: (text: string) => ansi.gray(text),
scrollInfo: (text: string) => ansi.dim(text),
noMatch: (text: string) => ansi.red(text),
},
};
}
@@ -0,0 +1,76 @@
// Provides shared support logic for the Code example.
import type { Terminal } from "@elizaos/tui";
import { ProcessTerminal } from "@elizaos/tui";
/**
* Wraps {@link ProcessTerminal} so the app can handle global shortcuts before
* the focused TUI component receives stdin.
*/
export class FilteringTerminal implements Terminal {
private readonly inner = new ProcessTerminal();
constructor(private readonly onIntercept: (data: string) => boolean) {
// onIntercept returns true when the event was handled and should not reach the TUI
}
start(onInput: (data: string) => void, onResize: () => void): void {
this.inner.start((data: string) => {
if (this.onIntercept(data)) {
return;
}
onInput(data);
}, onResize);
}
stop(): void {
this.inner.stop();
}
drainInput(maxMs?: number, idleMs?: number): Promise<void> {
return this.inner.drainInput(maxMs, idleMs);
}
write(data: string): void {
this.inner.write(data);
}
get columns(): number {
return this.inner.columns;
}
get rows(): number {
return this.inner.rows;
}
get kittyProtocolActive(): boolean {
return this.inner.kittyProtocolActive;
}
moveBy(lines: number): void {
this.inner.moveBy(lines);
}
hideCursor(): void {
this.inner.hideCursor();
}
showCursor(): void {
this.inner.showCursor();
}
clearLine(): void {
this.inner.clearLine();
}
clearFromCursor(): void {
this.inner.clearFromCursor();
}
clearScreen(): void {
this.inner.clearScreen();
}
setTitle(title: string): void {
this.inner.setTitle(title);
}
}
@@ -0,0 +1,46 @@
/** Verifies that plugin services cross the Code example boundary only when their contract is complete. */
import { describe, expect, it } from "bun:test";
import type { AgentRuntime, Service } from "@elizaos/core";
import type { CodeTaskService } from "../types.js";
import { getCodeTaskService } from "./get-code-task-service.js";
const methods = [
"createCodeTask",
"createTask",
"getCurrentTask",
"getTask",
"getTasks",
"startTaskExecution",
"pauseTask",
"resumeTask",
"cancelTask",
"deleteTask",
"renameTask",
"appendOutput",
"setCurrentTask",
"getCurrentTaskId",
"setUserStatus",
"setTaskSubAgentType",
"detectAndPauseInterruptedTasks",
"on",
] as const;
function runtimeReturning(
service: Service | null,
): Pick<AgentRuntime, "getService"> {
return { getService: <T extends Service>() => service as T | null };
}
describe("getCodeTaskService", () => {
it("rejects missing and incomplete services", () => {
expect(getCodeTaskService(runtimeReturning(null))).toBeNull();
expect(getCodeTaskService(runtimeReturning({} as Service))).toBeNull();
});
it("returns a service implementing every required operation", () => {
const service = Object.fromEntries(
methods.map((method) => [method, () => undefined]),
) as unknown as Service & CodeTaskService;
expect(getCodeTaskService(runtimeReturning(service))).toBe(service);
});
});
@@ -0,0 +1,52 @@
// Provides shared support logic for the Code example.
import type { AgentRuntime, Service } from "@elizaos/core";
import type { CodeTaskService } from "../types.js";
const CODE_TASK_SERVICE_ID = "CODE_TASK" as const;
function fnAt(obj: Service, key: keyof CodeTaskService): boolean {
if (!(key in obj)) return false;
const candidate = Reflect.get(obj, key as PropertyKey);
return typeof candidate === "function";
}
/**
* Structural check so we do not assert plugin services to {@link CodeTaskService} blindly.
*/
function isCodeTaskService(
service: Service,
): service is Service & CodeTaskService {
const keys = [
"createCodeTask",
"createTask",
"getCurrentTask",
"getTask",
"getTasks",
"startTaskExecution",
"pauseTask",
"resumeTask",
"cancelTask",
"deleteTask",
"renameTask",
"appendOutput",
"setCurrentTask",
"getCurrentTaskId",
"setUserStatus",
"setTaskSubAgentType",
"detectAndPauseInterruptedTasks",
"on",
] as const satisfies readonly (keyof CodeTaskService)[];
for (const k of keys) {
if (!fnAt(service, k)) return false;
}
return true;
}
export function getCodeTaskService(
runtime: Pick<AgentRuntime, "getService">,
): CodeTaskService | null {
const service = runtime.getService<Service>(CODE_TASK_SERVICE_ID);
if (!service || !isCodeTaskService(service)) return null;
return service;
}
@@ -0,0 +1,61 @@
// Provides shared support logic for the Code example.
import { stringToUuid, type UUID } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function isUuidString(value: string): value is UUID {
return UUID_REGEX.test(value);
}
/**
* Stable per-project identity values used to keep runtime memory and task state
* consistent across process restarts (TUI + non-interactive CLI).
*/
export interface SessionIdentity {
/** Random per-project identifier (persisted to `.eliza-code/session.json`). */
projectId: UUID;
/** The "user entity" id used for messages sent from this CLI/TUI. */
userId: UUID;
/** World id used to group rooms/tasks for this project. */
worldId: UUID;
/** Optional message server id used by core messaging/world helpers. */
messageServerId: UUID;
}
type PartialSessionIdentity = Partial<SessionIdentity>;
export function ensureSessionIdentity(
input: PartialSessionIdentity = {},
): SessionIdentity {
const projectId =
input.projectId && isUuidString(input.projectId)
? input.projectId
: (uuidv4() as UUID);
const userId =
input.userId && isUuidString(input.userId)
? input.userId
: (uuidv4() as UUID);
const worldId =
input.worldId && isUuidString(input.worldId)
? input.worldId
: stringToUuid(`eliza-code:world:${projectId}`);
const messageServerId =
input.messageServerId && isUuidString(input.messageServerId)
? input.messageServerId
: stringToUuid(`eliza-code:server:${projectId}`);
return { projectId, userId, worldId, messageServerId };
}
export function createRoomElizaId(identity: SessionIdentity): UUID {
// We deliberately derive this from (projectId + random) so it is:
// - unique within a project
// - stable across restarts once persisted in the session file
return stringToUuid(`eliza-code:room:${identity.projectId}:${uuidv4()}`);
}
export function getMainRoomElizaId(identity: SessionIdentity): UUID {
return stringToUuid(`eliza-code:room:${identity.projectId}:main`);
}
@@ -0,0 +1,24 @@
// Provides shared support logic for the Code example.
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { config } from "dotenv";
/**
* Load environment variables from:
* - `process.cwd()/.env` (default dotenv behavior)
* - repo root `.env` (useful when running from `examples/code`)
*/
export function loadEnv(): void {
// Load .env from current working directory if present.
config({ quiet: true });
// Also try to load from the monorepo root.
// This file lives at: examples/code/src/lib/load-env.ts
// Repo root is: ../../../../.env
const rootEnvPath = fileURLToPath(
new URL("../../../../.env", import.meta.url),
);
if (existsSync(rootEnvPath)) {
config({ path: rootEnvPath, override: false, quiet: true });
}
}
@@ -0,0 +1,36 @@
/**
* A chalk-based {@link MarkdownTheme} for eliza-code's chat transcript.
*
* `@elizaos/tui` ships a full Markdown renderer (headings, lists, tables,
* inline styles, fenced code blocks) but exports no ready-made production
* theme — only a test fixture. This maps each theme slot to a chalk style so
* assistant replies render with real markdown formatting + code-block framing
* instead of the previous flat wrapped text (the biggest look-and-feel gap vs
* opencode/claude-code). Colors intentionally track the existing transcript
* palette (cyan/green accents, dim chrome); orange is reserved as accent
* elsewhere so we avoid it here.
*/
import type { MarkdownTheme } from "@elizaos/tui";
import chalk from "chalk";
export function createChatMarkdownTheme(): MarkdownTheme {
return {
heading: (t) => chalk.bold.cyan(t),
link: (t) => chalk.underline.cyan(t),
linkUrl: (t) => chalk.dim(t),
code: (t) => chalk.yellowBright(t),
codeBlock: (t) => chalk.greenBright(t),
codeBlockBorder: (t) => chalk.dim(t),
quote: (t) => chalk.italic.dim(t),
quoteBorder: (t) => chalk.dim(t),
hr: (t) => chalk.dim(t),
listBullet: (t) => chalk.cyan(t),
bold: (t) => chalk.bold(t),
italic: (t) => chalk.italic(t),
strikethrough: (t) => chalk.strikethrough(t),
underline: (t) => chalk.underline(t),
// Code blocks get a two-space gutter so they read as an indented block.
codeBlockIndent: " ",
};
}
@@ -0,0 +1,84 @@
// Provides shared support logic for the Code example.
type ModelProvider = "anthropic" | "openai";
/**
* Make eliza-code a drop-in replacement for the `opencode` coding sub-agent:
* when explicit `OPENAI_*` aren't set, inherit the coding-agent provider config
* the elizaOS orchestrator already uses for opencode (`ELIZA_OPENCODE_*`, which
* points at Cerebras or any OpenAI-compatible endpoint). The orchestrator
* forwards the parent env to the spawned ACP process, so a host already
* configured for opencode runs eliza-code with no extra model config.
*
* Mutates `env` in place; only fills values that are unset, so an explicit
* `OPENAI_*` / `ELIZA_CODE_PROVIDER` always wins.
*/
export function applyOpencodeProviderEnv(
env: Record<string, string | undefined> = process.env,
): void {
const has = (v: string | undefined): v is string =>
typeof v === "string" && v.trim().length > 0;
if (!has(env.OPENAI_API_KEY) && has(env.ELIZA_OPENCODE_API_KEY)) {
env.OPENAI_API_KEY = env.ELIZA_OPENCODE_API_KEY;
if (!has(env.ELIZA_CODE_PROVIDER)) env.ELIZA_CODE_PROVIDER = "openai";
}
if (!has(env.OPENAI_BASE_URL) && has(env.ELIZA_OPENCODE_BASE_URL))
env.OPENAI_BASE_URL = env.ELIZA_OPENCODE_BASE_URL;
if (!has(env.OPENAI_LARGE_MODEL) && has(env.ELIZA_OPENCODE_MODEL_POWERFUL))
env.OPENAI_LARGE_MODEL = env.ELIZA_OPENCODE_MODEL_POWERFUL;
if (!has(env.OPENAI_SMALL_MODEL) && has(env.ELIZA_OPENCODE_MODEL_FAST))
env.OPENAI_SMALL_MODEL = env.ELIZA_OPENCODE_MODEL_FAST;
if (!has(env.OPENAI_MEDIUM_MODEL) && has(env.ELIZA_OPENCODE_MODEL_FAST))
env.OPENAI_MEDIUM_MODEL = env.ELIZA_OPENCODE_MODEL_FAST;
}
/**
* A short human label for the active coding model, for the status bar — the
* model name if one is configured (what the user cares about: "which model am I
* talking to"), else the bare provider. Returns null when no provider is
* resolvable (unconfigured) so the caller can omit it rather than crash — the
* status bar renders on every frame and must never throw.
*/
export function describeActiveModel(
env: Record<string, string | undefined> = process.env,
): string | null {
let provider: ModelProvider;
try {
provider = resolveModelProvider(env);
} catch {
return null;
}
// Only the env vars the provider plugins actually honor — showing a model
// from a var the agent ignores (OPENAI_MODEL / ANTHROPIC_MODEL) would lie.
const model =
provider === "openai"
? (env.OPENAI_LARGE_MODEL ?? env.OPENAI_SMALL_MODEL)
: (env.ANTHROPIC_LARGE_MODEL ?? env.ANTHROPIC_SMALL_MODEL);
const trimmed = model?.trim();
return trimmed && trimmed.length > 0 ? trimmed : provider;
}
export function resolveModelProvider(
env: Record<string, string | undefined>,
): ModelProvider {
const explicitRaw =
env.ELIZA_CODE_PROVIDER ?? env.ELIZA_CODE_MODEL_PROVIDER ?? "";
const explicit = explicitRaw.trim().toLowerCase();
if (explicit === "anthropic" || explicit === "claude") return "anthropic";
if (explicit === "openai" || explicit === "codex") return "openai";
// Auto-detect based on available keys (incl. the opencode-compatible key).
if (env.OPENAI_API_KEY && env.OPENAI_API_KEY.trim().length > 0)
return "openai";
if (
env.ELIZA_OPENCODE_API_KEY &&
env.ELIZA_OPENCODE_API_KEY.trim().length > 0
)
return "openai";
if (env.ANTHROPIC_API_KEY && env.ANTHROPIC_API_KEY.trim().length > 0)
return "anthropic";
throw new Error(
"No model provider configured. Set ANTHROPIC_API_KEY or OPENAI_API_KEY (or ELIZA_CODE_PROVIDER=anthropic|openai).",
);
}
+12
View File
@@ -0,0 +1,12 @@
// Provides shared support logic for the Code example.
export const CODE_ASSISTANT_SYSTEM_PROMPT = `
You are Eliza Code, an autonomous coding agent. You complete tasks by USING TOOLS to make real changes on disk — writing and editing files with the FILE action and running commands with the SHELL action — not by describing what should be done.
How you work:
- ACT, don't narrate. When asked to build, create, write, or fix something, immediately call the FILE action with the actual file contents (and SHELL to run/verify). NEVER reply with only a description or plan such as "I'll create..." , "Creating the app now", or "Here's the code:" followed by a code block — a turn that does not call a tool leaves nothing on disk and is a FAILED turn.
- Put the COMPLETE file content inside the FILE tool call's content argument, not in your text reply. For a single-file web app, write the whole self-contained HTML (inline CSS + JS) in one FILE call.
- For multi-file or multi-step tasks, perform each step with its own tool call before moving on; write every file before reporting done.
- Verify: after writing, read the file back or run it, then report the real result (e.g. the actual program output).
- Only send a text reply (REPLY) once the work is actually done — to briefly summarize what you changed — or to ask a genuinely blocking question. Never claim a file exists unless you wrote it this session.
- Prioritize modern best practices; keep changes minimal and correct.
`;
@@ -0,0 +1,98 @@
/** Covers durable session serialization, validation, and room-identity migration using the real filesystem. */
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ensureSessionIdentity } from "./identity.js";
import { loadSession, type SessionState, saveSession } from "./session.js";
const originalCwd = process.cwd();
let testCwd = "";
beforeEach(async () => {
testCwd = await mkdtemp(join(tmpdir(), "eliza-code-session-"));
process.chdir(testCwd);
});
afterEach(async () => {
process.chdir(originalCwd);
await rm(testCwd, { recursive: true });
});
function state(): SessionState {
return {
rooms: [
{
id: "main",
name: "Main",
createdAt: new Date(1_700_000_000_000),
taskIds: [],
elizaRoomId: ensureSessionIdentity().worldId,
messages: [
{
id: "message",
role: "user",
content: "hello",
timestamp: new Date(1_700_000_001_000),
roomId: "main",
kind: "chat",
},
],
},
],
currentRoomId: "main",
currentTaskId: null,
cwd: testCwd,
identity: ensureSessionIdentity(),
focusedPane: "tasks",
taskPaneVisibility: "shown",
taskPaneWidthFraction: 0.55,
showFinishedTasks: true,
selectedSubAgentType: "codex",
};
}
describe("session persistence", () => {
it("round-trips messages, identity, and UI state", async () => {
const expected = state();
await saveSession(expected);
const loaded = await loadSession();
expect(loaded?.currentRoomId).toBe("main");
expect(loaded?.rooms[0].messages[0].timestamp).toEqual(
new Date(1_700_000_001_000),
);
expect(loaded?.rooms[0].messages[0].kind).toBe("chat");
expect(loaded?.identity).toEqual(expected.identity);
expect(loaded?.taskPaneWidthFraction).toBe(0.55);
});
it("returns null for missing, malformed, and empty sessions", async () => {
expect(await loadSession()).toBeNull();
await mkdir(".eliza-code");
await writeFile(".eliza-code/session.json", "not json");
expect(await loadSession()).toBeNull();
await writeFile(
".eliza-code/session.json",
JSON.stringify({ version: 1, currentRoomId: "main", rooms: [] }),
);
expect(await loadSession()).toBeNull();
});
it("repairs invalid room identities and selects an existing current room", async () => {
await saveSession(state());
const path = ".eliza-code/session.json";
const persisted = JSON.parse(await readFile(path, "utf8"));
persisted.currentRoomId = "missing";
persisted.rooms[0].elizaRoomId = "not-a-uuid";
persisted.rooms[0].messages[0].role = "invalid";
persisted.rooms[0].messages[0].kind = "invalid";
await writeFile(path, JSON.stringify(persisted));
const loaded = await loadSession();
expect(loaded?.currentRoomId).toBe("main");
expect(loaded?.rooms[0].elizaRoomId).toMatch(/^[0-9a-f-]{36}$/);
expect(loaded?.rooms[0].messages[0].role).toBe("system");
expect(loaded?.rooms[0].messages[0].kind).toBeUndefined();
});
});
+324
View File
@@ -0,0 +1,324 @@
// Provides shared support logic for the Code example.
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { stringToUuid, type UUID } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import type {
ChatRoom,
JsonValue,
Message,
MessageKind,
MessageRole,
PaneFocus,
SubAgentType,
TaskPaneVisibility,
} from "../types.js";
import {
ensureSessionIdentity,
isUuidString,
type SessionIdentity,
} from "./identity.js";
const SESSION_DIR = ".eliza-code";
const SESSION_FILE = "session.json";
interface SessionData {
version: 1;
savedAt: number;
currentRoomId: string;
currentTaskId: string | null;
rooms: SerializedRoom[];
cwd: string;
selectedSubAgentType?: SubAgentType | null;
// Identity fields are optional for backwards compatibility with older sessions.
projectId?: UUID;
userId?: UUID;
worldId?: UUID;
messageServerId?: UUID;
// UI state (optional for backwards compatibility)
focusedPane?: PaneFocus;
taskPaneVisibility?: TaskPaneVisibility;
taskPaneWidthFraction?: number;
showFinishedTasks?: boolean;
}
interface SerializedRoom {
id: string;
name: string;
messages: SerializedMessage[];
createdAt: number;
taskIds: string[];
elizaRoomId: string;
}
interface SerializedMessage {
id: string;
role: "user" | "assistant" | "system";
content: string;
timestamp: number;
roomId: string;
taskId?: string;
kind?: MessageKind;
}
function getSessionPath(): string {
return path.join(process.cwd(), SESSION_DIR, SESSION_FILE);
}
async function ensureSessionDir(): Promise<void> {
const dir = path.join(process.cwd(), SESSION_DIR);
await fs.mkdir(dir, { recursive: true });
}
/**
* Safely convert a timestamp value to epoch milliseconds.
* Handles Date objects, numbers, and ISO strings.
*/
function toEpoch(value: Date | number | string | undefined): number {
if (!value) return Date.now();
if (typeof value === "number") return value;
if (value instanceof Date) return value.getTime();
if (typeof value === "string") {
const parsed = new Date(value).getTime();
return Number.isNaN(parsed) ? Date.now() : parsed;
}
return Date.now();
}
/**
* Safely convert an epoch timestamp to a Date object.
*/
function toDate(epoch: number | undefined): Date {
if (!epoch || typeof epoch !== "number" || Number.isNaN(epoch)) {
return new Date();
}
return new Date(epoch);
}
/**
* Validate and sanitize a message role.
*/
function sanitizeRole(role: string): MessageRole {
if (role === "user" || role === "assistant" || role === "system") {
return role;
}
return "system";
}
function sanitizeKind(kind: unknown): MessageKind | undefined {
return kind === "chat" || kind === "tool" ? kind : undefined;
}
function shouldPersistToDisk(): boolean {
if (process.env.ELIZA_CODE_DISABLE_SESSION_PERSISTENCE === "1") return false;
if (process.env.BUN_TEST === "1") return false;
if (process.env.NODE_ENV === "test") return false;
return true;
}
function serializeRoom(room: ChatRoom): SerializedRoom {
return {
id: room.id || uuidv4(),
name: room.name || "Chat",
messages: (room.messages || []).map((msg) => ({
id: msg.id || uuidv4(),
role: sanitizeRole(msg.role),
content: msg.content || "",
timestamp: toEpoch(msg.timestamp),
roomId: msg.roomId || room.id,
taskId: msg.taskId,
kind: msg.kind,
})),
createdAt: toEpoch(room.createdAt),
taskIds: room.taskIds || [],
elizaRoomId: room.elizaRoomId,
};
}
function deserializeRoom(data: SerializedRoom): ChatRoom {
// Validate required fields
const id = data.id || uuidv4();
const name = data.name || "Chat";
const elizaRoomIdRaw =
typeof data.elizaRoomId === "string" ? data.elizaRoomId : "";
const elizaRoomId: UUID = isUuidString(elizaRoomIdRaw)
? elizaRoomIdRaw
: stringToUuid(`eliza-code:room:${id}`);
return {
id,
name,
messages: (data.messages || []).map(
(msg): Message => ({
id: msg.id || uuidv4(),
role: sanitizeRole(msg.role),
content: msg.content || "",
timestamp: toDate(msg.timestamp),
roomId: msg.roomId || id,
taskId: msg.taskId,
kind: sanitizeKind(msg.kind),
}),
),
createdAt: toDate(data.createdAt),
taskIds: data.taskIds || [],
elizaRoomId,
};
}
/**
* Validate session data structure.
*/
function isValidSessionData(data: unknown): data is SessionData {
if (!data || typeof data !== "object" || Array.isArray(data)) return false;
const d = data as Record<string, unknown>;
return (
d.version === 1 &&
typeof d.currentRoomId === "string" &&
Array.isArray(d.rooms)
);
}
export interface SessionState {
rooms: ChatRoom[];
currentRoomId: string;
currentTaskId: string | null;
cwd: string;
identity: SessionIdentity;
selectedSubAgentType?: SubAgentType | null;
// UI state (optional)
focusedPane?: PaneFocus;
taskPaneVisibility?: TaskPaneVisibility;
taskPaneWidthFraction?: number;
showFinishedTasks?: boolean;
}
/**
* Save session state to disk
*/
export async function saveSession(state: SessionState): Promise<void> {
await ensureSessionDir();
const data: SessionData = {
version: 1,
savedAt: Date.now(),
currentRoomId: state.currentRoomId,
currentTaskId: state.currentTaskId,
rooms: state.rooms.map(serializeRoom),
cwd: state.cwd,
selectedSubAgentType: state.selectedSubAgentType ?? null,
projectId: state.identity.projectId,
userId: state.identity.userId,
worldId: state.identity.worldId,
messageServerId: state.identity.messageServerId,
focusedPane: state.focusedPane,
taskPaneVisibility: state.taskPaneVisibility,
taskPaneWidthFraction: state.taskPaneWidthFraction,
showFinishedTasks: state.showFinishedTasks,
};
const sessionPath = getSessionPath();
await fs.writeFile(sessionPath, JSON.stringify(data, null, 2), "utf-8");
}
/**
* Load session state from disk
*/
export async function loadSession(): Promise<SessionState | null> {
try {
const sessionPath = getSessionPath();
const content = await fs.readFile(sessionPath, "utf-8");
const data = JSON.parse(content) as JsonValue;
// Validate session structure
if (!isValidSessionData(data)) {
return null;
}
// Deserialize rooms with validation
let needsMigrationSave = false;
const rooms = data.rooms
.map((roomData) => {
try {
if (!isUuidString(roomData.elizaRoomId)) {
needsMigrationSave = true;
}
return deserializeRoom(roomData);
} catch {
return null;
}
})
.filter((room): room is ChatRoom => room !== null);
// Must have at least one valid room
if (rooms.length === 0) {
return null;
}
// Ensure currentRoomId exists in the rooms
const currentRoomId = rooms.some((r) => r.id === data.currentRoomId)
? data.currentRoomId
: rooms[0].id;
const record = data as Record<string, JsonValue>;
const identity = ensureSessionIdentity({
projectId:
typeof record.projectId === "string" && isUuidString(record.projectId)
? (record.projectId as UUID)
: undefined,
userId:
typeof record.userId === "string" && isUuidString(record.userId)
? (record.userId as UUID)
: undefined,
worldId:
typeof record.worldId === "string" && isUuidString(record.worldId)
? (record.worldId as UUID)
: undefined,
messageServerId:
typeof record.messageServerId === "string" &&
isUuidString(record.messageServerId)
? (record.messageServerId as UUID)
: undefined,
});
const state: SessionState = {
rooms,
currentRoomId,
currentTaskId: data.currentTaskId ?? null,
cwd: data.cwd || process.cwd(),
identity,
selectedSubAgentType:
typeof record.selectedSubAgentType === "string"
? (record.selectedSubAgentType as SubAgentType)
: null,
focusedPane:
typeof record.focusedPane === "string"
? (record.focusedPane as PaneFocus)
: undefined,
taskPaneVisibility:
typeof record.taskPaneVisibility === "string"
? (record.taskPaneVisibility as TaskPaneVisibility)
: undefined,
taskPaneWidthFraction:
typeof record.taskPaneWidthFraction === "number"
? record.taskPaneWidthFraction
: undefined,
showFinishedTasks:
typeof record.showFinishedTasks === "boolean"
? record.showFinishedTasks
: undefined,
};
// If we repaired invalid UUIDs (e.g. bad elizaRoomId from earlier runs/tests), persist the fixed session.
if (needsMigrationSave && shouldPersistToDisk()) {
await saveSession(state);
}
return state;
} catch {
return null;
}
}
/**
* Clear session file
*/
@@ -0,0 +1,37 @@
// @vitest-environment node
//
// removeMessage powers the eliza-code error-recovery path (#11266): when a turn
// throws, the empty assistant placeholder is dropped and an error system
// message is shown instead of a lingering blank bubble.
import { beforeEach, describe, expect, it } from "bun:test";
import { useStore } from "./store.js";
describe("store.removeMessage (#11266)", () => {
beforeEach(() => {
useStore.setState({ rooms: [] });
});
it("removes only the target message, leaving the rest intact", () => {
const s = useStore.getState();
const room = s.createRoom("Main");
s.addMessage(room.id, "user", "hello");
const placeholder = s.addMessage(room.id, "assistant", "");
s.addMessage(room.id, "system", "kept");
useStore.getState().removeMessage(room.id, placeholder.id);
const after = useStore.getState().rooms.find((r) => r.id === room.id);
expect(after?.messages.map((m) => m.role)).toEqual(["user", "system"]);
expect(after?.messages.some((m) => m.id === placeholder.id)).toBe(false);
});
it("is a no-op for an unknown id", () => {
const s = useStore.getState();
const room = s.createRoom("Main");
s.addMessage(room.id, "user", "hello");
useStore.getState().removeMessage(room.id, "does-not-exist");
const after = useStore.getState().rooms.find((r) => r.id === room.id);
expect(after?.messages).toHaveLength(1);
});
});
@@ -0,0 +1,91 @@
/** Exercises room, message, task, pane, and submission transitions in the Code example store. */
import { beforeEach, describe, expect, it } from "bun:test";
import { stringToUuid } from "@elizaos/core";
import type { CodeTask } from "../types.js";
import { useStore } from "./store.js";
function task(name: string): CodeTask {
return {
id: stringToUuid(`task:${name}`),
name,
metadata: {
status: "pending",
progress: 0,
output: [],
steps: [],
workingDirectory: "/tmp",
createdAt: 1,
},
};
}
beforeEach(() => {
process.env.ELIZA_CODE_DISABLE_SESSION_PERSISTENCE = "1";
useStore.setState({
rooms: [],
currentRoomId: "missing",
tasks: [],
currentTaskId: null,
focusedPane: "chat",
showFinishedTasks: false,
taskPaneVisibility: "hidden",
taskPaneWidthFraction: 0.4,
pendingSubmissions: [],
sessionLoaded: false,
});
});
describe("store transitions", () => {
it("manages rooms and message content", () => {
const first = useStore.getState().createRoom("First");
const second = useStore.getState().createRoom("Second");
useStore.getState().switchRoom(first.id);
const message = useStore.getState().addMessage(first.id, "assistant", "a");
useStore.getState().appendToMessage(first.id, message.id, "b");
useStore.getState().setMessageContent(first.id, message.id, "final");
expect(useStore.getState().getCurrentRoom().id).toBe(first.id);
expect(useStore.getState().rooms[0].messages[0].content).toBe("final");
useStore.getState().clearMessages(first.id);
useStore.getState().deleteRoom(first.id);
expect(useStore.getState().currentRoomId).toBe(second.id);
});
it("updates tasks and derives task-pane visibility", () => {
const first = task("first");
const second = task("second");
useStore.getState().setTasks([first, second]);
useStore.getState().setCurrentTaskId(first.id ?? null);
useStore.getState().updateTaskInStore(first.id ?? "", { name: "renamed" });
expect(useStore.getState().getCurrentTask()?.name).toBe("renamed");
useStore.getState().setTaskPaneVisibility("shown");
useStore.getState().setFocusedPane("tasks");
expect(useStore.getState().isTaskPaneVisible()).toBe(true);
useStore.getState().togglePane();
expect(useStore.getState().focusedPane).toBe("chat");
useStore.getState().setTaskPaneWidthFraction(1);
useStore.getState().adjustTaskPaneWidth(-1);
expect(useStore.getState().taskPaneWidthFraction).toBe(0.2);
useStore.getState().setTasks([second]);
expect(useStore.getState().currentTaskId).toBeNull();
});
it("tracks UI controls and drains queued submissions in FIFO order", () => {
const state = useStore.getState();
state.setInputValue("draft");
state.setAgentTyping(true);
state.setLoading(true);
state.setSelectedSubAgentType("codex");
state.toggleShowFinishedTasks();
expect(state.enqueuePendingSubmission("one")).toBe(1);
expect(state.enqueuePendingSubmission("two")).toBe(2);
expect(useStore.getState().takeNextPendingSubmission()).toBe("one");
expect(useStore.getState().clearPendingSubmissions()).toBe(1);
expect(useStore.getState()).toMatchObject({
inputValue: "draft",
isAgentTyping: true,
isLoading: true,
selectedSubAgentType: "codex",
showFinishedTasks: true,
});
});
});
+645
View File
@@ -0,0 +1,645 @@
// Provides shared support logic for the Code example.
import { stringToUuid } from "@elizaos/core";
import { v4 as uuidv4 } from "uuid";
import { create } from "zustand";
import type {
ChatRoom,
CodeTask,
Message,
PaneFocus,
SubAgentType,
TaskPaneVisibility,
TaskUserStatus,
} from "../types.js";
import { getCwd, setCwd } from "./cwd.js";
import {
createRoomElizaId,
ensureSessionIdentity,
getMainRoomElizaId,
isUuidString,
type SessionIdentity,
} from "./identity.js";
import { loadSession, type SessionState, saveSession } from "./session.js";
function shouldPersistSessionToDisk(): boolean {
if (process.env.ELIZA_CODE_DISABLE_SESSION_PERSISTENCE === "1") return false;
if (process.env.BUN_TEST === "1") return false;
if (process.env.NODE_ENV === "test") return false;
return true;
}
function getTaskUserStatus(
userStatus: TaskUserStatus | undefined,
): TaskUserStatus {
return userStatus ?? "open";
}
function hasOpenTasks(tasks: CodeTask[]): boolean {
return tasks.some(
(t) => getTaskUserStatus(t.metadata?.userStatus) !== "done",
);
}
function computeTaskPaneVisible(
visibility: TaskPaneVisibility,
tasks: CodeTask[],
focusedPane: PaneFocus,
): boolean {
if (visibility === "shown") return true;
if (visibility === "hidden") return false;
// auto: show when there are open tasks OR when the user is actively focused on the task pane
return hasOpenTasks(tasks) || focusedPane === "tasks";
}
// ============================================================================
// Store State Interface
// ============================================================================
interface ElizaCodeState {
// Identity (persisted per-project)
identity: SessionIdentity;
// Rooms
rooms: ChatRoom[];
currentRoomId: string;
// Tasks (synced from CodeTaskService)
tasks: CodeTask[];
currentTaskId: string | null;
/** Selected worker type for new tasks (/agent). */
selectedSubAgentType: SubAgentType | null;
// UI State
focusedPane: PaneFocus;
/** Whether to include finished tasks (userStatus=done) in the task list. */
showFinishedTasks: boolean;
taskPaneVisibility: TaskPaneVisibility;
/** Preferred task pane width as a fraction of terminal width (0-1). */
taskPaneWidthFraction: number;
isLoading: boolean;
inputValue: string;
isAgentTyping: boolean;
/**
* Submissions typed while a turn is in flight (queue-and-send). Drained
* FIFO when the turn completes; discarded when the turn is aborted.
* Ephemeral — never persisted to the session file.
*/
pendingSubmissions: string[];
// Session initialized flag
sessionLoaded: boolean;
// Room Actions
createRoom: (name: string) => ChatRoom;
switchRoom: (roomId: string) => void;
deleteRoom: (roomId: string) => void;
// Message Actions
addMessage: (
roomId: string,
role: Message["role"],
content: string,
taskId?: string,
kind?: Message["kind"],
) => Message;
/** Append text to an existing message (used for streaming). No-op if not found. */
appendToMessage: (roomId: string, messageId: string, delta: string) => void;
/** Replace message content (used for streaming finalization). No-op if not found. */
setMessageContent: (
roomId: string,
messageId: string,
content: string,
) => void;
/** Remove a single message (e.g. drop an empty assistant placeholder on error). No-op if not found. */
removeMessage: (roomId: string, messageId: string) => void;
clearMessages: (roomId: string) => void;
// Task Actions (for UI sync - actual data managed by CodeTaskService)
setTasks: (tasks: CodeTask[]) => void;
updateTaskInStore: (taskId: string, updates: Partial<CodeTask>) => void;
setCurrentTaskId: (taskId: string | null) => void;
setSelectedSubAgentType: (type: SubAgentType | null) => void;
// UI Actions
setFocusedPane: (pane: PaneFocus) => void;
togglePane: () => void;
setShowFinishedTasks: (show: boolean) => void;
toggleShowFinishedTasks: () => void;
setTaskPaneVisibility: (visibility: TaskPaneVisibility) => void;
setTaskPaneWidthFraction: (fraction: number) => void;
adjustTaskPaneWidth: (deltaFraction: number) => void;
setLoading: (loading: boolean) => void;
setInputValue: (value: string) => void;
setAgentTyping: (typing: boolean) => void;
/** Buffer a submission typed during a running turn. Returns the queue length. */
enqueuePendingSubmission: (text: string) => number;
/** Pop the oldest queued submission (FIFO), if any. */
takeNextPendingSubmission: () => string | undefined;
/** Drop all queued submissions. Returns how many were discarded. */
clearPendingSubmissions: () => number;
// Session Actions
loadSessionState: () => Promise<void>;
saveSessionState: () => Promise<void>;
restoreSession: (state: SessionState) => void;
// Getters
getCurrentRoom: () => ChatRoom;
getCurrentTask: () => CodeTask | null;
isTaskPaneVisible: () => boolean;
}
// ============================================================================
// Initial State
// ============================================================================
const INITIAL_ROOM_ID = "default-main-room";
const initialIdentity = ensureSessionIdentity();
const createInitialRoom = (identity: SessionIdentity): ChatRoom => ({
id: INITIAL_ROOM_ID,
name: "Main",
messages: [],
createdAt: new Date(),
taskIds: [],
elizaRoomId: getMainRoomElizaId(identity),
});
const initialRoom = createInitialRoom(initialIdentity);
// Debounced save - prevents excessive writes during rapid state changes
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
let isSaving = false;
const debouncedSave = (state: ElizaCodeState) => {
// Don't queue saves while already saving or before session is loaded
if (!shouldPersistSessionToDisk()) return;
if (isSaving || !state.sessionLoaded) return;
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
isSaving = true;
try {
await state.saveSessionState();
} finally {
isSaving = false;
}
}, 1000);
};
// ============================================================================
// Store Implementation
// ============================================================================
export const useStore = create<ElizaCodeState>((set, get) => ({
// Initial state
identity: initialIdentity,
rooms: [initialRoom],
currentRoomId: initialRoom.id,
tasks: [],
currentTaskId: null,
selectedSubAgentType: null,
focusedPane: "chat",
showFinishedTasks: false,
taskPaneVisibility: "hidden",
taskPaneWidthFraction: 0.4,
isLoading: false,
inputValue: "",
isAgentTyping: false,
pendingSubmissions: [],
sessionLoaded: false,
// Room Actions
createRoom: (name: string) => {
const identity = get().identity;
const room: ChatRoom = {
id: uuidv4(),
name,
messages: [],
createdAt: new Date(),
taskIds: [],
elizaRoomId: createRoomElizaId(identity),
};
set((state) => ({
rooms: [...state.rooms, room],
currentRoomId: room.id,
}));
debouncedSave(get());
return room;
},
switchRoom: (roomId: string) => {
const state = get();
if (state.rooms.some((r) => r.id === roomId)) {
set({ currentRoomId: roomId });
debouncedSave(get());
}
},
deleteRoom: (roomId: string) => {
const state = get();
if (state.rooms.length <= 1) return;
if (roomId === state.currentRoomId) {
const otherRoom = state.rooms.find((r) => r.id !== roomId);
if (otherRoom) {
set({ currentRoomId: otherRoom.id });
}
}
set((state) => ({
rooms: state.rooms.filter((r) => r.id !== roomId),
}));
debouncedSave(get());
},
// Message Actions
addMessage: (
roomId: string,
role: Message["role"],
content: string,
taskId?: string,
kind?: Message["kind"],
) => {
const message: Message = {
id: uuidv4(),
role,
...(kind ? { kind } : {}),
content,
timestamp: new Date(),
roomId,
taskId,
};
set((state) => ({
rooms: state.rooms.map((room) =>
room.id === roomId
? { ...room, messages: [...room.messages, message] }
: room,
),
}));
debouncedSave(get());
return message;
},
appendToMessage: (roomId: string, messageId: string, delta: string) => {
if (!delta) return;
set((state) => ({
rooms: state.rooms.map((room) => {
if (room.id !== roomId) return room;
const nextMessages = room.messages.map((m) =>
m.id === messageId ? { ...m, content: `${m.content}${delta}` } : m,
);
return { ...room, messages: nextMessages };
}),
}));
debouncedSave(get());
},
removeMessage: (roomId: string, messageId: string) => {
set((state) => ({
rooms: state.rooms.map((room) =>
room.id === roomId
? {
...room,
messages: room.messages.filter((m) => m.id !== messageId),
}
: room,
),
}));
debouncedSave(get());
},
setMessageContent: (roomId: string, messageId: string, content: string) => {
set((state) => ({
rooms: state.rooms.map((room) => {
if (room.id !== roomId) return room;
const nextMessages = room.messages.map((m) =>
m.id === messageId ? { ...m, content } : m,
);
return { ...room, messages: nextMessages };
}),
}));
debouncedSave(get());
},
clearMessages: (roomId: string) => {
set((state) => ({
rooms: state.rooms.map((room) =>
room.id === roomId ? { ...room, messages: [] } : room,
),
}));
debouncedSave(get());
},
// Task Actions (UI sync)
setTasks: (tasks: CodeTask[]) => {
let clearedCurrent = false;
set((state) => {
const hasCurrent =
state.currentTaskId !== null &&
tasks.some((t) => t.id === state.currentTaskId);
const nextCurrentTaskId = hasCurrent ? state.currentTaskId : null;
if (state.currentTaskId !== null && nextCurrentTaskId === null) {
clearedCurrent = true;
}
return { tasks, currentTaskId: nextCurrentTaskId };
});
if (clearedCurrent) {
debouncedSave(get());
}
},
updateTaskInStore: (taskId: string, updates: Partial<CodeTask>) => {
set((state) => ({
tasks: state.tasks.map((task) =>
task.id === taskId ? { ...task, ...updates } : task,
),
}));
},
setCurrentTaskId: (taskId: string | null) => {
set({ currentTaskId: taskId });
debouncedSave(get());
},
setSelectedSubAgentType: (type: SubAgentType | null) => {
set({ selectedSubAgentType: type });
debouncedSave(get());
},
// UI Actions
setFocusedPane: (pane: PaneFocus) => {
set((state) => {
// Respect explicit pane hiding: do not allow focusing a hidden pane.
if (pane === "tasks" && state.taskPaneVisibility === "hidden") {
return { focusedPane: "chat" };
}
return { focusedPane: pane };
});
debouncedSave(get());
},
togglePane: () => {
set((state) => {
if (state.focusedPane === "chat") {
if (state.taskPaneVisibility === "hidden") {
return state;
}
return { focusedPane: "tasks" };
}
return { focusedPane: "chat" };
});
debouncedSave(get());
},
setShowFinishedTasks: (show: boolean) => {
set({ showFinishedTasks: show });
debouncedSave(get());
},
toggleShowFinishedTasks: () => {
set((state) => ({ showFinishedTasks: !state.showFinishedTasks }));
debouncedSave(get());
},
setTaskPaneVisibility: (visibility: TaskPaneVisibility) => {
set((state) => {
const visible = computeTaskPaneVisible(
visibility,
state.tasks,
state.focusedPane,
);
if (!visible && state.focusedPane === "tasks") {
return { taskPaneVisibility: visibility, focusedPane: "chat" };
}
return { taskPaneVisibility: visibility };
});
debouncedSave(get());
},
setTaskPaneWidthFraction: (fraction: number) => {
const clamped = Math.max(0.2, Math.min(0.75, fraction));
set({ taskPaneWidthFraction: clamped });
debouncedSave(get());
},
adjustTaskPaneWidth: (deltaFraction: number) => {
set((state) => {
const next = Math.max(
0.2,
Math.min(0.75, state.taskPaneWidthFraction + deltaFraction),
);
return { taskPaneWidthFraction: next };
});
debouncedSave(get());
},
setLoading: (loading: boolean) => {
set({ isLoading: loading });
},
setInputValue: (value: string) => {
set({ inputValue: value });
},
setAgentTyping: (typing: boolean) => {
set({ isAgentTyping: typing });
},
enqueuePendingSubmission: (text: string) => {
const next = [...get().pendingSubmissions, text];
set({ pendingSubmissions: next });
return next.length;
},
takeNextPendingSubmission: () => {
const [head, ...rest] = get().pendingSubmissions;
if (head === undefined) return undefined;
set({ pendingSubmissions: rest });
return head;
},
clearPendingSubmissions: () => {
const count = get().pendingSubmissions.length;
if (count > 0) {
set({ pendingSubmissions: [] });
}
return count;
},
// Session Actions
loadSessionState: async () => {
let restored = false;
const session = await loadSession();
if (session?.rooms && session.rooms.length > 0) {
get().restoreSession(session);
restored = true;
}
set({ sessionLoaded: true });
// If this is a fresh session (no file yet), persist immediately so identity + room IDs
// remain stable across restarts.
if (!restored && shouldPersistSessionToDisk()) {
await get().saveSessionState();
}
},
saveSessionState: async () => {
const state = get();
await saveSession({
rooms: state.rooms,
currentRoomId: state.currentRoomId,
currentTaskId: state.currentTaskId,
cwd: getCwd(),
identity: state.identity,
selectedSubAgentType: state.selectedSubAgentType,
focusedPane: state.focusedPane,
taskPaneVisibility: state.taskPaneVisibility,
taskPaneWidthFraction: state.taskPaneWidthFraction,
showFinishedTasks: state.showFinishedTasks,
});
},
restoreSession: (session: SessionState) => {
// Restore CWD first
if (session.cwd) {
setCwd(session.cwd).catch((err: Error) => {
const msg = err.message;
console.error(`[store] Failed to restore cwd: ${msg}`);
});
}
// Restore identity (or create defaults for older sessions).
const identity = ensureSessionIdentity(session.identity);
// Restore UI settings (with defaults for older sessions).
const focusedPane: PaneFocus =
session.focusedPane === "tasks" || session.focusedPane === "chat"
? session.focusedPane
: "chat";
const taskPaneVisibility: TaskPaneVisibility =
session.taskPaneVisibility === "shown" ||
session.taskPaneVisibility === "hidden" ||
session.taskPaneVisibility === "auto"
? session.taskPaneVisibility
: "auto";
const taskPaneWidthFraction =
typeof session.taskPaneWidthFraction === "number"
? Math.max(0.2, Math.min(0.75, session.taskPaneWidthFraction))
: 0.4;
const showFinishedTasks = session.showFinishedTasks === true;
const selectedSubAgentType =
session.selectedSubAgentType === null ||
session.selectedSubAgentType === undefined ||
typeof session.selectedSubAgentType === "string"
? (session.selectedSubAgentType ?? null)
: null;
// Restore the currently-selected worker into process env so runtime actions
// (e.g., CREATE_TASK) can read it.
if (selectedSubAgentType) {
process.env.ELIZA_CODE_ACTIVE_SUB_AGENT = selectedSubAgentType;
} else {
delete process.env.ELIZA_CODE_ACTIVE_SUB_AGENT;
}
// Validate and restore rooms
if (
session.rooms &&
Array.isArray(session.rooms) &&
session.rooms.length > 0
) {
// Ensure all rooms have valid structure
const validRooms = session.rooms
.filter(
(room) =>
room &&
typeof room.id === "string" &&
typeof room.name === "string" &&
Array.isArray(room.messages),
)
.map((room) => {
// Repair invalid persisted room UUIDs (e.g. "main-room-uuid") which break the SQL adapter.
if (
typeof room.elizaRoomId === "string" &&
isUuidString(room.elizaRoomId)
) {
return room;
}
const repairedId =
room.id === INITIAL_ROOM_ID
? getMainRoomElizaId(identity)
: stringToUuid(
`eliza-code:room:${identity.projectId}:${room.id}`,
);
return { ...room, elizaRoomId: repairedId };
});
if (validRooms.length > 0) {
// Ensure currentRoomId points to a valid room
const validCurrentRoomId = validRooms.some(
(r) => r.id === session.currentRoomId,
)
? session.currentRoomId
: validRooms[0].id;
set({
identity,
rooms: validRooms,
currentRoomId: validCurrentRoomId,
currentTaskId: session.currentTaskId,
selectedSubAgentType,
focusedPane:
taskPaneVisibility === "hidden" && focusedPane === "tasks"
? "chat"
: focusedPane,
taskPaneVisibility,
taskPaneWidthFraction,
showFinishedTasks,
});
}
} else {
// Still update identity even if rooms are invalid, so we can persist stable IDs.
set({
identity,
selectedSubAgentType,
focusedPane:
taskPaneVisibility === "hidden" && focusedPane === "tasks"
? "chat"
: focusedPane,
taskPaneVisibility,
taskPaneWidthFraction,
showFinishedTasks,
});
}
},
// Getters
getCurrentRoom: () => {
const state = get();
const room = state.rooms.find((r) => r.id === state.currentRoomId);
if (!room) {
throw new Error("Current room not found");
}
return room;
},
getCurrentTask: () => {
const state = get();
if (!state.currentTaskId) return null;
return state.tasks.find((t) => t.id === state.currentTaskId) ?? null;
},
isTaskPaneVisible: () => {
const state = get();
return computeTaskPaneVisible(
state.taskPaneVisibility,
state.tasks,
state.focusedPane,
);
},
}));
@@ -0,0 +1,39 @@
// @vitest-environment node
//
// #11294: padEndVisible pads to VISIBLE width, ignoring ANSI SGR — the fix for
// box-border misalignment (TaskPane/ChatPane padded chalk-colored strings with
// String.padEnd, which counts invisible escape bytes → under-padded → the right
// │ border collapsed inward on every styled row).
import { describe, expect, it } from "bun:test";
import chalk from "chalk";
import { padEndVisible } from "./text-width.js";
describe("padEndVisible (#11294)", () => {
it("pads a plain string exactly like padEnd", () => {
expect(padEndVisible("hi", 5)).toBe("hi ");
});
it("pads a chalk-colored string to its VISIBLE width, not raw length", () => {
const prev = chalk.level;
chalk.level = 3; // force SGR codes
try {
const colored = chalk.cyan("hi"); // visible width 2, raw length ~11
const padded = padEndVisible(colored, 5);
// Ends with exactly 3 spaces (5 - 2 visible), NOT fewer.
expect(padded.endsWith(" ")).toBe(true);
// The SGR prefix is preserved (still colored).
expect(padded.startsWith(colored)).toBe(true);
// String.padEnd would have added ZERO spaces here (raw length already > 5).
expect(colored.padEnd(5)).toBe(colored);
expect(padded).not.toBe(colored);
} finally {
chalk.level = prev;
}
});
it("returns the string unchanged when it already meets/exceeds the target", () => {
expect(padEndVisible("hello", 3)).toBe("hello");
expect(padEndVisible("abc", 3)).toBe("abc");
});
});
@@ -0,0 +1,17 @@
// Provides shared support logic for the Code example.
import { visibleWidth } from "@elizaos/tui";
/**
* Pad `s` with trailing spaces so its VISIBLE width reaches `target`.
*
* Unlike `String.prototype.padEnd`, this ignores ANSI SGR (color) codes.
* padEnd counts a chalk-styled string's invisible escape bytes toward its
* length, so padding a short colored string (a task header, a status line, the
* composer prompt) added too few spaces — the box's right `│` border collapsed
* inward against the text on every styled row. Overflow beyond `target` is left
* to the caller / MainScreen's `truncateToWidth`.
*/
export function padEndVisible(s: string, target: number): string {
const width = visibleWidth(s);
return width >= target ? s : s + " ".repeat(target - width);
}
@@ -0,0 +1,72 @@
// Exercises the Code example behavior that this module protects.
import { describe, expect, test } from "bun:test";
import { type ActionEventPayload, stringToUuid } from "@elizaos/core";
import {
formatToolCompleted,
formatToolStarted,
shouldShowToolAction,
} from "./tool-transcript.js";
function payload(args: {
action: string;
data?: Record<string, unknown>;
text?: string;
success?: boolean;
}): ActionEventPayload {
return Object.assign(Object.create(null) as ActionEventPayload, {
roomId: stringToUuid("room"),
world: stringToUuid("world"),
content: {
actions: [args.action],
actionResult: {
success: args.success ?? true,
...(args.text ? { text: args.text } : {}),
...(args.data ? { data: args.data } : {}),
},
},
});
}
describe("tool transcript formatting (#11330)", () => {
test("formats edit result line counts", () => {
expect(
formatToolCompleted(
payload({
action: "FILE",
data: {
path: "/workspace/src/foo.ts",
replacements: 1,
addedLines: 2,
removedLines: 1,
},
}),
{ cwd: "/workspace" },
),
).toBe("edit src/foo.ts +2/-1");
});
test("formats shell commands from structured data or result text", () => {
expect(
formatToolCompleted(
payload({
action: "SHELL",
data: { command: "bun test", exit_code: 0 },
}),
),
).toBe("run bun test exited 0");
expect(
formatToolCompleted(
payload({
action: "SHELL",
text: "$ git status\n[exit 0]",
}),
),
).toBe("run git status");
});
test("hides terminal protocol actions", () => {
expect(shouldShowToolAction("REPLY")).toBe(false);
expect(formatToolStarted("FILE")).toBe("tool file");
});
});
@@ -0,0 +1,209 @@
// Provides shared support logic for the Code example.
import path from "node:path";
import type { ActionEventPayload } from "@elizaos/core";
const HIDDEN_ACTIONS = new Set([
"REPLY",
"IGNORE",
"NONE",
"STOP",
"FOLLOW_ROOM",
]);
interface FormatOptions {
cwd?: string;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function readString(record: Record<string, unknown> | null, key: string) {
const value = record?.[key];
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function readNumber(record: Record<string, unknown> | null, key: string) {
const value = record?.[key];
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
function readBoolean(record: Record<string, unknown> | null, key: string) {
const value = record?.[key];
return typeof value === "boolean" ? value : undefined;
}
function readArray(record: Record<string, unknown> | null, key: string) {
const value = record?.[key];
return Array.isArray(value) ? value : undefined;
}
function actionResult(
payload: ActionEventPayload,
): Record<string, unknown> | null {
const content = asRecord(payload.content);
return asRecord(content?.actionResult);
}
function actionData(
payload: ActionEventPayload,
): Record<string, unknown> | null {
return asRecord(actionResult(payload)?.data);
}
function actionText(payload: ActionEventPayload): string | undefined {
return readString(actionResult(payload), "text");
}
function shortPath(
filePath: string | undefined,
options: FormatOptions,
): string {
if (!filePath) return "";
const cwd = options.cwd;
if (cwd && path.isAbsolute(filePath)) {
const relative = path.relative(cwd, filePath);
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
return relative;
}
}
return filePath;
}
function plural(count: number, singular: string): string {
return `${count} ${singular}${count === 1 ? "" : "s"}`;
}
function statusSuffix(payload: ActionEventPayload): string {
const result = actionResult(payload);
const success = readBoolean(result, "success") !== false;
if (!success) return " failed";
const data = actionData(payload);
const exitCode = readNumber(data, "exit_code");
return typeof exitCode === "number" ? ` exited ${exitCode}` : "";
}
function commandFromText(text: string | undefined): string | undefined {
const firstLine = text?.split("\n")[0]?.trim();
return firstLine?.startsWith("$ ") ? firstLine.slice(2).trim() : undefined;
}
function formatShell(payload: ActionEventPayload): string {
const data = actionData(payload);
const command =
readString(data, "command") ?? commandFromText(actionText(payload));
const suffix = statusSuffix(payload);
return command ? `run ${command}${suffix}` : `run SHELL${suffix}`;
}
function formatFile(
payload: ActionEventPayload,
options: FormatOptions,
): string {
const data = actionData(payload);
const result = actionResult(payload);
const success = readBoolean(result, "success") !== false;
const filePath = shortPath(readString(data, "path"), options);
const addedLines = readNumber(data, "addedLines");
const removedLines = readNumber(data, "removedLines");
const replacements = readNumber(data, "replacements");
if (
typeof addedLines === "number" ||
typeof removedLines === "number" ||
typeof replacements === "number"
) {
return `edit ${filePath || "file"} +${addedLines ?? 0}/-${removedLines ?? 0}${success ? "" : " failed"}`;
}
const bytes = readNumber(data, "bytes");
if (typeof bytes === "number") {
return `write ${filePath || "file"} ${plural(bytes, "byte")}${success ? "" : " failed"}`;
}
const lines = readNumber(data, "lines");
const totalLines = readNumber(data, "totalLines");
if (typeof lines === "number") {
const total = typeof totalLines === "number" ? `/${totalLines}` : "";
return `read ${filePath || "file"} ${lines}${total} lines${success ? "" : " failed"}`;
}
const matches = readNumber(data, "matches_count");
if (typeof matches === "number") {
return `grep ${plural(matches, "match")}${success ? "" : " failed"}`;
}
const files = readArray(data, "files");
if (files) {
return `glob ${plural(files.length, "file")}${success ? "" : " failed"}`;
}
const entries = readArray(data, "entries");
if (entries) {
return `ls ${plural(entries.length, "entry")}${success ? "" : " failed"}`;
}
return `file ${success ? "done" : "failed"}`;
}
function formatWorktree(
payload: ActionEventPayload,
options: FormatOptions,
): string {
const data = actionData(payload);
const result = actionResult(payload);
const success = readBoolean(result, "success") !== false;
const entered = shortPath(readString(data, "worktreePath"), options);
if (entered) return `worktree enter ${entered}${success ? "" : " failed"}`;
const restored = shortPath(readString(data, "restoredTo"), options);
if (restored) return `worktree exit ${restored}${success ? "" : " failed"}`;
return `worktree ${success ? "done" : "failed"}`;
}
export function actionNameFromPayload(
payload: ActionEventPayload,
): string | null {
const actions = payload.content?.actions;
const first = Array.isArray(actions) ? actions[0] : undefined;
return typeof first === "string" && first.length > 0 ? first : null;
}
export function shouldShowToolAction(
actionName: string | null,
): actionName is string {
return Boolean(actionName && !HIDDEN_ACTIONS.has(actionName));
}
export function toolTranscriptKey(
payload: ActionEventPayload,
actionName: string,
): string {
return `${payload.messageId ?? payload.roomId}:${actionName}`;
}
export function formatToolStarted(actionName: string): string {
return `tool ${actionName.toLowerCase()}`;
}
export function formatToolCompleted(
payload: ActionEventPayload,
options: FormatOptions = {},
): string {
const actionName = actionNameFromPayload(payload);
switch (actionName) {
case "SHELL":
return formatShell(payload);
case "FILE":
return formatFile(payload, options);
case "WORKTREE":
return formatWorktree(payload, options);
default: {
const result = actionResult(payload);
const success = readBoolean(result, "success") !== false;
return `${actionName?.toLowerCase() ?? "tool"} ${success ? "done" : "failed"}`;
}
}
}
@@ -0,0 +1,284 @@
/**
* Headless xterm-backed terminal used by the restored example-code tests.
* Keeping this harness beside its consumers avoids reviving the deliberately
* retired in-tree TUI package solely for its unpublished testing subpath.
*/
import type { Terminal } from "@elizaos/tui";
import type { Terminal as XtermTerminalType } from "@xterm/headless";
import xterm from "@xterm/headless";
// Extract Terminal class from the module
const XtermTerminal = xterm.Terminal;
/**
* Virtual terminal for testing using xterm.js for accurate terminal emulation.
*
* Implements the same {@link Terminal} interface the real `ProcessTerminal`
* does, so it drops straight into any TUI host (`new TUI(terminal)`,
* `startAgentTerminalTui({ terminal })`) and renders into a real `@xterm/headless`
* grid. Tests drive it with {@link sendInput}/{@link resize} and read the
* rendered result back cell-accurately via {@link getViewport},
* {@link getScrollBuffer}, {@link getCursorPosition}, and
* {@link getCellAttributes} — no ANSI-stripping regex required.
*/
export class VirtualTerminal implements Terminal {
private xterm: XtermTerminalType;
private inputHandler?: (data: string) => void;
private resizeHandler?: () => void;
private _columns: number;
private _rows: number;
/** Raw ANSI stream written by the host, in order — the source for recordings. */
private writeLog: string[] = [];
constructor(columns = 80, rows = 24) {
this._columns = columns;
this._rows = rows;
// Create xterm instance with specified dimensions
this.xterm = new XtermTerminal({
cols: columns,
rows: rows,
// Disable all interactive features for testing
disableStdin: true,
allowProposedApi: true,
});
}
start(onInput: (data: string) => void, onResize: () => void): void {
this.inputHandler = onInput;
this.resizeHandler = onResize;
// Enable bracketed paste mode for consistency with ProcessTerminal
this.xterm.write("\x1b[?2004h");
}
async drainInput(_maxMs?: number, _idleMs?: number): Promise<void> {
// Virtual terminal has no stdin to drain.
}
stop(): void {
// Disable bracketed paste mode
this.xterm.write("\x1b[?2004l");
this.inputHandler = undefined;
this.resizeHandler = undefined;
}
write(data: string): void {
this.writeLog.push(data);
this.xterm.write(data);
}
get columns(): number {
return this._columns;
}
get rows(): number {
return this._rows;
}
get kittyProtocolActive(): boolean {
// Virtual terminal always reports Kitty protocol as active for testing
return true;
}
moveBy(lines: number): void {
if (lines > 0) {
// Move down
this.xterm.write(`\x1b[${lines}B`);
} else if (lines < 0) {
// Move up
this.xterm.write(`\x1b[${-lines}A`);
}
// lines === 0: no movement
}
hideCursor(): void {
this.xterm.write("\x1b[?25l");
}
showCursor(): void {
this.xterm.write("\x1b[?25h");
}
clearLine(): void {
this.xterm.write("\x1b[K");
}
clearFromCursor(): void {
this.xterm.write("\x1b[J");
}
clearScreen(): void {
this.xterm.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1)
}
setTitle(title: string): void {
// OSC 0;title BEL - set terminal window title
this.xterm.write(`\x1b]0;${title}\x07`);
}
// Test-specific methods not in Terminal interface
/**
* Simulate keyboard input
*/
sendInput(data: string): void {
if (this.inputHandler) {
this.inputHandler(data);
}
}
/**
* Resize the terminal
*/
resize(columns: number, rows: number): void {
this._columns = columns;
this._rows = rows;
this.xterm.resize(columns, rows);
if (this.resizeHandler) {
this.resizeHandler();
}
}
/**
* Wait for all pending writes to complete. Viewport and scroll buffer will be updated.
*/
async flush(): Promise<void> {
// Write an empty string to ensure all previous writes are flushed
return new Promise<void>((resolve) => {
this.xterm.write("", () => resolve());
});
}
/**
* Flush and get viewport - convenience method for tests
*/
async flushAndGetViewport(): Promise<string[]> {
await this.flush();
return this.getViewport();
}
/**
* Get the visible viewport (what's currently on screen)
* Note: You should use getViewportAfterWrite() for testing after writing data
*/
getViewport(): string[] {
const lines: string[] = [];
const buffer = this.xterm.buffer.active;
// Get only the visible lines (viewport)
for (let i = 0; i < this.xterm.rows; i++) {
const line = buffer.getLine(buffer.viewportY + i);
if (line) {
lines.push(line.translateToString(true));
} else {
lines.push("");
}
}
return lines;
}
/**
* Get the entire scroll buffer
*/
getScrollBuffer(): string[] {
const lines: string[] = [];
const buffer = this.xterm.buffer.active;
// Get all lines in the buffer (including scrollback)
for (let i = 0; i < buffer.length; i++) {
const line = buffer.getLine(i);
if (line) {
lines.push(line.translateToString(true));
} else {
lines.push("");
}
}
return lines;
}
/**
* The raw ANSI stream the host has written so far, concatenated. This is the
* exact byte stream a real terminal would render — the source for an
* asciinema recording or a raw-output capture artifact.
*/
getWriteLog(): string {
return this.writeLog.join("");
}
/**
* Each individual `write()` the host issued, in order. One entry per frame
* the differential renderer flushed — the per-event source for building an
* asciicast (`.cast`) recording where each write becomes a playback frame.
*/
getWriteEvents(): string[] {
return [...this.writeLog];
}
/**
* Clear the terminal viewport
*/
clear(): void {
this.xterm.clear();
}
/**
* Reset the terminal completely
*/
reset(): void {
this.xterm.reset();
}
/**
* Get cursor position
*/
getCursorPosition(): { x: number; y: number } {
const buffer = this.xterm.buffer.active;
return {
x: buffer.cursorX,
y: buffer.cursorY,
};
}
/**
* Get cell attributes at a specific position.
* Used for testing ANSI style rendering.
*/
getCellAttributes(row: number, col: number): CellAttributes | null {
const buffer = this.xterm.buffer.active;
const line = buffer.getLine(buffer.viewportY + row);
if (!line) return null;
const cell = line.getCell(col);
if (!cell) return null;
return {
isItalic: cell.isItalic(),
isBold: cell.isBold(),
isUnderline: cell.isUnderline(),
isDim: cell.isDim(),
isBlink: cell.isBlink(),
isInverse: cell.isInverse(),
isInvisible: cell.isInvisible(),
isStrikethrough: cell.isStrikethrough(),
getFgColor: cell.getFgColor(),
getBgColor: cell.getBgColor(),
};
}
}
/**
* Cell attributes for testing
*/
export interface CellAttributes {
isItalic: number;
isBold: number;
isUnderline: number;
isDim: number;
isBlink: number;
isInverse: number;
isInvisible: number;
isStrikethrough: number;
getFgColor: number;
getBgColor: number;
}
@@ -0,0 +1,96 @@
// @vitest-environment node
import { beforeEach, describe, expect, test } from "bun:test";
import {
type ActionEventPayload,
type AgentRuntime,
EventType,
stringToUuid,
} from "@elizaos/core";
import { App } from "./App.js";
import { useStore } from "./lib/store.js";
type RuntimeHandler = (payload: ActionEventPayload) => Promise<void>;
function makeRuntime(): {
runtime: AgentRuntime;
emit: (event: EventType, payload: ActionEventPayload) => Promise<void>;
} {
const handlers = new Map<string, RuntimeHandler[]>();
const runtime = Object.assign(Object.create(null) as AgentRuntime, {
agentId: "test",
character: { name: "Eliza" },
getService: () => null,
registerEvent: (event: string, handler: RuntimeHandler) => {
const list = handlers.get(event) ?? [];
list.push(handler);
handlers.set(event, list);
},
});
return {
runtime,
emit: async (event, payload) => {
for (const handler of handlers.get(event) ?? []) {
await handler(payload);
}
},
};
}
function actionPayload(args: {
action: string;
roomId: ActionEventPayload["roomId"];
data?: Record<string, unknown>;
}): ActionEventPayload {
return Object.assign(Object.create(null) as ActionEventPayload, {
roomId: args.roomId,
world: stringToUuid("tool-transcript-world"),
content: {
actions: [args.action],
actionResult: {
success: true,
...(args.data ? { data: args.data } : {}),
},
},
});
}
describe("App tool transcript events (#11330)", () => {
beforeEach(() => {
useStore.setState({ rooms: [] });
});
test("updates a started tool line with the completed shell command", async () => {
const { runtime, emit } = makeRuntime();
const state = useStore.getState();
const room = state.createRoom("Tool transcript");
const app = new App(runtime);
// biome-ignore lint/complexity/useLiteralKeys: private test hook
app["initializeManagers"]();
await emit(
EventType.ACTION_STARTED,
actionPayload({ action: "SHELL", roomId: room.elizaRoomId }),
);
let messages = useStore.getState().getCurrentRoom().messages;
expect(messages).toHaveLength(1);
expect(messages[0]?.kind).toBe("tool");
expect(messages[0]?.content).toBe("tool shell");
await emit(
EventType.ACTION_COMPLETED,
actionPayload({
action: "SHELL",
roomId: room.elizaRoomId,
data: { command: "bun test", exit_code: 0 },
}),
);
messages = useStore.getState().getCurrentRoom().messages;
expect(messages).toHaveLength(1);
expect(messages[0]?.kind).toBe("tool");
expect(messages[0]?.content).toBe("run bun test exited 0");
});
});
@@ -0,0 +1,307 @@
// @vitest-environment node
//
// Queue-and-send + abort-key semantics for the eliza-code TUI (#11294):
// * Enter during a running turn buffers the submission (no second concurrent
// turn) and fires it when the turn completes — opencode behavior.
// * Aborting a turn discards the queue ("stop everything").
// * A second Ctrl+C while an aborted turn is still unwinding quits the app
// instead of being eaten; Ctrl+C when idle quits on the first press.
//
// Pattern follows global-input.test.ts: construct a real App around a minimal
// runtime stub and drive the real private handlers.
import { beforeEach, describe, expect, it } from "bun:test";
import type { AgentRuntime } from "@elizaos/core";
import { App } from "./App.js";
import { getAgentClient, resetAgentClient } from "./lib/agent-client.js";
import { useStore } from "./lib/store.js";
import type { Message } from "./types.js";
function makeAbortError(): Error {
const err = new Error("aborted");
err.name = "AbortError";
return err;
}
async function waitFor(cond: () => boolean, label: string): Promise<void> {
const deadline = Date.now() + 2000;
while (!cond()) {
if (Date.now() > deadline) {
throw new Error(`timed out waiting for ${label}`);
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
function roomMessages(roomId: string): Message[] {
return (
useStore.getState().rooms.find((room) => room.id === roomId)?.messages ?? []
);
}
interface DeferredTurn {
text: string;
resolve: () => void;
}
/**
* App wired to a runtime whose turns block until the test resolves them, so a
* second submission can arrive while the first turn is verifiably in flight.
* Turns honor the abort signal (reject with AbortError).
*/
function makeQueueApp() {
const sentTexts: string[] = [];
const turns: DeferredTurn[] = [];
const runtime = Object.assign(Object.create(null) as AgentRuntime, {
agentId: "test",
character: { name: "Eliza" },
getService: () => null,
ensureConnection: async () => {},
messageService: {
handleMessage: async (
_runtime: unknown,
message: { content?: { text?: string } },
callback: (content: { text: string }) => Promise<unknown>,
options?: { abortSignal?: AbortSignal },
) => {
const text = message.content?.text ?? "";
sentTexts.push(text);
await new Promise<void>((resolve, reject) => {
turns.push({ text, resolve });
options?.abortSignal?.addEventListener(
"abort",
() => reject(makeAbortError()),
{ once: true },
);
});
await callback({ text: `echo: ${text}` });
return { didRespond: true, responseMessages: [] };
},
},
});
getAgentClient().setRuntime(runtime);
const app = new App(runtime);
return {
app,
// biome-ignore lint/complexity/useLiteralKeys: private test hook
send: (text: string): Promise<void> => app["handleSendMessage"](text),
// biome-ignore lint/complexity/useLiteralKeys: private test hook
consume: (data: string): boolean => app["consumeGlobalInput"](data),
sentTexts: () => sentTexts,
turns,
};
}
/**
* App wired to a runtime that wedges: handleMessage never settles and ignores
* the abort signal entirely — the worst case a slow/broken provider produces.
*/
function makeWedgedApp() {
let turnStarted = false;
const runtime = Object.assign(Object.create(null) as AgentRuntime, {
agentId: "test",
character: { name: "Eliza" },
getService: () => null,
ensureConnection: async () => {},
messageService: {
handleMessage: async () => {
turnStarted = true;
await new Promise(() => {});
},
},
});
getAgentClient().setRuntime(runtime);
const app = new App(runtime);
return {
app,
// biome-ignore lint/complexity/useLiteralKeys: private test hook
send: (text: string): Promise<void> => app["handleSendMessage"](text),
// biome-ignore lint/complexity/useLiteralKeys: private test hook
consume: (data: string): boolean => app["consumeGlobalInput"](data),
turnStarted: () => turnStarted,
};
}
/** Stub out session persistence and App.stop; returns spies + restore. */
function instrumentQuit(app: App): {
stopped: () => boolean;
restore: () => void;
} {
let stopped = false;
const originalSave = useStore.getState().saveSessionState;
useStore.setState({ saveSessionState: async () => {} });
(app as { stop: () => void }).stop = () => {
stopped = true;
};
return {
stopped: () => stopped,
restore: () => {
useStore.setState({ saveSessionState: originalSave });
},
};
}
beforeEach(() => {
useStore.setState({
focusedPane: "chat",
inputValue: "",
rooms: [],
pendingSubmissions: [],
isLoading: false,
isAgentTyping: false,
});
resetAgentClient();
});
describe("eliza-code queue-and-send (#11294)", () => {
it("queues a submission made during a running turn and sends it when the turn completes", async () => {
const { send, sentTexts, turns } = makeQueueApp();
const room = useStore.getState().createRoom("Queue test");
const turnA = send("first question");
await waitFor(() => turns.length === 1, "first turn to start");
// Second submission while the turn runs: buffered, NOT sent concurrently.
await send("second question");
expect(sentTexts()).toEqual(["first question"]);
expect(useStore.getState().pendingSubmissions).toEqual(["second question"]);
const queuedNotice = roomMessages(room.id)
.filter((message) => message.role === "system")
.at(-1);
expect(queuedNotice?.content).toContain("Queued (1): second question");
// Turn A completes → the queued submission fires as its own turn.
turns[0]?.resolve();
await turnA;
await waitFor(() => turns.length === 2, "queued turn to start");
expect(sentTexts()).toEqual(["first question", "second question"]);
expect(useStore.getState().pendingSubmissions).toEqual([]);
turns[1]?.resolve();
await waitFor(
() => !useStore.getState().isLoading && turns.length === 2,
"queued turn to finish",
);
const finalMessages = roomMessages(room.id);
expect(finalMessages.map((message) => message.role)).toEqual([
"user",
"assistant",
"system",
"user",
"assistant",
]);
expect(finalMessages[1]?.content).toBe("echo: first question");
expect(finalMessages[3]?.content).toBe("second question");
expect(finalMessages[4]?.content).toBe("echo: second question");
});
it("drains multiple queued submissions in FIFO order", async () => {
const { send, sentTexts, turns } = makeQueueApp();
useStore.getState().createRoom("FIFO test");
const turnA = send("one");
await waitFor(() => turns.length === 1, "first turn to start");
await send("two");
await send("three");
expect(useStore.getState().pendingSubmissions).toEqual(["two", "three"]);
turns[0]?.resolve();
await turnA;
await waitFor(() => turns.length === 2, "second turn to start");
turns[1]?.resolve();
await waitFor(() => turns.length === 3, "third turn to start");
turns[2]?.resolve();
await waitFor(
() => !useStore.getState().isLoading && turns.length === 3,
"all turns to finish",
);
expect(sentTexts()).toEqual(["one", "two", "three"]);
expect(useStore.getState().pendingSubmissions).toEqual([]);
});
it("discards queued submissions when the turn is aborted", async () => {
const { send, sentTexts, consume, turns } = makeQueueApp();
const room = useStore.getState().createRoom("Abort-discard test");
const turnA = send("running turn");
await waitFor(() => turns.length === 1, "turn to start");
await send("queued behind it");
expect(useStore.getState().pendingSubmissions).toEqual([
"queued behind it",
]);
expect(consume("\x03")).toBe(true);
await turnA;
expect(useStore.getState().pendingSubmissions).toEqual([]);
const systemLines = roomMessages(room.id)
.filter((message) => message.role === "system")
.map((message) => message.content);
expect(systemLines).toContain("Turn aborted.");
expect(systemLines).toContain("Discarded 1 queued message.");
// Give a drain (if one were wrongly scheduled) a chance to fire.
await new Promise((resolve) => setTimeout(resolve, 25));
expect(sentTexts()).toEqual(["running turn"]);
});
it("still runs slash commands immediately during a turn instead of queueing them", async () => {
const { send, turns } = makeQueueApp();
const room = useStore.getState().createRoom("Slash-during-turn test");
const turnA = send("busy turn");
await waitFor(() => turns.length === 1, "turn to start");
await send("/pwd");
expect(useStore.getState().pendingSubmissions).toEqual([]);
const lastSystem = roomMessages(room.id)
.filter((message) => message.role === "system")
.at(-1);
expect(lastSystem?.content).not.toContain("Queued");
turns[0]?.resolve();
await turnA;
});
});
describe("eliza-code abort-key fallthrough (#11294)", () => {
it("quits on the second Ctrl+C while an aborted turn is still unwinding", async () => {
const { app, send, consume, turnStarted } = makeWedgedApp();
const quit = instrumentQuit(app);
try {
useStore.getState().createRoom("Wedge test");
void send("hang forever");
await waitFor(turnStarted, "wedged turn to start");
// First Ctrl+C: consumed, requests the abort — must NOT quit.
expect(consume("\x03")).toBe(true);
expect(quit.stopped()).toBe(false);
// Esc after the abort request falls through without quitting.
expect(consume("\x1b")).toBe(false);
expect(quit.stopped()).toBe(false);
// Second Ctrl+C: the turn never unwound (provider ignores the signal),
// so the keystroke falls through to the quit handler.
expect(consume("\x03")).toBe(true);
expect(quit.stopped()).toBe(true);
} finally {
quit.restore();
}
});
it("quits on the first Ctrl+C when no turn is running", () => {
const { app, consume } = makeQueueApp();
const quit = instrumentQuit(app);
try {
expect(consume("\x03")).toBe(true);
expect(quit.stopped()).toBe(true);
} finally {
quit.restore();
}
});
});
+297
View File
@@ -0,0 +1,297 @@
// Defines shared TypeScript types for the Code example.
import type { Task as CoreTask, UUID } from "@elizaos/core";
// ============================================================================
// JSON-safe value types (no `any` / `unknown`)
// ============================================================================
type JsonPrimitive = string | number | boolean | null;
export type JsonValue =
| JsonPrimitive
| JsonValue[]
| { [key: string]: JsonValue };
// ============================================================================
// Task Types (extends core elizaOS Task)
// ============================================================================
export type TaskStatus =
| "pending"
| "running"
| "completed"
| "failed"
| "paused"
| "cancelled";
/**
* User-controlled status for task lifecycle in the UI.
* This is intentionally separate from execution `TaskStatus` so the agent can
* finish work while the user decides when a task is "done".
*/
export type TaskUserStatus = "open" | "done";
interface TaskStep {
id: string;
description: string;
status: TaskStatus;
output?: string;
/** Additional metadata for the step */
metadata?: Record<string, JsonValue>;
}
interface TaskResult {
success: boolean;
summary: string;
filesModified: string[];
filesCreated: string[];
error?: string;
/** Additional metadata for the result */
metadata?: Record<string, JsonValue>;
}
type TaskTraceLevel = "info" | "warning" | "error";
type TaskTraceStatus = "paused" | "resumed" | "cancelled";
interface TaskTraceBase {
ts: number;
seq: number;
[key: string]: JsonValue | undefined;
}
interface TaskTraceNoteEvent extends TaskTraceBase {
kind: "note";
level: TaskTraceLevel;
message: string;
[key: string]: JsonValue | undefined;
}
interface TaskTraceLlmEvent extends TaskTraceBase {
kind: "llm";
iteration: number;
modelType: string;
response: string;
responsePreview: string;
prompt?: string;
[key: string]: JsonValue | undefined;
}
interface TaskTraceToolCallEvent extends TaskTraceBase {
kind: "tool_call";
iteration: number;
name: string;
args: Record<string, string>;
[key: string]: JsonValue | undefined;
}
interface TaskTraceToolResultEvent extends TaskTraceBase {
kind: "tool_result";
iteration: number;
name: string;
success: boolean;
output: string;
outputPreview: string;
[key: string]: JsonValue | undefined;
}
interface TaskTraceStatusEvent extends TaskTraceBase {
kind: "status";
status: TaskTraceStatus;
message?: string;
[key: string]: JsonValue | undefined;
}
export type TaskTraceEvent =
| TaskTraceNoteEvent
| TaskTraceLlmEvent
| TaskTraceToolCallEvent
| TaskTraceToolResultEvent
| TaskTraceStatusEvent;
// ============================================================================
// Sub-Agent Type Definitions
// ============================================================================
/**
* Available sub-agent types for task execution.
* - eliza: Default ElizaOS tool-calling worker using runtime model
* - claude-code: Claude Agent SDK-based worker
* - codex: OpenAI Codex SDK-based worker
* - opencode: OpenCode CLI-based worker (supports 75+ LLM providers)
* - elizaos-native: Best-of-all native ElizaOS agent with monologue reasoning
*/
export type SubAgentType =
| "eliza"
| "claude"
| "claude-code"
| "codex"
| "opencode"
| "elizaos-native";
/**
* Goal data available to sub-agents
*/
interface SubAgentGoal {
id: string;
name: string;
description?: string;
isCompleted: boolean;
tags?: string[];
}
/**
* Todo item available to sub-agents
*/
interface SubAgentTodo {
id: string;
name: string;
description?: string;
type: "daily" | "one-off" | "aspirational";
priority?: 1 | 2 | 3 | 4;
isCompleted: boolean;
isUrgent?: boolean;
}
/** Extended metadata for code tasks */
interface CodeTaskMetadata {
status: TaskStatus;
progress: number;
output: string[];
steps: TaskStep[];
trace?: TaskTraceEvent[];
result?: TaskResult;
/**
* User-controlled lifecycle status (independent of execution status).
* - open: visible by default, expected to be reviewed/iterated on
* - done: user has marked the task as finished (may be hidden in UI)
*/
userStatus?: TaskUserStatus;
/** Timestamp (ms) when `userStatus` last changed. */
userStatusUpdatedAt?: number;
/**
* Convenience mirrors of the last run result.
* These are duplicated for quick access in UIs/providers without needing to
* dereference `result`.
*/
filesModified?: string[];
filesCreated?: string[];
workingDirectory: string;
subAgentType?: SubAgentType | string;
createdAt: number;
startedAt?: number;
completedAt?: number;
error?: string;
updateInterval?: number;
/** Optional items for UI state selections */
options?: Array<{ name: string; description: string }>;
/** Goals context for the sub-agent */
goals?: SubAgentGoal[];
/** Todos created during task execution */
todos?: SubAgentTodo[];
}
/** Code task - uses core Task with typed metadata */
export interface CodeTask extends Omit<CoreTask, "metadata"> {
metadata: CodeTaskMetadata;
}
export interface CodeTaskService {
createCodeTask(
name: string,
description: string,
metadata?: Record<string, JsonValue>,
subAgentType?: SubAgentType,
): Promise<CodeTask>;
/** Alias used by game-generation / orchestration flows */
createTask(
name: string,
description: string,
metadata?: Record<string, JsonValue>,
subAgentType?: SubAgentType,
): Promise<CodeTask>;
getCurrentTask(): Promise<CodeTask | null>;
getTask(taskId: string): Promise<CodeTask | null | undefined>;
getTasks(): Promise<CodeTask[]>;
startTaskExecution(taskId: string): Promise<void>;
pauseTask(taskId: string): Promise<void>;
resumeTask(taskId: string): Promise<void>;
cancelTask(taskId: string): Promise<void>;
deleteTask(taskId: string): Promise<void>;
renameTask(taskId: string, name: string): Promise<void>;
appendOutput(taskId: string, line: string): Promise<void>;
setCurrentTask(taskId: string): void;
getCurrentTaskId(): string | null;
setUserStatus(taskId: string, status: TaskUserStatus): Promise<void>;
setTaskSubAgentType(
taskId: string,
subAgentType: SubAgentType,
): Promise<void>;
detectAndPauseInterruptedTasks(): Promise<CodeTask[]>;
on(event: "task", handler: (event: TaskEvent) => Promise<void> | void): void;
}
// ============================================================================
// Event Types
// ============================================================================
type TaskEventType =
| "task:created"
| "task:started"
| "task:progress"
| "task:output"
| "task:trace"
| "task:completed"
| "task:failed"
| "task:cancelled"
| "task:paused"
| "task:resumed"
| "task:message";
export interface TaskEvent {
type: TaskEventType;
taskId: string;
data?: Record<string, JsonValue>;
}
// ============================================================================
// Chat/Message Types
// ============================================================================
export type MessageRole = "user" | "assistant" | "system";
export type MessageKind = "chat" | "tool";
export interface Message {
id: string;
role: MessageRole;
kind?: MessageKind;
content: string;
timestamp: Date;
roomId: string;
taskId?: string;
}
export interface ChatRoom {
id: string;
name: string;
messages: Message[];
createdAt: Date;
taskIds: string[];
elizaRoomId: UUID;
}
// ============================================================================
// UI State Types
// ============================================================================
export type PaneFocus = "chat" | "tasks";
// ============================================================================
// UI Layout Types
// ============================================================================
/**
* Controls whether the task pane is rendered.
* - auto: show only when there are open tasks
* - shown: always show
* - hidden: never show
*/
export type TaskPaneVisibility = "auto" | "shown" | "hidden";