Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
# T3 Code

T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, Cursor, and OpenCode, more coming soon).
T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, Cursor, OpenCode, and Pi, more coming soon).

## Installation

> [!WARNING]
> T3 Code currently supports Codex, Claude, Cursor, and OpenCode.
> T3 Code currently supports Codex, Claude, Cursor, OpenCode, and Pi.
> Install and authenticate at least one provider before use:
>
> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`
> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`
> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `cursor-agent login`
> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login`
> - Pi: install the Pi CLI and configure a provider API key (Pi is Early Access and disabled by default — see [docs/providers/pi.md](./docs/providers/pi.md))

### Run without installing

Expand Down
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"node-pty": "^1.1.0"
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "^0.80.2",
"@effect/vitest": "catalog:",
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
Expand Down
7 changes: 7 additions & 0 deletions apps/server/scripts/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";
import { fromYaml } from "@t3tools/shared/schemaYaml";
import { resolveSpawnCommand } from "@t3tools/shared/shell";
import serverPackageJson from "../package.json" with { type: "json" };
import { copyRequiredBuildAsset } from "./cliBuildAssets.ts";
import {
ServerCliBuildAssetMissingError,
ServerCliCommandExitError,
Expand Down Expand Up @@ -180,6 +181,12 @@ const buildCmd = Command.make(
const webDist = path.join(repoRoot, "apps/web/dist");
const clientTarget = path.join(serverDir, "dist/client");

// Pi loads the approval-gate extension at runtime; vp pack won't emit it, so copy it next to the bundle.
const piExtensionSource = path.join(serverDir, "src/provider/assets/pi/t3-approvals.ts");
const piExtensionTarget = path.join(serverDir, "dist/assets/pi/t3-approvals.ts");
yield* copyRequiredBuildAsset(piExtensionSource, piExtensionTarget);
yield* Effect.log("[cli] Bundled Pi approval extension into dist/assets/pi");

if (yield* fs.exists(webDist)) {
yield* fs.copy(webDist, clientTarget);
yield* applyDevelopmentIconOverrides(repoRoot, serverDir);
Expand Down
47 changes: 47 additions & 0 deletions apps/server/scripts/cliBuildAssets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";

import { copyRequiredBuildAsset } from "./cliBuildAssets.ts";

it.layer(NodeServices.layer)("server CLI build assets", (it) => {
it.effect("fails when a required build asset is missing", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-cli-required-asset-",
});
const sourcePath = path.join(tempDir, "missing.ts");
const targetPath = path.join(tempDir, "dist", "required.ts");

const error = yield* copyRequiredBuildAsset(sourcePath, targetPath).pipe(Effect.flip);

assert.equal(error._tag, "ServerCliBuildAssetMissingError");
if (error._tag !== "ServerCliBuildAssetMissingError") {
return assert.fail(`Unexpected error: ${error._tag}`);
}
assert.equal(error.assetPath, sourcePath);
assert.equal(yield* fileSystem.exists(targetPath), false);
}),
);

it.effect("copies a required build asset into its target directory", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-cli-required-asset-",
});
const sourcePath = path.join(tempDir, "required.ts");
const targetPath = path.join(tempDir, "dist", "assets", "required.ts");
yield* fileSystem.writeFileString(sourcePath, "export const required = true;\n");

yield* copyRequiredBuildAsset(sourcePath, targetPath);

assert.equal(yield* fileSystem.readFileString(targetPath), "export const required = true;\n");
}),
);
});
20 changes: 20 additions & 0 deletions apps/server/scripts/cliBuildAssets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";

import { ServerCliBuildAssetMissingError } from "./cliErrors.ts";

export const copyRequiredBuildAsset = Effect.fn("copyRequiredBuildAsset")(function* (
sourcePath: string,
targetPath: string,
) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

if (!(yield* fileSystem.exists(sourcePath))) {
return yield* new ServerCliBuildAssetMissingError({ assetPath: sourcePath });
}

yield* fileSystem.makeDirectory(path.dirname(targetPath), { recursive: true });
yield* fileSystem.copyFile(sourcePath, targetPath);
});
150 changes: 150 additions & 0 deletions apps/server/scripts/pi-mock-rpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
#!/usr/bin/env node
// @effect-diagnostics nodeBuiltinImport:off globalTimers:off - Standalone JSONL process fixture.
// Fake `pi --mode rpc` for tests; driven by `PI_MOCK_*` env vars.
import * as NodeReadline from "node:readline";
import * as NodeTimers from "node:timers";

const assistantText = process.env["PI_MOCK_ASSISTANT_TEXT"] ?? '{"title":"Mock title"}';
const emitInvalidJson = process.env["PI_MOCK_EMIT_INVALID_JSON"] === "1";
const lastTextFails = process.env["PI_MOCK_LAST_TEXT_FAILS"] === "1";
const exitOnPrompt = process.env["PI_MOCK_EXIT_ON_PROMPT"] === "1";
const retryOnce = process.env["PI_MOCK_RETRY_ONCE"] === "1";
const requireImage = process.env["PI_MOCK_REQUIRE_IMAGE"] === "1";
const expectedImageData = process.env["PI_MOCK_EXPECT_IMAGE_DATA"];

const replyText = emitInvalidJson
? "Sure — here is the answer, with no JSON at all."
: assistantText;
let lastAssistantText: string | null = null;
let didRetry = false;

function write(obj: unknown): void {
process.stdout.write(`${JSON.stringify(obj)}\n`);
}

const rl = NodeReadline.createInterface({ input: process.stdin });

rl.on("line", (line: string) => {
const trimmed = line.trim();
if (!trimmed) return;
let command: { type?: string; id?: string; images?: unknown };
try {
command = JSON.parse(trimmed) as { type?: string; id?: string; images?: unknown };
} catch {
return;
}

switch (command.type) {
case "prompt":
case "steer":
case "follow_up": {
if (exitOnPrompt) {
process.exit(17);
}

const hasExpectedImage =
Array.isArray(command.images) &&
command.images.some(
(image) =>
image !== null &&
typeof image === "object" &&
(image as Record<string, unknown>)["type"] === "image" &&
(image as Record<string, unknown>)["mimeType"] === "image/png" &&
(expectedImageData === undefined ||
(image as Record<string, unknown>)["data"] === expectedImageData),
);
const responseText =
requireImage && !hasExpectedImage
? "The required image payload was not received."
: replyText;
const emitSuccessfulAttempt = (): void => {
write({ type: "agent_start" });
write({ type: "turn_start" });
write({
type: "message_update",
assistantMessageEvent: { type: "text_delta", delta: responseText },
});
lastAssistantText = responseText;
write({ type: "message_end" });
write({ type: "turn_end" });
write({ type: "agent_end", willRetry: false });
};

if (retryOnce && !didRetry) {
didRetry = true;
write({ type: "agent_start" });
write({ type: "agent_end", messages: [], willRetry: true });
NodeTimers.setTimeout(emitSuccessfulAttempt, 100);
} else {
emitSuccessfulAttempt();
}
return;
}
case "get_last_assistant_text": {
write(
lastTextFails
? {
type: "response",
id: command.id,
command: "get_last_assistant_text",
success: false,
error: "no assistant text",
}
: {
type: "response",
id: command.id,
command: "get_last_assistant_text",
success: true,
data: { text: lastAssistantText },
},
);
return;
}
case "get_state": {
write({
type: "response",
id: command.id,
command: "get_state",
success: true,
data: {
sessionId: "mock-session",
sessionFile: "/tmp/pi-mock-session.json",
thinkingLevel: "off",
isStreaming: false,
isCompacting: false,
steeringMode: "all",
followUpMode: "all",
autoCompactionEnabled: false,
messageCount: 0,
pendingMessageCount: 0,
},
});
return;
}
case "get_commands": {
write({
type: "response",
id: command.id,
command: "get_commands",
success: true,
data: { commands: [] },
});
return;
}
default: {
if (command.id !== undefined) {
write({
type: "response",
id: command.id,
command: command.type ?? "unknown",
success: true,
});
}
return;
}
}
});

rl.on("close", () => {
process.exit(0);
});
Loading
Loading