@cspeach/cli 0.7.1 → 0.9.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.
Files changed (50) hide show
  1. package/bench/README.md +78 -0
  2. package/bench/prompts/abap-document-cds.md +44 -0
  3. package/bench/prompts/abap-explain-bdef-handler.md +57 -0
  4. package/bench/prompts/abap-test-method.md +42 -0
  5. package/bench/results/abap-document-cds/claude-haiku-4-5.md +189 -0
  6. package/bench/results/abap-document-cds/claude-opus-4-7.md +120 -0
  7. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +151 -0
  8. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +112 -0
  9. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +101 -0
  10. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +101 -0
  11. package/bench/results/abap-test-method/claude-haiku-4-5.md +186 -0
  12. package/bench/results/abap-test-method/claude-opus-4-7.md +193 -0
  13. package/bench/results/abap-test-method/claude-sonnet-4-6.md +234 -0
  14. package/dist/agent/loop.js +144 -26
  15. package/dist/agent/steering-queue.js +27 -0
  16. package/dist/approvals/jwt.js +45 -5
  17. package/dist/approvals/render.js +38 -0
  18. package/dist/classifier/client.js +6 -2
  19. package/dist/commands/plan-resume.js +308 -0
  20. package/dist/config/loader.js +13 -6
  21. package/dist/cost/pricing.js +14 -5
  22. package/dist/one-shot.js +6 -0
  23. package/dist/projects/email-template.js +2 -0
  24. package/dist/projects/extract-plan.js +85 -0
  25. package/dist/projects/index.js +3 -0
  26. package/dist/projects/plan-run.js +120 -0
  27. package/dist/projects/plan-schema.js +150 -0
  28. package/dist/projects/promote-command.js +1 -0
  29. package/dist/projects/save-command.js +17 -1
  30. package/dist/projects/status.js +20 -0
  31. package/dist/projects/validate.js +2 -0
  32. package/dist/renderer/question-normalizer.js +16 -6
  33. package/dist/renderer/syntax.js +16 -1
  34. package/dist/renderer/thinking-heartbeat.js +13 -1
  35. package/dist/repl/at-picker.js +39 -20
  36. package/dist/repl/slash-picker.js +15 -4
  37. package/dist/repl.js +216 -17
  38. package/dist/router/classifier.js +74 -8
  39. package/dist/skill-catalog.js +8 -2
  40. package/dist/tools/subagent/agent_run.js +12 -0
  41. package/dist/ui/app.js +80 -11
  42. package/dist/ui/body.js +61 -2
  43. package/dist/ui/command-palette.js +46 -10
  44. package/dist/ui/file-palette.js +44 -0
  45. package/dist/ui/footer.js +8 -5
  46. package/dist/ui/line-resolution.js +81 -0
  47. package/dist/ui/turn-status-emitter.js +52 -0
  48. package/dist/ui/turn-status.js +59 -0
  49. package/dist/ui/widgets/ask-question-modal.js +26 -1
  50. package/package.json +6 -2
