@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/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 runTurn({
644
- provider,
645
- userMessage: rr.prompt,
646
- skill: rr.skill,
647
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
648
- chunkEmitter,
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 prompt = parsed.prompt;
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
- chunkEmitter.emit('chunk', chalk.dim(`↪ Auto-routed: ${dispatched}\n`));
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
- console.log(chalk.dim(`↪ Auto-routed: ${dispatchedCmd}`));
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 prompt = parsed.prompt;
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.
@@ -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 33 skills
5
- * in .claude/skills/ as of 2026-04-19. It will drift if skills are added or
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
- const [showPalette, setShowPalette] = useState(false);
37
- const [paletteResult, setPaletteResult] = useState('');
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: !showPalette && !showTimeline && !pendingApproval && !coachingPending });
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
- setShowPalette(true);
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: onSubmit, onSlashTrigger: handleSlashTrigger, onHistoryTrigger: handleHistoryTrigger, paletteResult: paletteResult, onValueChange: setFooterValue,
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
- || showPalette
114
- || showTimeline }), showPalette && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(CommandPalette, { onSelect: (s) => {
115
- setPaletteResult(s);
116
- setShowPalette(false);
117
- }, onCancel: () => setShowPalette(false) }) })), 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: () => {
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 Fuse from 'fuse.js';
6
- import { SKILL_CATALOG } from '../skill-catalog.js';
7
- export function CommandPalette({ onSelect, onCancel }) {
8
- const [query, setQuery] = useState('');
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 fuse = useMemo(() => new Fuse(SKILL_CATALOG, { keys: ['name', 'description'], threshold: 0.4 }), []);
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(`/${results[cursor].name} `);
56
+ onSelect(results[cursor].name);
30
57
  return;
31
58
  }
32
59
  });
33
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 2, paddingY: 1, width: 60, children: [_jsx(Text, { bold: true, color: "cyan", children: "Command palette" }), _jsx(TextInput, { value: query, onChange: setQuery, placeholder: "Type to filter..." }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: results.map((skill, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: i === cursor ? 'cyan' : 'white', bold: i === cursor, children: [i === cursor ? '\u25B8' : ' ', " /", skill.name.padEnd(20)] }), _jsx(Text, { dimColor: true, children: skill.description })] }, skill.name))) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter: insert Esc: cancel" }) })] }));
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, paletteResult, disabled }) {
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 result, pre-fill our input.
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 (paletteResult && paletteResult.length > 0)
40
- setValue(paletteResult);
41
- }, [paletteResult]);
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));