pi-soly 1.11.2 → 1.12.0
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/README.md +60 -14
- package/artifact/index.ts +241 -0
- package/artifact/render.ts +239 -0
- package/artifact/server.ts +384 -0
- package/artifact/session.ts +368 -0
- package/ask/README.md +20 -8
- package/ask/index.ts +100 -36
- package/ask/picker.ts +345 -29
- package/commands.ts +246 -30
- package/config.ts +124 -8
- package/core.ts +48 -236
- package/deck/deck.ts +386 -0
- package/deck/index.ts +113 -0
- package/index.ts +153 -37
- package/iteration.ts +1 -9
- package/mcp/commands.ts +12 -0
- package/mcp/direct-tools.ts +19 -2
- package/mcp/index.ts +144 -2
- package/mcp/init.ts +11 -0
- package/mcp/lifecycle.ts +9 -2
- package/mcp/server-manager.ts +30 -18
- package/mcp/state.ts +7 -0
- package/mcp/tool-cache.ts +135 -0
- package/nudge.ts +20 -3
- package/package.json +10 -3
- package/skills/soly-framework/SKILL.md +64 -37
- package/tool-hints.ts +62 -0
- package/tools.ts +28 -100
- package/util.ts +250 -0
- package/visual/chrome.ts +166 -0
- package/visual/colors.ts +24 -0
- package/visual/data.ts +62 -0
- package/visual/footer.ts +129 -0
- package/visual/format.ts +73 -0
- package/visual/glyphs.ts +35 -0
- package/visual/gradient.ts +122 -0
- package/visual/index.ts +18 -0
- package/visual/list-panel.ts +207 -0
- package/visual/segments.ts +136 -0
- package/visual/style.ts +34 -0
- package/visual/topbar.ts +55 -0
- package/visual/welcome.ts +265 -0
- package/visual/working.ts +66 -0
- package/workflows/execute.ts +17 -7
- package/workflows/index.ts +91 -44
- package/workflows/inspect.ts +23 -26
- package/workflows/migrate.ts +85 -0
- package/workflows/parser.ts +4 -2
- package/workflows/planning.ts +9 -8
- package/workflows/verify.ts +194 -0
- package/workflows-data/discuss-phase.md +10 -6
- package/agents-install.ts +0 -106
- package/ask/prompt.ts +0 -41
- package/ask/tests/prompt.test.ts +0 -54
package/core.ts
CHANGED
|
@@ -17,6 +17,35 @@ import * as fs from "node:fs";
|
|
|
17
17
|
import * as os from "node:os";
|
|
18
18
|
import * as path from "node:path";
|
|
19
19
|
|
|
20
|
+
// Shared leaf utilities now live in util.ts; re-exported here so existing
|
|
21
|
+
// `import { ... } from "./core.ts"` call sites keep working unchanged.
|
|
22
|
+
import {
|
|
23
|
+
atomicWriteFileSync,
|
|
24
|
+
estimateTokens,
|
|
25
|
+
extractFilePathsFromPrompt,
|
|
26
|
+
findMarkdownFiles,
|
|
27
|
+
formatTok,
|
|
28
|
+
globToRegExp,
|
|
29
|
+
matchesGlob,
|
|
30
|
+
parseRuleFrontmatter,
|
|
31
|
+
parseStateFrontmatter,
|
|
32
|
+
readIfExists,
|
|
33
|
+
splitFrontmatter,
|
|
34
|
+
} from "./util.ts";
|
|
35
|
+
export {
|
|
36
|
+
atomicWriteFileSync,
|
|
37
|
+
estimateTokens,
|
|
38
|
+
extractFilePathsFromPrompt,
|
|
39
|
+
findMarkdownFiles,
|
|
40
|
+
formatTok,
|
|
41
|
+
globToRegExp,
|
|
42
|
+
matchesGlob,
|
|
43
|
+
parseRuleFrontmatter,
|
|
44
|
+
parseStateFrontmatter,
|
|
45
|
+
readIfExists,
|
|
46
|
+
splitFrontmatter,
|
|
47
|
+
} from "./util.ts";
|
|
48
|
+
|
|
20
49
|
// ============================================================================
|
|
21
50
|
// Types
|
|
22
51
|
// ============================================================================
|
|
@@ -103,7 +132,12 @@ export interface PhaseInfo {
|
|
|
103
132
|
planCount: number;
|
|
104
133
|
contextExists: boolean;
|
|
105
134
|
researchExists: boolean;
|
|
135
|
+
/** Legacy standalone plan files (`NN-MM-PLAN.md`). Superseded by `tasks`. */
|
|
106
136
|
plans: string[];
|
|
137
|
+
/** Unified model: tasks under `phases/<N>/tasks/<id>/`. Optional during the
|
|
138
|
+
* phases→tasks migration (legacy phases have none); the loader always sets
|
|
139
|
+
* an array. Becomes required once plans are fully retired. */
|
|
140
|
+
tasks?: TaskInfo[];
|
|
107
141
|
}
|
|
108
142
|
|
|
109
143
|
/**
|
|
@@ -188,242 +222,6 @@ export const C = {
|
|
|
188
222
|
reset: "\x1b[0m",
|
|
189
223
|
} as const;
|
|
190
224
|
|
|
191
|
-
// ============================================================================
|
|
192
|
-
// Frontmatter parsers
|
|
193
|
-
// ============================================================================
|
|
194
|
-
|
|
195
|
-
// Simple parser for .soly/rules/ frontmatter.
|
|
196
|
-
export function parseRuleFrontmatter(raw: string): {
|
|
197
|
-
meta: RuleFrontmatter;
|
|
198
|
-
body: string;
|
|
199
|
-
} {
|
|
200
|
-
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
201
|
-
if (!match) return { meta: {}, body: raw };
|
|
202
|
-
|
|
203
|
-
const yamlText = match[1];
|
|
204
|
-
const body = match[2];
|
|
205
|
-
const meta: RuleFrontmatter = {};
|
|
206
|
-
|
|
207
|
-
for (const line of yamlText.split(/\r?\n/)) {
|
|
208
|
-
const trimmed = line.trim();
|
|
209
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
210
|
-
|
|
211
|
-
const colonIdx = trimmed.indexOf(":");
|
|
212
|
-
if (colonIdx === -1) continue;
|
|
213
|
-
|
|
214
|
-
const key = trimmed.slice(0, colonIdx).trim();
|
|
215
|
-
let value: string | string[] | boolean = trimmed.slice(colonIdx + 1).trim();
|
|
216
|
-
|
|
217
|
-
if (
|
|
218
|
-
typeof value === "string" &&
|
|
219
|
-
((value.startsWith('"') && value.endsWith('"')) ||
|
|
220
|
-
(value.startsWith("'") && value.endsWith("'")))
|
|
221
|
-
) {
|
|
222
|
-
value = value.slice(1, -1);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (
|
|
226
|
-
typeof value === "string" &&
|
|
227
|
-
value.startsWith("[") &&
|
|
228
|
-
value.endsWith("]")
|
|
229
|
-
) {
|
|
230
|
-
const inner = value.slice(1, -1).trim();
|
|
231
|
-
value =
|
|
232
|
-
inner.length === 0
|
|
233
|
-
? []
|
|
234
|
-
: inner.split(",").map((v) => v.trim().replace(/^["']|["']$/g, ""));
|
|
235
|
-
} else if (value === "true") {
|
|
236
|
-
value = true;
|
|
237
|
-
} else if (value === "false") {
|
|
238
|
-
value = false;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
(meta as Record<string, unknown>)[key] = value;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
return { meta, body };
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// YAML-ish parser for .soly/STATE.md. Handles 2-level nested objects (for `progress:`).
|
|
248
|
-
export function parseStateFrontmatter(yaml: string): {
|
|
249
|
-
meta: Record<string, unknown>;
|
|
250
|
-
progress: ProgressInfo;
|
|
251
|
-
} {
|
|
252
|
-
const root: Record<string, unknown> = {};
|
|
253
|
-
const stack: { indent: number; obj: Record<string, unknown> }[] = [
|
|
254
|
-
{ indent: -1, obj: root },
|
|
255
|
-
];
|
|
256
|
-
|
|
257
|
-
for (const rawLine of yaml.split(/\r?\n/)) {
|
|
258
|
-
const line = rawLine.replace(/\s+$/, "");
|
|
259
|
-
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
260
|
-
|
|
261
|
-
const indent = line.match(/^(\s*)/)?.[1].length ?? 0;
|
|
262
|
-
const content = line.trim();
|
|
263
|
-
|
|
264
|
-
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
|
|
265
|
-
stack.pop();
|
|
266
|
-
}
|
|
267
|
-
const parent = stack[stack.length - 1].obj as Record<string, unknown>;
|
|
268
|
-
|
|
269
|
-
const colonIdx = content.indexOf(":");
|
|
270
|
-
if (colonIdx === -1) continue;
|
|
271
|
-
const key = content.slice(0, colonIdx).trim();
|
|
272
|
-
let value: unknown = content.slice(colonIdx + 1).trim();
|
|
273
|
-
|
|
274
|
-
if (value === "") {
|
|
275
|
-
const newObj: Record<string, unknown> = {};
|
|
276
|
-
parent[key] = newObj;
|
|
277
|
-
stack.push({ indent, obj: newObj });
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
if (typeof value === "string") {
|
|
282
|
-
if (
|
|
283
|
-
(value.startsWith('"') && value.endsWith('"')) ||
|
|
284
|
-
(value.startsWith("'") && value.endsWith("'"))
|
|
285
|
-
) {
|
|
286
|
-
value = value.slice(1, -1);
|
|
287
|
-
} else if (value === "true") {
|
|
288
|
-
value = true;
|
|
289
|
-
} else if (value === "false") {
|
|
290
|
-
value = false;
|
|
291
|
-
} else if (/^-?\d+(\.\d+)?$/.test(value)) {
|
|
292
|
-
const n = Number(value);
|
|
293
|
-
if (!Number.isNaN(n)) value = n;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
parent[key] = value;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
const progressObj =
|
|
300
|
-
(root.progress as Record<string, unknown> | undefined) ?? {};
|
|
301
|
-
return {
|
|
302
|
-
meta: root,
|
|
303
|
-
progress: {
|
|
304
|
-
totalPhases: Number(progressObj.total_phases ?? 0),
|
|
305
|
-
completedPhases: Number(progressObj.completed_phases ?? 0),
|
|
306
|
-
totalPlans: Number(progressObj.total_plans ?? 0),
|
|
307
|
-
completedPlans: Number(progressObj.completed_plans ?? 0),
|
|
308
|
-
percent: Number(progressObj.percent ?? 0),
|
|
309
|
-
},
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
export function splitFrontmatter(
|
|
314
|
-
raw: string,
|
|
315
|
-
): { yaml: string; body: string } | null {
|
|
316
|
-
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
317
|
-
return m ? { yaml: m[1], body: m[2] } : null;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// ============================================================================
|
|
321
|
-
// File helpers
|
|
322
|
-
// ============================================================================
|
|
323
|
-
|
|
324
|
-
export function readIfExists(p: string): string | null {
|
|
325
|
-
try {
|
|
326
|
-
return fs.readFileSync(p, "utf-8");
|
|
327
|
-
} catch {
|
|
328
|
-
return null;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* Atomic file write: write to a tmp file in the same directory, then rename
|
|
334
|
-
* to the target. Avoids partial writes when concurrent readers/processes
|
|
335
|
-
* (hot reload, another tool, git status) would otherwise see a half-written
|
|
336
|
-
* file. Best-effort: if rename fails, falls back to a direct write.
|
|
337
|
-
*/
|
|
338
|
-
export function atomicWriteFileSync(
|
|
339
|
-
target: string,
|
|
340
|
-
content: string,
|
|
341
|
-
encoding: BufferEncoding = "utf-8",
|
|
342
|
-
): void {
|
|
343
|
-
const dir = path.dirname(target);
|
|
344
|
-
const base = path.basename(target);
|
|
345
|
-
const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
|
|
346
|
-
try {
|
|
347
|
-
fs.writeFileSync(tmp, content, encoding);
|
|
348
|
-
fs.renameSync(tmp, target);
|
|
349
|
-
} catch {
|
|
350
|
-
// Fallback: direct write (e.g. cross-device rename on some systems)
|
|
351
|
-
try {
|
|
352
|
-
fs.writeFileSync(target, content, encoding);
|
|
353
|
-
} catch {
|
|
354
|
-
// best effort — caller handles errors
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
export function findMarkdownFiles(dir: string, basePath = ""): string[] {
|
|
360
|
-
const results: string[] = [];
|
|
361
|
-
if (!fs.existsSync(dir)) return results;
|
|
362
|
-
|
|
363
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
364
|
-
for (const entry of entries) {
|
|
365
|
-
if (entry.name.startsWith(".")) continue;
|
|
366
|
-
if (entry.name === "node_modules") continue;
|
|
367
|
-
const relPath = basePath ? `${basePath}/${entry.name}` : entry.name;
|
|
368
|
-
const fullPath = path.join(dir, entry.name);
|
|
369
|
-
if (entry.isDirectory()) {
|
|
370
|
-
results.push(...findMarkdownFiles(fullPath, relPath));
|
|
371
|
-
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
372
|
-
results.push(relPath);
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
return results;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
export function globToRegExp(glob: string): RegExp {
|
|
379
|
-
let re = "";
|
|
380
|
-
for (let i = 0; i < glob.length; i++) {
|
|
381
|
-
const c = glob[i];
|
|
382
|
-
if (c === "*") {
|
|
383
|
-
if (glob[i + 1] === "*") {
|
|
384
|
-
re += ".*";
|
|
385
|
-
i++;
|
|
386
|
-
if (glob[i + 1] === "/") i++;
|
|
387
|
-
} else {
|
|
388
|
-
re += "[^/]*";
|
|
389
|
-
}
|
|
390
|
-
} else if (c === "?") {
|
|
391
|
-
re += "[^/]";
|
|
392
|
-
} else if (".()+|^${}\\".includes(c)) {
|
|
393
|
-
re += "\\" + c;
|
|
394
|
-
} else {
|
|
395
|
-
re += c;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
return new RegExp("^" + re + "$");
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
export function matchesGlob(pathStr: string, glob: string): boolean {
|
|
402
|
-
return globToRegExp(glob).test(pathStr);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
export function extractFilePathsFromPrompt(prompt: string): string[] {
|
|
406
|
-
// Only match paths that look like real file references: must contain a slash
|
|
407
|
-
// or start with ./ and end with a short extension. Avoids catching "1.5",
|
|
408
|
-
// "i.e.", etc.
|
|
409
|
-
const matches =
|
|
410
|
-
prompt.match(
|
|
411
|
-
/(?:\.{0,2}\/)?(?:[A-Za-z0-9_\-]+\/)+[A-Za-z0-9_\-.]+\.[A-Za-z0-9]{1,5}/g,
|
|
412
|
-
) ||
|
|
413
|
-
prompt.match(/[A-Za-z0-9_\-.]+\.[a-z]{1,5}/g) ||
|
|
414
|
-
[];
|
|
415
|
-
return matches;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
export function estimateTokens(text: string): number {
|
|
419
|
-
return Math.ceil(text.length / 4);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
export function formatTok(n: number): string {
|
|
423
|
-
if (n <= 0) return "0";
|
|
424
|
-
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
425
|
-
return String(n);
|
|
426
|
-
}
|
|
427
225
|
|
|
428
226
|
// ============================================================================
|
|
429
227
|
// Rules
|
|
@@ -1294,6 +1092,7 @@ function loadPhaseDir(phaseDir: string): PhaseInfo {
|
|
|
1294
1092
|
contextExists: files.some((f) => /-CONTEXT\.md$/.test(f)),
|
|
1295
1093
|
researchExists: files.some((f) => /-RESEARCH\.md$/.test(f)),
|
|
1296
1094
|
plans,
|
|
1095
|
+
tasks: listPhaseTasks(phaseDir),
|
|
1297
1096
|
};
|
|
1298
1097
|
}
|
|
1299
1098
|
|
|
@@ -1418,6 +1217,19 @@ function loadTaskDir(taskDir: string): TaskInfo | null {
|
|
|
1418
1217
|
};
|
|
1419
1218
|
}
|
|
1420
1219
|
|
|
1220
|
+
/** Unified model: load tasks under a phase's `tasks/` subdir. */
|
|
1221
|
+
function listPhaseTasks(phaseDir: string): TaskInfo[] {
|
|
1222
|
+
const tasksDir = path.join(phaseDir, "tasks");
|
|
1223
|
+
if (!fs.existsSync(tasksDir)) return [];
|
|
1224
|
+
const tasks: TaskInfo[] = [];
|
|
1225
|
+
for (const e of fs.readdirSync(tasksDir, { withFileTypes: true })) {
|
|
1226
|
+
if (!e.isDirectory()) continue;
|
|
1227
|
+
const t = loadTaskDir(path.join(tasksDir, e.name));
|
|
1228
|
+
if (t) tasks.push(t);
|
|
1229
|
+
}
|
|
1230
|
+
return tasks.sort((a, b) => a.id.localeCompare(b.id));
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1421
1233
|
function listTasks(solyDir: string): TaskInfo[] {
|
|
1422
1234
|
const tasks: TaskInfo[] = [];
|
|
1423
1235
|
const featuresDir = path.join(solyDir, "features");
|
package/deck/deck.ts
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// deck.ts — full-screen decision-deck TUI component
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Renders ONE architectural/design decision as a stack of framed "cards" — one
|
|
6
|
+
// option per card — that the user flips through with ←/→ (or 1-N) and picks
|
|
7
|
+
// with Enter. Each card shows a title, prose summary, an optional syntax-
|
|
8
|
+
// highlighted code snippet, and pros/cons. Esc cancels.
|
|
9
|
+
//
|
|
10
|
+
// This is the richer cousin of ask_pro's side-panel preview: instead of a thin
|
|
11
|
+
// column it gives a full card to each option, so the user can compare concrete
|
|
12
|
+
// code shapes and trade-offs before committing. Purely native (no browser, no
|
|
13
|
+
// server) — it composes in pi's TUI like any other custom component and reuses
|
|
14
|
+
// the same hard width-capping discipline as the ask_pro picker.
|
|
15
|
+
// =============================================================================
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
Container,
|
|
19
|
+
Text,
|
|
20
|
+
Input,
|
|
21
|
+
truncateToWidth,
|
|
22
|
+
type Component,
|
|
23
|
+
type KeybindingsManager,
|
|
24
|
+
} from "@earendil-works/pi-tui";
|
|
25
|
+
|
|
26
|
+
/** Minimal theme shape (matches pi's Theme.fg / .bold). */
|
|
27
|
+
export interface DeckTheme {
|
|
28
|
+
fg: (color: string, text: string) => string;
|
|
29
|
+
bold: (text: string) => string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DeckOption {
|
|
33
|
+
/** Card title (e.g. "Event bus"). */
|
|
34
|
+
title: string;
|
|
35
|
+
/** 1-3 sentence prose explanation. */
|
|
36
|
+
summary?: string;
|
|
37
|
+
/** Optional code snippet shown on the card. */
|
|
38
|
+
code?: string;
|
|
39
|
+
/** Language hint for the code snippet (e.g. "ts"). */
|
|
40
|
+
lang?: string;
|
|
41
|
+
/** Upsides, rendered as green "+ …" lines. */
|
|
42
|
+
pros?: string[];
|
|
43
|
+
/** Downsides, rendered as "− …" lines. */
|
|
44
|
+
cons?: string[];
|
|
45
|
+
/** Mark as the ⭐ recommended option (cursor starts here). */
|
|
46
|
+
recommended?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DeckResult {
|
|
50
|
+
/** Chosen option index, when the user pressed Enter. */
|
|
51
|
+
chosen?: number;
|
|
52
|
+
/** Set when the user cancelled (Esc). */
|
|
53
|
+
cancelled?: boolean;
|
|
54
|
+
/** Optional free-text note the user attached to the decision (via `n`). */
|
|
55
|
+
note?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface DeckComponentDeps {
|
|
59
|
+
options: DeckOption[];
|
|
60
|
+
theme: DeckTheme;
|
|
61
|
+
keybindings: KeybindingsManager;
|
|
62
|
+
done: (result: DeckResult) => void;
|
|
63
|
+
/** Overall decision title. */
|
|
64
|
+
title?: string;
|
|
65
|
+
/** The question being decided. */
|
|
66
|
+
prompt?: string;
|
|
67
|
+
/** Optional syntax highlighter (pi's highlightCode). */
|
|
68
|
+
highlight?: (code: string, lang?: string) => string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const KEY_ESC = "\x1b";
|
|
72
|
+
const KEY_ENTER = "\n";
|
|
73
|
+
const KEY_ENTER_CR = "\r";
|
|
74
|
+
const KEY_UP = "\x1b[A";
|
|
75
|
+
const KEY_DOWN = "\x1b[B";
|
|
76
|
+
const KEY_RIGHT = "\x1b[C";
|
|
77
|
+
const KEY_LEFT = "\x1b[D";
|
|
78
|
+
const KEY_BACKSPACE = "\x7f";
|
|
79
|
+
const KEY_TAB = "\t";
|
|
80
|
+
|
|
81
|
+
/** Max card width; cards never grow wider than this even on huge terminals. */
|
|
82
|
+
const MAX_BOX = 96;
|
|
83
|
+
|
|
84
|
+
export class DeckComponent extends Container {
|
|
85
|
+
private options: DeckOption[];
|
|
86
|
+
private theme: DeckTheme;
|
|
87
|
+
private keybindings: KeybindingsManager;
|
|
88
|
+
private done: (result: DeckResult) => void;
|
|
89
|
+
private title: string;
|
|
90
|
+
private prompt: string;
|
|
91
|
+
private highlight?: (code: string, lang?: string) => string[];
|
|
92
|
+
|
|
93
|
+
private index = 0;
|
|
94
|
+
private completed = false;
|
|
95
|
+
private body!: Text;
|
|
96
|
+
/** Free-text note attached to the decision (via `n`). */
|
|
97
|
+
private note = "";
|
|
98
|
+
/** True while the inline note field owns the keyboard. */
|
|
99
|
+
private noteMode = false;
|
|
100
|
+
/** The inline Input rendered while noteMode is active. */
|
|
101
|
+
private noteInput: Input | null = null;
|
|
102
|
+
|
|
103
|
+
constructor(deps: DeckComponentDeps) {
|
|
104
|
+
super();
|
|
105
|
+
this.options = deps.options;
|
|
106
|
+
this.theme = deps.theme;
|
|
107
|
+
this.keybindings = deps.keybindings;
|
|
108
|
+
this.done = deps.done;
|
|
109
|
+
this.title = deps.title ?? "decision";
|
|
110
|
+
this.prompt = deps.prompt ?? "";
|
|
111
|
+
this.highlight = deps.highlight;
|
|
112
|
+
this.index = Math.max(
|
|
113
|
+
0,
|
|
114
|
+
this.options.findIndex((o) => o.recommended),
|
|
115
|
+
);
|
|
116
|
+
this.body = new Text("", 1, 0);
|
|
117
|
+
this.addChild(this.body);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
getIndex(): number {
|
|
121
|
+
return this.index;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The note currently attached to the decision ("" until one is added). */
|
|
125
|
+
getNote(): string {
|
|
126
|
+
return this.note;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --------------------------------------------------------------------------
|
|
130
|
+
// Input
|
|
131
|
+
// --------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
handleInput(keyData: string): void {
|
|
134
|
+
if (this.completed) return;
|
|
135
|
+
|
|
136
|
+
// --- inline note field owns the keyboard (Enter commits, Esc cancels) ---
|
|
137
|
+
if (this.noteMode) {
|
|
138
|
+
this.handleNoteKey(keyData);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (keyData === KEY_ESC) {
|
|
143
|
+
this.finish({ cancelled: true });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// `n` — open the inline note field (pre-fills any existing note).
|
|
147
|
+
if (keyData === "n") {
|
|
148
|
+
this.openNoteInput();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
this.keybindings.matches(keyData, "tui.select.confirm") ||
|
|
153
|
+
keyData === KEY_ENTER ||
|
|
154
|
+
keyData === KEY_ENTER_CR
|
|
155
|
+
) {
|
|
156
|
+
this.finish({ chosen: this.index });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (keyData === KEY_LEFT || keyData === KEY_UP || keyData === "h" || keyData === "k") {
|
|
160
|
+
this.index = Math.max(0, this.index - 1);
|
|
161
|
+
this.body.invalidate();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (keyData === KEY_RIGHT || keyData === KEY_DOWN || keyData === "l" || keyData === "j") {
|
|
165
|
+
this.index = Math.min(this.options.length - 1, this.index + 1);
|
|
166
|
+
this.body.invalidate();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const num = parseInt(keyData, 10);
|
|
170
|
+
if (!isNaN(num) && num >= 1 && num <= this.options.length) {
|
|
171
|
+
this.index = num - 1;
|
|
172
|
+
this.body.invalidate();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private finish(result: DeckResult): void {
|
|
177
|
+
this.completed = true;
|
|
178
|
+
// Attach the note (if any) to a choice — never to a cancellation.
|
|
179
|
+
if (!result.cancelled && this.note) result.note = this.note;
|
|
180
|
+
this.done(result);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Build a fresh inline Input pre-filled with `value`, cursor at the end so
|
|
184
|
+
* the user can extend or backspace-edit it. */
|
|
185
|
+
private makeInput(value: string): Input {
|
|
186
|
+
const input = new Input();
|
|
187
|
+
for (const ch of value) input.handleInput(ch);
|
|
188
|
+
return input;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Open the inline note field, pre-filled with any existing note so it can
|
|
192
|
+
* be edited. While open, all keys route to the field except Enter/Esc. */
|
|
193
|
+
private openNoteInput(): void {
|
|
194
|
+
this.noteInput = this.makeInput(this.note);
|
|
195
|
+
this.noteMode = true;
|
|
196
|
+
this.body.invalidate();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Route a keystroke to the active note field, intercepting Enter (commit)
|
|
200
|
+
* and Esc (cancel). Mirrors ask_pro's inline-input behavior. */
|
|
201
|
+
private handleNoteKey(keyData: string): void {
|
|
202
|
+
if (!this.noteInput) return;
|
|
203
|
+
if (keyData === KEY_ESC) {
|
|
204
|
+
this.closeNote(false);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (
|
|
208
|
+
this.keybindings.matches(keyData, "tui.select.confirm") ||
|
|
209
|
+
keyData === KEY_ENTER ||
|
|
210
|
+
keyData === KEY_ENTER_CR
|
|
211
|
+
) {
|
|
212
|
+
this.closeNote(true);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// Tab also commits (common "done typing" gesture) so the field doesn't
|
|
216
|
+
// swallow Tab trying to insert a literal tab.
|
|
217
|
+
if (keyData === KEY_TAB) {
|
|
218
|
+
this.closeNote(true);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
this.noteInput.handleInput(keyData);
|
|
222
|
+
this.body.invalidate();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Commit (commit=true) or discard the note field, then return to the
|
|
226
|
+
* card deck. A committed empty value clears any existing note. */
|
|
227
|
+
private closeNote(commit: boolean): void {
|
|
228
|
+
const text = this.noteInput?.getValue() ?? "";
|
|
229
|
+
this.noteInput = null;
|
|
230
|
+
this.noteMode = false;
|
|
231
|
+
if (commit) {
|
|
232
|
+
const trimmed = text.trim();
|
|
233
|
+
this.note = trimmed === "" ? "" : trimmed;
|
|
234
|
+
}
|
|
235
|
+
this.body.invalidate();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
dispose(): void {
|
|
239
|
+
this.completed = true;
|
|
240
|
+
this.noteMode = false;
|
|
241
|
+
this.noteInput = null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --------------------------------------------------------------------------
|
|
245
|
+
// Rendering — every emitted line is hard-capped to `width` visible columns.
|
|
246
|
+
// --------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
render(width: number): string[] {
|
|
249
|
+
const boxWidth = Math.max(12, Math.min(width, MAX_BOX));
|
|
250
|
+
const inner = Math.max(4, boxWidth - 4);
|
|
251
|
+
const opt = this.options[this.index];
|
|
252
|
+
const out: string[] = [];
|
|
253
|
+
|
|
254
|
+
out.push(this.center(this.theme.bold(this.theme.fg("accent", this.title)), boxWidth));
|
|
255
|
+
for (const l of this.wrap(this.prompt, boxWidth)) {
|
|
256
|
+
if (l) out.push(this.center(this.theme.fg("dim", l), boxWidth));
|
|
257
|
+
}
|
|
258
|
+
out.push("");
|
|
259
|
+
out.push(this.center(this.renderPager(), boxWidth));
|
|
260
|
+
out.push("");
|
|
261
|
+
if (opt) for (const l of this.renderCard(opt, boxWidth, inner)) out.push(l);
|
|
262
|
+
// Inline note field — shown below the card when noteMode is active.
|
|
263
|
+
// It owns the keyboard (Enter commits, Esc cancels); see handleNoteKey.
|
|
264
|
+
if (this.noteMode && this.noteInput) {
|
|
265
|
+
out.push("");
|
|
266
|
+
out.push(
|
|
267
|
+
this.center(
|
|
268
|
+
this.theme.fg("dim", "Note: (enter ⏎ confirm · esc cancel)"),
|
|
269
|
+
boxWidth,
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
for (const l of this.noteInput.render(inner)) {
|
|
273
|
+
out.push(truncateToWidth(l, width, "", false));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
out.push("");
|
|
277
|
+
out.push(this.center(this.theme.fg("dim", this.footerHints()), boxWidth));
|
|
278
|
+
|
|
279
|
+
return out.map((l) => truncateToWidth(l, width, "", false));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** A framed card for one option. */
|
|
283
|
+
private renderCard(opt: DeckOption, boxWidth: number, inner: number): string[] {
|
|
284
|
+
const bar = "━".repeat(boxWidth - 2);
|
|
285
|
+
const lines: string[] = [this.theme.fg("accent", `┏${bar}┓`)];
|
|
286
|
+
const star = opt.recommended ? "⭐ " : "";
|
|
287
|
+
lines.push(this.box(this.theme.bold(star + opt.title), inner));
|
|
288
|
+
|
|
289
|
+
if (opt.summary) {
|
|
290
|
+
lines.push(this.box("", inner));
|
|
291
|
+
for (const l of this.wrap(opt.summary, inner)) {
|
|
292
|
+
lines.push(this.box(this.theme.fg("text", l), inner));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (opt.code) {
|
|
296
|
+
lines.push(this.box("", inner));
|
|
297
|
+
for (const l of this.highlightLines(opt.code, opt.lang)) {
|
|
298
|
+
lines.push(this.box(l, inner));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const pros = opt.pros ?? [];
|
|
302
|
+
const cons = opt.cons ?? [];
|
|
303
|
+
if (pros.length || cons.length) {
|
|
304
|
+
lines.push(this.box("", inner));
|
|
305
|
+
for (const p of pros) lines.push(this.box(this.theme.fg("success", `+ ${p}`), inner));
|
|
306
|
+
for (const c of cons) lines.push(this.box(this.theme.fg("warning", `− ${c}`), inner));
|
|
307
|
+
}
|
|
308
|
+
lines.push(this.theme.fg("accent", `┗${bar}┛`));
|
|
309
|
+
return lines;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Frame one content line inside the card borders, padded to `inner`. */
|
|
313
|
+
private box(content: string, inner: number): string {
|
|
314
|
+
const edge = this.theme.fg("accent", "┃");
|
|
315
|
+
return `${edge} ${truncateToWidth(content, inner, "", true)} ${edge}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** `◀ ●○○ 2/3 ▶` — the option pager. */
|
|
319
|
+
private renderPager(): string {
|
|
320
|
+
const dots = this.options
|
|
321
|
+
.map((_, i) => this.theme.fg(i === this.index ? "accent" : "dim", i === this.index ? "●" : "○"))
|
|
322
|
+
.join("");
|
|
323
|
+
const left = this.theme.fg(this.index > 0 ? "accent" : "dim", "◀");
|
|
324
|
+
const right = this.theme.fg(this.index < this.options.length - 1 ? "accent" : "dim", "▶");
|
|
325
|
+
const counter = this.theme.fg("dim", `${this.index + 1}/${this.options.length}`);
|
|
326
|
+
return `${left} ${dots} ${counter} ${right}`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private footerHints(): string {
|
|
330
|
+
// While the note field is open it owns the keyboard — show only its
|
|
331
|
+
// affordances.
|
|
332
|
+
if (this.noteMode) return "⏎ confirm · esc cancel";
|
|
333
|
+
const noteHint = this.note ? "n ✓note" : "n note";
|
|
334
|
+
return `←/→ flip · 1-N jump · ${noteHint} · ⏎ choose · esc cancel`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Highlight (or dim-fallback) a code snippet into per-line styled strings. */
|
|
338
|
+
private highlightLines(code: string, lang?: string): string[] {
|
|
339
|
+
const src = code.replace(/\r\n/g, "\n").replace(/\s+$/, "");
|
|
340
|
+
return this.highlight
|
|
341
|
+
? this.highlight(src, lang)
|
|
342
|
+
: src.split("\n").map((l) => this.theme.fg("dim", l));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Center a (possibly styled) string within `width` visible columns. */
|
|
346
|
+
private center(s: string, width: number): string {
|
|
347
|
+
const pad = Math.max(0, Math.floor((width - this.visible(s)) / 2));
|
|
348
|
+
return " ".repeat(pad) + s;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Visible width of a styled string (strips ANSI/OSC for the count). */
|
|
352
|
+
private visible(s: string): number {
|
|
353
|
+
// eslint-disable-next-line no-control-regex
|
|
354
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "").replace(/\x1b\]8;[^\x07]*\x07/g, "").length;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Greedy word-wrap (split on existing newlines first). `.length` is a safe
|
|
358
|
+
* upper bound — callers hard-cap the final row, so a miscount can't overflow. */
|
|
359
|
+
private wrap(text: string, maxWidth: number): string[] {
|
|
360
|
+
if (!text) return [];
|
|
361
|
+
const out: string[] = [];
|
|
362
|
+
for (const raw of text.replace(/\r\n/g, "\n").split("\n")) {
|
|
363
|
+
if (raw.trim() === "") {
|
|
364
|
+
out.push("");
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
let cur = "";
|
|
368
|
+
for (const w of raw.split(" ")) {
|
|
369
|
+
const word = w.length > maxWidth ? truncateToWidth(w, maxWidth, "") : w;
|
|
370
|
+
if (cur.length === 0) cur = word;
|
|
371
|
+
else if (cur.length + 1 + word.length <= maxWidth) cur += ` ${word}`;
|
|
372
|
+
else {
|
|
373
|
+
out.push(cur);
|
|
374
|
+
cur = word;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
out.push(cur);
|
|
378
|
+
}
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Type guard for the public component. */
|
|
384
|
+
export function isDeckComponent(c: Component): c is DeckComponent {
|
|
385
|
+
return c instanceof DeckComponent;
|
|
386
|
+
}
|