diff --git a/package.json b/package.json index ff794180..79bf8f6e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test": "bun test packages", "check:bun": "bun packages/core/scripts/check-bun-version.ts", "e2e": "python3 scripts/e2e-iterm-tui.py && python3 scripts/e2e-iterm-plan-mode.py", - "e2e:pty": "python3 scripts/e2e-pty.py && python3 scripts/e2e-wizard-pty.py && python3 scripts/e2e-config-repl-pty.py && python3 scripts/e2e-help-menu-pty.py && python3 scripts/e2e-editor-pty.py && python3 scripts/e2e-agents-pty.py && python3 scripts/e2e-spawn-agent-pty.py", + "e2e:pty": "python3 scripts/e2e-pty.py && python3 scripts/e2e-wizard-pty.py && python3 scripts/e2e-config-repl-pty.py && python3 scripts/e2e-help-menu-pty.py && python3 scripts/e2e-scaffold-command-pty.py && python3 scripts/e2e-editor-pty.py && python3 scripts/e2e-agents-pty.py && python3 scripts/e2e-spawn-agent-pty.py", "validate": "bun run check:bun && bun run typecheck && bun run lint && bun run format:check && bun run test && bun run e2e:pty", "rules:build": "bun packages/core/scripts/build-rules-md.ts", "rules:docs": "bun packages/core/scripts/build-rule-docs.ts", diff --git a/packages/core/src/cli/capabilities.ts b/packages/core/src/cli/capabilities.ts index eac27286..5dac8966 100644 --- a/packages/core/src/cli/capabilities.ts +++ b/packages/core/src/cli/capabilities.ts @@ -28,6 +28,19 @@ const SESSION_AND_COST = "Session & cost"; // ── Command group mapping ──────────────────────────────────────────────────── +// Slash commands whose discovery home in the browser is a WIZARD row, not a +// generated command row. `/scaffold` opens the scaffold wizard: the browser must +// route it through the awaited wizard path (`openWizard`), never a fire-and-forget +// `void runLine`, or the wizard and the editor both consume stdin (the double-typed +// -text failure). So it gets NO command-capability row here — the "Build something +// new" wizard row (below) is its single browser home. It stays in COMMANDS for the +// `/help` text, the `/` palette, and typed dispatch (all of which are already safe). +export const COMMAND_WIZARD_HOME: Readonly< + Record +> = { + "/scaffold": "scaffold", +}; + const COMMAND_TO_GROUP: Readonly> = { "/review": UNDERSTAND_YOUR_CODE, "/map": UNDERSTAND_YOUR_CODE, @@ -53,7 +66,11 @@ function commandCapabilities(): ICapability[] { const capabilities: ICapability[] = []; for (const spec of COMMANDS) { - if (exempt.has(spec.name)) { + // Skip the browser-exempt commands and any command whose home is a wizard row. + if ( + exempt.has(spec.name) || + Object.hasOwn(COMMAND_WIZARD_HOME, spec.name) + ) { continue; } diff --git a/packages/core/src/cli/commands.ts b/packages/core/src/cli/commands.ts index 82628bc9..816023a5 100644 --- a/packages/core/src/cli/commands.ts +++ b/packages/core/src/cli/commands.ts @@ -15,6 +15,11 @@ export interface ICommandSpec { export const COMMANDS: readonly ICommandSpec[] = [ { name: "/help", summary: "show this help" }, + { + name: "/scaffold", + summary: + "create a new full-stack project here (BoringStack / Astro) via the wizard", + }, { name: "/compact", summary: "summarize the conversation to free up context", diff --git a/packages/core/src/cli/repl.ts b/packages/core/src/cli/repl.ts index 91eed378..8ab43928 100644 --- a/packages/core/src/cli/repl.ts +++ b/packages/core/src/cli/repl.ts @@ -799,8 +799,9 @@ export async function repl(args: ICliArgs): Promise { await runSend(line); }; - // Placeholder declaration for handleHelp; defined after runLine is available. + // Placeholder declarations; defined after runLine / editorControl are available. let handleHelp: () => Promise; + let openScaffold: () => Promise; // Slash-command dispatch. Returns true to EXIT the REPL. Kept as a closure so // it can rebuild `session` (e.g. /clear) and reach config/persist. @@ -898,6 +899,10 @@ export async function repl(args: ICliArgs): Promise { await handleConfig(); break; + case "scaffold": + await openScaffold(); + break; + case "setup": { const { runSetup } = await import("../setup/run-setup"); @@ -1687,6 +1692,29 @@ export async function repl(args: ICliArgs): Promise { } }; + // Open the in-REPL scaffold wizard (create a new project here), reachable as a + // first-class typed/palette command so it's discoverable at the prompt. This + // path AWAITS openScaffoldInRepl, so the wizard's suspend/resume owns the screen + // for its whole lifetime. (The `/help` capability browser reaches scaffold + // through its dedicated wizard row instead — never a fire-and-forget + // `void runLine` command row, which would race the wizard for stdin.) Extracted + // from the command switch to keep that dispatcher's cognitive complexity down. + openScaffold = async (): Promise => { + await openScaffoldInRepl({ + cwd: args.dir, + suspend: () => { + editorControl?.suspend(); + editorControl?.setInputInert(true); + }, + resume: () => { + editorControl?.setInputInert(false); + editorControl?.resume(); + editorControl?.getBuffer().setText(""); + }, + out: (s) => process.stdout.write(s), + }); + }; + // Helper: repaint the editor buffer to the status bar after palette insertion. const repaintEditor = (handle: IEditorHandle): void => { const { line, col } = handle.getBuffer().getCursor(); diff --git a/packages/core/tests/capabilities.test.ts b/packages/core/tests/capabilities.test.ts index e5436186..0c4a2535 100644 --- a/packages/core/tests/capabilities.test.ts +++ b/packages/core/tests/capabilities.test.ts @@ -1,5 +1,8 @@ import { test, expect } from "bun:test"; -import { buildCapabilities } from "../src/cli/capabilities"; +import { + buildCapabilities, + COMMAND_WIZARD_HOME, +} from "../src/cli/capabilities"; import { COMMANDS } from "../src/cli/commands"; const deps = { hasRecipes: true }; @@ -31,6 +34,12 @@ test("ANTI-DRIFT: every slash command has a discovery home", () => { : "" ) ); + // A command whose home is a wizard row is covered iff that wizard capability exists. + const wizardOpeners = new Set( + caps + .filter((c) => c.invoke?.type === "wizard") + .map((c) => (c.invoke?.type === "wizard" ? c.invoke.opener : "")) + ); // Commands intentionally excluded from the browser (they ARE the browser / trivial). const exempt = new Set(["/help", "/exit"]); @@ -39,10 +48,34 @@ test("ANTI-DRIFT: every slash command has a discovery home", () => { continue; } - expect(covered.has(spec.name)).toBe(true); + const wizardHome = COMMAND_WIZARD_HOME[spec.name]; + const hasHome = + covered.has(spec.name) || + (wizardHome !== undefined && wizardOpeners.has(wizardHome)); + + expect(hasHome).toBe(true); } }); +test("ANTI-DRIFT: /scaffold has a WIZARD home, never a fire-and-forget command row", () => { + const caps = buildCapabilities(deps); + + // No generated command row for /scaffold (that path uses `void runLine` from the + // browser, which races the wizard for stdin). Its home is the scaffold wizard row. + const runRows = caps.filter( + (c) => + (c.invoke?.type === "run" || c.invoke?.type === "prefill") && + c.invoke.command === "/scaffold" + ); + + expect(runRows).toHaveLength(0); + expect( + caps.some( + (c) => c.invoke?.type === "wizard" && c.invoke.opener === "scaffold" + ) + ).toBe(true); +}); + test("recipe row is present only when recipes exist", () => { expect( buildCapabilities({ hasRecipes: true }).some((c) => c.id === "recipe") diff --git a/scripts/e2e-help-menu-pty.py b/scripts/e2e-help-menu-pty.py index fe8e541f..39181545 100644 --- a/scripts/e2e-help-menu-pty.py +++ b/scripts/e2e-help-menu-pty.py @@ -72,7 +72,9 @@ def main(): # Selecting a command must actually RUN it (regression: runCommand prepended a # slash to the already-slashed name → "//sessions" → unknown command). Reopen - # /help, pick /plan (rows 0=/compact 1=/clear 2=/plan), confirm it toggled mode. + # /help, pick /plan (rows 0=/compact 1=/clear 2=/plan; /scaffold's home is the + # wizard row under "Build something new", not a command row), confirm it toggled + # mode. os.write(m, b"/") read_until(m, lambda b: "commands" in b, 8) os.write(m, b"help\r") diff --git a/scripts/e2e-scaffold-command-pty.py b/scripts/e2e-scaffold-command-pty.py new file mode 100644 index 00000000..c0b021db --- /dev/null +++ b/scripts/e2e-scaffold-command-pty.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Drive the REAL `/scaffold` command in a pty and assert it OPENS the scaffold +wizard and that the editor cleanly hands the screen over and back: + 1. Selecting `/scaffold` from the `/` palette RUNS it (reaches the command switch, + not a dead entry) and the wizard's first screen renders ("Choose a project type"). + 2. Esc CANCELS the wizard ("cancelled — nothing was created") — the awaited + openScaffoldInRepl path returns control without creating anything. + 3. tsforge is STILL RUNNING afterwards and the prompt is usable — proving the + wizard's suspend/resume owned stdin alone (no double-typed-text race, the + failure the reviewer panel flagged on the fire-and-forget browser path). + +Uses the shared deterministic model stub so boot succeeds offline.""" +import os +import sys +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib")) +from ptyharness import ( # noqa: E402 + Checker, + alive, + drain, + read_until, + reap, + spawn_tsforge, + start_stub_server, + wait_for, +) + + +def main(): + t = Checker() + srv, port = start_stub_server() + home = tempfile.mkdtemp(prefix="tsforge-scaffold-") + pid, m = spawn_tsforge(port, home=home, rows=24, cols=100) + + got, _ = read_until(m, lambda b: "plan mode" in b or "› " in b, 40) + t.check("REPL boots", got) + + # Run /scaffold via the palette (open with "/", filter, Enter). + os.write(m, b"/") + read_until(m, lambda b: "commands" in b, 10) + os.write(m, b"scaffold\r") + + # The wizard's first screen must render — proves the command actually executed. + opened, obuf = read_until( + m, lambda b: "Choose a project type" in b or "tsforge scaffold" in b, 12 + ) + t.check("/scaffold RUNS and opens the wizard (first screen renders)", opened) + t.check("no 'unknown command' when running /scaffold", "unknown command" not in obuf) + + # Type WHILE the wizard is active. Enter selects the default project type + # (boringstack) and MUST advance the archetype step to its Review screen. If the + # editor/readline were also consuming stdin, this keystroke would not drive the + # wizard forward — this is what proves the wizard owns stdin (the race the panel + # flagged on the fire-and-forget browser path). + os.write(m, b"\r") + reviewed, _ = read_until( + m, lambda b: "nothing is written until you Apply" in b and "Boringstack" in b, 10 + ) + t.check("keystroke reaches the WIZARD (Enter → Review, Boringstack selected)", reviewed) + + # A second Enter applies the archetype choice and opens the config wizard whose + # first step is "Project directory" — proving keystrokes keep flowing to the wizard. + os.write(m, b"\r") + advanced, _ = read_until(m, lambda b: "Project directory" in b, 10) + t.check("wizard advances to the config step (Project directory)", advanced) + + # Esc cancels the wizard cleanly (awaited path returns, nothing created). + os.write(m, b"\x1b") + cancelled, _ = read_until(m, lambda b: "cancelled" in b and "nothing was created" in b, 8) + t.check("Esc cancels the wizard — nothing was created", cancelled) + + # The REPL must survive the wizard and accept input again (suspend/resume worked). + died = wait_for(lambda: not alive(pid), 0.8) + t.check("tsforge STILL RUNNING after the wizard closes", not died) + + drain(m, 0.3) + os.write(m, b"hello") + echoed, _ = read_until(m, lambda b: "hello" in b, 5) + t.check("prompt is usable after the wizard (editor resumed, no double-stdin)", echoed) + + reap(pid, m, exit_cmd=b"") + srv.shutdown() + sys.exit(t.finish()) + + +if __name__ == "__main__": + main()