@cspeach/cli 0.8.0 → 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.
- package/dist/agent/loop.js +123 -18
- package/dist/agent/steering-queue.js +27 -0
- package/dist/approvals/jwt.js +45 -5
- package/dist/approvals/render.js +38 -0
- package/dist/classifier/client.js +6 -2
- package/dist/commands/plan-resume.js +308 -0
- package/dist/config/loader.js +7 -2
- package/dist/cost/pricing.js +14 -5
- package/dist/projects/email-template.js +2 -0
- package/dist/projects/extract-plan.js +85 -0
- package/dist/projects/index.js +3 -0
- package/dist/projects/plan-run.js +120 -0
- package/dist/projects/plan-schema.js +150 -0
- package/dist/projects/promote-command.js +1 -0
- package/dist/projects/save-command.js +17 -1
- package/dist/projects/status.js +20 -0
- package/dist/projects/validate.js +2 -0
- package/dist/renderer/syntax.js +16 -1
- package/dist/renderer/thinking-heartbeat.js +13 -1
- package/dist/repl/at-picker.js +39 -20
- package/dist/repl/slash-picker.js +15 -4
- package/dist/repl.js +195 -13
- package/dist/skill-catalog.js +8 -2
- package/dist/ui/app.js +80 -11
- package/dist/ui/command-palette.js +46 -10
- package/dist/ui/file-palette.js +44 -0
- package/dist/ui/footer.js +8 -5
- package/dist/ui/line-resolution.js +81 -0
- package/dist/ui/turn-status-emitter.js +52 -0
- package/dist/ui/turn-status.js +59 -0
- package/dist/ui/widgets/ask-question-modal.js +26 -1
- package/package.json +5 -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();
|
package/dist/projects/status.js
CHANGED
|
@@ -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',
|
package/dist/renderer/syntax.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/dist/repl/at-picker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
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) {
|