@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
package/dist/repl.js
CHANGED
|
@@ -37,6 +37,7 @@ import { slashCompleter } from './repl/slash-completer.js';
|
|
|
37
37
|
import { openSlashPicker, isExactSlashMatch } from './repl/slash-picker.js';
|
|
38
38
|
import { openAtPicker, pendingAtTokenPrefix } from './repl/at-picker.js';
|
|
39
39
|
import { atCompleter } from './repl/at-completer.js';
|
|
40
|
+
import { isPlanResumeCommand } from './commands/plan-resume.js';
|
|
40
41
|
/**
|
|
41
42
|
* Pre-fill the readline buffer with `text`, cursor at end, and trigger
|
|
42
43
|
* a redraw. Bypasses rl.write() — which simulates per-character
|
|
@@ -640,13 +641,20 @@ export async function runRepl(opts) {
|
|
|
640
641
|
chunkEmitter.emit('chunk', chalk.dim(`\n↪ Rerouting "${preview}" → /${rr.skill}\n`));
|
|
641
642
|
if (rr.warning)
|
|
642
643
|
chunkEmitter.emit('chunk', chalk.dim(` (${rr.warning})\n`));
|
|
643
|
-
await
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
644
|
+
const { turnStatusEmitter: rerouteStatus } = await import('./ui/turn-status-emitter.js');
|
|
645
|
+
rerouteStatus.start(`/${rr.skill}`);
|
|
646
|
+
try {
|
|
647
|
+
await runTurn({
|
|
648
|
+
provider,
|
|
649
|
+
userMessage: rr.prompt,
|
|
650
|
+
skill: rr.skill,
|
|
651
|
+
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
652
|
+
chunkEmitter,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
finally {
|
|
656
|
+
rerouteStatus.stop();
|
|
657
|
+
}
|
|
650
658
|
return;
|
|
651
659
|
}
|
|
652
660
|
// /new — clear any "awaiting skill answer" state so the next prompt
|
|
@@ -673,7 +681,9 @@ export async function runRepl(opts) {
|
|
|
673
681
|
// - `@ZCL_ORDER explain this`: stored as `explain this` — @OBJECT
|
|
674
682
|
// context is lost on reroute, acceptable v0.3.1 limitation
|
|
675
683
|
session.lastUserPrompt = parsed.prompt;
|
|
676
|
-
const
|
|
684
|
+
// `let`, not `const` — the B3 plan-resume interception below rewrites
|
|
685
|
+
// the prompt with the prepared bounded phase context before runTurn.
|
|
686
|
+
let prompt = parsed.prompt;
|
|
677
687
|
const extractedObject = parsed.object ?? null;
|
|
678
688
|
const previousSkill = session.skill;
|
|
679
689
|
const previousObject = session.last_object ?? null;
|
|
@@ -765,7 +775,7 @@ export async function runRepl(opts) {
|
|
|
765
775
|
// consume model tokens, so we intercept here AFTER skill resolution
|
|
766
776
|
// but BEFORE runTurn. The body lives in `prompt` (post-parseShortcut),
|
|
767
777
|
// e.g. `--status @./foo.cspeach.json`.
|
|
768
|
-
if ((skill === 'abap-spec-gap' || skill === 'abap-design' || skill === 'abap-estimate') &&
|
|
778
|
+
if ((skill === 'abap-spec-gap' || skill === 'abap-design' || skill === 'abap-estimate' || skill === 'abap-plan') &&
|
|
769
779
|
/(^|\s)--status(\s|$)/.test(prompt)) {
|
|
770
780
|
try {
|
|
771
781
|
const files = prompt
|
|
@@ -785,6 +795,42 @@ export async function runRepl(opts) {
|
|
|
785
795
|
session.last_object = extractedObject ?? previousObject ?? null;
|
|
786
796
|
return;
|
|
787
797
|
}
|
|
798
|
+
// B3 (2026-06-06) — `/abap-plan --resume @plan-file`. Token-free
|
|
799
|
+
// pre-work (load envelope, pick next eligible phase, print the
|
|
800
|
+
// tracker board, inline the phase's rule files) happens in
|
|
801
|
+
// preparePlanResume; terminal states (plan complete / all blocked)
|
|
802
|
+
// print and end the turn without any model call. When a phase IS
|
|
803
|
+
// eligible, the prepared bounded prompt replaces the user prompt
|
|
804
|
+
// and falls through to runTurn below — with suppressSaveHook so
|
|
805
|
+
// the new-envelope save hook stays quiet; finishPlanResume then
|
|
806
|
+
// persists the version-N+1 REVISION after the turn.
|
|
807
|
+
let planResume = null;
|
|
808
|
+
let planResumeMsgStart = 0;
|
|
809
|
+
if (skill === 'abap-plan' && /(^|\s)--resume(\s|$)/.test(prompt)) {
|
|
810
|
+
try {
|
|
811
|
+
const { preparePlanResume } = await import('./commands/plan-resume.js');
|
|
812
|
+
planResume = await preparePlanResume({
|
|
813
|
+
body: prompt,
|
|
814
|
+
cwd: process.cwd(),
|
|
815
|
+
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
catch (err) {
|
|
819
|
+
chunkEmitter.emit('chunk', chalk.red(`\n[--resume] ${err.message}\n`));
|
|
820
|
+
}
|
|
821
|
+
if (!planResume) {
|
|
822
|
+
session.last_skill = previousSkill ?? null;
|
|
823
|
+
session.last_object = extractedObject ?? previousObject ?? null;
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
planResumeMsgStart = session.messages.length;
|
|
827
|
+
prompt = planResume.llmPrompt;
|
|
828
|
+
}
|
|
829
|
+
// 2026-06-08 — manifest-on-interrupt. Set true once the mid-turn
|
|
830
|
+
// onPlanManifest hook has persisted the revision, so the post-turn
|
|
831
|
+
// finishPlanResume below doesn't write it a second time.
|
|
832
|
+
let planSavedEarly = false;
|
|
833
|
+
const planResumeForHook = planResume;
|
|
788
834
|
// Phase A** wiring fix (2026-05-16) — snapshot pre-turn counters so
|
|
789
835
|
// pushPostTurnStatus can compute per-turn deltas. The classic-mode
|
|
790
836
|
// turn loop has its own equivalent below; both paths now call the
|
|
@@ -798,6 +844,11 @@ export async function runRepl(opts) {
|
|
|
798
844
|
// the signal we thread into runTurn.
|
|
799
845
|
currentTurnAbort = new AbortController();
|
|
800
846
|
interruptCount = 0;
|
|
847
|
+
// 2026-06-07 — persistent status row lifecycle. Started here (not in
|
|
848
|
+
// loop.ts) so the try/finally GUARANTEES the row clears even when the
|
|
849
|
+
// turn throws; loop.ts only narrates activity changes in between.
|
|
850
|
+
const { turnStatusEmitter } = await import('./ui/turn-status-emitter.js');
|
|
851
|
+
turnStatusEmitter.start(`/${skill}`);
|
|
801
852
|
try {
|
|
802
853
|
await runTurn({
|
|
803
854
|
provider,
|
|
@@ -806,6 +857,27 @@ export async function runRepl(opts) {
|
|
|
806
857
|
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
807
858
|
chunkEmitter,
|
|
808
859
|
signal: currentTurnAbort.signal,
|
|
860
|
+
suppressSaveHook: planResume !== null,
|
|
861
|
+
// Persist the plan revision the instant its manifest is finalised
|
|
862
|
+
// (before the continuation modal) so a Ctrl+C can't lose the phase.
|
|
863
|
+
onPlanManifest: planResumeForHook
|
|
864
|
+
? async (assistantText) => {
|
|
865
|
+
try {
|
|
866
|
+
const { finishPlanResume } = await import('./commands/plan-resume.js');
|
|
867
|
+
await finishPlanResume({
|
|
868
|
+
prepared: planResumeForHook,
|
|
869
|
+
messages: session.messages,
|
|
870
|
+
messagesStart: planResumeMsgStart,
|
|
871
|
+
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
872
|
+
textOverride: assistantText,
|
|
873
|
+
});
|
|
874
|
+
planSavedEarly = true;
|
|
875
|
+
}
|
|
876
|
+
catch (err) {
|
|
877
|
+
chunkEmitter.emit('chunk', chalk.red(`\n[plan] early revision save failed: ${err.message}\n`));
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
: undefined,
|
|
809
881
|
});
|
|
810
882
|
}
|
|
811
883
|
catch (err) {
|
|
@@ -817,8 +889,31 @@ export async function runRepl(opts) {
|
|
|
817
889
|
});
|
|
818
890
|
}
|
|
819
891
|
finally {
|
|
892
|
+
turnStatusEmitter.stop();
|
|
820
893
|
currentTurnAbort = null;
|
|
821
894
|
}
|
|
895
|
+
// B3 — persist the plan revision from the resume turn's manifest
|
|
896
|
+
// block (runs even after a turn error: partial assistant content is
|
|
897
|
+
// already in session.messages, and a phase may have written real SAP
|
|
898
|
+
// objects whose status must not be lost). FALLBACK path: skipped when
|
|
899
|
+
// the mid-turn onPlanManifest hook already saved it (manifest-on-
|
|
900
|
+
// interrupt, 2026-06-08). Still runs when no manifest was streamed —
|
|
901
|
+
// e.g. the turn errored before emitting one — so behaviour is unchanged
|
|
902
|
+
// for the no-manifest case.
|
|
903
|
+
if (planResume && !planSavedEarly) {
|
|
904
|
+
try {
|
|
905
|
+
const { finishPlanResume } = await import('./commands/plan-resume.js');
|
|
906
|
+
await finishPlanResume({
|
|
907
|
+
prepared: planResume,
|
|
908
|
+
messages: session.messages,
|
|
909
|
+
messagesStart: planResumeMsgStart,
|
|
910
|
+
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
catch (err) {
|
|
914
|
+
chunkEmitter.emit('chunk', chalk.red(`\n[plan] revision save failed: ${err.message}\n`));
|
|
915
|
+
}
|
|
916
|
+
}
|
|
822
917
|
pushPostTurnStatus({ session, selectedAlias, snapshot: turnSnapshot, chunkEmitter });
|
|
823
918
|
session.last_skill = previousSkill ?? null;
|
|
824
919
|
session.last_object = extractedObject ?? previousObject ?? null;
|
|
@@ -830,7 +925,18 @@ export async function runRepl(opts) {
|
|
|
830
925
|
const dispatched = pendingDispatchAccessor.get();
|
|
831
926
|
if (dispatched) {
|
|
832
927
|
pendingDispatchAccessor.set(null);
|
|
833
|
-
|
|
928
|
+
// Auto-fire next phase: a plan resume rebuilds its bounded prompt from
|
|
929
|
+
// the envelope, so clear the finished phase's transcript first — the
|
|
930
|
+
// next phase then costs exactly what a manual fresh resume costs.
|
|
931
|
+
// (The phase is already saved via manifest-on-interrupt, so nothing is
|
|
932
|
+
// lost; prior prose is mirrored to ~/.cspeach/streams/.)
|
|
933
|
+
if (isPlanResumeCommand(dispatched)) {
|
|
934
|
+
session.messages = [];
|
|
935
|
+
chunkEmitter.emit('chunk', chalk.dim('↪ Auto-running next phase in fresh context\n'));
|
|
936
|
+
}
|
|
937
|
+
else {
|
|
938
|
+
chunkEmitter.emit('chunk', chalk.dim(`↪ Auto-routed: ${dispatched}\n`));
|
|
939
|
+
}
|
|
834
940
|
// Schedule on next tick so the current handleSubmit's promise
|
|
835
941
|
// resolves cleanly before the next one starts. Recursion via
|
|
836
942
|
// setImmediate keeps the call stack flat across long chains.
|
|
@@ -1164,7 +1270,13 @@ export async function runRepl(opts) {
|
|
|
1164
1270
|
const dispatchedCmd = pendingDispatchAccessor.get();
|
|
1165
1271
|
if (dispatchedCmd) {
|
|
1166
1272
|
pendingDispatchAccessor.set(null);
|
|
1167
|
-
|
|
1273
|
+
if (isPlanResumeCommand(dispatchedCmd)) {
|
|
1274
|
+
session.messages = [];
|
|
1275
|
+
console.log(chalk.dim('↪ Auto-running next phase in fresh context'));
|
|
1276
|
+
}
|
|
1277
|
+
else {
|
|
1278
|
+
console.log(chalk.dim(`↪ Auto-routed: ${dispatchedCmd}`));
|
|
1279
|
+
}
|
|
1168
1280
|
line = dispatchedCmd;
|
|
1169
1281
|
}
|
|
1170
1282
|
else {
|
|
@@ -1513,7 +1625,9 @@ export async function runRepl(opts) {
|
|
|
1513
1625
|
// - `@ZCL_ORDER explain this`: stored as `explain this` — @OBJECT
|
|
1514
1626
|
// context is lost on reroute, acceptable v0.3.1 limitation
|
|
1515
1627
|
session.lastUserPrompt = parsed.prompt;
|
|
1516
|
-
const
|
|
1628
|
+
// `let`, not `const` — the B3 plan-resume interception below rewrites
|
|
1629
|
+
// the prompt with the prepared bounded phase context before runTurn.
|
|
1630
|
+
let prompt = parsed.prompt;
|
|
1517
1631
|
const extractedObject = parsed.object ?? null;
|
|
1518
1632
|
const previousSkill = session.skill; // snapshot before this turn dispatches
|
|
1519
1633
|
const previousObject = session.last_object ?? null; // carries from two turns ago
|
|
@@ -1601,7 +1715,7 @@ export async function runRepl(opts) {
|
|
|
1601
1715
|
// /abap-design, and /abap-estimate (renderer in projects/status.ts is
|
|
1602
1716
|
// type-agnostic). Intercept after skill resolution, before runTurn. The
|
|
1603
1717
|
// prompt body (post-parseShortcut) looks like `--status @./foo.cspeach.json`.
|
|
1604
|
-
if ((skill === 'abap-spec-gap' || skill === 'abap-design' || skill === 'abap-estimate') &&
|
|
1718
|
+
if ((skill === 'abap-spec-gap' || skill === 'abap-design' || skill === 'abap-estimate' || skill === 'abap-plan') &&
|
|
1605
1719
|
/(^|\s)--status(\s|$)/.test(prompt)) {
|
|
1606
1720
|
try {
|
|
1607
1721
|
const files = prompt
|
|
@@ -1621,6 +1735,38 @@ export async function runRepl(opts) {
|
|
|
1621
1735
|
session.last_object = extractedObject ?? previousObject ?? null;
|
|
1622
1736
|
continue;
|
|
1623
1737
|
}
|
|
1738
|
+
// B3 (2026-06-06) — `/abap-plan --resume @plan-file` (classic-mode
|
|
1739
|
+
// mirror of the Ink path above; see that block for the full design
|
|
1740
|
+
// note). Token-free pre-work; terminal states end the turn with no
|
|
1741
|
+
// model call; otherwise the prepared bounded prompt falls through to
|
|
1742
|
+
// runTurn with suppressSaveHook, and finishPlanResume persists the
|
|
1743
|
+
// envelope revision after the turn.
|
|
1744
|
+
let planResume = null;
|
|
1745
|
+
let planResumeMsgStart = 0;
|
|
1746
|
+
if (skill === 'abap-plan' && /(^|\s)--resume(\s|$)/.test(prompt)) {
|
|
1747
|
+
try {
|
|
1748
|
+
const { preparePlanResume } = await import('./commands/plan-resume.js');
|
|
1749
|
+
planResume = await preparePlanResume({
|
|
1750
|
+
body: prompt,
|
|
1751
|
+
cwd: process.cwd(),
|
|
1752
|
+
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
catch (err) {
|
|
1756
|
+
console.log(chalk.red(`\n[--resume] ${err.message}\n`));
|
|
1757
|
+
}
|
|
1758
|
+
if (!planResume) {
|
|
1759
|
+
session.last_skill = previousSkill ?? null;
|
|
1760
|
+
session.last_object = extractedObject ?? previousObject ?? null;
|
|
1761
|
+
continue;
|
|
1762
|
+
}
|
|
1763
|
+
planResumeMsgStart = session.messages.length;
|
|
1764
|
+
prompt = planResume.llmPrompt;
|
|
1765
|
+
}
|
|
1766
|
+
// 2026-06-08 — manifest-on-interrupt (classic path). See the Ink path for
|
|
1767
|
+
// the full rationale; persist the revision mid-turn so a Ctrl+C can't lose it.
|
|
1768
|
+
let planSavedEarly = false;
|
|
1769
|
+
const planResumeForHook = planResume;
|
|
1624
1770
|
const turnStart = Date.now();
|
|
1625
1771
|
// H1 — `session.usage` is session-LIFETIME (initialised once in
|
|
1626
1772
|
// newSession, saved across turns). The footer's "this turn" label was
|
|
@@ -1675,6 +1821,25 @@ export async function runRepl(opts) {
|
|
|
1675
1821
|
skill: skill,
|
|
1676
1822
|
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
1677
1823
|
signal: turnAbort.signal,
|
|
1824
|
+
suppressSaveHook: planResume !== null,
|
|
1825
|
+
onPlanManifest: planResumeForHook
|
|
1826
|
+
? async (assistantText) => {
|
|
1827
|
+
try {
|
|
1828
|
+
const { finishPlanResume } = await import('./commands/plan-resume.js');
|
|
1829
|
+
await finishPlanResume({
|
|
1830
|
+
prepared: planResumeForHook,
|
|
1831
|
+
messages: session.messages,
|
|
1832
|
+
messagesStart: planResumeMsgStart,
|
|
1833
|
+
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
1834
|
+
textOverride: assistantText,
|
|
1835
|
+
});
|
|
1836
|
+
planSavedEarly = true;
|
|
1837
|
+
}
|
|
1838
|
+
catch (err) {
|
|
1839
|
+
console.log(chalk.red(`\n[plan] early revision save failed: ${err.message}\n`));
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
: undefined,
|
|
1678
1843
|
});
|
|
1679
1844
|
}
|
|
1680
1845
|
catch (err) {
|
|
@@ -1694,6 +1859,23 @@ export async function runRepl(opts) {
|
|
|
1694
1859
|
rl.removeListener('SIGINT', onSigint);
|
|
1695
1860
|
turnInProgress = false;
|
|
1696
1861
|
}
|
|
1862
|
+
// B3 — persist the plan revision from the resume turn's manifest block.
|
|
1863
|
+
// FALLBACK: skipped when the mid-turn onPlanManifest hook already saved it
|
|
1864
|
+
// (manifest-on-interrupt, 2026-06-08); still runs when no manifest streamed.
|
|
1865
|
+
if (planResume && !planSavedEarly) {
|
|
1866
|
+
try {
|
|
1867
|
+
const { finishPlanResume } = await import('./commands/plan-resume.js');
|
|
1868
|
+
await finishPlanResume({
|
|
1869
|
+
prepared: planResume,
|
|
1870
|
+
messages: session.messages,
|
|
1871
|
+
messagesStart: planResumeMsgStart,
|
|
1872
|
+
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
catch (err) {
|
|
1876
|
+
console.log(chalk.red(`\n[plan] revision save failed: ${err.message}\n`));
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1697
1879
|
// Status footer after each turn.
|
|
1698
1880
|
// tool_result messages also have role:'user' but content is an array —
|
|
1699
1881
|
// filter them out so we count actual user prompts, not tool rounds.
|
package/dist/skill-catalog.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Static skill catalog for CSPeach CLI v0.2.
|
|
3
3
|
*
|
|
4
|
-
* STALENESS RISK (spec §3.3): This constant is hardcoded against the
|
|
5
|
-
* in .claude/skills/ as of 2026-
|
|
4
|
+
* STALENESS RISK (spec §3.3): This constant is hardcoded against the 34 skills
|
|
5
|
+
* in .claude/skills/ as of 2026-06-06 (abap-plan added). It will drift if skills are added or
|
|
6
6
|
* removed. At Plan 4b kickoff decision the build-time generation option
|
|
7
7
|
* (scripts/generate-skill-catalog.ts reads SKILL.md frontmatter) or runtime
|
|
8
8
|
* fetch (/v1/skills endpoint) will be chosen to replace this file. Until then,
|
|
@@ -219,4 +219,10 @@ export const SKILL_CATALOG = [
|
|
|
219
219
|
description: 'Effort estimation for ABAP tickets — component breakdown with hour ranges (optimistic / realistic / pessimistic) + risk adjustments',
|
|
220
220
|
whenToUse: `pick this for hour ranges + risk adjustments on a confirmed object list (step 3 of 3 — needs /abap-design first; high uncertainty without it)`,
|
|
221
221
|
},
|
|
222
|
+
{
|
|
223
|
+
name: 'abap-plan',
|
|
224
|
+
category: 'Pre-Coding',
|
|
225
|
+
description: 'Multi-session project plan — phase envelope holds state across sessions; create from a goal or spec-gap file, resume one bounded phase per session',
|
|
226
|
+
whenToUse: `pick this when the work spans multiple sessions or components (not /abap-design — that's one design in one session; plan calls design per phase)`,
|
|
227
|
+
},
|
|
222
228
|
];
|
package/dist/ui/app.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
3
3
|
import { Box, useInput } from 'ink';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import { Body } from './body.js';
|
|
6
6
|
import { Footer } from './footer.js';
|
|
7
7
|
import { ApprovalModal } from './approval-modal.js';
|
|
8
8
|
import { CommandPalette } from './command-palette.js';
|
|
9
|
+
import { FilePalette } from './file-palette.js';
|
|
10
|
+
import { nextResolutionStep, applySlashPick, applyAtPick } from './line-resolution.js';
|
|
11
|
+
import { TurnStatus } from './turn-status.js';
|
|
12
|
+
import { turnStatusEmitter } from './turn-status-emitter.js';
|
|
13
|
+
import { listWorkspaceFileChoices } from '../repl/at-picker.js';
|
|
9
14
|
import { RichErrorBox } from './rich-error-box.js';
|
|
10
15
|
import { SessionTimeline, toTimelineEntry } from './session-timeline.js';
|
|
11
16
|
import { approvalEmitter } from './approval-emitter.js';
|
|
@@ -15,6 +20,7 @@ import { coachingPickerEmitter } from './coaching-picker-emitter.js';
|
|
|
15
20
|
import { CoachingPicker } from './widgets/coaching-picker.js';
|
|
16
21
|
import { askQuestionEmitter } from './ask-question-emitter.js';
|
|
17
22
|
import { AskQuestionModal } from './widgets/ask-question-modal.js';
|
|
23
|
+
import { enqueueSteering } from '../agent/steering-queue.js';
|
|
18
24
|
/**
|
|
19
25
|
* App — inline-mode Ink frame (Phase D6, 2026-05-17). After the
|
|
20
26
|
* alt-screen detour, reverted to CC's architecture:
|
|
@@ -33,8 +39,13 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
33
39
|
const [pendingApproval, setPendingApproval] = useState(null);
|
|
34
40
|
const [coachingPending, setCoachingPending] = useState(null);
|
|
35
41
|
const [askQuestionPending, setAskQuestionPending] = useState(null);
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
// 2026-06-06 (Ink picker parity) — pre-dispatch line resolution. A
|
|
43
|
+
// submitted line flows through nextResolutionStep (line-resolution.ts);
|
|
44
|
+
// open-slash / open-at steps mount the matching palette seeded with the
|
|
45
|
+
// user's typed fragment; cancel restores the original line into the
|
|
46
|
+
// Footer via `prefill`. Replaces the old bare-`/`-only palette flow.
|
|
47
|
+
const [lineResolution, setLineResolution] = useState(null);
|
|
48
|
+
const [prefill, setPrefill] = useState({ text: '', nonce: 0 });
|
|
38
49
|
const [errorStack, setErrorStack] = useState([]);
|
|
39
50
|
const [showTimeline, setShowTimeline] = useState(false);
|
|
40
51
|
const [footerValue, setFooterValue] = useState('');
|
|
@@ -72,20 +83,73 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
72
83
|
errorEmitter.on('error', onError);
|
|
73
84
|
return () => { errorEmitter.off('error', onError); };
|
|
74
85
|
}, []);
|
|
86
|
+
// Run one step of the pre-dispatch pipeline and act on the outcome.
|
|
87
|
+
const handleStep = useCallback((step, original, files) => {
|
|
88
|
+
switch (step.kind) {
|
|
89
|
+
case 'submit':
|
|
90
|
+
onSubmit(step.line);
|
|
91
|
+
return;
|
|
92
|
+
case 'prefill':
|
|
93
|
+
chunkEmitter.emit('chunk', `\x1b[2m${step.hint}\x1b[22m\n`);
|
|
94
|
+
setPrefill((p) => ({ text: step.text, nonce: p.nonce + 1 }));
|
|
95
|
+
return;
|
|
96
|
+
case 'open-slash':
|
|
97
|
+
setLineResolution({ mode: 'slash', prefix: step.prefix, rest: step.rest, original, files });
|
|
98
|
+
return;
|
|
99
|
+
case 'open-at':
|
|
100
|
+
setLineResolution({ mode: 'at', prefix: step.prefix, line: step.line, original, files });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}, [onSubmit, chunkEmitter]);
|
|
104
|
+
// Footer submissions enter here instead of going straight to the REPL
|
|
105
|
+
// handler — slash + @ tokens get resolved (palette if ambiguous) first.
|
|
106
|
+
const beginSubmit = useCallback((line) => {
|
|
107
|
+
// 2026-06-07 (B5 smoke): Enter while a turn is streaming used to slip
|
|
108
|
+
// through — the pipeline opened a palette OVER the live stream, and a
|
|
109
|
+
// pick would have dispatched a SECOND concurrent turn into the same
|
|
110
|
+
// session. Keep the typed text in the input and tell the user; proper
|
|
111
|
+
// CC-style input queueing is a follow-up (docs/backlog.md).
|
|
112
|
+
if (turnStatusEmitter.snapshot().active) {
|
|
113
|
+
// CC-style mid-turn steering (2026-06-08): the user typed a correction
|
|
114
|
+
// while the model works. Queue it — the turn loop injects it as a user
|
|
115
|
+
// message at the next round boundary — clear the box, and confirm it
|
|
116
|
+
// landed. (Was: bounce the input and tell them to wait.)
|
|
117
|
+
const t = line.trim();
|
|
118
|
+
if (t.length > 0) {
|
|
119
|
+
enqueueSteering(t);
|
|
120
|
+
chunkEmitter.emit('chunk', `\x1b[36m↳ queued ▸ ${t}\x1b[39m\n`);
|
|
121
|
+
}
|
|
122
|
+
setPrefill((p) => ({ text: '', nonce: p.nonce + 1 }));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const files = listWorkspaceFileChoices();
|
|
126
|
+
handleStep(nextResolutionStep(line, files), line, files);
|
|
127
|
+
}, [handleStep, chunkEmitter]);
|
|
128
|
+
// Palette cancel: restore the user's original line so they can edit it.
|
|
129
|
+
const cancelResolution = useCallback(() => {
|
|
130
|
+
const lr = lineResolution;
|
|
131
|
+
setLineResolution(null);
|
|
132
|
+
if (lr && lr.original.trim().length > 0) {
|
|
133
|
+
setPrefill((p) => ({ text: lr.original, nonce: p.nonce + 1 }));
|
|
134
|
+
}
|
|
135
|
+
}, [lineResolution]);
|
|
75
136
|
// `?` key opens the timeline when the footer is empty and no other overlay
|
|
76
137
|
// is active.
|
|
77
138
|
useInput((input, _key) => {
|
|
78
139
|
if (input === '?' && footerValue === '')
|
|
79
140
|
setShowTimeline(true);
|
|
80
|
-
}, { isActive: !
|
|
141
|
+
}, { isActive: !lineResolution && !showTimeline && !pendingApproval && !coachingPending });
|
|
81
142
|
// Ctrl+C handler — abort in-flight turn or exit at idle.
|
|
82
143
|
useInput((input, key) => {
|
|
83
144
|
if (key.ctrl && input === 'c')
|
|
84
145
|
onInterrupt?.();
|
|
85
146
|
}, { isActive: true });
|
|
86
147
|
const clearApproval = () => setPendingApproval(null);
|
|
148
|
+
// Bare `/` + Enter — browse the full catalogue. Same palette as the
|
|
149
|
+
// typed-prefix flow, just unfiltered, with nothing to restore on cancel.
|
|
87
150
|
const handleSlashTrigger = () => {
|
|
88
|
-
|
|
151
|
+
const files = listWorkspaceFileChoices();
|
|
152
|
+
setLineResolution({ mode: 'slash', prefix: '', rest: '', original: '', files });
|
|
89
153
|
onSlashTrigger?.();
|
|
90
154
|
};
|
|
91
155
|
const handleHistoryTrigger = () => {
|
|
@@ -103,18 +167,23 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
103
167
|
} })), askQuestionPending && (_jsx(AskQuestionModal, { params: askQuestionPending, onResolve: (result) => {
|
|
104
168
|
askQuestionPending.resolve(result);
|
|
105
169
|
setAskQuestionPending(null);
|
|
106
|
-
} })), _jsx(Footer, { onSubmit:
|
|
170
|
+
} })), _jsx(TurnStatus, {}), _jsx(Footer, { onSubmit: beginSubmit, onSlashTrigger: handleSlashTrigger, onHistoryTrigger: handleHistoryTrigger, prefill: prefill, onValueChange: setFooterValue,
|
|
107
171
|
// 2026-05-17 — disable Footer input while any modal owns
|
|
108
172
|
// input, so keystrokes don't double-fire (modal answer +
|
|
109
173
|
// spurious Footer submission with same text).
|
|
110
174
|
disabled: !!coachingPending
|
|
111
175
|
|| !!askQuestionPending
|
|
112
176
|
|| !!pendingApproval
|
|
113
|
-
||
|
|
114
|
-
|| showTimeline }),
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
177
|
+
|| !!lineResolution
|
|
178
|
+
|| showTimeline }), lineResolution?.mode === 'slash' && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(CommandPalette, { initialQuery: lineResolution.prefix, onSelect: (name) => {
|
|
179
|
+
const lr = lineResolution;
|
|
180
|
+
setLineResolution(null);
|
|
181
|
+
handleStep(applySlashPick(name, lr.rest, lr.files), lr.original, lr.files);
|
|
182
|
+
}, onCancel: cancelResolution }) })), lineResolution?.mode === 'at' && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(FilePalette, { files: lineResolution.files, initialQuery: lineResolution.prefix, onSelect: (filename) => {
|
|
183
|
+
const lr = lineResolution;
|
|
184
|
+
setLineResolution(null);
|
|
185
|
+
handleStep(applyAtPick(lr.line, filename, lr.files), lr.original, lr.files);
|
|
186
|
+
}, onCancel: cancelResolution }) })), errorStack.map((e) => (_jsx(Box, { children: _jsx(RichErrorBox, { code: e.code, message: e.body, hint: e.hint, severity: "error" }) }, e.id))), showTimeline && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(SessionTimeline, { entries: timelineEntries, onClose: () => setShowTimeline(false) }) })), pendingApproval && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(ApprovalModal, { change: pendingApproval.change, effectiveRisk: pendingApproval.effectiveRisk, transport: pendingApproval.transport, onApply: () => {
|
|
118
187
|
pendingApproval.resolve({ approved: true });
|
|
119
188
|
clearApproval();
|
|
120
189
|
}, onSkip: () => {
|
|
@@ -2,15 +2,42 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useState, useEffect, useMemo } from 'react';
|
|
3
3
|
import { Box, Text, useInput } from 'ink';
|
|
4
4
|
import TextInput from 'ink-text-input';
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
import { ALL_SLASH_ENTRIES } from '../repl/slash-picker.js';
|
|
6
|
+
const VISIBLE_ROWS = 8;
|
|
7
|
+
// Longest command is /abap-transport-analysis (24) — fixed column keeps
|
|
8
|
+
// every row single-line; the outer wrap="truncate-end" clips descriptions
|
|
9
|
+
// at the terminal edge instead of wrapping into multi-line soup.
|
|
10
|
+
const NAME_COL = 26;
|
|
11
|
+
/**
|
|
12
|
+
* Deterministic CC-style ranking (2026-06-06 — replaced Fuse): prefix
|
|
13
|
+
* matches first, then substring matches, each group alphabetical. A
|
|
14
|
+
* query like `abap-` lists everything in order instead of fuzzy-score
|
|
15
|
+
* noise; `rada` still surfaces /abap-radar via the substring group.
|
|
16
|
+
*/
|
|
17
|
+
export function rankCommandMatches(query) {
|
|
18
|
+
const q = query.trim().toLowerCase().replace(/^\//, '');
|
|
19
|
+
if (q.length === 0)
|
|
20
|
+
return [...ALL_SLASH_ENTRIES];
|
|
21
|
+
const starts = [];
|
|
22
|
+
const contains = [];
|
|
23
|
+
for (const e of ALL_SLASH_ENTRIES) {
|
|
24
|
+
const bare = e.name.slice(1).toLowerCase();
|
|
25
|
+
if (bare.startsWith(q))
|
|
26
|
+
starts.push(e);
|
|
27
|
+
else if (bare.includes(q))
|
|
28
|
+
contains.push(e);
|
|
29
|
+
}
|
|
30
|
+
return [...starts, ...contains];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* CC-style command menu (2026-06-06 rewrite): borderless single-line
|
|
34
|
+
* rows under the filter input, full result list navigable via a
|
|
35
|
+
* scrolling window — the cursor is never clamped to the first page.
|
|
36
|
+
*/
|
|
37
|
+
export function CommandPalette({ initialQuery, onSelect, onCancel }) {
|
|
38
|
+
const [query, setQuery] = useState(initialQuery ?? '');
|
|
9
39
|
const [cursor, setCursor] = useState(0);
|
|
10
|
-
const
|
|
11
|
-
const results = useMemo(() => query
|
|
12
|
-
? fuse.search(query).slice(0, 8).map((r) => r.item)
|
|
13
|
-
: SKILL_CATALOG.slice(0, 8), [query, fuse]);
|
|
40
|
+
const results = useMemo(() => rankCommandMatches(query), [query]);
|
|
14
41
|
useEffect(() => { setCursor(0); }, [query]);
|
|
15
42
|
useInput((_input, key) => {
|
|
16
43
|
if (key.escape) {
|
|
@@ -26,9 +53,18 @@ export function CommandPalette({ onSelect, onCancel }) {
|
|
|
26
53
|
return;
|
|
27
54
|
}
|
|
28
55
|
if (key.return && results[cursor]) {
|
|
29
|
-
onSelect(
|
|
56
|
+
onSelect(results[cursor].name);
|
|
30
57
|
return;
|
|
31
58
|
}
|
|
32
59
|
});
|
|
33
|
-
|
|
60
|
+
// Scrolling window: keep the cursor ~3 rows from the top while there is
|
|
61
|
+
// more below, like CC's menu.
|
|
62
|
+
const windowStart = Math.max(0, Math.min(cursor - 3, results.length - VISIBLE_ROWS));
|
|
63
|
+
const visible = results.slice(windowStart, windowStart + VISIBLE_ROWS);
|
|
64
|
+
const below = Math.max(0, results.length - (windowStart + VISIBLE_ROWS));
|
|
65
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: "/ " }), _jsx(TextInput, { value: query, onChange: setQuery, placeholder: "type to filter commands" })] }), results.length === 0 && (_jsx(Text, { dimColor: true, children: " no matching command \u2014 edit the filter or Esc to cancel" })), windowStart > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", windowStart, " more"] }), visible.map((e, i) => {
|
|
66
|
+
const idx = windowStart + i;
|
|
67
|
+
const sel = idx === cursor;
|
|
68
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: sel ? 'cyan' : undefined, bold: sel, children: [sel ? '▸ ' : ' ', e.name.padEnd(NAME_COL)] }), _jsx(Text, { dimColor: true, children: e.description })] }, e.name));
|
|
69
|
+
}), below > 0 && _jsxs(Text, { dimColor: true, children: [" \u2193 ", below, " more"] }), _jsx(Text, { dimColor: true, children: " \u2191\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel" })] }));
|
|
34
70
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import TextInput from 'ink-text-input';
|
|
5
|
+
import { filterFileChoices } from '../repl/at-picker.js';
|
|
6
|
+
const VISIBLE_ROWS = 8;
|
|
7
|
+
/**
|
|
8
|
+
* CC-style workspace-file menu (2026-06-06, matches CommandPalette):
|
|
9
|
+
* borderless single-line rows, substring filtering (filenames are
|
|
10
|
+
* structured ids — fuzzy would surface lookalike envelope versions),
|
|
11
|
+
* full result list navigable via a scrolling window.
|
|
12
|
+
*/
|
|
13
|
+
export function FilePalette({ files, initialQuery, onSelect, onCancel }) {
|
|
14
|
+
const [query, setQuery] = useState(initialQuery ?? '');
|
|
15
|
+
const [cursor, setCursor] = useState(0);
|
|
16
|
+
const results = useMemo(() => filterFileChoices(query, files), [query, files]);
|
|
17
|
+
useEffect(() => { setCursor(0); }, [query]);
|
|
18
|
+
useInput((_input, key) => {
|
|
19
|
+
if (key.escape) {
|
|
20
|
+
onCancel();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (key.upArrow) {
|
|
24
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (key.downArrow) {
|
|
28
|
+
setCursor((c) => Math.min(results.length - 1, c + 1));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (key.return && results[cursor]) {
|
|
32
|
+
onSelect(results[cursor].filename);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
const windowStart = Math.max(0, Math.min(cursor - 3, results.length - VISIBLE_ROWS));
|
|
37
|
+
const visible = results.slice(windowStart, windowStart + VISIBLE_ROWS);
|
|
38
|
+
const below = Math.max(0, results.length - (windowStart + VISIBLE_ROWS));
|
|
39
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: "@ " }), _jsx(TextInput, { value: query, onChange: setQuery, placeholder: "type to filter workspace files" })] }), results.length === 0 && (_jsx(Text, { dimColor: true, children: " no matching file \u2014 edit the filter or Esc to cancel" })), windowStart > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", windowStart, " more"] }), visible.map((f, i) => {
|
|
40
|
+
const idx = windowStart + i;
|
|
41
|
+
const sel = idx === cursor;
|
|
42
|
+
return (_jsx(Text, { wrap: "truncate-end", children: _jsxs(Text, { color: sel ? 'cyan' : undefined, bold: sel, children: [sel ? '▸ ' : ' ', f.filename] }) }, f.filename));
|
|
43
|
+
}), below > 0 && _jsxs(Text, { dimColor: true, children: [" \u2193 ", below, " more"] }), _jsx(Text, { dimColor: true, children: " \u2191\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel" })] }));
|
|
44
|
+
}
|
package/dist/ui/footer.js
CHANGED
|
@@ -24,7 +24,7 @@ function appendHistory(line, history) {
|
|
|
24
24
|
history.push(line);
|
|
25
25
|
}
|
|
26
26
|
const PEACH = '#FFCBA4';
|
|
27
|
-
export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange,
|
|
27
|
+
export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange, prefill, disabled }) {
|
|
28
28
|
const [value, setValue] = useState('');
|
|
29
29
|
const [history] = useState(loadHistory);
|
|
30
30
|
const [histIdx, setHistIdx] = useState(-1);
|
|
@@ -34,11 +34,14 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
|
|
|
34
34
|
const fuse = React.useMemo(() => new Fuse(history, { threshold: 0.4 }), [history]);
|
|
35
35
|
// Mirror state into parent when it cares (e.g. App's ? key-binding gate).
|
|
36
36
|
useEffect(() => { onValueChange?.(value); }, [value, onValueChange]);
|
|
37
|
-
// If the parent pushes a palette
|
|
37
|
+
// If the parent pushes a prefill (palette pick, palette cancel restore,
|
|
38
|
+
// empty-body guard), pre-fill our input. Keyed on the nonce so identical
|
|
39
|
+
// text can re-fire.
|
|
38
40
|
useEffect(() => {
|
|
39
|
-
if (
|
|
40
|
-
setValue(
|
|
41
|
-
|
|
41
|
+
if (prefill && prefill.text.length > 0)
|
|
42
|
+
setValue(prefill.text);
|
|
43
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
44
|
+
}, [prefill?.nonce]);
|
|
42
45
|
useEffect(() => {
|
|
43
46
|
if (fuzzyMode) {
|
|
44
47
|
setFuzzyResults(fuse.search(fuzzyQuery).slice(0, 8).map((r) => r.item));
|