Skip to content
Merged
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
11 changes: 11 additions & 0 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,11 @@ because an earlier version could abandon the awaited promise on Escape and
leave the session parked with no recovery path short of killing the process;
Escape must always settle the promise it is dismissing.

The permissions prompt's title line names `/yolo` as the way to skip further
prompts, longest first as the terminal narrows: `Esc cancel · Enter choose ·
/yolo skip prompts`, then `Esc · Enter · /yolo`, then `Esc · Enter`. Operator
questions and the model/provider picker do not advertise `/yolo`.

Once a permission or operator prompt is answered — or cancelled, timed out,
or auto-settled by a grant / abort / teardown — it leaves the screen and
does **not** replay the request, the command, or the chosen option into the
Expand Down Expand Up @@ -356,6 +361,12 @@ or transcript. The shortcut list it used to open is still reachable, as
via `openCommandSurface`'s `"help"` case, `command-surfaces.ts`); the `/` row
in `SHELL_SHORTCUTS` documents that in place of a dedicated `?` row.

The idle landing paints two doors beside the mark, keys aligned so the
descriptions share a column (`LANDING_HINTS` in `src/tui/landing.ts`): `/`
for commands, and `/yolo` so Corbits Code does not have to ask for
permissions. An 80-column terminal still seats the compact mark next to
them; when the terminal is too narrow, the hints win and the mark drops.

The running build version is chrome, not part of the landing composition:
`shell.ts`'s `versionRow`/`versionBadge`, a dedicated row pinned to the
terminal's last line and right-aligned, distinct from `landing.ts`'s hero and
Expand Down
56 changes: 53 additions & 3 deletions src/agent/environment.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, test } from "bun:test";
import { afterEach, expect, test } from "bun:test";
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
Expand All @@ -9,6 +9,30 @@ import { gatherEnvironment, getGitBranch } from "./environment.js";

const run = promisify(execFile);

const GIT_FATAL = "fatal: not a git repository";

function captureStderr(): { output: () => string; restore: () => void } {
const original = process.stderr.write.bind(process.stderr);
let wrote = "";
process.stderr.write = ((chunk: string | Uint8Array) => {
wrote += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
return true;
}) as typeof process.stderr.write;
return {
output: () => wrote,
restore: () => {
process.stderr.write = original;
},
};
}

let restoreStderr: (() => void) | undefined;

afterEach(() => {
restoreStderr?.();
restoreStderr = undefined;
});