@@ -0,0 +1,150 @@
1
+ // cspeach-cli/src/projects/plan-schema.ts
2
+ //
3
+ // Deep validation for the 'plan' artefact content (/abap-plan envelope).
4
+ //
5
+ // Split of responsibilities, matching the rest of the store:
6
+ // - validate.ts — top-level envelope shape, hand-rolled (adds 'plan'
7
+ // to its known types + status enum; content is opaque)
8
+ // - plan-schema.ts — Zod schema for the PlanContent payload, invoked by
9
+ // the --resume path before any phase is executed
10
+ //
11
+ // The TS interfaces live in types.ts with the other content shapes; the
12
+ // `z.ZodType<PlanContent>` annotation below keeps schema and interface in
13
+ // lockstep at compile time.
14
+ import { z } from 'zod';
15
+ /** Keep in sync with PlanLayer in types.ts. */
16
+ export const PLAN_LAYERS = [
17
+ 'types', 'persistence', 'data-model', 'behavior', 'service',
18
+ 'orchestration', 'integration', 'ui',
19
+ ];
20
+ /**
21
+ * Existing skills a phase may delegate to. Keep in sync with
22
+ * PlanDelegateSkill in types.ts. Extending = one entry here + one in the
23
+ * union there. Typical per-layer defaults (guidance lives in the
24
+ * abap-plan SKILL.md, not enforced here):
25
+ * types/persistence → abap-data-model
26
+ * data-model/behavior/service → abap-rap (abap-eml for handler logic)
27
+ * orchestration/integration → abap-generate (abap-segw for ECC OData)
28
+ */
29
+ export const PLAN_DELEGATE_SKILLS = [
30
+ 'abap-design', 'abap-data-model', 'abap-rap', 'abap-eml',
31
+ 'abap-generate', 'abap-segw', 'abap-test',
32
+ ];
33
+ /** Keep in sync with ItemStatusByType['plan'] in types.ts. */
34
+ export const PLAN_PHASE_STATUSES = [
35
+ 'todo', 'designing', 'building', 'verifying', 'validated', 'blocked',
36
+ ];
37
+ const SHA256_RE = /^[0-9a-f]{64}$/;
38
+ const planPhaseManifestSchema = z.object({
39
+ reads: z.array(z.string()),
40
+ rules: z.array(z.string()),
41
+ writesBack: z.array(z.string()),
42
+ });
43
+ // .passthrough(): live smoke showed models enrich `work` with valuable
44
+ // extras (blockedReason, unblockPath, groundingChecks) — strip mode was
45
+ // silently discarding them from the persisted envelope.
46
+ const planPhaseWorkSchema = z.object({
47
+ generated: z.array(z.string()).optional(),
48
+ transport: z.string().optional(),
49
+ snapshot: z.string().optional(),
50
+ notes: z.string().optional(),
51
+ }).passthrough();
52
+ const planPhaseSchema = z.object({
53
+ id: z.string().min(1),
54
+ component: z.string().min(1),
55
+ layer: z.enum(PLAN_LAYERS),
56
+ entryCriteria: z.array(z.string()),
57
+ delegateTo: z.enum(PLAN_DELEGATE_SKILLS),
58
+ manifest: planPhaseManifestSchema,
59
+ exitGate: z.string().min(1),
60
+ approval: z.boolean().optional(),
61
+ work: planPhaseWorkSchema.optional(),
62
+ });
63
+ // Input type is `unknown` (third param): the `.default({})` on `from` means
64
+ // the schema ACCEPTS input without the key while still OUTPUTTING PlanContent.
65
+ export const planContentSchema = z
66
+ .object({
67
+ project: z.object({
68
+ goal: z.string().min(1),
69
+ source: z.string(),
70
+ target: z.string(),
71
+ }),
72
+ // Tolerant by lesson (2026-06-06 live smoke): the model omitted the whole
73
+ // `from` key for a goal-only plan and the strict schema killed the save.
74
+ // Missing `from` normalises to {} — only the inner specGap ref is shaped.
75
+ from: z
76
+ .object({
77
+ specGap: z
78
+ .object({
79
+ path: z.string().min(1),
80
+ sha256: z.string().regex(SHA256_RE, 'must be a lowercase hex sha256'),
81
+ })
82
+ .optional(),
83
+ })
84
+ .default({}),
85
+ phases: z.array(planPhaseSchema).min(1),
86
+ summary: z.object({
87
+ total: z.number().int().nonnegative(),
88
+ validated: z.number().int().nonnegative(),
89
+ blocked: z.number().int().nonnegative(),
90
+ next: z.string().nullable(),
91
+ }),
92
+ })
93
+ .superRefine((content, ctx) => {
94
+ // Phase ids must be unique, and entryCriteria may only reference phases
95
+ // EARLIER in the array. Bottom-up ordering by construction — this is what
96
+ // guarantees resume is deterministic and the dependency graph acyclic
97
+ // (a forward or self reference can never form, so no cycle can either).
98
+ const seen = new Set();
99
+ content.phases.forEach((p, i) => {
100
+ if (seen.has(p.id)) {
101
+ ctx.addIssue({
102
+ code: z.ZodIssueCode.custom,
103
+ path: ['phases', i, 'id'],
104
+ message: `Duplicate phase id: ${p.id}`,
105
+ });
106
+ }
107
+ p.entryCriteria.forEach((dep, j) => {
108
+ if (!seen.has(dep)) {
109
+ const known = content.phases.some((q) => q.id === dep);
110
+ ctx.addIssue({
111
+ code: z.ZodIssueCode.custom,
112
+ path: ['phases', i, 'entryCriteria', j],
113
+ message: known
114
+ ? `entryCriteria '${dep}' must reference an earlier phase (plans are ordered bottom-up)`
115
+ : `entryCriteria references unknown phase: ${dep}`,
116
+ });
117
+ }
118
+ });
119
+ seen.add(p.id);
120
+ });
121
+ if (content.summary.total !== content.phases.length) {
122
+ ctx.addIssue({
123
+ code: z.ZodIssueCode.custom,
124
+ path: ['summary', 'total'],
125
+ message: `summary.total (${content.summary.total}) must equal phases.length (${content.phases.length})`,
126
+ });
127
+ }
128
+ if (content.summary.next !== null && !seen.has(content.summary.next)) {
129
+ ctx.addIssue({
130
+ code: z.ZodIssueCode.custom,
131
+ path: ['summary', 'next'],
132
+ message: `summary.next references unknown phase: ${content.summary.next}`,
133
+ });
134
+ }
135
+ });
136
+ /**
137
+ * Validate an unknown value as PlanContent. Returns flattened, path-prefixed
138
+ * error strings suitable for printing directly in the REPL — the --resume
139
+ * command bails on the first failed parse rather than executing a phase
140
+ * against a malformed plan.
141
+ */
142
+ export function parsePlanContent(input) {
143
+ const r = planContentSchema.safeParse(input);
144
+ if (r.success)
145
+ return { ok: true, content: r.data };
146
+ return {
147
+ ok: false,
148
+ errors: r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`),
149
+ };
150
+ }
@@ -18,6 +18,7 @@ const PREDECESSOR_BY_TARGET = {
18
18
  'abap-upgrade-verify': 'upgrade-progress',
19
19
  'abap-modernize': 'cca-assessment', // v0.7: new chain
20
20
  'abap-test': 'cca-assessment', // v0.7: new chain
21
+ 'abap-plan': 'spec-gap', // B3: plan consumes answered gaps
21
22
  };
22
23
  function affirmative(reply) {
23
24
  const v = reply.trim().toLowerCase();
@@ -5,6 +5,7 @@ import { extractUpgradeScan, extractUpgradeProgress, extractUpgradeReport } from
5
5
  import { extractCcaAssessment } from './extract-cca.js';
6
6
  import { extractModernizeResult } from './extract-modernize.js';
7
7
  import { extractTestCoverage } from './extract-test-coverage.js';
8
+ import { extractPlan } from './extract-plan.js';
8
9
  import { buildEnvelope } from './build.js';
9
10
  import { saveProject } from './save.js';
10
11
  import { renderEmailTemplate } from './email-template.js';
@@ -69,6 +70,15 @@ const SKILL_REGISTRY = {
69
70
  extract: (md) => extractTestCoverage(md),
70
71
  itemNoun: 'tests',
71
72
  },
73
+ 'abap-plan': {
74
+ // 2026-06-06 B3: Mode 1 (create) save only. Mode 2 (--resume) turns
75
+ // suppress this hook (RunTurnParams.suppressSaveHook) and persist a
76
+ // REVISION of the existing envelope via commands/plan-resume.ts.
77
+ artefactType: 'plan',
78
+ extract: (md) => extractPlan(md),
79
+ itemNoun: 'phases',
80
+ manifestMarker: 'csforge:plan-manifest',
81
+ },
72
82
  };
73
83
  export async function runSaveCommand(args) {
74
84
  const spec = SKILL_REGISTRY[args.skillName];
@@ -78,7 +88,13 @@ export async function runSaveCommand(args) {
78
88
  try {
79
89
  extract = spec.extract(args.skillOutput);
80
90
  }
81
- catch {
91
+ catch (e) {
92
+ if (spec.manifestMarker && args.skillOutput.includes(spec.manifestMarker)) {
93
+ args.log('');
94
+ args.log(`[save] ${args.skillName}: a ${spec.manifestMarker} block IS present but failed to parse — artefact NOT saved.`);
95
+ args.log(`[save] ${e instanceof Error ? e.message : String(e)}`);
96
+ args.log(`[save] The output above is otherwise complete — re-run the skill or report this as a manifest bug.`);
97
+ }
82
98
  return null;
83
99
  }
84
100
  const answer = (await args.prompt('Save as project file? [y/N]: ')).trim().toLowerCase();
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { validateEnvelope } from './validate.js';
3
3
  import { canonicalSha256 } from './canonicalize.js';
4
+ import { statusesFromItems, computeNextPhase, renderPlanTracker } from './plan-run.js';
4
5
  export function readProjectFile(path) {
5
6
  const raw = readFileSync(path, 'utf8');
6
7
  let parsed;
@@ -22,6 +23,7 @@ const STATUS_BY_TYPE = {
22
23
  'estimate': ['confirmed', 'disputed', 'deferred'],
23
24
  'cca': ['keep', 'fix', 'retire', 'redesign'],
24
25
  'incident': ['pending', 'done'],
26
+ 'plan': ['validated', 'todo', 'designing', 'building', 'verifying', 'blocked'],
25
27
  };
26
28
  const PRIMARY_BY_TYPE = {
27
29
  'spec-gap': 'answered',
@@ -29,6 +31,7 @@ const PRIMARY_BY_TYPE = {
29
31
  'estimate': 'confirmed',
30
32
  'cca': 'keep',
31
33
  'incident': 'done',
34
+ 'plan': 'validated',
32
35
  };
33
36
  const PROMOTE_HINT_BY_TYPE = {
34
37
  'spec-gap': '/abap-design --from',
@@ -36,6 +39,7 @@ const PROMOTE_HINT_BY_TYPE = {
36
39
  'estimate': null,
37
40
  'cca': null,
38
41
  'incident': null,
42
+ 'plan': '/abap-plan --resume',
39
43
  };
40
44
  export function renderStatus(env) {
41
45
  const items = env.interaction.items;
@@ -85,6 +89,22 @@ export function renderStatus(env) {
85
89
  lines.push(` ... ${stillOpen.length - 10} more`);
86
90
  lines.push('');
87
91
  }
92
+ // B3 (2026-06-06): plan envelopes get the phase tracker board — the same
93
+ // renderer the --resume flow prints before/after each phase. Header off:
94
+ // title + version were already printed above.
95
+ if (env.artefactType === 'plan') {
96
+ const content = env.content;
97
+ const statuses = statusesFromItems(items, content.phases);
98
+ lines.push(renderPlanTracker({
99
+ title: env.title,
100
+ version: env.version,
101
+ content,
102
+ statuses,
103
+ currentId: computeNextPhase(content.phases, statuses)?.id ?? null,
104
+ includeHeader: false,
105
+ }));
106
+ lines.push('');
107
+ }
88
108
  // Promote hint fires whenever a "next phase" exists for this artefactType.
89
109
  // Unlike the v1 implementation we no longer gate on `primaryCount > 0` —
90
110
  // a design with zero `keep` items (all `redesign` / `defer`) is still a
@@ -5,6 +5,7 @@ const KNOWN_ARTEFACT_TYPES = [
5
5
  'upgrade-scan', 'upgrade-progress', 'upgrade-report',
6
6
  'cca-assessment',
7
7
  'modernize-result', 'test-coverage',
8
+ 'plan',
8
9
  ];
9
10
  const STATUS_BY_TYPE = {
10
11
  'spec-gap': ['open', 'answered', 'deferred', 'n-a'],
@@ -18,6 +19,7 @@ const STATUS_BY_TYPE = {
18
19
  'cca-assessment': ['open', 'reviewed', 'classified', 'archived'],
19
20
  'modernize-result': ['applied', 'skipped', 'failed', 'pending'],
20
21
  'test-coverage': ['generated', 'skipped', 'failed', 'pending'],
22
+ 'plan': ['todo', 'designing', 'building', 'verifying', 'validated', 'blocked'],
21
23
  };
22
24
  const REQUIRED_TOP_LEVEL = [
23
25
  'schemaVersion', 'artefactType', 'id', 'title',
@@ -74,26 +74,36 @@ function findBalancedJson(text, from) {
74
74
  * Returns the offset where the block begins in `text`.
75
75
  */
76
76
  function findBlockStart(text, jsonStart) {
77
- let pos = jsonStart;
78
77
  // Walk backward over preceding lines that are empty, the sentinel, or
79
78
  // a fence-open marker. Stop on any line that has other content.
80
- while (pos > 0) {
81
- const prevNewline = text.lastIndexOf('\n', pos - 1);
79
+ //
80
+ // 2026-06-06 — rewritten to guarantee forward progress. The previous
81
+ // version set `pos = lineStart` and looped; when the `{` sat at the
82
+ // start of a line (lineStart === pos — the CANONICAL widget shape),
83
+ // pos never moved and the loop spun forever, pegging a core. The scan
84
+ // position now steps over the preceding newline each iteration
85
+ // (strictly decreasing), and the accepted block start is tracked
86
+ // separately so the return value is still a line-start offset.
87
+ let blockStart = jsonStart;
88
+ let scanEnd = jsonStart; // exclusive end of the line-chunk under examination
89
+ while (scanEnd > 0) {
90
+ const prevNewline = text.lastIndexOf('\n', scanEnd - 1);
82
91
  const lineStart = prevNewline === -1 ? 0 : prevNewline + 1;
83
- const line = text.slice(lineStart, pos).trim();
92
+ const line = text.slice(lineStart, scanEnd).trim();
84
93
  if (line === '' ||
85
94
  line === SENTINEL ||
86
95
  /^```(?:cspeach-widget:\S+)?$/.test(line) ||
87
96
  /^````(?:cspeach-widget:\S+)?$/.test(line)) {
88
- pos = lineStart;
97
+ blockStart = lineStart;
89
98
  if (prevNewline === -1)
90
99
  break;
100
+ scanEnd = prevNewline; // step over the newline — strictly decreasing
91
101
  }
92
102
  else {
93
103
  break;
94
104
  }
95
105
  }
96
- return pos;
106
+ return blockStart;
97
107
  }
