@agjs/tsforge 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/sweep.ts +2 -0
- package/src/cli/commands.ts +97 -0
- package/src/cli.ts +69 -20
- package/src/config/config.constants.ts +1 -0
- package/src/config/flags.ts +4 -0
- package/src/loop/prompt/index.ts +8 -1
- package/src/loop/prompt/prompt.ts +36 -1
- package/src/loop/run.ts +11 -5
- package/src/render/command-menu.ts +174 -0
- package/src/stack-detection/detect.ts +15 -0
- package/src/stack-detection/index.ts +1 -1
package/package.json
CHANGED
package/scripts/sweep.ts
CHANGED
|
@@ -87,6 +87,8 @@ function variantToEnvVars(variant: IFeatureVariant): Record<string, string> {
|
|
|
87
87
|
envVars.TSFORGE_HASHLINE = state === "1" ? "1" : "0";
|
|
88
88
|
} else if (dim === "lsp_write_feedback") {
|
|
89
89
|
envVars.TSFORGE_LSP_WRITE_FEEDBACK = state === "1" ? "1" : "0";
|
|
90
|
+
} else if (dim === "simplicity") {
|
|
91
|
+
envVars.TSFORGE_SIMPLICITY = state === "1" ? "1" : "0";
|
|
90
92
|
}
|
|
91
93
|
// else: unknown dimension, skip
|
|
92
94
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single source of truth for the REPL's slash commands — drives BOTH the
|
|
3
|
+
* `/help` text and the interactive `/` palette, so they can never drift and the
|
|
4
|
+
* user never has to memorize what exists. The executor stays the `command()`
|
|
5
|
+
* switch in cli.ts; `commandVerbs()` lets a test assert the two stay in sync.
|
|
6
|
+
*/
|
|
7
|
+
export interface ICommandSpec {
|
|
8
|
+
/** Full token incl. leading slash, e.g. "/gate". */
|
|
9
|
+
readonly name: string;
|
|
10
|
+
/** Argument hint shown after the name (e.g. "<cmd>", "[name]"); omitted if none. */
|
|
11
|
+
readonly arg?: string;
|
|
12
|
+
/** One-line description. */
|
|
13
|
+
readonly summary: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const COMMANDS: readonly ICommandSpec[] = [
|
|
17
|
+
{ name: "/help", summary: "show this help" },
|
|
18
|
+
{
|
|
19
|
+
name: "/compact",
|
|
20
|
+
summary: "summarize the conversation to free up context",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "/clear",
|
|
24
|
+
summary: "reset the conversation (keeps the workspace + gate)",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "/plan",
|
|
28
|
+
summary:
|
|
29
|
+
"toggle plan mode (explore → clarify → plan; 'approve' implements)",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "/gate",
|
|
33
|
+
arg: "<cmd>",
|
|
34
|
+
summary: "set the gate command (empty to clear)",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: "/files",
|
|
38
|
+
arg: "<globs>",
|
|
39
|
+
summary: "set the editable scope (comma-separated; empty = all)",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "/model",
|
|
43
|
+
arg: "[name]",
|
|
44
|
+
summary: "list configured models (★ active), or switch to <name>",
|
|
45
|
+
},
|
|
46
|
+
{ name: "/sessions", summary: "list saved sessions" },
|
|
47
|
+
{ name: "/cost", summary: "rough conversation size (messages + ~tokens)" },
|
|
48
|
+
{
|
|
49
|
+
name: "/metrics",
|
|
50
|
+
summary: "token totals + generation rate (tok/s) this session",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "/memory",
|
|
54
|
+
arg: "[forget]",
|
|
55
|
+
summary: "show learned failure→fix lessons (forget to clear)",
|
|
56
|
+
},
|
|
57
|
+
{ name: "/exit", summary: "leave the session" },
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
/** True when the spec takes an argument — the palette prefills `"<name> "` and
|
|
61
|
+
* lets the user type instead of running immediately. */
|
|
62
|
+
export function takesArg(spec: ICommandSpec): boolean {
|
|
63
|
+
return spec.arg !== undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Command verbs (no slash) that MUST have an executor case, incl. the `/quit`
|
|
67
|
+
* alias that isn't listed in the palette. Used by the registry↔switch parity test. */
|
|
68
|
+
export const COMMAND_VERBS: readonly string[] = [
|
|
69
|
+
...COMMANDS.map((c) => c.name.slice(1)),
|
|
70
|
+
"quit",
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/** The `/help` body — the command table (from the registry) plus the footer. */
|
|
74
|
+
export function formatHelp(): string {
|
|
75
|
+
const width = Math.max(
|
|
76
|
+
...COMMANDS.map(
|
|
77
|
+
(c) => c.name.length + (c.arg === undefined ? 0 : c.arg.length + 1)
|
|
78
|
+
)
|
|
79
|
+
);
|
|
80
|
+
const rows = COMMANDS.map((c) => {
|
|
81
|
+
const left = c.arg === undefined ? c.name : `${c.name} ${c.arg}`;
|
|
82
|
+
|
|
83
|
+
return ` ${left.padEnd(width)} ${c.summary}`;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return [
|
|
87
|
+
"Commands:",
|
|
88
|
+
...rows,
|
|
89
|
+
" /quit alias for /exit",
|
|
90
|
+
"",
|
|
91
|
+
"Press / for an interactive menu (↑/↓ to select, type to filter, Enter to run).",
|
|
92
|
+
"Anything else is sent to the agent. It works with its tools; when it stops,",
|
|
93
|
+
'the gate (if set) confirms "done".',
|
|
94
|
+
"While it's working: type a message to STEER the next turn (e.g. 'use Tailwind');",
|
|
95
|
+
"Ctrl-C interrupts the current run.",
|
|
96
|
+
].join("\n");
|
|
97
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { join, isAbsolute } from "node:path";
|
|
3
3
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { emitKeypressEvents } from "node:readline";
|
|
6
|
+
import { formatHelp, takesArg } from "./cli/commands";
|
|
7
|
+
import { pickCommand } from "./render/command-menu";
|
|
5
8
|
import { runTask, RUN_STATUS, Session, PLAN_APPROVED_NOTE } from "./loop";
|
|
6
9
|
import {
|
|
7
10
|
PROVIDER_LIMITS,
|
|
@@ -664,26 +667,9 @@ export function isPlanApproval(line: string): boolean {
|
|
|
664
667
|
return /^(approve|approved|go|lgtm|implement)[.!]?$/i.test(line.trim());
|
|
665
668
|
}
|
|
666
669
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
" /compact summarize the conversation to free up context",
|
|
671
|
-
" /clear reset the conversation (keeps the workspace + gate)",
|
|
672
|
-
" /plan toggle plan mode (read-only: explore → clarify → plan; 'approve' implements)",
|
|
673
|
-
" /gate <cmd> set the gate command (empty to clear)",
|
|
674
|
-
" /files <globs> set the editable scope (comma-separated; empty = all)",
|
|
675
|
-
" /model [name] list configured models (★ active), or switch to <name>",
|
|
676
|
-
" /sessions list saved sessions (resume one with: tsforge --resume <id>)",
|
|
677
|
-
" /cost rough conversation size (messages + ~tokens)",
|
|
678
|
-
" /metrics token totals + generation rate (tok/s) this session",
|
|
679
|
-
" /memory show learned failure→fix lessons (/memory forget to clear)",
|
|
680
|
-
" /exit, /quit leave the session",
|
|
681
|
-
"",
|
|
682
|
-
"Anything else is sent to the agent. It works with its tools; when it stops,",
|
|
683
|
-
'the gate (if set) confirms "done".',
|
|
684
|
-
"While it's working: type a message to STEER the next turn (e.g. 'use Tailwind');",
|
|
685
|
-
"Ctrl-C interrupts the current run.",
|
|
686
|
-
].join("\n");
|
|
670
|
+
// The /help body is generated from the command registry (src/cli/commands.ts) so
|
|
671
|
+
// the help text and the interactive `/` palette can never drift.
|
|
672
|
+
const HELP = formatHelp();
|
|
687
673
|
|
|
688
674
|
/** The session status line — distinguishes off / new / resumed. */
|
|
689
675
|
function sessionLine(id: string, resumed: ISessionRecord | null): string {
|
|
@@ -1191,6 +1177,7 @@ async function repl(args: ICliArgs): Promise<number> {
|
|
|
1191
1177
|
session.setPlanMode(planMode); // a /clear must not silently drop the mode
|
|
1192
1178
|
planDiscussed = false;
|
|
1193
1179
|
await persist();
|
|
1180
|
+
clearScreen(); // wipe the visible terminal + scrollback, not just the state
|
|
1194
1181
|
process.stdout.write("conversation cleared\n");
|
|
1195
1182
|
break;
|
|
1196
1183
|
|
|
@@ -1356,6 +1343,23 @@ async function repl(args: ICliArgs): Promise<number> {
|
|
|
1356
1343
|
statusBar.teardown();
|
|
1357
1344
|
});
|
|
1358
1345
|
|
|
1346
|
+
// Wipe the visible terminal + scrollback (2J + 3J + home), re-pinning the status
|
|
1347
|
+
// bar around it so its scroll region stays correct. Used by /clear so the screen
|
|
1348
|
+
// is a clean slate, not just the conversation state.
|
|
1349
|
+
const clearScreen = (): void => {
|
|
1350
|
+
const wasActive = statusBar.active;
|
|
1351
|
+
|
|
1352
|
+
if (wasActive) {
|
|
1353
|
+
statusBar.teardown();
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
|
|
1357
|
+
|
|
1358
|
+
if (wasActive) {
|
|
1359
|
+
statusBar.install(statusInfo());
|
|
1360
|
+
}
|
|
1361
|
+
};
|
|
1362
|
+
|
|
1359
1363
|
// The prompt. With the bar pinned it repaints the bar and shows only the
|
|
1360
1364
|
// input marker; otherwise it prints the inline status line above the marker.
|
|
1361
1365
|
const prompt = (): void => {
|
|
@@ -1374,6 +1378,7 @@ async function repl(args: ICliArgs): Promise<number> {
|
|
|
1374
1378
|
await new Promise<void>((resolveLoop) => {
|
|
1375
1379
|
let busy = false;
|
|
1376
1380
|
let closed = false;
|
|
1381
|
+
let paletteOpen = false;
|
|
1377
1382
|
|
|
1378
1383
|
// Finish the loop only when stdin has closed AND no run is in flight — so a
|
|
1379
1384
|
// stdin EOF (piped input / Ctrl-D) never kills a build mid-turn.
|
|
@@ -1417,6 +1422,50 @@ async function repl(args: ICliArgs): Promise<number> {
|
|
|
1417
1422
|
}
|
|
1418
1423
|
};
|
|
1419
1424
|
|
|
1425
|
+
// Open the interactive `/` command palette: pick a command from a navigable
|
|
1426
|
+
// list, then either run it (no-arg) or prefill the line so the user types the
|
|
1427
|
+
// argument. Cancel ⇒ back to a clean prompt. Only meaningful on a TTY.
|
|
1428
|
+
const openPalette = async (): Promise<void> => {
|
|
1429
|
+
paletteOpen = true;
|
|
1430
|
+
|
|
1431
|
+
try {
|
|
1432
|
+
rl.write(null, { ctrl: true, name: "u" }); // clear the typed "/"
|
|
1433
|
+
|
|
1434
|
+
const picked = await pickCommand(process.stdout.isTTY);
|
|
1435
|
+
|
|
1436
|
+
// The palette ran on the alternate screen; exiting it restored the prompt
|
|
1437
|
+
// verbatim, so don't re-draw it (that left stray `›` lines). On cancel,
|
|
1438
|
+
// nothing to do; for an arg command, prefill so the user types the value.
|
|
1439
|
+
if (picked !== null) {
|
|
1440
|
+
if (takesArg(picked)) {
|
|
1441
|
+
rl.write(`${picked.name} `);
|
|
1442
|
+
} else {
|
|
1443
|
+
void runLine(picked.name);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
} finally {
|
|
1447
|
+
paletteOpen = false;
|
|
1448
|
+
}
|
|
1449
|
+
};
|
|
1450
|
+
|
|
1451
|
+
// Press `/` on an empty line ⇒ open the palette. setImmediate lets readline
|
|
1452
|
+
// finish inserting the slash first, so `rl.line === "/"` confirms it's a fresh
|
|
1453
|
+
// command start (not a slash mid-text). No-op while busy or already open.
|
|
1454
|
+
if (process.stdin.isTTY) {
|
|
1455
|
+
emitKeypressEvents(process.stdin);
|
|
1456
|
+
process.stdin.on("keypress", (str: string | undefined) => {
|
|
1457
|
+
if (busy || paletteOpen || str !== "/") {
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
setImmediate(() => {
|
|
1462
|
+
if (!busy && !paletteOpen && rl.line === "/") {
|
|
1463
|
+
void openPalette();
|
|
1464
|
+
}
|
|
1465
|
+
});
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1420
1469
|
// Event-driven (not for-await) so stdin is read DURING a run: a line typed
|
|
1421
1470
|
// mid-run is queued to steer the next turn (or, if "/exit", aborts). This is
|
|
1422
1471
|
// what makes it feel like a real harness — you can redirect without waiting.
|
package/src/config/flags.ts
CHANGED
|
@@ -29,4 +29,8 @@ export const flags = {
|
|
|
29
29
|
* (A/B control, default ON — set to "0" to disable). */
|
|
30
30
|
lspWriteFeedback: (): boolean =>
|
|
31
31
|
process.env.TSFORGE_LSP_WRITE_FEEDBACK !== "0",
|
|
32
|
+
/** Scratch-utility simplicity guidance — appends a "shortest correct solution"
|
|
33
|
+
* block to the build prompt for from-scratch, non-web tasks (A/B control,
|
|
34
|
+
* default OFF until a sweep validates it). */
|
|
35
|
+
simplicity: (): boolean => isOn(ENV_FLAG.simplicity),
|
|
32
36
|
};
|
package/src/loop/prompt/index.ts
CHANGED
|
@@ -1,2 +1,9 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
SYSTEM,
|
|
3
|
+
CHAT_SYSTEM,
|
|
4
|
+
COMPACT_SYSTEM,
|
|
5
|
+
SCRATCH_SIMPLICITY_GUIDANCE,
|
|
6
|
+
buildSystemPrompt,
|
|
7
|
+
seedPrompt,
|
|
8
|
+
} from "./prompt";
|
|
2
9
|
export { renderFileSection, exportedSymbols } from "./project-map";
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ITask } from "../../spec";
|
|
2
2
|
import type { IFileView } from "../../lib/fs";
|
|
3
|
-
import { PACK_REGISTRY } from "../../stack-detection";
|
|
3
|
+
import { PACK_REGISTRY, isWebStack } from "../../stack-detection";
|
|
4
4
|
import type { IStackProfile } from "../../stack-detection";
|
|
5
|
+
import { flags } from "../../config";
|
|
5
6
|
import { renderFileSection } from "./project-map";
|
|
6
7
|
|
|
7
8
|
/** The implement-agent system prompt: who it is, the tools, and the strict-TS
|
|
@@ -16,6 +17,40 @@ export const SYSTEM = [
|
|
|
16
17
|
"The gate is `tsc` strict + eslint with every rule an error, so write TypeScript that satisfies it: interfaces are `I`-prefixed; `===`; no `var`; never the non-null `!` — guard index access (`const x = arr[i]; if (x === undefined) {...}`); no `any` and no `as` — type every parameter (e.g. `.reduce((acc: number, r: number) => …, 0)`); explicit boolean conditions. When the gate flags errors in read-only files (tests/types), they come from your editable file being missing or wrong-shaped and vanish once it's correct — don't edit them.",
|
|
17
18
|
].join("\n");
|
|
18
19
|
|
|
20
|
+
/** Appended to SYSTEM for from-scratch, NON-web utility builds when the simplicity
|
|
21
|
+
* flag is on. Pushes the model toward the shortest correct solution — the axis the
|
|
22
|
+
* gate is blind to (it checks correctness, never concision). Carve-outs keep it
|
|
23
|
+
* from fighting the gate's hard rules. NOT for web builds (the views/components
|
|
24
|
+
* architecture legitimately needs many small files). */
|
|
25
|
+
export const SCRATCH_SIMPLICITY_GUIDANCE = [
|
|
26
|
+
"SIMPLICITY — write the SHORTEST correct solution that passes the gate:",
|
|
27
|
+
" • The task's `files:` are the ceiling — do NOT add modules, classes, or",
|
|
28
|
+
" abstractions the task didn't ask for. One focused implementation.",
|
|
29
|
+
" • Prefer built-ins and a direct expression over step-by-step temporaries:",
|
|
30
|
+
" chain the transforms (`xs.filter(...).map(...)`) instead of naming each",
|
|
31
|
+
" intermediate, when it stays readable.",
|
|
32
|
+
" • NO narration/step comments ('// Step 1', '// first we…') — the code is the",
|
|
33
|
+
" explanation. A comment earns its place only for a non-obvious WHY.",
|
|
34
|
+
" • This NEVER overrides the gate: keep `I`-prefixed interfaces, no `as`/`any`/`!`,",
|
|
35
|
+
" real validation at trust boundaries, and any test siblings the gate requires.",
|
|
36
|
+
].join("\n");
|
|
37
|
+
|
|
38
|
+
/** SYSTEM + the simplicity block when it applies, else SYSTEM unchanged. Gated on
|
|
39
|
+
* the `simplicity` flag AND a from-scratch (`!hasExistingCode`) NON-web build —
|
|
40
|
+
* so it never touches existing-repo edits or web/UI apps. */
|
|
41
|
+
export function buildSystemPrompt(
|
|
42
|
+
hasExistingCode: boolean,
|
|
43
|
+
stack: IStackProfile | undefined
|
|
44
|
+
): string {
|
|
45
|
+
const webish = stack !== undefined && isWebStack(stack);
|
|
46
|
+
|
|
47
|
+
if (!flags.simplicity() || hasExistingCode || webish) {
|
|
48
|
+
return SYSTEM;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return `${SYSTEM}\n\n${SCRATCH_SIMPLICITY_GUIDANCE}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
19
54
|
/**
|
|
20
55
|
* The INTERACTIVE assistant prompt (the CLI's `Session`). Unlike `SYSTEM` — which
|
|
21
56
|
* drives a single task to a gate and is told to "keep going until green" — this
|
package/src/loop/run.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type {
|
|
|
17
17
|
} from "./loop.types";
|
|
18
18
|
import { mineLessons, consolidate as consolidateMemory } from "./memory";
|
|
19
19
|
import { flags } from "../config";
|
|
20
|
-
import {
|
|
20
|
+
import { buildSystemPrompt, seedPrompt } from "./prompt";
|
|
21
21
|
import { detectStack } from "../stack-detection";
|
|
22
22
|
import type { TtsrManager } from "./ttsr";
|
|
23
23
|
import {
|
|
@@ -295,17 +295,23 @@ export async function runTask(
|
|
|
295
295
|
|
|
296
296
|
const editable = await readFiles(cwd, task.files);
|
|
297
297
|
const context = await readFiles(cwd, task.context ?? []);
|
|
298
|
+
|
|
299
|
+
// Existing code to navigate? (editable files already have content). Only then
|
|
300
|
+
// do the LSP nav tools earn their decision-surface cost — see toolsFor(). Also
|
|
301
|
+
// gates the scratch-simplicity guidance (from-scratch builds only).
|
|
302
|
+
const hasExistingCode = editable.some((f) => f.content.trim().length > 0);
|
|
303
|
+
|
|
298
304
|
const messages: IChatMessage[] = [
|
|
299
|
-
{
|
|
305
|
+
{
|
|
306
|
+
role: "system",
|
|
307
|
+
content: buildSystemPrompt(hasExistingCode, stackProfile),
|
|
308
|
+
},
|
|
300
309
|
{
|
|
301
310
|
role: "user",
|
|
302
311
|
content: seedPrompt(task, editable, context, stackProfile),
|
|
303
312
|
},
|
|
304
313
|
];
|
|
305
314
|
|
|
306
|
-
// Existing code to navigate? (editable files already have content). Only then
|
|
307
|
-
// do the LSP nav tools earn their decision-surface cost — see toolsFor().
|
|
308
|
-
const hasExistingCode = editable.some((f) => f.content.trim().length > 0);
|
|
309
315
|
const tools = toolsFor(hasExistingCode);
|
|
310
316
|
|
|
311
317
|
// Mode-aware reasoning cap: scratch tasks over-think unbounded, so default
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { emitKeypressEvents } from "node:readline";
|
|
2
|
+
import { STYLE, paint } from "./style";
|
|
3
|
+
import { COMMANDS, type ICommandSpec } from "../cli/commands";
|
|
4
|
+
|
|
5
|
+
const ESC = String.fromCharCode(27);
|
|
6
|
+
// Render the palette on the terminal's ALTERNATE screen buffer: enter on open,
|
|
7
|
+
// clear+home before each frame, exit on close — which restores the previous screen
|
|
8
|
+
// (conversation + status bar) verbatim. This avoids in-place cursor math fighting
|
|
9
|
+
// the status bar's scroll region (which caused frames to stack instead of redraw).
|
|
10
|
+
const ENTER_ALT = `${ESC}[?1049h${ESC}[r`; // alt screen + reset scroll margins
|
|
11
|
+
const EXIT_ALT = `${ESC}[?1049l`;
|
|
12
|
+
const HIDE_CURSOR = `${ESC}[?25l`;
|
|
13
|
+
const SHOW_CURSOR = `${ESC}[?25h`;
|
|
14
|
+
const CLEAR_HOME = `${ESC}[2J${ESC}[H`;
|
|
15
|
+
|
|
16
|
+
/** Filter commands by a query (the text typed after `/`). Leading slash and case
|
|
17
|
+
* are ignored; matches commands whose name contains the query. Empty ⇒ all. */
|
|
18
|
+
export function filterCommands(
|
|
19
|
+
commands: readonly ICommandSpec[],
|
|
20
|
+
query: string
|
|
21
|
+
): ICommandSpec[] {
|
|
22
|
+
const q = query.replace(/^\//u, "").toLowerCase();
|
|
23
|
+
|
|
24
|
+
if (q.length === 0) {
|
|
25
|
+
return [...commands];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return commands.filter((c) => c.name.slice(1).toLowerCase().includes(q));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Keep `selected` within `[0, count)` (wraps), so ↑/↓ never points off-list. */
|
|
32
|
+
export function clampIndex(selected: number, count: number): number {
|
|
33
|
+
if (count <= 0) {
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return ((selected % count) + count) % count;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Render the palette as a block of lines (no trailing newline). The header echoes
|
|
41
|
+
* the current filter + key hints; the selected row is brand-highlighted. */
|
|
42
|
+
export function renderMenu(
|
|
43
|
+
items: readonly ICommandSpec[],
|
|
44
|
+
selected: number,
|
|
45
|
+
query: string,
|
|
46
|
+
color: boolean
|
|
47
|
+
): string {
|
|
48
|
+
const header =
|
|
49
|
+
paint(`/${query}`, STYLE.brand, color) +
|
|
50
|
+
paint(
|
|
51
|
+
" ↑/↓ select · type to filter · enter run · esc cancel",
|
|
52
|
+
STYLE.dim,
|
|
53
|
+
color
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
if (items.length === 0) {
|
|
57
|
+
return `${header}\n ${paint("no matching command", STYLE.dim, color)}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const rows = items.map((c, i) => {
|
|
61
|
+
const active = i === selected;
|
|
62
|
+
const gutter = active ? paint("›", STYLE.brand, color) : " ";
|
|
63
|
+
const label = c.arg === undefined ? c.name : `${c.name} ${c.arg}`;
|
|
64
|
+
const name = paint(label, active ? STYLE.brand : STYLE.bold, color);
|
|
65
|
+
|
|
66
|
+
return `${gutter} ${name} ${paint(c.summary, STYLE.dim, color)}`;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return [header, ...rows].join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One keypress, as decoded by readline's `emitKeypressEvents`. */
|
|
73
|
+
interface IKeyInfo {
|
|
74
|
+
readonly name?: string;
|
|
75
|
+
readonly ctrl?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The interactive `/` palette. Owns `keypress` input for its lifetime — it detaches
|
|
80
|
+
* the existing keypress listeners (readline's line editor + the REPL's `/` trigger)
|
|
81
|
+
* so they don't also react, renders a navigable list, and resolves to the chosen
|
|
82
|
+
* command or null (Esc / Ctrl-C / backspace-past-empty). `finish()` ALWAYS restores
|
|
83
|
+
* the saved listeners, so input returns to normal. No-ops to null off a TTY.
|
|
84
|
+
*
|
|
85
|
+
* Note: stdin stays in the raw, flowing mode readline already set — we only swap
|
|
86
|
+
* WHO listens, never toggle raw mode, so the terminal can't be left wedged.
|
|
87
|
+
*/
|
|
88
|
+
export function pickCommand(
|
|
89
|
+
color: boolean,
|
|
90
|
+
out: (s: string) => void = (s) => process.stdout.write(s)
|
|
91
|
+
): Promise<ICommandSpec | null> {
|
|
92
|
+
const stdin = process.stdin;
|
|
93
|
+
|
|
94
|
+
if (!stdin.isTTY) {
|
|
95
|
+
return Promise.resolve(null);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
let query = "";
|
|
100
|
+
let selected = 0;
|
|
101
|
+
|
|
102
|
+
emitKeypressEvents(stdin);
|
|
103
|
+
|
|
104
|
+
// Take over keypress for the palette's lifetime: stash + detach the current
|
|
105
|
+
// listeners (readline's editor + the REPL `/` trigger) so only `onKey` reacts;
|
|
106
|
+
// restored in finish().
|
|
107
|
+
const saved = stdin.rawListeners("keypress");
|
|
108
|
+
|
|
109
|
+
stdin.removeAllListeners("keypress");
|
|
110
|
+
|
|
111
|
+
const draw = (): void => {
|
|
112
|
+
const items = filterCommands(COMMANDS, query);
|
|
113
|
+
|
|
114
|
+
selected = clampIndex(selected, items.length);
|
|
115
|
+
|
|
116
|
+
out(`${CLEAR_HOME}${renderMenu(items, selected, query, color)}`);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const finish = (result: ICommandSpec | null): void => {
|
|
120
|
+
stdin.removeListener("keypress", onKey);
|
|
121
|
+
out(`${SHOW_CURSOR}${EXIT_ALT}`); // restore the previous screen verbatim
|
|
122
|
+
|
|
123
|
+
// Restore the listeners we detached (readline's editor + the REPL trigger),
|
|
124
|
+
// forwarding through a thin wrapper so we don't fight the Function[] type.
|
|
125
|
+
for (const l of saved) {
|
|
126
|
+
stdin.on("keypress", (...args: unknown[]) => {
|
|
127
|
+
Reflect.apply(l, stdin, args);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
resolve(result);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const onKey = (str: string | undefined, key: IKeyInfo): void => {
|
|
135
|
+
try {
|
|
136
|
+
if ((key.ctrl === true && key.name === "c") || key.name === "escape") {
|
|
137
|
+
finish(null);
|
|
138
|
+
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const items = filterCommands(COMMANDS, query);
|
|
143
|
+
|
|
144
|
+
if (key.name === "return" || key.name === "enter") {
|
|
145
|
+
finish(items[clampIndex(selected, items.length)] ?? null);
|
|
146
|
+
} else if (key.name === "up") {
|
|
147
|
+
selected -= 1;
|
|
148
|
+
draw();
|
|
149
|
+
} else if (key.name === "down") {
|
|
150
|
+
selected += 1;
|
|
151
|
+
draw();
|
|
152
|
+
} else if (key.name === "backspace") {
|
|
153
|
+
if (query.length === 0) {
|
|
154
|
+
finish(null); // backspace past the slash closes the palette
|
|
155
|
+
} else {
|
|
156
|
+
query = query.slice(0, -1);
|
|
157
|
+
selected = 0;
|
|
158
|
+
draw();
|
|
159
|
+
}
|
|
160
|
+
} else if (key.ctrl !== true && str?.length === 1 && str >= " ") {
|
|
161
|
+
query += str;
|
|
162
|
+
selected = 0;
|
|
163
|
+
draw();
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
finish(null); // never let a render error wedge input
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
stdin.on("keypress", onKey);
|
|
171
|
+
out(`${ENTER_ALT}${HIDE_CURSOR}`);
|
|
172
|
+
draw();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -9,6 +9,21 @@ import {
|
|
|
9
9
|
type IPackId,
|
|
10
10
|
} from "./packs";
|
|
11
11
|
|
|
12
|
+
/** The pack ids that identify a WEB (browser UI) build. Used to scope behaviours
|
|
13
|
+
* that must NOT apply to web apps (e.g. the scratch-simplicity prompt, whose
|
|
14
|
+
* "shortest solution / no extra files" advice fights the views/components
|
|
15
|
+
* architecture the web scaffold requires). */
|
|
16
|
+
const WEB_PACK_IDS: readonly string[] = [
|
|
17
|
+
"react",
|
|
18
|
+
"react-component-architecture",
|
|
19
|
+
"tanstack-query",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/** True when the detected stack is a web/browser UI build. */
|
|
23
|
+
export function isWebStack(profile: IStackProfile): boolean {
|
|
24
|
+
return profile.packs.some((p) => WEB_PACK_IDS.includes(p));
|
|
25
|
+
}
|
|
26
|
+
|
|
12
27
|
/** Parse package.json and extract deps/devDeps, tolerating missing/invalid JSON. */
|
|
13
28
|
async function loadPackageDeps(cwd: string): Promise<{
|
|
14
29
|
deps: Set<string>;
|