test("gatherEnvironment reports cwd, platform, and date", async () => {
const date = new Date(2026, 5, 5);
const env = await gatherEnvironment(process.cwd(), date);
Expand All @@ -29,7 +53,7 @@ test("gatherEnvironment detects a git work tree and lists its top level", async
await mkdir(join(dir, "src"));
await writeFile(join(dir, "src", "seed.ts"), "export const seed = 1;\n");
await run("git", ["add", "."], { cwd: dir });
await run("git", ["commit", "-m", "seed"], { cwd: dir });
await run("git", ["-c", "core.hooksPath=/dev/null", "commit", "-m", "seed"], { cwd: dir });

const env = await gatherEnvironment(dir);
expect(env.isGitRepo).toBe(true);
Expand All @@ -49,7 +73,7 @@ test("gatherEnvironment gathers branch and dirty status from the same work tree"
await run("git", ["checkout", "-b", "trunk"], { cwd: dir });
await writeFile(join(dir, "seed.txt"), "seed");
await run("git", ["add", "."], { cwd: dir });
await run("git", ["commit", "-m", "seed"], { cwd: dir });
await run("git", ["-c", "core.hooksPath=/dev/null", "commit", "-m", "seed"], { cwd: dir });
await writeFile(join(dir, "a.txt"), "one");
await writeFile(join(dir, "b.txt"), "two");

Expand Down Expand Up @@ -96,3 +120,29 @@ test("gatherEnvironment reports a non-git directory without throwing", async ()
await rm(dir, { recursive: true, force: true });
}
});

test("getGitBranch does not leak git fatal stderr on a non-repo", async () => {
const dir = await mkdtemp(join(tmpdir(), "corbits-env-branch-nogit-"));
const cap = captureStderr();
restoreStderr = cap.restore;
try {
expect(await getGitBranch(dir)).toBe(null);
expect(cap.output()).not.toContain(GIT_FATAL);
} finally {
await rm(dir, { recursive: true, force: true });
}
});

test("gatherEnvironment does not leak git fatal stderr on a non-repo", async () => {
const dir = await mkdtemp(join(tmpdir(), "corbits-env-gather-nogit-"));
const cap = captureStderr();
restoreStderr = cap.restore;
try {
const env = await gatherEnvironment(dir);
expect(env.isGitRepo).toBe(false);
expect(env.gitBranch).toBeUndefined();
expect(cap.output()).not.toContain(GIT_FATAL);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
31 changes: 26 additions & 5 deletions src/agent/environment.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { execFile } from "node:child_process";
import { spawn } from "node:child_process";
import { readdir } from "node:fs/promises";
import { arch, release, type as osType } from "node:os";
import { promisify } from "node:util";

const run = promisify(execFile);

export interface EnvironmentInfo {
cwd: string;
Expand All @@ -27,7 +24,31 @@ const GIT_TIMEOUT_MS = 3000;

async function git(cwd: string, args: string[]): Promise<string | null> {
try {
const { stdout } = await run("git", args, { cwd, timeout: GIT_TIMEOUT_MS });
const stdout = await new Promise<string>((resolve, reject) => {
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] });
if (child.stdout === null) {
reject(new Error("git stdout is not available"));
return;
}
let out = "";
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
out += chunk;
});
const timer = setTimeout(() => {
child.kill();
reject(new Error("git timed out"));
}, GIT_TIMEOUT_MS);
child.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
child.on("close", (code) => {
clearTimeout(timer);
if (code === 0) resolve(out);
else reject(new Error("git failed"));
});
});
return stdout.trim();
} catch {
return null;
Expand Down
79 changes: 79 additions & 0 deletions src/permission/worktree-roots.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { listWorktreeRoots, listWorktreeRootsSync } from "./worktree-roots.js";

const GIT_FATAL = "fatal: not a git repository";

function captureStderr(): { output: () => string; restore: () => void } {
const original = process.stderr.write.bind(process.stderr);
let wrote = "";
process.stderr.write = ((chunk: string | Uint8Array) => {
wrote += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
return true;
}) as typeof process.stderr.write;
return {
output: () => wrote,
restore: () => {
process.stderr.write = original;
},
};
}

let restoreStderr: (() => void) | undefined;

afterEach(() => {
restoreStderr?.();
restoreStderr = undefined;
});

describe("listWorktreeRoots stderr", () => {
test("listWorktreeRootsSync does not leak git fatal on a non-repo", () => {
const dir = mkdtempSync(join(tmpdir(), "corbits-nogit-sync-"));
const cap = captureStderr();
restoreStderr = cap.restore;
const roots = listWorktreeRootsSync(dir);
expect(roots).toEqual([]);
expect(cap.output()).not.toContain(GIT_FATAL);
});

test("listWorktreeRoots does not leak git fatal on a non-repo", async () => {
const dir = mkdtempSync(join(tmpdir(), "corbits-nogit-async-"));
const cap = captureStderr();
restoreStderr = cap.restore;
const roots = await listWorktreeRoots(dir);
expect(roots).toEqual([]);
expect(cap.output()).not.toContain(GIT_FATAL);
});

test("listWorktreeRootsSync still lists sibling worktrees inside a repo", () => {
const base = mkdtempSync(join(tmpdir(), "corbits-git-sync-"));
const repo = join(base, "repo");
const worktree = join(base, "secondary");
mkdirSync(repo);
const git = (...args: string[]): void => {
execFileSync("git", args, { cwd: repo, stdio: "ignore" });
};
git("init", "-b", "main");
git(
"-c",
"core.hooksPath=/dev/null",
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"--allow-empty",
"-m",
"init",
);
git("worktree", "add", worktree);

const roots = listWorktreeRootsSync(repo);
expect(roots).toContain(realpathSync(worktree));
expect(roots).not.toContain(realpathSync(repo));
});
});
29 changes: 25 additions & 4 deletions src/permission/worktree-roots.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util";
import { execFileSync, spawn } from "node:child_process";
import { realpathSync } from "node:fs";
import { resolve } from "node:path";

const execFileAsync = promisify(execFile);
function gitWorktreeList(cwd: string): Promise<string> {
return new Promise((resolvePromise, reject) => {
const child = spawn("git", ["worktree", "list", "--porcelain"], {
cwd,
stdio: ["ignore", "pipe", "ignore"],
});
if (child.stdout === null) {
reject(new Error("git stdout is not available"));
return;
}
let stdout = "";
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) resolvePromise(stdout);
else reject(new Error("git worktree list failed"));
});
});
}

