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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 18 additions & 1 deletion packages/core/src/cli/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, "scaffold" | "recipe">
> = {
"/scaffold": "scaffold",
};

const COMMAND_TO_GROUP: Readonly<Record<string, string>> = {
"/review": UNDERSTAND_YOUR_CODE,
"/map": UNDERSTAND_YOUR_CODE,
Expand All @@ -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;
}

Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 29 additions & 1 deletion packages/core/src/cli/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -799,8 +799,9 @@ export async function repl(args: ICliArgs): Promise<number> {
await runSend(line);
};

// Placeholder declaration for handleHelp; defined after runLine is available.
// Placeholder declarations; defined after runLine / editorControl are available.
let handleHelp: () => Promise<void>;
let openScaffold: () => Promise<void>;

// 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.
Expand Down Expand Up @@ -898,6 +899,10 @@ export async function repl(args: ICliArgs): Promise<number> {
await handleConfig();
break;

case "scaffold":
await openScaffold();
break;

case "setup": {
const { runSetup } = await import("../setup/run-setup");

Expand Down Expand Up @@ -1687,6 +1692,29 @@ export async function repl(args: ICliArgs): Promise<number> {
}
};

// 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<void> => {
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();
Expand Down
37 changes: 35 additions & 2 deletions packages/core/tests/capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -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"]);

Expand All @@ -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")
Expand Down
4 changes: 3 additions & 1 deletion scripts/e2e-help-menu-pty.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
88 changes: 88 additions & 0 deletions scripts/e2e-scaffold-command-pty.py
Original file line number Diff line number Diff line change
@@ -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()