98
108
  /**
99
109
  * Walk forward from `jsonEnd` (one past the closing `}`) to include a
@@ -22,10 +22,25 @@
22
22
  * that object — the shape of the API is the same.
23
23
  */
24
24
  import { highlight } from 'cli-highlight';
25
- import hljs from 'highlight.js';
25
+ import { createRequire } from 'node:module';
26
26
  import abapGrammar from './highlighters/abap.js';
27
27
  import cdsGrammar from './highlighters/cds.js';
28
28
  import bdefGrammar from './highlighters/bdef.js';
29
+ // A5 fix (2026-06-06) — registration must hit the EXACT module instance
30
+ // cli-highlight loads, and on this pnpm/Windows layout that is NOT what a
31
+ // plain `import hljs from 'highlight.js'` (or even a local require)
32
+ // resolves to. Diagnosed live: our resolution lands on the top-level
33
+ // `node_modules/highlight.js` while cli-highlight requires its nested
34
+ // `.pnpm/highlight.js@10.7.3/...` copy — node treats them as two separate
35
+ // modules with separate language registries, so the grammars registered
36
+ // on ours were invisible to cli-highlight and EVERY abap/cds/bdef block
37
+ // silently fell back to unhighlighted echo ("Could not find the language
38
+ // 'abap'") — in production as well as tests, since the first release.
39
+ // Fix: chain createRequire through cli-highlight's own entry point so we
40
+ // follow its resolution and register on the instance it actually uses.
41
+ const require = createRequire(import.meta.url);
42
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
43
+ const hljs = createRequire(require.resolve('cli-highlight'))('highlight.js');
29
44
  // Module-load-time registration. The grammar modules export plain objects
30
45
  // (shape matches highlight.js `Language` definition). Cast is safe — the
31
46
  // grammars match the runtime contract even when strict typings drift.
@@ -29,7 +29,7 @@
29
29
  * fallback.
30
30
  */
31
31
  import chalk from 'chalk';
32
- const THRESHOLDS_MS = [
32
+ const LLM_THRESHOLDS = [
33
33
  { at: 3_000, label: 'thinking…' },
34
34
  { at: 10_000, label: 'thinking… (10s)' },
35
35
  { at: 30_000, label: 'thinking… (30s)' },
@@ -39,10 +39,22 @@ const THRESHOLDS_MS = [
39
39
  { at: 180_000, label: 'thinking… (3m — Ctrl+C is recommended; the stream may have stalled)' },
40
40
  { at: 300_000, label: 'thinking… (5m — Ctrl+C and try again)' },
41
41
  ];
42
+ /** Suffixes for labelled (tool / hook) waits — appended to opts.label. */
43
+ const GENERIC_SUFFIXES = [
44
+ { at: 3_000, suffix: '' },
45
+ { at: 10_000, suffix: ' (10s)' },
46
+ { at: 30_000, suffix: ' (30s)' },
47
+ { at: 60_000, suffix: ' (60s — still running, hold on)' },
48
+ { at: 120_000, suffix: ' (2m — still running; Ctrl+C aborts the turn)' },
49
+ { at: 300_000, suffix: ' (5m — likely stuck; Ctrl+C and check the VPN)' },
50
+ ];
42
51
  export function startThinkingHeartbeat(opts) {
43
52
  const startedAt = Date.now();
44
53
  let nextIdx = 0;
45
54
  let stopped = false;
55
+ const THRESHOLDS_MS = opts?.label
56
+ ? GENERIC_SUFFIXES.map((t) => ({ at: t.at, label: `${opts.label}${t.suffix}` }))
57
+ : LLM_THRESHOLDS;
46
58
  const emitLine = (line) => {
47
59
  if (opts?.chunkEmitter) {
48
60
  // Phase A #4 — route through Ink's chunk pipeline. The Body
@@ -22,6 +22,27 @@ import { withInquirer } from './inquirer-guard.js';
22
22
  import { inquirerTheme } from './inquirer-theme.js';
23
23
  import { ensureWorkspaceSync, listProjectFilesSync } from '../projects/workspace.js';
24
24
  const CANCEL_VALUE = '__cspeach_cancel__';
25
+ /**
26
+ * 2026-06-06 (Ink picker parity): workspace listing + substring filter,
27
+ * exported so the Ink FilePalette shares the same data + seeding
28
+ * semantics as this classic inquirer picker.
29
+ */
30
+ export function listWorkspaceFileChoices() {
31
+ try {
32
+ const ws = ensureWorkspaceSync();
33
+ return listProjectFilesSync(ws).map((f) => ({ filename: f.filename, mtimeMs: f.mtimeMs }));
34
+ }
35
+ catch {
36
+ return [];
37
+ }
38
+ }
39
+ /** Substring filter; empty prefix returns everything. */
40
+ export function filterFileChoices(prefix, all) {
41
+ const lower = prefix.trim().toLowerCase();
42
+ return lower.length === 0
43
+ ? [...all]
44
+ : all.filter((f) => f.filename.toLowerCase().includes(lower));
45
+ }
25
46
  /**
26
47
  * Open the at-picker pre-filtered to `prefix`. Returns the picked
27
48
  * filename (no leading @), or null on cancel / empty-workspace.
@@ -31,21 +52,11 @@ const CANCEL_VALUE = '__cspeach_cancel__';
31
52
  * (bare `@`) seeds the picker with all workspace files.
32
53
  */
33
54
  export async function openAtPicker(prefix) {
34
- let allFiles;
35
- try {
36
- const ws = ensureWorkspaceSync();
37
- allFiles = listProjectFilesSync(ws).map((f) => ({ filename: f.filename, mtimeMs: f.mtimeMs }));
38
- }
39
- catch {
40
- allFiles = [];
41
- }
55
+ const allFiles = listWorkspaceFileChoices();
42
56
  if (allFiles.length === 0) {
43
57
  return null;
44
58
  }
45
- const lowerPrefix = prefix.trim().toLowerCase();
46
- const seeded = lowerPrefix.length === 0
47
- ? allFiles
48
- : allFiles.filter((f) => f.filename.toLowerCase().includes(lowerPrefix));
59
+ const seeded = filterFileChoices(prefix, allFiles);
49
60
  // Single unique match — short-circuit, save the user a keystroke. Same
50
61
  // shortcut openSlashPicker uses.
51
62
  if (seeded.length === 1) {
@@ -95,7 +106,7 @@ export async function openAtPicker(prefix) {
95
106
  * null when the line doesn't end with an @-token or the token already
96
107
  * resolves exactly.
97
108
  */
98
- export function pendingAtTokenPrefix(trimmed) {
109
+ export function pendingAtTokenPrefix(trimmed, knownFilenames) {
99
110
  // Match the rightmost @<word> at the end of the line. \S+ means
100
111
  // non-whitespace only — `@filename with space` is not auto-detected
101
112
  // (user can quote with `@"name with space"` for those, handled by
@@ -106,16 +117,24 @@ export function pendingAtTokenPrefix(trimmed) {
106
117
  const prefix = m[1] ?? '';
107
118
  // If the prefix already exactly matches a workspace filename, the
108
119
  // existing @-resolution will pick it up — don't open the picker.
120
+ // `knownFilenames` lets callers (Ink pipeline, tests) inject the
121
+ // listing instead of hitting the filesystem here.
109
122
  if (prefix.length > 0) {
110
- try {
111
- const ws = ensureWorkspaceSync();
112
- const files = listProjectFilesSync(ws);
113
- const exact = files.some((f) => f.filename === prefix);
114
- if (exact)
123
+ if (knownFilenames) {
124
+ if (knownFilenames.includes(prefix))
115
125
  return null;
116
126
  }
117
- catch {
118
- // Fall through — open picker as a safe default.
127
+ else {
128
+ try {
129
+ const ws = ensureWorkspaceSync();
130
+ const files = listProjectFilesSync(ws);
131
+ const exact = files.some((f) => f.filename === prefix);
132
+ if (exact)
133
+ return null;
134
+ }
135
+ catch {
136
+ // Fall through — open picker as a safe default.
137
+ }
119
138
  }
120
139
  }
121
140
  return prefix;
@@ -44,6 +44,20 @@ function buildCatalogue() {
44
44
  return [...BUILTIN_ENTRIES, ...skillEntries].sort((a, b) => a.name.localeCompare(b.name));
45
45
  }
46
46
  const ALL_ENTRIES = buildCatalogue();
47
+ /**
48
+ * 2026-06-06 (Ink picker parity): the full command catalogue (builtins +
49
+ * skills) and the substring pre-filter, exported so the Ink CommandPalette
50
+ * shares the exact same data + seeding semantics as the classic inquirer
51
+ * picker instead of maintaining a drifting copy.
52
+ */
53
+ export const ALL_SLASH_ENTRIES = ALL_ENTRIES;
54
+ /** Substring filter over the catalogue; empty prefix returns everything. */
55
+ export function filterSlashEntries(prefix) {
56
+ const lower = prefix.trim().toLowerCase();
57
+ return lower.length === 0
58
+ ? [...ALL_ENTRIES]
59
+ : ALL_ENTRIES.filter((e) => e.name.toLowerCase().includes(lower));
60
+ }
47
61
  /**
48
62
  * Open the slash picker. Returns the picked command WITHOUT a leading
49
63
  * slash (caller is expected to construct the full prompt) or null if
@@ -58,10 +72,7 @@ export async function openSlashPicker(prefix) {
58
72
  // contains the typed prefix as a substring — substring rather than
59
73
  // startsWith because users often remember a fragment in the middle of
60
74
  // a name (e.g. 'fix' should bring up /abap-atc-fix and /abap-upgrade-fix).
61
- const lowerPrefix = prefix.trim().toLowerCase();
62
- const seeded = lowerPrefix.length === 0
63
- ? ALL_ENTRIES
64
- : ALL_ENTRIES.filter((e) => e.name.toLowerCase().includes(lowerPrefix));
75
+ const seeded = filterSlashEntries(prefix);
65
76
  // If the prefix narrows down to exactly one command, just return it
66
77
  // without opening a picker — saves the user a keystroke.
67
78
  if (seeded.length === 1) {