// Git prints realpaths; the caller's cwd may reach the same directory through a
// symlink (e.g. macOS /tmp, /var). Canonicalize both sides so self-exclusion
Expand Down Expand Up @@ -35,7 +55,7 @@ function parseWorktreePorcelain(stdout: string, cwd: string): string[] {
// the gate then confines autonomy to cwd alone, which is the safe floor.
export async function listWorktreeRoots(cwd: string): Promise<string[]> {
try {
const { stdout } = await execFileAsync("git", ["worktree", "list", "--porcelain"], { cwd });
const stdout = await gitWorktreeList(cwd);
return parseWorktreePorcelain(stdout, cwd);
} catch {
return [];
Expand All @@ -50,6 +70,7 @@ export function listWorktreeRootsSync(cwd: string): string[] {
const stdout = execFileSync("git", ["worktree", "list", "--porcelain"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
return parseWorktreePorcelain(stdout, cwd);
} catch {
Expand Down
21 changes: 14 additions & 7 deletions src/tui/landing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,17 +105,24 @@ describe("landing layout math", () => {
expect(text.some((line) => line.includes("telemetry"))).toBe(false);
});

test("the two doors are commands and /yolo", () => {
expect(LANDING_HINTS).toEqual([
{ key: "/", rest: "for commands" },
{ key: "/yolo", rest: "so Corbits Code doesn't have to ask for permissions" },
]);
});

test("the mark degrades through its tiers and then disappears", () => {
// Roomy: the hero grid, which is the only size that reads unambiguously.
expect(resolveMarkGrid(20, 96)).toBe(MARK_LARGE);
expect(resolveMarkGrid(20, 120)).toBe(MARK_LARGE);
// A row short of the hero, a tier down rather than a clipped hero.
expect(resolveMarkGrid(12, 96)).toBe(MARK_MID);
expect(resolveMarkGrid(9, 96)).toBe(MARK_SMALL);
expect(resolveMarkGrid(12, 120)).toBe(MARK_MID);
expect(resolveMarkGrid(9, 120)).toBe(MARK_SMALL);
// 80-column terminal: contentWidth is 78 after gutters; the compact mark
// still seats beside the two doors.
expect(resolveMarkGrid(20, 78)).toBe(MARK_SMALL);
// Narrow enough that the mark would crowd the hints: the hints win.
// (The version moved off this hint block into the shell's own chrome —
// CL-5736 — so the block is narrower and a bit more room stays for the
// mark at this width than before.)
expect(resolveMarkGrid(20, 50)).toBe(MARK_MID);
expect(resolveMarkGrid(20, 50)).toBeNull();
expect(resolveMarkGrid(20, 30)).toBeNull();
expect(resolveMarkGrid(3, 96)).toBeNull();
});
Expand Down
22 changes: 14 additions & 8 deletions src/tui/landing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
* ──── the prompt box and its hint row (owned by the shell)
* below the telemetry disclosure, then a few selectable starter prompts
*
* The mark is the screen. Beside it sit exactly two lines — the command menu
* and the shortcut sheet — because those two are the only doors an operator
* needs on a screen where nothing has happened yet; every other key is behind
* one of them, and listing keys here would trade the one legible thing on the
* screen for a reference card nobody reads twice.
* The mark is the screen. Beside it sit exactly two lines — `/` for commands
* and `/yolo` so permission prompts are not required — because those two are
* the only doors an operator needs on a screen where nothing has happened yet;
* every other key is behind one of them, and listing keys here would trade the
* one legible thing on the screen for a reference card nobody reads twice.
*
* The disclosure sits directly under the box rather than at the bottom edge
* because it has to be read, not discovered.
Expand Down Expand Up @@ -70,13 +70,19 @@ export function versionBadgeVisible(columns: number, rows: number): boolean {
}

/**
* The one door off the landing screen — `/help` (listed among the commands
* `/` opens) is the other, so this stays a single row rather than growing.
* The two doors off the landing screen. `/help` is among the commands `/`
* opens; `/yolo` is the other way in, so permission prompts do not have to be
* discovered the hard way.
*/
export const LANDING_HINTS: readonly {
readonly key: string;
readonly rest: string;
}[] = [{ key: "/", rest: "for commands" }];
}[] = [
{ key: "/", rest: "for commands" },
// One character shorter than the spoken line ("does not") so an 80-column
// terminal still seats the compact mark beside the aligned descriptions.
{ key: "/yolo", rest: "so Corbits Code doesn't have to ask for permissions" },
];

/**
* Columns held for the key, so the descriptions beside them start on one
Expand Down
Loading
Loading