From 163246e9566b9b7f9adc574196f24c22a415aad9 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 31 Aug 2026 08:22:07 -0700 Subject: [PATCH 1/2] Silence git fatal stderr outside a repository --- src/agent/environment.test.ts | 56 ++++++++++++++++++- src/agent/environment.ts | 31 +++++++++-- src/permission/worktree-roots.test.ts | 79 +++++++++++++++++++++++++++ src/permission/worktree-roots.ts | 29 ++++++++-- 4 files changed, 183 insertions(+), 12 deletions(-) create mode 100644 src/permission/worktree-roots.test.ts diff --git a/src/agent/environment.test.ts b/src/agent/environment.test.ts index 17ab46ad6..8400ca786 100644 --- a/src/agent/environment.test.ts +++ b/src/agent/environment.test.ts @@ -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"; @@ -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); @@ -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); @@ -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"); @@ -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 }); + } +}); diff --git a/src/agent/environment.ts b/src/agent/environment.ts index 63fec202a..41d50e040 100644 --- a/src/agent/environment.ts +++ b/src/agent/environment.ts @@ -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; @@ -27,7 +24,31 @@ const GIT_TIMEOUT_MS = 3000; async function git(cwd: string, args: string[]): Promise { try { - const { stdout } = await run("git", args, { cwd, timeout: GIT_TIMEOUT_MS }); + const stdout = await new Promise((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; diff --git a/src/permission/worktree-roots.test.ts b/src/permission/worktree-roots.test.ts new file mode 100644 index 000000000..e3fe4cbc9 --- /dev/null +++ b/src/permission/worktree-roots.test.ts @@ -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)); + }); +}); diff --git a/src/permission/worktree-roots.ts b/src/permission/worktree-roots.ts index 03f2bd233..3776fdd59 100644 --- a/src/permission/worktree-roots.ts +++ b/src/permission/worktree-roots.ts @@ -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 { + 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 @@ -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 { try { - const { stdout } = await execFileAsync("git", ["worktree", "list", "--porcelain"], { cwd }); + const stdout = await gitWorktreeList(cwd); return parseWorktreePorcelain(stdout, cwd); } catch { return []; @@ -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 { From 01f223afa6cc98fef03aa1a1cc2f83f77f615f3b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 31 Aug 2026 08:22:17 -0700 Subject: [PATCH 2/2] Surface /yolo on landing and permission prompts --- docs/TUI.md | 11 +++++++++++ src/tui/landing.test.ts | 21 ++++++++++++++------- src/tui/landing.ts | 22 ++++++++++++++-------- src/tui/overlays.test.ts | 25 +++++++++++++++++++++++++ src/tui/shell.ts | 8 ++++++++ 5 files changed, 72 insertions(+), 15 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 70dd1db90..e271feb3e 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -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 @@ -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 diff --git a/src/tui/landing.test.ts b/src/tui/landing.test.ts index 378984181..b4b8269a8 100644 --- a/src/tui/landing.test.ts +++ b/src/tui/landing.test.ts @@ -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(); }); diff --git a/src/tui/landing.ts b/src/tui/landing.ts index fa5816270..517774109 100644 --- a/src/tui/landing.ts +++ b/src/tui/landing.ts @@ -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. @@ -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 diff --git a/src/tui/overlays.test.ts b/src/tui/overlays.test.ts index add776495..939c78167 100644 --- a/src/tui/overlays.test.ts +++ b/src/tui/overlays.test.ts @@ -99,6 +99,8 @@ describe("permissions overlay", () => { // First option is in the list model (may clip if body short). expect(shell.overlayItems[0]).toBe("Allow once"); expect(frame).toMatch(/Allow/); + expect(frame).toContain("/yolo"); + expect(frame).toContain("Esc cancel · Enter choose · /yolo skip prompts"); // Navigate deep enough that window must scroll (keep-active-visible). const listH = shell.overlayList!.height; @@ -132,6 +134,27 @@ describe("permissions overlay", () => { ); }); + test("key hints drop to Esc · Enter on a narrow interior", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 32, rows: 24 }, + wireKeys: false, + }); + try { + openPermissionsOverlay(shell, { items: makePermissionItems(4) }); + await h.renderOnce(); + const frame = h.captureCharFrame(); + expect(frame).toContain("Esc · Enter"); + expect(frame).not.toContain("/yolo"); + } finally { + shell.dispose(); + } + }, + { width: 32, height: 24 }, + ); + }); + test("Esc key closes permissions overlay via wireKeys", async () => { await withTestRenderer( async (h) => { @@ -208,6 +231,7 @@ describe("operator question overlay", () => { expect(frame).toMatch(/Cancel|Allow/); // The overlay carries its own keys now that there is no hint strip. expect(frame).toContain("Esc cancel"); + expect(frame).not.toContain("/yolo"); // Empty title must not leave a leading middle-dot before the hints. expect(frame).not.toMatch(/·\s*Esc cancel/); @@ -245,6 +269,7 @@ describe("model / provider picker", () => { let frame = h.captureCharFrame(); expect(frame).toContain("model"); expect(frame).toMatch(/anthropic|openai|claude/i); + expect(frame).not.toContain("/yolo"); moveOverlaySelection(shell, 2); acceptOverlaySelection(shell); diff --git a/src/tui/shell.ts b/src/tui/shell.ts index 2f71647d5..6a88de539 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -1337,6 +1337,13 @@ const MODEL_PICKER_HINTS = [ "Esc · Enter", ] as const; +/** Permissions only: name `/yolo` so skip-prompts is discoverable at the ask. */ +const PERMISSIONS_HINTS = [ + "Esc cancel · Enter choose · /yolo skip prompts", + "Esc · Enter · /yolo", + "Esc · Enter", +] as const; + function overlayHints(shell: AppShell): readonly string[] { const answer = overlayAnswerState(shell); const hasChoices = shell.overlayItems.length > 0; @@ -1363,6 +1370,7 @@ function overlayHints(shell: AppShell): readonly string[] { ]; } } + if (shell.overlayKind === "permissions") return PERMISSIONS_HINTS; return DEFAULT_OVERLAY_HINTS; } if (answer.active) {