mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 21:03:26 +00:00
Rename Foundry handoffs to tasks (#239)
* Restore foundry onboarding stack * Consolidate foundry rename * Create foundry tasks without prompts * Rename Foundry handoffs to tasks
This commit is contained in:
parent
d30cc0bcc8
commit
d75e8c31d1
281 changed files with 9242 additions and 4356 deletions
427
foundry/packages/cli/src/backend/manager.ts
Normal file
427
foundry/packages/cli/src/backend/manager.ts
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
import * as childProcess from "node:child_process";
|
||||
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { checkBackendHealth } from "@sandbox-agent/foundry-client";
|
||||
import type { AppConfig } from "@sandbox-agent/foundry-shared";
|
||||
import { CLI_BUILD_ID } from "../build-id.js";
|
||||
|
||||
const HEALTH_TIMEOUT_MS = 1_500;
|
||||
const START_TIMEOUT_MS = 30_000;
|
||||
const STOP_TIMEOUT_MS = 5_000;
|
||||
const POLL_INTERVAL_MS = 150;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
||||
}
|
||||
|
||||
function sanitizeHost(host: string): string {
|
||||
return host
|
||||
.split("")
|
||||
.map((ch) => (/[a-zA-Z0-9]/.test(ch) ? ch : "-"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function backendStateDir(): string {
|
||||
const override = process.env.HF_BACKEND_STATE_DIR?.trim();
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
|
||||
const xdgDataHome = process.env.XDG_DATA_HOME?.trim();
|
||||
if (xdgDataHome) {
|
||||
return join(xdgDataHome, "foundry", "backend");
|
||||
}
|
||||
|
||||
return join(homedir(), ".local", "share", "foundry", "backend");
|
||||
}
|
||||
|
||||
function backendPidPath(host: string, port: number): string {
|
||||
return join(backendStateDir(), `backend-${sanitizeHost(host)}-${port}.pid`);
|
||||
}
|
||||
|
||||
function backendVersionPath(host: string, port: number): string {
|
||||
return join(backendStateDir(), `backend-${sanitizeHost(host)}-${port}.version`);
|
||||
}
|
||||
|
||||
function backendLogPath(host: string, port: number): string {
|
||||
return join(backendStateDir(), `backend-${sanitizeHost(host)}-${port}.log`);
|
||||
}
|
||||
|
||||
function readText(path: string): string | null {
|
||||
try {
|
||||
return readFileSync(path, "utf8").trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readPid(host: string, port: number): number | null {
|
||||
const raw = readText(backendPidPath(host, port));
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pid = Number.parseInt(raw, 10);
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return null;
|
||||
}
|
||||
return pid;
|
||||
}
|
||||
|
||||
function writePid(host: string, port: number, pid: number): void {
|
||||
const path = backendPidPath(host, port);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, String(pid), "utf8");
|
||||
}
|
||||
|
||||
function removePid(host: string, port: number): void {
|
||||
const path = backendPidPath(host, port);
|
||||
if (existsSync(path)) {
|
||||
rmSync(path);
|
||||
}
|
||||
}
|
||||
|
||||
function readBackendVersion(host: string, port: number): string | null {
|
||||
return readText(backendVersionPath(host, port));
|
||||
}
|
||||
|
||||
function writeBackendVersion(host: string, port: number, buildId: string): void {
|
||||
const path = backendVersionPath(host, port);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, buildId, "utf8");
|
||||
}
|
||||
|
||||
function removeBackendVersion(host: string, port: number): void {
|
||||
const path = backendVersionPath(host, port);
|
||||
if (existsSync(path)) {
|
||||
rmSync(path);
|
||||
}
|
||||
}
|
||||
|
||||
function readCliBuildId(): string {
|
||||
const override = process.env.HF_BUILD_ID?.trim();
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
|
||||
return CLI_BUILD_ID;
|
||||
}
|
||||
|
||||
function isVersionCurrent(host: string, port: number): boolean {
|
||||
return readBackendVersion(host, port) === readCliBuildId();
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | undefined)?.code === "EPERM") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeStateFiles(host: string, port: number): void {
|
||||
removePid(host, port);
|
||||
removeBackendVersion(host, port);
|
||||
}
|
||||
|
||||
async function checkHealth(host: string, port: number): Promise<boolean> {
|
||||
return await checkBackendHealth({
|
||||
endpoint: `http://${host}:${port}/api/rivet`,
|
||||
timeoutMs: HEALTH_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHealth(host: string, port: number, timeoutMs: number, pid?: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (pid && !isProcessRunning(pid)) {
|
||||
throw new Error(`backend process ${pid} exited before becoming healthy`);
|
||||
}
|
||||
|
||||
if (await checkHealth(host, port)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
throw new Error(`backend did not become healthy within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
async function waitForChildPid(child: childProcess.ChildProcess): Promise<number | null> {
|
||||
if (child.pid && child.pid > 0) {
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await sleep(50);
|
||||
if (child.pid && child.pid > 0) {
|
||||
return child.pid;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface LaunchSpec {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
function resolveBunCommand(): string {
|
||||
const override = process.env.HF_BUN?.trim();
|
||||
if (override && (override === "bun" || existsSync(override))) {
|
||||
return override;
|
||||
}
|
||||
|
||||
const homeBun = join(homedir(), ".bun", "bin", "bun");
|
||||
if (existsSync(homeBun)) {
|
||||
return homeBun;
|
||||
}
|
||||
|
||||
return "bun";
|
||||
}
|
||||
|
||||
function resolveLaunchSpec(host: string, port: number): LaunchSpec {
|
||||
const repoRoot = resolve(fileURLToPath(new URL("../../..", import.meta.url)));
|
||||
const backendEntry = resolve(fileURLToPath(new URL("../../backend/dist/index.js", import.meta.url)));
|
||||
|
||||
if (existsSync(backendEntry)) {
|
||||
return {
|
||||
command: resolveBunCommand(),
|
||||
args: [backendEntry, "start", "--host", host, "--port", String(port)],
|
||||
cwd: repoRoot,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: "pnpm",
|
||||
args: ["--filter", "@sandbox-agent/foundry-backend", "exec", "bun", "src/index.ts", "start", "--host", host, "--port", String(port)],
|
||||
cwd: repoRoot,
|
||||
};
|
||||
}
|
||||
|
||||
async function startBackend(host: string, port: number): Promise<void> {
|
||||
if (await checkHealth(host, port)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingPid = readPid(host, port);
|
||||
if (existingPid && isProcessRunning(existingPid)) {
|
||||
await waitForHealth(host, port, START_TIMEOUT_MS, existingPid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingPid) {
|
||||
removeStateFiles(host, port);
|
||||
}
|
||||
|
||||
const logPath = backendLogPath(host, port);
|
||||
mkdirSync(dirname(logPath), { recursive: true });
|
||||
const fd = openSync(logPath, "a");
|
||||
|
||||
const launch = resolveLaunchSpec(host, port);
|
||||
const child = childProcess.spawn(launch.command, launch.args, {
|
||||
cwd: launch.cwd,
|
||||
detached: true,
|
||||
stdio: ["ignore", fd, fd],
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(`failed to launch backend: ${String(error)}`);
|
||||
});
|
||||
|
||||
child.unref();
|
||||
closeSync(fd);
|
||||
|
||||
const pid = await waitForChildPid(child);
|
||||
|
||||
writeBackendVersion(host, port, readCliBuildId());
|
||||
if (pid) {
|
||||
writePid(host, port, pid);
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForHealth(host, port, START_TIMEOUT_MS, pid ?? undefined);
|
||||
} catch (error) {
|
||||
if (pid) {
|
||||
removeStateFiles(host, port);
|
||||
} else {
|
||||
removeBackendVersion(host, port);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function trySignal(pid: number, signal: NodeJS.Signals): boolean {
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | undefined)?.code === "ESRCH") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function findProcessOnPort(port: number): number | null {
|
||||
try {
|
||||
const out = childProcess
|
||||
.execFileSync("lsof", ["-i", `:${port}`, "-t", "-sTCP:LISTEN"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
})
|
||||
.trim();
|
||||
|
||||
const pidRaw = out.split("\n")[0]?.trim();
|
||||
if (!pidRaw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pid = Number.parseInt(pidRaw, 10);
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return pid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopBackend(host: string, port: number): Promise<void> {
|
||||
let pid = readPid(host, port);
|
||||
|
||||
if (!pid) {
|
||||
if (!(await checkHealth(host, port))) {
|
||||
removeStateFiles(host, port);
|
||||
return;
|
||||
}
|
||||
|
||||
pid = findProcessOnPort(port);
|
||||
if (!pid) {
|
||||
throw new Error(`backend is healthy at ${host}:${port} but no PID could be resolved`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isProcessRunning(pid)) {
|
||||
removeStateFiles(host, port);
|
||||
return;
|
||||
}
|
||||
|
||||
trySignal(pid, "SIGTERM");
|
||||
|
||||
const deadline = Date.now() + STOP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isProcessRunning(pid)) {
|
||||
removeStateFiles(host, port);
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
trySignal(pid, "SIGKILL");
|
||||
removeStateFiles(host, port);
|
||||
}
|
||||
|
||||
export interface BackendStatus {
|
||||
running: boolean;
|
||||
pid: number | null;
|
||||
version: string | null;
|
||||
versionCurrent: boolean;
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
export async function getBackendStatus(host: string, port: number): Promise<BackendStatus> {
|
||||
const logPath = backendLogPath(host, port);
|
||||
const pid = readPid(host, port);
|
||||
|
||||
if (pid) {
|
||||
if (isProcessRunning(pid)) {
|
||||
return {
|
||||
running: true,
|
||||
pid,
|
||||
version: readBackendVersion(host, port),
|
||||
versionCurrent: isVersionCurrent(host, port),
|
||||
logPath,
|
||||
};
|
||||
}
|
||||
removeStateFiles(host, port);
|
||||
}
|
||||
|
||||
if (await checkHealth(host, port)) {
|
||||
return {
|
||||
running: true,
|
||||
pid: null,
|
||||
version: readBackendVersion(host, port),
|
||||
versionCurrent: isVersionCurrent(host, port),
|
||||
logPath,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
running: false,
|
||||
pid: null,
|
||||
version: readBackendVersion(host, port),
|
||||
versionCurrent: false,
|
||||
logPath,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureBackendRunning(config: AppConfig): Promise<void> {
|
||||
const host = config.backend.host;
|
||||
const port = config.backend.port;
|
||||
|
||||
if (await checkHealth(host, port)) {
|
||||
if (!isVersionCurrent(host, port)) {
|
||||
await stopBackend(host, port);
|
||||
await startBackend(host, port);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = readPid(host, port);
|
||||
if (pid && isProcessRunning(pid)) {
|
||||
try {
|
||||
await waitForHealth(host, port, START_TIMEOUT_MS, pid);
|
||||
if (!isVersionCurrent(host, port)) {
|
||||
await stopBackend(host, port);
|
||||
await startBackend(host, port);
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
await stopBackend(host, port);
|
||||
await startBackend(host, port);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (pid) {
|
||||
removeStateFiles(host, port);
|
||||
}
|
||||
|
||||
await startBackend(host, port);
|
||||
}
|
||||
|
||||
export function parseBackendPort(value: string | undefined, fallback: number): number {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
throw new Error(`Invalid backend port: ${value}`);
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
3
foundry/packages/cli/src/build-id.ts
Normal file
3
foundry/packages/cli/src/build-id.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
declare const __HF_BUILD_ID__: string | undefined;
|
||||
|
||||
export const CLI_BUILD_ID = typeof __HF_BUILD_ID__ === "string" && __HF_BUILD_ID__.trim().length > 0 ? __HF_BUILD_ID__.trim() : "dev";
|
||||
731
foundry/packages/cli/src/index.ts
Normal file
731
foundry/packages/cli/src/index.ts
Normal file
|
|
@ -0,0 +1,731 @@
|
|||
#!/usr/bin/env bun
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { AgentTypeSchema, CreateTaskInputSchema, type TaskRecord } from "@sandbox-agent/foundry-shared";
|
||||
import { readBackendMetadata, createBackendClientFromConfig, formatRelativeAge, groupTaskStatus, summarizeTasks } from "@sandbox-agent/foundry-client";
|
||||
import { ensureBackendRunning, getBackendStatus, parseBackendPort, stopBackend } from "./backend/manager.js";
|
||||
import { openEditorForTask } from "./task-editor.js";
|
||||
import { spawnCreateTmuxWindow } from "./tmux.js";
|
||||
import { loadConfig, resolveWorkspace, saveConfig } from "./workspace/config.js";
|
||||
|
||||
async function ensureBunRuntime(): Promise<void> {
|
||||
if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferred = process.env.HF_BUN?.trim();
|
||||
const candidates = [preferred, `${homedir()}/.bun/bin/bun`, "bun"].filter((item): item is string => Boolean(item && item.length > 0));
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const command = candidate;
|
||||
const canExec = command === "bun" || existsSync(command);
|
||||
if (!canExec) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const child = spawnSync(command, [process.argv[1] ?? "", ...process.argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
if (child.error) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const code = child.status ?? 1;
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
throw new Error("hf requires Bun runtime. Set HF_BUN or install Bun at ~/.bun/bin/bun.");
|
||||
}
|
||||
|
||||
async function runTuiCommand(config: ReturnType<typeof loadConfig>, workspaceId: string): Promise<void> {
|
||||
const mod = await import("./tui.js");
|
||||
await mod.runTui(config, workspaceId);
|
||||
}
|
||||
|
||||
function readOption(args: string[], flag: string): string | undefined {
|
||||
const idx = args.indexOf(flag);
|
||||
if (idx < 0) return undefined;
|
||||
return args[idx + 1];
|
||||
}
|
||||
|
||||
function hasFlag(args: string[], flag: string): boolean {
|
||||
return args.includes(flag);
|
||||
}
|
||||
|
||||
function parseIntOption(value: string | undefined, fallback: number, label: string): number {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`Invalid ${label}: ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function positionals(args: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const item = args[i];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.startsWith("--")) {
|
||||
const next = args[i + 1];
|
||||
if (next && !next.startsWith("--")) {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`
|
||||
Usage:
|
||||
hf backend start [--host HOST] [--port PORT]
|
||||
hf backend stop [--host HOST] [--port PORT]
|
||||
hf backend status
|
||||
hf backend inspect
|
||||
hf status [--workspace WS] [--json]
|
||||
hf history [--workspace WS] [--limit N] [--branch NAME] [--task ID] [--json]
|
||||
hf workspace use <name>
|
||||
hf tui [--workspace WS]
|
||||
|
||||
hf create [task] [--workspace WS] --repo <git-remote> [--name NAME|--branch NAME] [--title TITLE] [--agent claude|codex] [--on BRANCH]
|
||||
hf list [--workspace WS] [--format table|json] [--full]
|
||||
hf switch [task-id | -] [--workspace WS]
|
||||
hf attach <task-id> [--workspace WS]
|
||||
hf merge <task-id> [--workspace WS]
|
||||
hf archive <task-id> [--workspace WS]
|
||||
hf push <task-id> [--workspace WS]
|
||||
hf sync <task-id> [--workspace WS]
|
||||
hf kill <task-id> [--workspace WS] [--delete-branch] [--abandon]
|
||||
hf prune [--workspace WS] [--dry-run] [--yes]
|
||||
hf statusline [--workspace WS] [--format table|claude-code]
|
||||
hf db path
|
||||
hf db nuke
|
||||
|
||||
Tips:
|
||||
hf status --help Show status output format and examples
|
||||
hf history --help Show history output format and examples
|
||||
hf switch - Switch to most recently updated task
|
||||
`);
|
||||
}
|
||||
|
||||
function printStatusUsage(): void {
|
||||
console.log(`
|
||||
Usage:
|
||||
hf status [--workspace WS] [--json]
|
||||
|
||||
Text Output:
|
||||
workspace=<workspace-id>
|
||||
backend running=<true|false> pid=<pid|unknown> version=<version|unknown>
|
||||
tasks total=<number>
|
||||
status queued=<n> running=<n> idle=<n> archived=<n> killed=<n> error=<n>
|
||||
providers <provider-id>=<count> ...
|
||||
providers -
|
||||
|
||||
JSON Output:
|
||||
{
|
||||
"workspaceId": "default",
|
||||
"backend": { ...backend status object... },
|
||||
"tasks": {
|
||||
"total": 4,
|
||||
"byStatus": { "queued": 0, "running": 1, "idle": 2, "archived": 1, "killed": 0, "error": 0 },
|
||||
"byProvider": { "daytona": 4 }
|
||||
}
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
function printHistoryUsage(): void {
|
||||
console.log(`
|
||||
Usage:
|
||||
hf history [--workspace WS] [--limit N] [--branch NAME] [--task ID] [--json]
|
||||
|
||||
Text Output:
|
||||
<iso8601>\t<event-kind>\t<branch|task|repo|->\t<payload-json>
|
||||
<iso8601>\t<event-kind>\t<branch|task|repo|->\t<payload-json...>
|
||||
no events
|
||||
|
||||
Notes:
|
||||
- payload is truncated to 120 characters in text mode.
|
||||
- --limit defaults to 20.
|
||||
|
||||
JSON Output:
|
||||
[
|
||||
{
|
||||
"id": "...",
|
||||
"workspaceId": "default",
|
||||
"kind": "task.created",
|
||||
"taskId": "...",
|
||||
"repoId": "...",
|
||||
"branchName": "feature/foo",
|
||||
"payloadJson": "{\\"providerId\\":\\"daytona\\"}",
|
||||
"createdAt": 1770607522229
|
||||
}
|
||||
]
|
||||
`);
|
||||
}
|
||||
|
||||
async function handleBackend(args: string[]): Promise<void> {
|
||||
const sub = args[0] ?? "start";
|
||||
const config = loadConfig();
|
||||
const host = readOption(args, "--host") ?? config.backend.host;
|
||||
const port = parseBackendPort(readOption(args, "--port"), config.backend.port);
|
||||
const backendConfig = {
|
||||
...config,
|
||||
backend: {
|
||||
...config.backend,
|
||||
host,
|
||||
port,
|
||||
},
|
||||
};
|
||||
|
||||
if (sub === "start") {
|
||||
await ensureBackendRunning(backendConfig);
|
||||
const status = await getBackendStatus(host, port);
|
||||
const pid = status.pid ?? "unknown";
|
||||
const version = status.version ?? "unknown";
|
||||
const stale = status.running && !status.versionCurrent ? " [outdated]" : "";
|
||||
console.log(`running=true pid=${pid} version=${version}${stale} log=${status.logPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "stop") {
|
||||
await stopBackend(host, port);
|
||||
console.log(`running=false host=${host} port=${port}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "status") {
|
||||
const status = await getBackendStatus(host, port);
|
||||
const pid = status.pid ?? "unknown";
|
||||
const version = status.version ?? "unknown";
|
||||
const stale = status.running && !status.versionCurrent ? " [outdated]" : "";
|
||||
console.log(`running=${status.running} pid=${pid} version=${version}${stale} host=${host} port=${port} log=${status.logPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "inspect") {
|
||||
await ensureBackendRunning(backendConfig);
|
||||
const metadata = await readBackendMetadata({
|
||||
endpoint: `http://${host}:${port}/api/rivet`,
|
||||
timeoutMs: 4_000,
|
||||
});
|
||||
const managerEndpoint = metadata.clientEndpoint ?? `http://${host}:${port}`;
|
||||
const inspectorUrl = `https://inspect.rivet.dev?u=${encodeURIComponent(managerEndpoint)}`;
|
||||
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
|
||||
spawnSync(openCmd, [inspectorUrl], { stdio: "ignore" });
|
||||
console.log(inspectorUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown backend subcommand: ${sub}`);
|
||||
}
|
||||
|
||||
async function handleWorkspace(args: string[]): Promise<void> {
|
||||
const sub = args[0];
|
||||
if (sub !== "use") {
|
||||
throw new Error("Usage: hf workspace use <name>");
|
||||
}
|
||||
|
||||
const name = args[1];
|
||||
if (!name) {
|
||||
throw new Error("Missing workspace name");
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
config.workspace.default = name;
|
||||
saveConfig(config);
|
||||
|
||||
const client = createBackendClientFromConfig(config);
|
||||
try {
|
||||
await client.useWorkspace(name);
|
||||
} catch {
|
||||
// Backend may not be running yet. Config is already updated.
|
||||
}
|
||||
|
||||
console.log(`workspace=${name}`);
|
||||
}
|
||||
|
||||
async function handleList(args: string[]): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const format = readOption(args, "--format") ?? "table";
|
||||
const full = hasFlag(args, "--full");
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const rows = await client.listTasks(workspaceId);
|
||||
|
||||
if (format === "json") {
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log("no tasks");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const age = formatRelativeAge(row.updatedAt);
|
||||
let line = `${row.taskId}\t${row.branchName}\t${row.status}\t${row.providerId}\t${age}`;
|
||||
if (full) {
|
||||
const task = row.task.length > 60 ? `${row.task.slice(0, 57)}...` : row.task;
|
||||
line += `\t${row.title}\t${task}\t${row.activeSessionId ?? "-"}\t${row.activeSandboxId ?? "-"}`;
|
||||
}
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePush(args: string[]): Promise<void> {
|
||||
const taskId = positionals(args)[0];
|
||||
if (!taskId) {
|
||||
throw new Error("Missing task id for push");
|
||||
}
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const client = createBackendClientFromConfig(config);
|
||||
await client.runAction(workspaceId, taskId, "push");
|
||||
console.log("ok");
|
||||
}
|
||||
|
||||
async function handleSync(args: string[]): Promise<void> {
|
||||
const taskId = positionals(args)[0];
|
||||
if (!taskId) {
|
||||
throw new Error("Missing task id for sync");
|
||||
}
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const client = createBackendClientFromConfig(config);
|
||||
await client.runAction(workspaceId, taskId, "sync");
|
||||
console.log("ok");
|
||||
}
|
||||
|
||||
async function handleKill(args: string[]): Promise<void> {
|
||||
const taskId = positionals(args)[0];
|
||||
if (!taskId) {
|
||||
throw new Error("Missing task id for kill");
|
||||
}
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const deleteBranch = hasFlag(args, "--delete-branch");
|
||||
const abandon = hasFlag(args, "--abandon");
|
||||
|
||||
if (deleteBranch) {
|
||||
console.log("info: --delete-branch flag set, branch will be deleted after kill");
|
||||
}
|
||||
if (abandon) {
|
||||
console.log("info: --abandon flag set, Graphite abandon will be attempted");
|
||||
}
|
||||
|
||||
const client = createBackendClientFromConfig(config);
|
||||
await client.runAction(workspaceId, taskId, "kill");
|
||||
console.log("ok");
|
||||
}
|
||||
|
||||
async function handlePrune(args: string[]): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const dryRun = hasFlag(args, "--dry-run");
|
||||
const yes = hasFlag(args, "--yes");
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const rows = await client.listTasks(workspaceId);
|
||||
const prunable = rows.filter((r) => r.status === "archived" || r.status === "killed");
|
||||
|
||||
if (prunable.length === 0) {
|
||||
console.log("nothing to prune");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of prunable) {
|
||||
const age = formatRelativeAge(row.updatedAt);
|
||||
console.log(`${dryRun ? "[dry-run] " : ""}${row.taskId}\t${row.branchName}\t${row.status}\t${age}`);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n${prunable.length} task(s) would be pruned`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!yes) {
|
||||
console.log("\nnot yet implemented: auto-pruning requires confirmation");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n${prunable.length} task(s) would be pruned (pruning not yet implemented)`);
|
||||
}
|
||||
|
||||
async function handleStatusline(args: string[]): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const format = readOption(args, "--format") ?? "table";
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const rows = await client.listTasks(workspaceId);
|
||||
const summary = summarizeTasks(rows);
|
||||
const running = summary.byStatus.running;
|
||||
const idle = summary.byStatus.idle;
|
||||
const errorCount = summary.byStatus.error;
|
||||
|
||||
if (format === "claude-code") {
|
||||
console.log(`hf:${running}R/${idle}I/${errorCount}E`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`running=${running} idle=${idle} error=${errorCount}`);
|
||||
}
|
||||
|
||||
async function handleDb(args: string[]): Promise<void> {
|
||||
const sub = args[0];
|
||||
if (sub === "path") {
|
||||
const config = loadConfig();
|
||||
const dbPath = config.backend.dbPath.replace(/^~/, homedir());
|
||||
console.log(dbPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "nuke") {
|
||||
console.log("WARNING: hf db nuke would delete the entire database. This is a placeholder and does not delete anything.");
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Usage: hf db path | hf db nuke");
|
||||
}
|
||||
|
||||
async function waitForTaskReady(
|
||||
client: ReturnType<typeof createBackendClientFromConfig>,
|
||||
workspaceId: string,
|
||||
taskId: string,
|
||||
timeoutMs: number,
|
||||
): Promise<TaskRecord> {
|
||||
const start = Date.now();
|
||||
let delayMs = 250;
|
||||
|
||||
for (;;) {
|
||||
const record = await client.getTask(workspaceId, taskId);
|
||||
const hasName = Boolean(record.branchName && record.title);
|
||||
const hasSandbox = Boolean(record.activeSandboxId);
|
||||
|
||||
if (record.status === "error") {
|
||||
throw new Error(`task entered error state while provisioning: ${taskId}`);
|
||||
}
|
||||
if (hasName && hasSandbox) {
|
||||
return record;
|
||||
}
|
||||
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(`timed out waiting for task provisioning: ${taskId}`);
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
delayMs = Math.min(Math.round(delayMs * 1.5), 2_000);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(args: string[]): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
|
||||
const repoRemote = readOption(args, "--repo");
|
||||
if (!repoRemote) {
|
||||
throw new Error("Missing required --repo <git-remote>");
|
||||
}
|
||||
const explicitBranchName = readOption(args, "--name") ?? readOption(args, "--branch");
|
||||
const explicitTitle = readOption(args, "--title");
|
||||
|
||||
const agentRaw = readOption(args, "--agent");
|
||||
const agentType = agentRaw ? AgentTypeSchema.parse(agentRaw) : undefined;
|
||||
const onBranch = readOption(args, "--on");
|
||||
|
||||
const taskFromArgs = positionals(args).join(" ").trim();
|
||||
const task = taskFromArgs || openEditorForTask();
|
||||
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const repo = await client.addRepo(workspaceId, repoRemote);
|
||||
|
||||
const payload = CreateTaskInputSchema.parse({
|
||||
workspaceId,
|
||||
repoId: repo.repoId,
|
||||
task,
|
||||
explicitTitle: explicitTitle || undefined,
|
||||
explicitBranchName: explicitBranchName || undefined,
|
||||
agentType,
|
||||
onBranch,
|
||||
});
|
||||
|
||||
const created = await client.createTask(payload);
|
||||
const task = await waitForTaskReady(client, workspaceId, created.taskId, 180_000);
|
||||
const switched = await client.switchTask(workspaceId, task.taskId);
|
||||
const attached = await client.attachTask(workspaceId, task.taskId);
|
||||
|
||||
console.log(`Branch: ${task.branchName ?? "-"}`);
|
||||
console.log(`Task: ${task.taskId}`);
|
||||
console.log(`Provider: ${task.providerId}`);
|
||||
console.log(`Session: ${attached.sessionId ?? "none"}`);
|
||||
console.log(`Target: ${switched.switchTarget || attached.target}`);
|
||||
console.log(`Title: ${task.title ?? "-"}`);
|
||||
|
||||
const tmuxResult = spawnCreateTmuxWindow({
|
||||
branchName: task.branchName ?? task.taskId,
|
||||
targetPath: switched.switchTarget || attached.target,
|
||||
sessionId: attached.sessionId,
|
||||
});
|
||||
|
||||
if (tmuxResult.created) {
|
||||
console.log(`Window: created (${task.branchName})`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(`Run: hf switch ${task.taskId}`);
|
||||
if ((switched.switchTarget || attached.target).startsWith("/")) {
|
||||
console.log(`cd ${switched.switchTarget || attached.target}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTui(args: string[]): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
await runTuiCommand(config, workspaceId);
|
||||
}
|
||||
|
||||
async function handleStatus(args: string[]): Promise<void> {
|
||||
if (hasFlag(args, "--help") || hasFlag(args, "-h")) {
|
||||
printStatusUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const backendStatus = await getBackendStatus(config.backend.host, config.backend.port);
|
||||
const rows = await client.listTasks(workspaceId);
|
||||
const summary = summarizeTasks(rows);
|
||||
|
||||
if (hasFlag(args, "--json")) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
workspaceId,
|
||||
backend: backendStatus,
|
||||
tasks: {
|
||||
total: summary.total,
|
||||
byStatus: summary.byStatus,
|
||||
byProvider: summary.byProvider,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`workspace=${workspaceId}`);
|
||||
console.log(`backend running=${backendStatus.running} pid=${backendStatus.pid ?? "unknown"} version=${backendStatus.version ?? "unknown"}`);
|
||||
console.log(`tasks total=${summary.total}`);
|
||||
console.log(
|
||||
`status queued=${summary.byStatus.queued} running=${summary.byStatus.running} idle=${summary.byStatus.idle} archived=${summary.byStatus.archived} killed=${summary.byStatus.killed} error=${summary.byStatus.error}`,
|
||||
);
|
||||
const providerSummary = Object.entries(summary.byProvider)
|
||||
.map(([provider, count]) => `${provider}=${count}`)
|
||||
.join(" ");
|
||||
console.log(`providers ${providerSummary || "-"}`);
|
||||
}
|
||||
|
||||
async function handleHistory(args: string[]): Promise<void> {
|
||||
if (hasFlag(args, "--help") || hasFlag(args, "-h")) {
|
||||
printHistoryUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const limit = parseIntOption(readOption(args, "--limit"), 20, "limit");
|
||||
const branch = readOption(args, "--branch");
|
||||
const taskId = readOption(args, "--task");
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const rows = await client.listHistory({
|
||||
workspaceId,
|
||||
limit,
|
||||
branch: branch || undefined,
|
||||
taskId: taskId || undefined,
|
||||
});
|
||||
|
||||
if (hasFlag(args, "--json")) {
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log("no events");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const ts = new Date(row.createdAt).toISOString();
|
||||
const target = row.branchName || row.taskId || row.repoId || "-";
|
||||
let payload = row.payloadJson;
|
||||
if (payload.length > 120) {
|
||||
payload = `${payload.slice(0, 117)}...`;
|
||||
}
|
||||
console.log(`${ts}\t${row.kind}\t${target}\t${payload}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSwitchLike(cmd: string, args: string[]): Promise<void> {
|
||||
let taskId = positionals(args)[0];
|
||||
if (!taskId && cmd === "switch") {
|
||||
await handleTui(args);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!taskId) {
|
||||
throw new Error(`Missing task id for ${cmd}`);
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const client = createBackendClientFromConfig(config);
|
||||
|
||||
if (cmd === "switch" && taskId === "-") {
|
||||
const rows = await client.listTasks(workspaceId);
|
||||
const active = rows.filter((r) => {
|
||||
const group = groupTaskStatus(r.status);
|
||||
return group === "running" || group === "idle" || group === "queued";
|
||||
});
|
||||
const sorted = active.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
const target = sorted[0];
|
||||
if (!target) {
|
||||
throw new Error("No active tasks to switch to");
|
||||
}
|
||||
taskId = target.taskId;
|
||||
}
|
||||
|
||||
if (cmd === "switch") {
|
||||
const result = await client.switchTask(workspaceId, taskId);
|
||||
console.log(`cd ${result.switchTarget}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "attach") {
|
||||
const result = await client.attachTask(workspaceId, taskId);
|
||||
console.log(`target=${result.target} session=${result.sessionId ?? "none"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "merge" || cmd === "archive") {
|
||||
await client.runAction(workspaceId, taskId, cmd);
|
||||
console.log("ok");
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported action: ${cmd}`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await ensureBunRuntime();
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const cmd = args[0];
|
||||
const rest = args.slice(1);
|
||||
|
||||
if (cmd === "help" || cmd === "--help" || cmd === "-h") {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "backend") {
|
||||
await handleBackend(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
await ensureBackendRunning(config);
|
||||
|
||||
if (!cmd || cmd.startsWith("--")) {
|
||||
await handleTui(args);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "workspace") {
|
||||
await handleWorkspace(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "create") {
|
||||
await handleCreate(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "list") {
|
||||
await handleList(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "tui") {
|
||||
await handleTui(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "status") {
|
||||
await handleStatus(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "history") {
|
||||
await handleHistory(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "push") {
|
||||
await handlePush(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "sync") {
|
||||
await handleSync(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "kill") {
|
||||
await handleKill(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "prune") {
|
||||
await handlePrune(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "statusline") {
|
||||
await handleStatusline(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "db") {
|
||||
await handleDb(rest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (["switch", "attach", "merge", "archive"].includes(cmd)) {
|
||||
await handleSwitchLike(cmd, rest);
|
||||
return;
|
||||
}
|
||||
|
||||
printUsage();
|
||||
throw new Error(`Unknown command: ${cmd}`);
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
||||
console.error(msg);
|
||||
process.exit(1);
|
||||
});
|
||||
41
foundry/packages/cli/src/task-editor.ts
Normal file
41
foundry/packages/cli/src/task-editor.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const DEFAULT_EDITOR_TEMPLATE = ["# Enter task task details below.", "# Lines starting with # are ignored.", ""].join("\n");
|
||||
|
||||
export function sanitizeEditorTask(input: string): string {
|
||||
return input
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => !line.trim().startsWith("#"))
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function openEditorForTask(): string {
|
||||
const editor = process.env.VISUAL?.trim() || process.env.EDITOR?.trim() || "vi";
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "hf-task-"));
|
||||
const taskPath = join(tempDir, "task.md");
|
||||
|
||||
try {
|
||||
writeFileSync(taskPath, DEFAULT_EDITOR_TEMPLATE, "utf8");
|
||||
const result = spawnSync(editor, [taskPath], { stdio: "inherit" });
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
throw new Error(`Editor exited with status ${result.status ?? "unknown"}`);
|
||||
}
|
||||
|
||||
const raw = readFileSync(taskPath, "utf8");
|
||||
const task = sanitizeEditorTask(raw);
|
||||
if (!task) {
|
||||
throw new Error("Missing task task text");
|
||||
}
|
||||
return task;
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
792
foundry/packages/cli/src/theme.ts
Normal file
792
foundry/packages/cli/src/theme.ts
Normal file
|
|
@ -0,0 +1,792 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { cwd } from "node:process";
|
||||
import * as toml from "@iarna/toml";
|
||||
import type { AppConfig } from "@sandbox-agent/foundry-shared";
|
||||
import opencodeThemePackJson from "./themes/opencode-pack.json" with { type: "json" };
|
||||
|
||||
export type ThemeMode = "dark" | "light";
|
||||
|
||||
export interface TuiTheme {
|
||||
background: string;
|
||||
text: string;
|
||||
muted: string;
|
||||
header: string;
|
||||
status: string;
|
||||
highlightBg: string;
|
||||
highlightFg: string;
|
||||
selectionBorder: string;
|
||||
success: string;
|
||||
warning: string;
|
||||
error: string;
|
||||
info: string;
|
||||
diffAdd: string;
|
||||
diffDel: string;
|
||||
diffSep: string;
|
||||
agentRunning: string;
|
||||
agentIdle: string;
|
||||
agentNone: string;
|
||||
agentError: string;
|
||||
prUnpushed: string;
|
||||
author: string;
|
||||
ciRunning: string;
|
||||
ciPass: string;
|
||||
ciFail: string;
|
||||
ciNone: string;
|
||||
reviewApproved: string;
|
||||
reviewChanges: string;
|
||||
reviewPending: string;
|
||||
reviewNone: string;
|
||||
}
|
||||
|
||||
export interface TuiThemeResolution {
|
||||
theme: TuiTheme;
|
||||
name: string;
|
||||
source: string;
|
||||
mode: ThemeMode;
|
||||
}
|
||||
|
||||
interface ThemeCandidate {
|
||||
theme: TuiTheme;
|
||||
name: string;
|
||||
}
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
type ConfigLike = AppConfig & { theme?: string };
|
||||
|
||||
const DEFAULT_THEME: TuiTheme = {
|
||||
background: "#282828",
|
||||
text: "#ffffff",
|
||||
muted: "#6b7280",
|
||||
header: "#6b7280",
|
||||
status: "#6b7280",
|
||||
highlightBg: "#282828",
|
||||
highlightFg: "#ffffff",
|
||||
selectionBorder: "#d946ef",
|
||||
success: "#22c55e",
|
||||
warning: "#eab308",
|
||||
error: "#ef4444",
|
||||
info: "#22d3ee",
|
||||
diffAdd: "#22c55e",
|
||||
diffDel: "#ef4444",
|
||||
diffSep: "#6b7280",
|
||||
agentRunning: "#22c55e",
|
||||
agentIdle: "#eab308",
|
||||
agentNone: "#6b7280",
|
||||
agentError: "#ef4444",
|
||||
prUnpushed: "#eab308",
|
||||
author: "#22d3ee",
|
||||
ciRunning: "#eab308",
|
||||
ciPass: "#22c55e",
|
||||
ciFail: "#ef4444",
|
||||
ciNone: "#6b7280",
|
||||
reviewApproved: "#22c55e",
|
||||
reviewChanges: "#ef4444",
|
||||
reviewPending: "#eab308",
|
||||
reviewNone: "#6b7280",
|
||||
};
|
||||
|
||||
const OPENCODE_THEME_PACK = opencodeThemePackJson as Record<string, unknown>;
|
||||
|
||||
export function resolveTuiTheme(config: AppConfig, baseDir = cwd()): TuiThemeResolution {
|
||||
const mode = opencodeStateThemeMode() ?? "dark";
|
||||
const configWithTheme = config as ConfigLike;
|
||||
const override = typeof configWithTheme.theme === "string" ? configWithTheme.theme.trim() : "";
|
||||
|
||||
if (override) {
|
||||
const candidate = loadFromSpec(override, [], mode, baseDir);
|
||||
if (candidate) {
|
||||
return {
|
||||
theme: candidate.theme,
|
||||
name: candidate.name,
|
||||
source: "foundry config",
|
||||
mode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const fromConfig = loadOpencodeThemeFromConfig(mode, baseDir);
|
||||
if (fromConfig) {
|
||||
return fromConfig;
|
||||
}
|
||||
|
||||
const fromState = loadOpencodeThemeFromState(mode, baseDir);
|
||||
if (fromState) {
|
||||
return fromState;
|
||||
}
|
||||
|
||||
return {
|
||||
theme: DEFAULT_THEME,
|
||||
name: "opencode-default",
|
||||
source: "default",
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
function loadOpencodeThemeFromConfig(mode: ThemeMode, baseDir: string): TuiThemeResolution | null {
|
||||
for (const path of opencodeConfigPaths(baseDir)) {
|
||||
if (!existsSync(path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = readJsonWithComments(path);
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const themeValue = findOpencodeThemeValue(value);
|
||||
if (themeValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = themeFromOpencodeValue(themeValue, opencodeThemeDirs(dirname(path), baseDir), mode, baseDir);
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
theme: candidate.theme,
|
||||
name: candidate.name,
|
||||
source: `opencode config (${path})`,
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadOpencodeThemeFromState(mode: ThemeMode, baseDir: string): TuiThemeResolution | null {
|
||||
const path = opencodeStatePath();
|
||||
if (!path || !existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = readJsonWithComments(path);
|
||||
if (!isObject(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const spec = value.theme;
|
||||
if (typeof spec !== "string" || !spec.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = loadFromSpec(spec.trim(), opencodeThemeDirs(undefined, baseDir), mode, baseDir);
|
||||
if (!candidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
theme: candidate.theme,
|
||||
name: candidate.name,
|
||||
source: `opencode state (${path})`,
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
function loadFromSpec(spec: string, searchDirs: string[], mode: ThemeMode, baseDir: string): ThemeCandidate | null {
|
||||
if (isDefaultThemeName(spec)) {
|
||||
return {
|
||||
theme: DEFAULT_THEME,
|
||||
name: "opencode-default",
|
||||
};
|
||||
}
|
||||
|
||||
if (isPathLike(spec)) {
|
||||
const resolved = resolvePath(spec, baseDir);
|
||||
if (existsSync(resolved)) {
|
||||
const candidate = loadThemeFromPath(resolved, mode);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of searchDirs) {
|
||||
for (const ext of ["json", "toml"]) {
|
||||
const path = join(dir, `${spec}.${ext}`);
|
||||
if (!existsSync(path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = loadThemeFromPath(path, mode);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const builtIn = OPENCODE_THEME_PACK[spec];
|
||||
if (builtIn !== undefined) {
|
||||
const theme = themeFromOpencodeJson(builtIn, mode);
|
||||
if (theme) {
|
||||
return {
|
||||
theme,
|
||||
name: spec,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadThemeFromPath(path: string, mode: ThemeMode): ThemeCandidate | null {
|
||||
const content = safeReadText(path);
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.endsWith(".toml")) {
|
||||
try {
|
||||
const parsed = toml.parse(content);
|
||||
const theme = themeFromAny(parsed);
|
||||
if (!theme) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
theme,
|
||||
name: themeNameFromPath(path),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const value = parseJsonWithComments(content);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const opencodeTheme = themeFromOpencodeJson(value, mode);
|
||||
if (opencodeTheme) {
|
||||
return {
|
||||
theme: opencodeTheme,
|
||||
name: themeNameFromPath(path),
|
||||
};
|
||||
}
|
||||
|
||||
const paletteTheme = themeFromAny(value);
|
||||
if (!paletteTheme) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
theme: paletteTheme,
|
||||
name: themeNameFromPath(path),
|
||||
};
|
||||
}
|
||||
|
||||
function themeNameFromPath(path: string): string {
|
||||
const base = path.split(/[\\/]/).pop() ?? path;
|
||||
if (base.endsWith(".json") || base.endsWith(".toml")) {
|
||||
return base.replace(/\.(json|toml)$/i, "");
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function themeFromOpencodeValue(value: unknown, searchDirs: string[], mode: ThemeMode, baseDir: string): ThemeCandidate | null {
|
||||
if (typeof value === "string") {
|
||||
return loadFromSpec(value, searchDirs, mode, baseDir);
|
||||
}
|
||||
|
||||
if (!isObject(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.theme !== undefined) {
|
||||
const theme = themeFromOpencodeJson(value, mode);
|
||||
if (theme) {
|
||||
return {
|
||||
theme,
|
||||
name: typeof value.name === "string" ? value.name : "inline",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const paletteTheme = themeFromAny(value.colors ?? value.palette ?? value);
|
||||
if (paletteTheme) {
|
||||
return {
|
||||
theme: paletteTheme,
|
||||
name: typeof value.name === "string" ? value.name : "inline",
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value.name === "string") {
|
||||
const named = loadFromSpec(value.name, searchDirs, mode, baseDir);
|
||||
if (named) {
|
||||
return named;
|
||||
}
|
||||
}
|
||||
|
||||
const pathLike = value.path ?? value.file;
|
||||
if (typeof pathLike === "string") {
|
||||
const resolved = resolvePath(pathLike, baseDir);
|
||||
const candidate = loadThemeFromPath(resolved, mode);
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function themeFromOpencodeJson(value: unknown, mode: ThemeMode): TuiTheme | null {
|
||||
if (!isObject(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const themeMap = value.theme;
|
||||
if (!isObject(themeMap)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const defs = isObject(value.defs) ? value.defs : {};
|
||||
|
||||
const background =
|
||||
opencodeColor(themeMap, defs, mode, "background") ??
|
||||
opencodeColor(themeMap, defs, mode, "backgroundPanel") ??
|
||||
opencodeColor(themeMap, defs, mode, "backgroundElement") ??
|
||||
DEFAULT_THEME.background;
|
||||
|
||||
const text = opencodeColor(themeMap, defs, mode, "text") ?? DEFAULT_THEME.text;
|
||||
const muted = opencodeColor(themeMap, defs, mode, "textMuted") ?? DEFAULT_THEME.muted;
|
||||
const highlightBg = opencodeColor(themeMap, defs, mode, "text") ?? text;
|
||||
const highlightFg =
|
||||
opencodeColor(themeMap, defs, mode, "backgroundElement") ??
|
||||
opencodeColor(themeMap, defs, mode, "backgroundPanel") ??
|
||||
opencodeColor(themeMap, defs, mode, "background") ??
|
||||
DEFAULT_THEME.highlightFg;
|
||||
|
||||
const selectionBorder =
|
||||
opencodeColor(themeMap, defs, mode, "secondary") ??
|
||||
opencodeColor(themeMap, defs, mode, "accent") ??
|
||||
opencodeColor(themeMap, defs, mode, "primary") ??
|
||||
DEFAULT_THEME.selectionBorder;
|
||||
|
||||
const success = opencodeColor(themeMap, defs, mode, "success") ?? DEFAULT_THEME.success;
|
||||
const warning = opencodeColor(themeMap, defs, mode, "warning") ?? DEFAULT_THEME.warning;
|
||||
const error = opencodeColor(themeMap, defs, mode, "error") ?? DEFAULT_THEME.error;
|
||||
const info = opencodeColor(themeMap, defs, mode, "info") ?? DEFAULT_THEME.info;
|
||||
const diffAdd = opencodeColor(themeMap, defs, mode, "diffAdded") ?? success;
|
||||
const diffDel = opencodeColor(themeMap, defs, mode, "diffRemoved") ?? error;
|
||||
const diffSep = opencodeColor(themeMap, defs, mode, "diffContext") ?? opencodeColor(themeMap, defs, mode, "diffHunkHeader") ?? muted;
|
||||
|
||||
return {
|
||||
background,
|
||||
text,
|
||||
muted,
|
||||
header: muted,
|
||||
status: muted,
|
||||
highlightBg,
|
||||
highlightFg,
|
||||
selectionBorder,
|
||||
success,
|
||||
warning,
|
||||
error,
|
||||
info,
|
||||
diffAdd,
|
||||
diffDel,
|
||||
diffSep,
|
||||
agentRunning: success,
|
||||
agentIdle: warning,
|
||||
agentNone: muted,
|
||||
agentError: error,
|
||||
prUnpushed: warning,
|
||||
author: info,
|
||||
ciRunning: warning,
|
||||
ciPass: success,
|
||||
ciFail: error,
|
||||
ciNone: muted,
|
||||
reviewApproved: success,
|
||||
reviewChanges: error,
|
||||
reviewPending: warning,
|
||||
reviewNone: muted,
|
||||
};
|
||||
}
|
||||
|
||||
function opencodeColor(themeMap: JsonObject, defs: JsonObject, mode: ThemeMode, key: string): string | null {
|
||||
const raw = themeMap[key];
|
||||
if (raw === undefined) {
|
||||
return null;
|
||||
}
|
||||
return resolveOpencodeColor(raw, themeMap, defs, mode, 0);
|
||||
}
|
||||
|
||||
function resolveOpencodeColor(value: unknown, themeMap: JsonObject, defs: JsonObject, mode: ThemeMode, depth: number): string | null {
|
||||
if (depth > 12) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.toLowerCase() === "transparent" || trimmed.toLowerCase() === "none") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fromDefs = defs[trimmed];
|
||||
if (fromDefs !== undefined) {
|
||||
return resolveOpencodeColor(fromDefs, themeMap, defs, mode, depth + 1);
|
||||
}
|
||||
|
||||
const fromTheme = themeMap[trimmed];
|
||||
if (fromTheme !== undefined) {
|
||||
return resolveOpencodeColor(fromTheme, themeMap, defs, mode, depth + 1);
|
||||
}
|
||||
|
||||
if (isColorLike(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isObject(value)) {
|
||||
const nested = value[mode];
|
||||
if (nested !== undefined) {
|
||||
return resolveOpencodeColor(nested, themeMap, defs, mode, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function themeFromAny(value: unknown): TuiTheme | null {
|
||||
const palette = extractPalette(value);
|
||||
if (!palette) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pick = (keys: string[], fallback: string): string => {
|
||||
for (const key of keys) {
|
||||
const v = palette[normalizeKey(key)];
|
||||
if (v && isColorLike(v)) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const background = pick(["background", "bg", "base", "background_color"], DEFAULT_THEME.background);
|
||||
const text = pick(["text", "foreground", "fg", "primary"], DEFAULT_THEME.text);
|
||||
const muted = pick(["muted", "subtle", "secondary", "dim"], DEFAULT_THEME.muted);
|
||||
const header = pick(["header", "header_text"], muted);
|
||||
const status = pick(["status", "status_text"], muted);
|
||||
const highlightBg = pick(["highlight_bg", "selection", "highlight", "accent_bg"], DEFAULT_THEME.highlightBg);
|
||||
const highlightFg = pick(["highlight_fg", "selection_fg", "accent_fg"], text);
|
||||
const selectionBorder = pick(["selection_border", "highlight_border", "accent", "secondary"], DEFAULT_THEME.selectionBorder);
|
||||
const success = pick(["success", "green"], DEFAULT_THEME.success);
|
||||
const warning = pick(["warning", "yellow"], DEFAULT_THEME.warning);
|
||||
const error = pick(["error", "red"], DEFAULT_THEME.error);
|
||||
const info = pick(["info", "cyan", "blue"], DEFAULT_THEME.info);
|
||||
const diffAdd = pick(["diff_add", "diff_addition", "add"], success);
|
||||
const diffDel = pick(["diff_del", "diff_deletion", "delete"], error);
|
||||
const diffSep = pick(["diff_sep", "diff_separator", "separator"], muted);
|
||||
|
||||
return {
|
||||
background,
|
||||
text,
|
||||
muted,
|
||||
header,
|
||||
status,
|
||||
highlightBg,
|
||||
highlightFg,
|
||||
selectionBorder,
|
||||
success,
|
||||
warning,
|
||||
error,
|
||||
info,
|
||||
diffAdd,
|
||||
diffDel,
|
||||
diffSep,
|
||||
agentRunning: pick(["agent_running", "running"], success),
|
||||
agentIdle: pick(["agent_idle", "idle"], warning),
|
||||
agentNone: pick(["agent_none", "none"], muted),
|
||||
agentError: pick(["agent_error", "agent_failed"], error),
|
||||
prUnpushed: pick(["pr_unpushed", "unpushed"], warning),
|
||||
author: pick(["author"], info),
|
||||
ciRunning: pick(["ci_running"], warning),
|
||||
ciPass: pick(["ci_pass", "ci_success"], success),
|
||||
ciFail: pick(["ci_fail", "ci_error"], error),
|
||||
ciNone: pick(["ci_none", "ci_unknown"], muted),
|
||||
reviewApproved: pick(["review_approved", "approved"], success),
|
||||
reviewChanges: pick(["review_changes", "changes"], error),
|
||||
reviewPending: pick(["review_pending", "pending"], warning),
|
||||
reviewNone: pick(["review_none", "review_unknown"], muted),
|
||||
};
|
||||
}
|
||||
|
||||
function extractPalette(value: unknown): Record<string, string> | null {
|
||||
if (!isObject(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const colors = isObject(value.colors) ? value.colors : undefined;
|
||||
const palette = isObject(value.palette) ? value.palette : undefined;
|
||||
const source = colors ?? palette ?? value;
|
||||
if (!isObject(source)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, raw] of Object.entries(source)) {
|
||||
if (typeof raw !== "string") {
|
||||
continue;
|
||||
}
|
||||
out[normalizeKey(key)] = raw;
|
||||
}
|
||||
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
}
|
||||
|
||||
function normalizeKey(key: string): string {
|
||||
return key.toLowerCase().replace(/[\-\s.]/g, "_");
|
||||
}
|
||||
|
||||
function isColorLike(value: string): boolean {
|
||||
const lower = value.trim().toLowerCase();
|
||||
if (!lower) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/^#[0-9a-f]{3}$/.test(lower) || /^#[0-9a-f]{6}$/.test(lower) || /^#[0-9a-f]{8}$/.test(lower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+(\s*,\s*[\d.]+)?\s*\)$/.test(lower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /^[a-z_\-]+$/.test(lower);
|
||||
}
|
||||
|
||||
function findOpencodeThemeValue(value: unknown): unknown {
|
||||
if (!isObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (value.theme !== undefined) {
|
||||
return value.theme;
|
||||
}
|
||||
|
||||
return pointer(value, ["ui", "theme"]) ?? pointer(value, ["tui", "theme"]) ?? pointer(value, ["options", "theme"]);
|
||||
}
|
||||
|
||||
function pointer(obj: JsonObject, parts: string[]): unknown {
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (!isObject(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function opencodeConfigPaths(baseDir: string): string[] {
|
||||
const paths: string[] = [];
|
||||
|
||||
const rootish = opencodeProjectConfigPaths(baseDir);
|
||||
paths.push(...rootish);
|
||||
|
||||
const configDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
||||
const opencodeDir = join(configDir, "opencode");
|
||||
paths.push(join(opencodeDir, "opencode.json"));
|
||||
paths.push(join(opencodeDir, "opencode.jsonc"));
|
||||
paths.push(join(opencodeDir, "config.json"));
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
function opencodeThemeDirs(configDir: string | undefined, baseDir: string): string[] {
|
||||
const dirs: string[] = [];
|
||||
|
||||
if (configDir) {
|
||||
dirs.push(join(configDir, "themes"));
|
||||
}
|
||||
|
||||
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
||||
dirs.push(join(xdgConfig, "opencode", "themes"));
|
||||
dirs.push(join(homedir(), ".opencode", "themes"));
|
||||
|
||||
dirs.push(...opencodeProjectThemeDirs(baseDir));
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function opencodeProjectConfigPaths(baseDir: string): string[] {
|
||||
const dirs = ancestorDirs(baseDir);
|
||||
const out: string[] = [];
|
||||
for (const dir of dirs) {
|
||||
out.push(join(dir, "opencode.json"));
|
||||
out.push(join(dir, "opencode.jsonc"));
|
||||
out.push(join(dir, ".opencode", "opencode.json"));
|
||||
out.push(join(dir, ".opencode", "opencode.jsonc"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function opencodeProjectThemeDirs(baseDir: string): string[] {
|
||||
const dirs = ancestorDirs(baseDir);
|
||||
const out: string[] = [];
|
||||
for (const dir of dirs) {
|
||||
out.push(join(dir, ".opencode", "themes"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ancestorDirs(start: string): string[] {
|
||||
const out: string[] = [];
|
||||
let current = resolve(start);
|
||||
|
||||
while (true) {
|
||||
out.push(current);
|
||||
const parent = dirname(current);
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function opencodeStatePath(): string | null {
|
||||
const stateHome = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
|
||||
return join(stateHome, "opencode", "kv.json");
|
||||
}
|
||||
|
||||
function opencodeStateThemeMode(): ThemeMode | null {
|
||||
const path = opencodeStatePath();
|
||||
if (!path || !existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = readJsonWithComments(path);
|
||||
if (!isObject(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mode = value.theme_mode;
|
||||
if (typeof mode !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lower = mode.toLowerCase();
|
||||
if (lower === "dark" || lower === "light") {
|
||||
return lower;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseJsonWithComments(content: string): unknown {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(stripJsoncComments(content));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonWithComments(path: string): unknown {
|
||||
const content = safeReadText(path);
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
return parseJsonWithComments(content);
|
||||
}
|
||||
|
||||
function stripJsoncComments(input: string): string {
|
||||
let output = "";
|
||||
let i = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
|
||||
while (i < input.length) {
|
||||
const ch = input[i];
|
||||
|
||||
if (inString) {
|
||||
output += ch;
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (ch === "\\") {
|
||||
escaped = true;
|
||||
} else if (ch === '"') {
|
||||
inString = false;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
output += ch;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "/" && input[i + 1] === "/") {
|
||||
i += 2;
|
||||
while (i < input.length && input[i] !== "\n") {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "/" && input[i + 1] === "*") {
|
||||
i += 2;
|
||||
while (i < input.length) {
|
||||
if (input[i] === "*" && input[i + 1] === "/") {
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
output += ch;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function safeReadText(path: string): string | null {
|
||||
try {
|
||||
return readFileSync(path, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePath(path: string, baseDir: string): string {
|
||||
if (path.startsWith("~/")) {
|
||||
return join(homedir(), path.slice(2));
|
||||
}
|
||||
if (isAbsolute(path)) {
|
||||
return path;
|
||||
}
|
||||
return resolve(baseDir, path);
|
||||
}
|
||||
|
||||
function isPathLike(spec: string): boolean {
|
||||
return spec.includes("/") || spec.includes("\\") || spec.endsWith(".json") || spec.endsWith(".toml");
|
||||
}
|
||||
|
||||
function isDefaultThemeName(spec: string): boolean {
|
||||
const lower = spec.toLowerCase();
|
||||
return lower === "default" || lower === "opencode" || lower === "opencode-default" || lower === "system";
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is JsonObject {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
7408
foundry/packages/cli/src/themes/opencode-pack.json
Normal file
7408
foundry/packages/cli/src/themes/opencode-pack.json
Normal file
File diff suppressed because it is too large
Load diff
180
foundry/packages/cli/src/tmux.ts
Normal file
180
foundry/packages/cli/src/tmux.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
const SYMBOL_RUNNING = "▶";
|
||||
const SYMBOL_IDLE = "✓";
|
||||
const DEFAULT_OPENCODE_ENDPOINT = "http://127.0.0.1:4097/opencode";
|
||||
|
||||
export interface TmuxWindowMatch {
|
||||
target: string;
|
||||
windowName: string;
|
||||
}
|
||||
|
||||
export interface SpawnCreateTmuxWindowInput {
|
||||
branchName: string;
|
||||
targetPath: string;
|
||||
sessionId?: string | null;
|
||||
opencodeEndpoint?: string;
|
||||
}
|
||||
|
||||
export interface SpawnCreateTmuxWindowResult {
|
||||
created: boolean;
|
||||
reason: "created" | "not-in-tmux" | "not-local-path" | "window-exists" | "tmux-new-window-failed";
|
||||
}
|
||||
|
||||
function isTmuxSession(): boolean {
|
||||
return Boolean(process.env.TMUX);
|
||||
}
|
||||
|
||||
function isAbsoluteLocalPath(path: string): boolean {
|
||||
return path.startsWith("/");
|
||||
}
|
||||
|
||||
function runTmux(args: string[]): boolean {
|
||||
const result = spawnSync("tmux", args, { stdio: "ignore" });
|
||||
return !result.error && result.status === 0;
|
||||
}
|
||||
|
||||
function shellEscape(value: string): string {
|
||||
if (value.length === 0) {
|
||||
return "''";
|
||||
}
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function opencodeExistsOnPath(): boolean {
|
||||
const probe = spawnSync("which", ["opencode"], { stdio: "ignore" });
|
||||
return !probe.error && probe.status === 0;
|
||||
}
|
||||
|
||||
function resolveOpencodeBinary(): string {
|
||||
const envOverride = process.env.HF_OPENCODE_BIN?.trim();
|
||||
if (envOverride) {
|
||||
return envOverride;
|
||||
}
|
||||
|
||||
if (opencodeExistsOnPath()) {
|
||||
return "opencode";
|
||||
}
|
||||
|
||||
const bundledCandidates = [`${homedir()}/.local/share/sandbox-agent/bin/opencode`, `${homedir()}/.opencode/bin/opencode`];
|
||||
|
||||
for (const candidate of bundledCandidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return "opencode";
|
||||
}
|
||||
|
||||
function attachCommand(sessionId: string, targetPath: string, endpoint: string): string {
|
||||
const opencode = resolveOpencodeBinary();
|
||||
return [shellEscape(opencode), "attach", shellEscape(endpoint), "--session", shellEscape(sessionId), "--dir", shellEscape(targetPath)].join(" ");
|
||||
}
|
||||
|
||||
export function stripStatusPrefix(windowName: string): string {
|
||||
return windowName
|
||||
.trimStart()
|
||||
.replace(new RegExp(`^${SYMBOL_RUNNING}\\s+`), "")
|
||||
.replace(new RegExp(`^${SYMBOL_IDLE}\\s+`), "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function findTmuxWindowsByBranch(branchName: string): TmuxWindowMatch[] {
|
||||
const output = spawnSync("tmux", ["list-windows", "-a", "-F", "#{session_name}:#{window_id}:#{window_name}"], { encoding: "utf8" });
|
||||
|
||||
if (output.error || output.status !== 0 || !output.stdout) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = output.stdout.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||
const matches: TmuxWindowMatch[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split(":", 3);
|
||||
if (parts.length !== 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionName = parts[0] ?? "";
|
||||
const windowId = parts[1] ?? "";
|
||||
const windowName = parts[2] ?? "";
|
||||
const clean = stripStatusPrefix(windowName);
|
||||
if (clean !== branchName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
matches.push({
|
||||
target: `${sessionName}:${windowId}`,
|
||||
windowName,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function spawnCreateTmuxWindow(input: SpawnCreateTmuxWindowInput): SpawnCreateTmuxWindowResult {
|
||||
if (!isTmuxSession()) {
|
||||
return { created: false, reason: "not-in-tmux" };
|
||||
}
|
||||
|
||||
if (!isAbsoluteLocalPath(input.targetPath)) {
|
||||
return { created: false, reason: "not-local-path" };
|
||||
}
|
||||
|
||||
if (findTmuxWindowsByBranch(input.branchName).length > 0) {
|
||||
return { created: false, reason: "window-exists" };
|
||||
}
|
||||
|
||||
const windowName = input.sessionId ? `${SYMBOL_RUNNING} ${input.branchName}` : input.branchName;
|
||||
const endpoint = input.opencodeEndpoint ?? DEFAULT_OPENCODE_ENDPOINT;
|
||||
let output = "";
|
||||
try {
|
||||
output = execFileSync("tmux", ["new-window", "-d", "-P", "-F", "#{window_id}", "-n", windowName, "-c", input.targetPath], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch {
|
||||
return { created: false, reason: "tmux-new-window-failed" };
|
||||
}
|
||||
|
||||
const windowId = output.trim();
|
||||
if (!windowId) {
|
||||
return { created: false, reason: "tmux-new-window-failed" };
|
||||
}
|
||||
|
||||
if (input.sessionId) {
|
||||
const leftPane = `${windowId}.0`;
|
||||
|
||||
// Split left pane horizontally → creates right pane; capture its pane ID
|
||||
let rightPane: string;
|
||||
try {
|
||||
rightPane = execFileSync("tmux", ["split-window", "-h", "-P", "-F", "#{pane_id}", "-t", leftPane, "-c", input.targetPath], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return { created: true, reason: "created" };
|
||||
}
|
||||
|
||||
if (!rightPane) {
|
||||
return { created: true, reason: "created" };
|
||||
}
|
||||
|
||||
// Split right pane vertically → top-right (rightPane) + bottom-right (new)
|
||||
runTmux(["split-window", "-v", "-t", rightPane, "-c", input.targetPath]);
|
||||
|
||||
// Left pane 60% width, top-right pane 70% height
|
||||
runTmux(["resize-pane", "-t", leftPane, "-x", "60%"]);
|
||||
runTmux(["resize-pane", "-t", rightPane, "-y", "70%"]);
|
||||
|
||||
// Editor in left pane, agent attach in top-right pane
|
||||
runTmux(["send-keys", "-t", leftPane, "nvim .", "Enter"]);
|
||||
runTmux(["send-keys", "-t", rightPane, attachCommand(input.sessionId, input.targetPath, endpoint), "Enter"]);
|
||||
runTmux(["select-pane", "-t", rightPane]);
|
||||
}
|
||||
|
||||
return { created: true, reason: "created" };
|
||||
}
|
||||
608
foundry/packages/cli/src/tui.ts
Normal file
608
foundry/packages/cli/src/tui.ts
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
import type { AppConfig, TaskRecord } from "@sandbox-agent/foundry-shared";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createBackendClientFromConfig, filterTasks, formatRelativeAge, groupTaskStatus } from "@sandbox-agent/foundry-client";
|
||||
import { CLI_BUILD_ID } from "./build-id.js";
|
||||
import { resolveTuiTheme, type TuiTheme } from "./theme.js";
|
||||
|
||||
interface KeyEventLike {
|
||||
name?: string;
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
}
|
||||
|
||||
const HELP_LINES = [
|
||||
"Shortcuts",
|
||||
"Ctrl-H toggle cheatsheet",
|
||||
"Enter switch to branch",
|
||||
"Ctrl-A attach to session",
|
||||
"Ctrl-O open PR in browser",
|
||||
"Ctrl-X archive branch / close PR",
|
||||
"Ctrl-Y merge highlighted PR",
|
||||
"Ctrl-S sync task with remote",
|
||||
"Ctrl-N / Down next row",
|
||||
"Ctrl-P / Up previous row",
|
||||
"Backspace delete filter",
|
||||
"Type filter by branch/PR/author",
|
||||
"Esc / Ctrl-C cancel",
|
||||
"",
|
||||
"Legend",
|
||||
"Agent: \u{1F916} running \u{1F4AC} idle \u25CC queued",
|
||||
];
|
||||
|
||||
const COLUMN_WIDTHS = {
|
||||
diff: 10,
|
||||
agent: 5,
|
||||
pr: 6,
|
||||
author: 10,
|
||||
ci: 7,
|
||||
review: 8,
|
||||
age: 5,
|
||||
} as const;
|
||||
|
||||
interface DisplayRow {
|
||||
name: string;
|
||||
diff: string;
|
||||
agent: string;
|
||||
pr: string;
|
||||
author: string;
|
||||
ci: string;
|
||||
review: string;
|
||||
age: string;
|
||||
}
|
||||
|
||||
interface RenderOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
function pad(input: string, width: number): string {
|
||||
if (width <= 0) {
|
||||
return "";
|
||||
}
|
||||
const chars = Array.from(input);
|
||||
const text = chars.length > width ? `${chars.slice(0, Math.max(1, width - 1)).join("")}…` : input;
|
||||
return text.padEnd(width, " ");
|
||||
}
|
||||
|
||||
function truncateToLen(input: string, maxLen: number): string {
|
||||
if (maxLen <= 0) {
|
||||
return "";
|
||||
}
|
||||
return Array.from(input).slice(0, maxLen).join("");
|
||||
}
|
||||
|
||||
function fitLine(input: string, width: number): string {
|
||||
if (width <= 0) {
|
||||
return "";
|
||||
}
|
||||
const clipped = truncateToLen(input, width);
|
||||
const len = Array.from(clipped).length;
|
||||
if (len >= width) {
|
||||
return clipped;
|
||||
}
|
||||
return `${clipped}${" ".repeat(width - len)}`;
|
||||
}
|
||||
|
||||
function overlayLine(base: string, overlay: string, startCol: number, width: number): string {
|
||||
const out = Array.from(fitLine(base, width));
|
||||
const src = Array.from(truncateToLen(overlay, Math.max(0, width - startCol)));
|
||||
for (let i = 0; i < src.length; i += 1) {
|
||||
const col = startCol + i;
|
||||
if (col >= 0 && col < out.length) {
|
||||
out[col] = src[i] ?? " ";
|
||||
}
|
||||
}
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function buildFooterLine(width: number, segments: string[], right: string): string {
|
||||
if (width <= 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const rightLen = Array.from(right).length;
|
||||
if (width <= rightLen + 1) {
|
||||
return truncateToLen(right, width);
|
||||
}
|
||||
|
||||
const leftMax = width - rightLen - 1;
|
||||
let used = 0;
|
||||
let left = "";
|
||||
let first = true;
|
||||
|
||||
for (const segment of segments) {
|
||||
const chunk = first ? segment : ` | ${segment}`;
|
||||
const clipped = truncateToLen(chunk, leftMax - used);
|
||||
if (!clipped) {
|
||||
break;
|
||||
}
|
||||
left += clipped;
|
||||
used += Array.from(clipped).length;
|
||||
first = false;
|
||||
if (used >= leftMax) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const padding = " ".repeat(Math.max(0, leftMax - used) + 1);
|
||||
return `${left}${padding}${right}`;
|
||||
}
|
||||
|
||||
function agentSymbol(status: TaskRecord["status"]): string {
|
||||
const group = groupTaskStatus(status);
|
||||
if (group === "running") return "🤖";
|
||||
if (group === "idle") return "💬";
|
||||
if (group === "error") return "⚠";
|
||||
if (group === "queued") return "◌";
|
||||
return "-";
|
||||
}
|
||||
|
||||
function toDisplayRow(row: TaskRecord): DisplayRow {
|
||||
const conflictPrefix = row.conflictsWithMain === "true" ? "\u26A0 " : "";
|
||||
|
||||
const prLabel = row.prUrl ? `#${row.prUrl.match(/\/pull\/(\d+)/)?.[1] ?? "?"}` : row.prSubmitted ? "sub" : "-";
|
||||
|
||||
const ciLabel = row.ciStatus ?? "-";
|
||||
const reviewLabel = row.reviewStatus
|
||||
? row.reviewStatus === "approved"
|
||||
? "ok"
|
||||
: row.reviewStatus === "changes_requested"
|
||||
? "chg"
|
||||
: row.reviewStatus === "pending"
|
||||
? "..."
|
||||
: row.reviewStatus
|
||||
: "-";
|
||||
|
||||
return {
|
||||
name: `${conflictPrefix}${row.title || row.branchName}`,
|
||||
diff: row.diffStat ?? "-",
|
||||
agent: agentSymbol(row.status),
|
||||
pr: prLabel,
|
||||
author: row.prAuthor ?? "-",
|
||||
ci: ciLabel,
|
||||
review: reviewLabel,
|
||||
age: formatRelativeAge(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function helpLines(width: number): string[] {
|
||||
const popupWidth = Math.max(40, Math.min(width - 2, 100));
|
||||
const innerWidth = Math.max(2, popupWidth - 2);
|
||||
const borderTop = `┌${"─".repeat(innerWidth)}┐`;
|
||||
const borderBottom = `└${"─".repeat(innerWidth)}┘`;
|
||||
|
||||
const lines = [borderTop];
|
||||
for (const line of HELP_LINES) {
|
||||
lines.push(`│${pad(line, innerWidth)}│`);
|
||||
}
|
||||
lines.push(borderBottom);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function formatRows(
|
||||
rows: TaskRecord[],
|
||||
selected: number,
|
||||
workspaceId: string,
|
||||
status: string,
|
||||
searchQuery = "",
|
||||
showHelp = false,
|
||||
options: RenderOptions = {},
|
||||
): string {
|
||||
const totalWidth = options.width ?? process.stdout.columns ?? 120;
|
||||
const totalHeight = Math.max(6, options.height ?? process.stdout.rows ?? 24);
|
||||
const fixedWidth =
|
||||
COLUMN_WIDTHS.diff + COLUMN_WIDTHS.agent + COLUMN_WIDTHS.pr + COLUMN_WIDTHS.author + COLUMN_WIDTHS.ci + COLUMN_WIDTHS.review + COLUMN_WIDTHS.age;
|
||||
const separators = 7;
|
||||
const prefixWidth = 2;
|
||||
const branchWidth = Math.max(20, totalWidth - (fixedWidth + separators + prefixWidth));
|
||||
|
||||
const branchHeader = searchQuery ? `Branch/PR: ${searchQuery}_` : "Branch/PR (type to filter)";
|
||||
const header = [
|
||||
` ${pad(branchHeader, branchWidth)} ${pad("Diff", COLUMN_WIDTHS.diff)} ${pad("Agent", COLUMN_WIDTHS.agent)} ${pad("PR", COLUMN_WIDTHS.pr)} ${pad("Author", COLUMN_WIDTHS.author)} ${pad("CI", COLUMN_WIDTHS.ci)} ${pad("Review", COLUMN_WIDTHS.review)} ${pad("Age", COLUMN_WIDTHS.age)}`,
|
||||
"-".repeat(Math.max(24, Math.min(totalWidth, 180))),
|
||||
];
|
||||
|
||||
const body =
|
||||
rows.length === 0
|
||||
? ["No branches found."]
|
||||
: rows.map((row, index) => {
|
||||
const marker = index === selected ? "┃ " : " ";
|
||||
const display = toDisplayRow(row);
|
||||
return `${marker}${pad(display.name, branchWidth)} ${pad(display.diff, COLUMN_WIDTHS.diff)} ${pad(display.agent, COLUMN_WIDTHS.agent)} ${pad(display.pr, COLUMN_WIDTHS.pr)} ${pad(display.author, COLUMN_WIDTHS.author)} ${pad(display.ci, COLUMN_WIDTHS.ci)} ${pad(display.review, COLUMN_WIDTHS.review)} ${pad(display.age, COLUMN_WIDTHS.age)}`;
|
||||
});
|
||||
|
||||
const footer = fitLine(buildFooterLine(totalWidth, ["Ctrl-H:cheatsheet", `workspace:${workspaceId}`, status], `v${CLI_BUILD_ID}`), totalWidth);
|
||||
|
||||
const contentHeight = totalHeight - 1;
|
||||
const lines = [...header, ...body].map((line) => fitLine(line, totalWidth));
|
||||
const page = lines.slice(0, contentHeight);
|
||||
while (page.length < contentHeight) {
|
||||
page.push(" ".repeat(totalWidth));
|
||||
}
|
||||
|
||||
if (showHelp) {
|
||||
const popup = helpLines(totalWidth);
|
||||
const startRow = Math.max(0, Math.floor((contentHeight - popup.length) / 2));
|
||||
for (let i = 0; i < popup.length; i += 1) {
|
||||
const target = startRow + i;
|
||||
if (target >= page.length) {
|
||||
break;
|
||||
}
|
||||
const popupLine = popup[i] ?? "";
|
||||
const popupLen = Array.from(popupLine).length;
|
||||
const startCol = Math.max(0, Math.floor((totalWidth - popupLen) / 2));
|
||||
page[target] = overlayLine(page[target] ?? "", popupLine, startCol, totalWidth);
|
||||
}
|
||||
}
|
||||
|
||||
return [...page, footer].join("\n");
|
||||
}
|
||||
|
||||
interface OpenTuiLike {
|
||||
createCliRenderer?: (options?: Record<string, unknown>) => Promise<any>;
|
||||
TextRenderable?: new (
|
||||
ctx: any,
|
||||
options: { id: string; content: string },
|
||||
) => {
|
||||
content: unknown;
|
||||
fg?: string;
|
||||
bg?: string;
|
||||
};
|
||||
fg?: (color: string) => (input: unknown) => unknown;
|
||||
bg?: (color: string) => (input: unknown) => unknown;
|
||||
StyledText?: new (chunks: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
interface StyledTextApi {
|
||||
fg: (color: string) => (input: unknown) => unknown;
|
||||
bg: (color: string) => (input: unknown) => unknown;
|
||||
StyledText: new (chunks: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
function buildStyledContent(content: string, theme: TuiTheme, api: StyledTextApi): unknown {
|
||||
const lines = content.split("\n");
|
||||
const chunks: unknown[] = [];
|
||||
const footerIndex = Math.max(0, lines.length - 1);
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i] ?? "";
|
||||
|
||||
let fgColor = theme.text;
|
||||
let bgColor: string | undefined;
|
||||
|
||||
if (line.startsWith("┃ ")) {
|
||||
const marker = "┃ ";
|
||||
const rest = line.slice(marker.length);
|
||||
bgColor = theme.highlightBg;
|
||||
const markerChunk = api.bg(bgColor)(api.fg(theme.selectionBorder)(marker));
|
||||
const restChunk = api.bg(bgColor)(api.fg(theme.highlightFg)(rest));
|
||||
chunks.push(markerChunk);
|
||||
chunks.push(restChunk);
|
||||
if (i < lines.length - 1) {
|
||||
chunks.push(api.fg(theme.text)("\n"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i === 0) {
|
||||
fgColor = theme.header;
|
||||
} else if (i === 1) {
|
||||
fgColor = theme.muted;
|
||||
} else if (i === footerIndex) {
|
||||
fgColor = theme.status;
|
||||
} else if (line.startsWith("┌") || line.startsWith("│") || line.startsWith("└")) {
|
||||
fgColor = theme.info;
|
||||
}
|
||||
|
||||
let chunk: unknown = api.fg(fgColor)(line);
|
||||
if (bgColor) {
|
||||
chunk = api.bg(bgColor)(chunk);
|
||||
}
|
||||
chunks.push(chunk);
|
||||
|
||||
if (i < lines.length - 1) {
|
||||
chunks.push(api.fg(theme.text)("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
return new api.StyledText(chunks);
|
||||
}
|
||||
|
||||
export async function runTui(config: AppConfig, workspaceId: string): Promise<void> {
|
||||
const core = (await import("@opentui/core")) as OpenTuiLike;
|
||||
const createCliRenderer = core.createCliRenderer;
|
||||
const TextRenderable = core.TextRenderable;
|
||||
const styleApi = core.fg && core.bg && core.StyledText ? { fg: core.fg, bg: core.bg, StyledText: core.StyledText } : null;
|
||||
|
||||
if (!createCliRenderer || !TextRenderable) {
|
||||
throw new Error("OpenTUI runtime missing createCliRenderer/TextRenderable exports");
|
||||
}
|
||||
|
||||
const themeResolution = resolveTuiTheme(config);
|
||||
const client = createBackendClientFromConfig(config);
|
||||
const renderer = await createCliRenderer({ exitOnCtrlC: false });
|
||||
const text = new TextRenderable(renderer, {
|
||||
id: "foundry-switch",
|
||||
content: "Loading...",
|
||||
});
|
||||
text.fg = themeResolution.theme.text;
|
||||
text.bg = themeResolution.theme.background;
|
||||
renderer.root.add(text);
|
||||
renderer.start();
|
||||
|
||||
let allRows: TaskRecord[] = [];
|
||||
let filteredRows: TaskRecord[] = [];
|
||||
let selected = 0;
|
||||
let searchQuery = "";
|
||||
let showHelp = false;
|
||||
let status = "loading...";
|
||||
let busy = false;
|
||||
let closed = false;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const clampSelected = (): void => {
|
||||
if (filteredRows.length === 0) {
|
||||
selected = 0;
|
||||
return;
|
||||
}
|
||||
if (selected < 0) {
|
||||
selected = 0;
|
||||
return;
|
||||
}
|
||||
if (selected >= filteredRows.length) {
|
||||
selected = filteredRows.length - 1;
|
||||
}
|
||||
};
|
||||
|
||||
const render = (): void => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
const output = formatRows(filteredRows, selected, workspaceId, status, searchQuery, showHelp, {
|
||||
width: renderer.width ?? process.stdout.columns,
|
||||
height: renderer.height ?? process.stdout.rows,
|
||||
});
|
||||
text.content = styleApi ? buildStyledContent(output, themeResolution.theme, styleApi) : output;
|
||||
renderer.requestRender();
|
||||
};
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
allRows = await client.listTasks(workspaceId);
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
filteredRows = filterTasks(allRows, searchQuery);
|
||||
clampSelected();
|
||||
status = `tasks=${allRows.length} filtered=${filteredRows.length}`;
|
||||
} catch (err) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
status = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
render();
|
||||
};
|
||||
|
||||
const selectedRow = (): TaskRecord | null => {
|
||||
if (filteredRows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return filteredRows[selected] ?? null;
|
||||
};
|
||||
|
||||
let resolveDone: () => void = () => {};
|
||||
const done = new Promise<void>((resolve) => {
|
||||
resolveDone = () => resolve();
|
||||
});
|
||||
|
||||
const close = (output?: string): void => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
process.off("SIGINT", handleSignal);
|
||||
process.off("SIGTERM", handleSignal);
|
||||
renderer.destroy();
|
||||
if (output) {
|
||||
console.log(output);
|
||||
}
|
||||
resolveDone();
|
||||
};
|
||||
|
||||
const handleSignal = (): void => {
|
||||
close();
|
||||
};
|
||||
|
||||
const runActionWithRefresh = async (label: string, fn: () => Promise<void>, success: string): Promise<void> => {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
status = `${label}...`;
|
||||
render();
|
||||
try {
|
||||
await fn();
|
||||
status = success;
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
status = err instanceof Error ? err.message : String(err);
|
||||
render();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
await refresh();
|
||||
timer = setInterval(() => {
|
||||
void refresh();
|
||||
}, 10_000);
|
||||
process.once("SIGINT", handleSignal);
|
||||
process.once("SIGTERM", handleSignal);
|
||||
|
||||
const keyInput = (renderer.keyInput ?? renderer.keyHandler) as { on: (name: string, cb: (event: KeyEventLike) => void) => void } | undefined;
|
||||
|
||||
if (!keyInput) {
|
||||
clearInterval(timer);
|
||||
renderer.destroy();
|
||||
throw new Error("OpenTUI key input handler is unavailable");
|
||||
}
|
||||
|
||||
keyInput.on("keypress", (event: KeyEventLike) => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = event.name ?? "";
|
||||
const ctrl = Boolean(event.ctrl);
|
||||
|
||||
if (ctrl && name === "h") {
|
||||
showHelp = !showHelp;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (showHelp) {
|
||||
if (name === "escape") {
|
||||
showHelp = false;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === "q" || name === "escape" || (ctrl && name === "c")) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((ctrl && name === "n") || name === "down") {
|
||||
if (filteredRows.length > 0) {
|
||||
selected = selected >= filteredRows.length - 1 ? 0 : selected + 1;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ((ctrl && name === "p") || name === "up") {
|
||||
if (filteredRows.length > 0) {
|
||||
selected = selected <= 0 ? filteredRows.length - 1 : selected - 1;
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === "backspace") {
|
||||
searchQuery = searchQuery.slice(0, -1);
|
||||
filteredRows = filterTasks(allRows, searchQuery);
|
||||
selected = 0;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === "return" || name === "enter") {
|
||||
const row = selectedRow();
|
||||
if (!row || busy) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
status = `switching ${row.taskId}...`;
|
||||
render();
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await client.switchTask(workspaceId, row.taskId);
|
||||
close(`cd ${result.switchTarget}`);
|
||||
} catch (err) {
|
||||
busy = false;
|
||||
status = err instanceof Error ? err.message : String(err);
|
||||
render();
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && name === "a") {
|
||||
const row = selectedRow();
|
||||
if (!row || busy) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
status = `attaching ${row.taskId}...`;
|
||||
render();
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await client.attachTask(workspaceId, row.taskId);
|
||||
close(`target=${result.target} session=${result.sessionId ?? "none"}`);
|
||||
} catch (err) {
|
||||
busy = false;
|
||||
status = err instanceof Error ? err.message : String(err);
|
||||
render();
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && name === "x") {
|
||||
const row = selectedRow();
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
void runActionWithRefresh(`archiving ${row.taskId}`, async () => client.runAction(workspaceId, row.taskId, "archive"), `archived ${row.taskId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && name === "s") {
|
||||
const row = selectedRow();
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
void runActionWithRefresh(`syncing ${row.taskId}`, async () => client.runAction(workspaceId, row.taskId, "sync"), `synced ${row.taskId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && name === "y") {
|
||||
const row = selectedRow();
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
void runActionWithRefresh(
|
||||
`merging ${row.taskId}`,
|
||||
async () => {
|
||||
await client.runAction(workspaceId, row.taskId, "merge");
|
||||
await client.runAction(workspaceId, row.taskId, "archive");
|
||||
},
|
||||
`merged+archived ${row.taskId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && name === "o") {
|
||||
const row = selectedRow();
|
||||
if (!row?.prUrl) {
|
||||
status = "no PR URL available for this task";
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
|
||||
spawnSync(openCmd, [row.prUrl], { stdio: "ignore" });
|
||||
status = `opened ${row.prUrl}`;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctrl && !event.meta && name.length === 1) {
|
||||
searchQuery += name;
|
||||
filteredRows = filterTasks(allRows, searchQuery);
|
||||
selected = 0;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
await done;
|
||||
}
|
||||
25
foundry/packages/cli/src/workspace/config.ts
Normal file
25
foundry/packages/cli/src/workspace/config.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import * as toml from "@iarna/toml";
|
||||
import { ConfigSchema, resolveWorkspaceId, type AppConfig } from "@sandbox-agent/foundry-shared";
|
||||
|
||||
export const CONFIG_PATH = `${homedir()}/.config/foundry/config.toml`;
|
||||
|
||||
export function loadConfig(path = CONFIG_PATH): AppConfig {
|
||||
if (!existsSync(path)) {
|
||||
return ConfigSchema.parse({});
|
||||
}
|
||||
|
||||
const raw = readFileSync(path, "utf8");
|
||||
return ConfigSchema.parse(toml.parse(raw));
|
||||
}
|
||||
|
||||
export function saveConfig(config: AppConfig, path = CONFIG_PATH): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, toml.stringify(config), "utf8");
|
||||
}
|
||||
|
||||
export function resolveWorkspace(flagWorkspace: string | undefined, config: AppConfig): string {
|
||||
return resolveWorkspaceId(flagWorkspace, config);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue