@cspeach/cli 0.8.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +330 -36
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/steering-queue.js +27 -0
  7. package/dist/agent/tool-dispatch.js +15 -0
  8. package/dist/approvals/canonical.js +91 -0
  9. package/dist/approvals/jwt.js +84 -7
  10. package/dist/approvals/render.js +38 -0
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +24 -5
  13. package/dist/commands/config-set.js +95 -0
  14. package/dist/commands/login.js +31 -14
  15. package/dist/commands/plan-model-tier.js +83 -0
  16. package/dist/commands/plan-resume.js +435 -0
  17. package/dist/config/loader.js +102 -3
  18. package/dist/cost/pricing.js +14 -5
  19. package/dist/doctor/checks/_http-probe.js +1 -0
  20. package/dist/doctor/checks/cert.js +14 -3
  21. package/dist/doctor/checks/sap.js +30 -8
  22. package/dist/doctor/checks/zcspeach.js +19 -4
  23. package/dist/one-shot.js +52 -4
  24. package/dist/projects/answer-blockers.js +137 -0
  25. package/dist/projects/email-template.js +2 -0
  26. package/dist/projects/extract-cca.js +108 -16
  27. package/dist/projects/extract-modernize.js +1 -1
  28. package/dist/projects/extract-plan.js +178 -0
  29. package/dist/projects/extract-spec-gap.js +34 -7
  30. package/dist/projects/extract-test-coverage.js +1 -1
  31. package/dist/projects/extract-upgrade.js +113 -22
  32. package/dist/projects/index.js +7 -1
  33. package/dist/projects/merge-cca.js +292 -0
  34. package/dist/projects/merge-upgrade.js +173 -0
  35. package/dist/projects/migration.js +103 -1
  36. package/dist/projects/output-paths.js +27 -0
  37. package/dist/projects/plan-run.js +254 -0
  38. package/dist/projects/plan-schema.js +210 -0
  39. package/dist/projects/promote-command.js +26 -2
  40. package/dist/projects/promote.js +128 -0
  41. package/dist/projects/save-command.js +263 -20
  42. package/dist/projects/status.js +22 -0
  43. package/dist/projects/validate.js +2 -0
  44. package/dist/projects/workspace.js +164 -20
  45. package/dist/renderer/notices.js +64 -0
  46. package/dist/renderer/progress-chatter.js +8 -0
  47. package/dist/renderer/syntax.js +16 -1
  48. package/dist/renderer/thinking-heartbeat.js +13 -1
  49. package/dist/renderer/tool-widget.js +18 -4
  50. package/dist/renderer/tty.js +43 -4
  51. package/dist/renderer/verify-chain.js +77 -0
  52. package/dist/repl/at-picker.js +93 -21
  53. package/dist/repl/builtin-commands.js +37 -0
  54. package/dist/repl/early-line-buffer.js +68 -0
  55. package/dist/repl/inquirer-guard.js +70 -5
  56. package/dist/repl/numbered-menu.js +131 -0
  57. package/dist/repl/post-turn-status.js +2 -2
  58. package/dist/repl/rule8-detector.js +17 -2
  59. package/dist/repl/safety-confirm.js +111 -2
  60. package/dist/repl/safety-mode-state.js +19 -3
  61. package/dist/repl/slash-picker.js +25 -19
  62. package/dist/repl.js +470 -22
  63. package/dist/router/classifier.js +150 -6
  64. package/dist/sap/capability-matrix.js +20 -0
  65. package/dist/sap/capability-matrix.json +11236 -0
  66. package/dist/sap/capability.js +146 -0
  67. package/dist/sap/connection-manager.js +19 -1
  68. package/dist/sap/onboarding.js +42 -4
  69. package/dist/session/pending.js +27 -0
  70. package/dist/skill-catalog.js +54 -43
  71. package/dist/skills/bundled-skills.js +279 -1
  72. package/dist/skills/promotion-dispatch.js +23 -0
  73. package/dist/tools/_command-shared.js +36 -12
  74. package/dist/tools/_filesystem-shared.js +139 -4
  75. package/dist/tools/_flag.js +25 -0
  76. package/dist/tools/approval.js +64 -21
  77. package/dist/tools/ask-question.js +96 -4
  78. package/dist/tools/capability/tool.js +74 -0
  79. package/dist/tools/dispatch-skill.js +22 -1
  80. package/dist/tools/extend-model/anchored-insert.js +810 -0
  81. package/dist/tools/extend-model/tool.js +188 -0
  82. package/dist/tools/filesystem/extract-document.js +57 -0
  83. package/dist/tools/filesystem/file-edit.js +12 -2
  84. package/dist/tools/filesystem/file-read.js +2 -2
  85. package/dist/tools/filesystem/file-write.js +11 -2
  86. package/dist/tools/filesystem/glob.js +11 -0
  87. package/dist/tools/filesystem/grep.js +10 -0
  88. package/dist/tools/filesystem/read-document.js +107 -0
  89. package/dist/tools/fiori/apply.js +50 -0
  90. package/dist/tools/fiori/bin.js +3 -0
  91. package/dist/tools/fiori/catalog/index.js +27 -0
  92. package/dist/tools/fiori/catalog/value-help.js +230 -0
  93. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  94. package/dist/tools/fiori/cli.js +71 -0
  95. package/dist/tools/fiori/deploy-config.js +73 -0
  96. package/dist/tools/fiori/fe-scaffold.js +45 -0
  97. package/dist/tools/fiori/i18n.js +39 -0
  98. package/dist/tools/fiori/manifest.js +70 -0
  99. package/dist/tools/fiori/render.js +77 -0
  100. package/dist/tools/fiori/scaffold.js +39 -0
  101. package/dist/tools/fiori/tools.js +356 -0
  102. package/dist/tools/fiori/types.js +1 -0
  103. package/dist/tools/local-build.js +76 -0
  104. package/dist/tools/local-files.js +31 -0
  105. package/dist/tools/project/_merge-shared.js +68 -0
  106. package/dist/tools/project/cca_merge.js +164 -0
  107. package/dist/tools/project/playbook_get.js +1 -1
  108. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  109. package/dist/tools/sap-read.js +53 -9
  110. package/dist/tools/sap-write.js +530 -21
  111. package/dist/tools/shell/shell_exec.js +41 -6
  112. package/dist/tools/snapshot.js +37 -14
  113. package/dist/tools/subagent/background_run.js +17 -1
  114. package/dist/tools/transport-resolution.js +86 -0
  115. package/dist/tools/transport.js +224 -5
  116. package/dist/tools/write-mode.js +4 -0
  117. package/dist/ui/app.js +84 -11
  118. package/dist/ui/body.js +13 -0
  119. package/dist/ui/command-palette.js +46 -10
  120. package/dist/ui/file-palette.js +44 -0
  121. package/dist/ui/footer.js +28 -11
  122. package/dist/ui/line-resolution.js +92 -0
  123. package/dist/ui/session-timeline.js +1 -0
  124. package/dist/ui/text-input.js +150 -0
  125. package/dist/ui/turn-status-emitter.js +52 -0
  126. package/dist/ui/turn-status.js +59 -0
  127. package/dist/ui/widgets/ask-question-modal.js +30 -2
  128. package/package.json +23 -4
  129. package/bench/README.md +0 -78
  130. package/bench/prompts/abap-document-cds.md +0 -44
  131. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  132. package/bench/prompts/abap-test-method.md +0 -42
  133. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  134. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  135. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  136. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  137. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  138. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  139. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  140. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  141. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
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 } 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,27 @@ 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
+ // Insert the picked filename into the @-token and KEEP the input —
186
+ // do NOT auto-submit. This lets the user keep typing after picking
187
+ // (e.g. add `--scope ZMM_VENDOR_CHECK` after `--from @<file>`).
188
+ const resolved = lr.line.replace(/@\S*$/, `@${filename}`);
189
+ setPrefill((p) => ({ text: `${resolved} `, nonce: p.nonce + 1 }));
190
+ }, 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
191
  pendingApproval.resolve({ approved: true });
119
192
  clearApproval();
120
193
  }, onSkip: () => {
package/dist/ui/body.js CHANGED
@@ -3,6 +3,7 @@ import React, { useReducer, useEffect } from 'react';
3
3
  import { Text, Static } from 'ink';
4
4
  import { WIDGET_COMPONENTS } from './widgets/component-registry.js';
5
5
  import { WIDGET_CONTROL } from './widget-control.js';
6
+ import { formatNoticeLine } from '../renderer/notices.js';
6
7
  /**
7
8
  * Frame interval for batching streaming chunks before dispatching to the
8
9
  * reducer (W2.2 — 2026-05-18). Ink already throttles its stdout writes to
@@ -162,9 +163,21 @@ export const Body = React.memo(function Body({ chunkEmitter }) {
162
163
  // synchronously on unmount so end-of-stream output isn't dropped.
163
164
  const batcher = createChunkBatcher((buffered) => dispatch({ chunk: buffered }));
164
165
  const handler = (chunk) => batcher.push(chunk);
166
+ // B2 fix (2026-06-12) — consume the 'warn'/'info' notice channel.
167
+ // Tool handlers (transport-resolution Rule 9 notices, write-tool
168
+ // snapshot lines) emit these levels on the same emitter; before this
169
+ // they were silent no-ops in the live Ink REPL. Notices are formatted
170
+ // to a single line and pushed through the SAME batcher so their order
171
+ // relative to surrounding chunk text is preserved.
172
+ const warnHandler = (msg) => batcher.push(formatNoticeLine('warn', msg));
173
+ const infoHandler = (msg) => batcher.push(formatNoticeLine('info', msg));
165
174
  chunkEmitter.on('chunk', handler);
175
+ chunkEmitter.on('warn', warnHandler);
176
+ chunkEmitter.on('info', infoHandler);
166
177
  return () => {
167
178
  chunkEmitter.off('chunk', handler);
179
+ chunkEmitter.off('warn', warnHandler);
180
+ chunkEmitter.off('info', infoHandler);
168
181
  batcher.dispose();
169
182
  };
170
183
  }, [chunkEmitter]);
@@ -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
@@ -1,15 +1,22 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useState, useEffect, useCallback } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
- import TextInput from 'ink-text-input';
4
+ // D29 paste fix (2026-06-12): vendored paste-safe replacement for
5
+ // ink-text-input — multi-chunk pastes were losing every chunk but the
6
+ // last (stale-prop closure in the upstream component). See text-input.tsx.
7
+ import TextInput from './text-input.js';
5
8
  import Fuse from 'fuse.js';
6
9
  import * as fs from 'node:fs';
7
10
  import * as os from 'node:os';
8
11
  import * as path from 'node:path';
9
- const HISTORY_FILE = path.join(os.homedir(), '.cspeach', 'history');
12
+ // Resolved lazily (not at module load) so it honours the HOME/USERPROFILE
13
+ // active at render time — lets tests point it at a temp dir.
14
+ function historyFile() {
15
+ return path.join(os.homedir(), '.cspeach', 'history');
16
+ }
10
17
  function loadHistory() {
11
18
  try {
12
- return fs.readFileSync(HISTORY_FILE, 'utf8').split('\n').filter(Boolean);
19
+ return fs.readFileSync(historyFile(), 'utf8').split('\n').filter(Boolean);
13
20
  }
14
21
  catch {
15
22
  return [];
@@ -19,12 +26,13 @@ function appendHistory(line, history) {
19
26
  // Skip consecutive duplicates (matches bash HISTCONTROL=ignoredups).
20
27
  if (history[history.length - 1] === line)
21
28
  return;
22
- fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
23
- fs.appendFileSync(HISTORY_FILE, line + '\n');
29
+ const file = historyFile();
30
+ fs.mkdirSync(path.dirname(file), { recursive: true });
31
+ fs.appendFileSync(file, line + '\n');
24
32
  history.push(line);
25
33
  }
26
34
  const PEACH = '#FFCBA4';
27
- export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange, paletteResult, disabled }) {
35
+ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange, prefill, disabled }) {
28
36
  const [value, setValue] = useState('');
29
37
  const [history] = useState(loadHistory);
30
38
  const [histIdx, setHistIdx] = useState(-1);
@@ -34,11 +42,14 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
34
42
  const fuse = React.useMemo(() => new Fuse(history, { threshold: 0.4 }), [history]);
35
43
  // Mirror state into parent when it cares (e.g. App's ? key-binding gate).
36
44
  useEffect(() => { onValueChange?.(value); }, [value, onValueChange]);
37
- // If the parent pushes a palette result, pre-fill our input.
45
+ // If the parent pushes a prefill (palette pick, palette cancel restore,
46
+ // empty-body guard), pre-fill our input. Keyed on the nonce so identical
47
+ // text can re-fire.
38
48
  useEffect(() => {
39
- if (paletteResult && paletteResult.length > 0)
40
- setValue(paletteResult);
41
- }, [paletteResult]);
49
+ if (prefill && prefill.text.length > 0)
50
+ setValue(prefill.text);
51
+ // eslint-disable-next-line react-hooks/exhaustive-deps
52
+ }, [prefill?.nonce]);
42
53
  useEffect(() => {
43
54
  if (fuzzyMode) {
44
55
  setFuzzyResults(fuse.search(fuzzyQuery).slice(0, 8).map((r) => r.item));
@@ -79,7 +90,13 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
79
90
  setValue(next === -1 ? '' : history[history.length - 1 - next] ?? '');
80
91
  return;
81
92
  }
82
- });
93
+ // 2026-06-16 — go dormant while a modal (ask_question / coaching picker)
94
+ // owns input. Without this, arrow-key navigation INSIDE the modal also
95
+ // hits this handler (Ink delivers every keystroke to every active
96
+ // useInput), recalling a history line into the hidden Footer — so the
97
+ // last command "comes back" in the input once the modal closes. Mirrors
98
+ // why the TextInput is swapped for a placeholder when disabled.
99
+ }, { isActive: !disabled });
83
100
  const handleSubmit = useCallback((line) => {
84
101
  if (!line.trim())
85
102
  return;
@@ -0,0 +1,92 @@
1
+ // cspeach-cli/src/ui/line-resolution.ts
2
+ //
3
+ // Pure pre-dispatch resolution pipeline for the Ink input path
4
+ // (2026-06-06 — Ink parity with the classic REPL's slash/at pickers,
5
+ // repl.tsx ~1535-1640). Given a submitted line, decide ONE of:
6
+ //
7
+ // submit — line is fully resolved; hand it to the REPL handler
8
+ // open-slash — line starts with a non-exact slash command; the App
9
+ // mounts the CommandPalette seeded with the typed prefix
10
+ // open-at — line ends with a partial/unresolved @-token; the App
11
+ // mounts the FilePalette seeded with the typed prefix
12
+ // prefill — skill command with no body; put `<cmd> ` back in the
13
+ // input instead of dispatching an empty prompt the API
14
+ // would reject (mirrors classic repl.tsx ~1626)
15
+ //
16
+ // Same auto-shortcuts as the classic pickers: a prefix that narrows to
17
+ // exactly ONE command / file resolves silently — no palette, no extra
18
+ // keystroke. The App calls nextResolutionStep again after every palette
19
+ // pick, so a line flows slash → at → empty-body guard → submit no
20
+ // matter where it entered the pipeline.
21
+ import { isExactSlashMatch, filterSlashEntries } from '../repl/slash-picker.js';
22
+ import { pendingAtTokenPrefix } from '../repl/at-picker.js';
23
+ import { SKILL_CATALOG, SKILL_ALIASES } from '../skill-catalog.js';
24
+ export function nextResolutionStep(line, files) {
25
+ const trimmed = line.trim();
26
+ if (trimmed.startsWith('/') && !isExactSlashMatch(trimmed)) {
27
+ const headEnd = trimmed.search(/\s/);
28
+ const head = headEnd === -1 ? trimmed : trimmed.slice(0, headEnd);
29
+ const rest = headEnd === -1 ? '' : trimmed.slice(headEnd);
30
+ const prefix = head.slice(1);
31
+ const seeded = filterSlashEntries(prefix);
32
+ if (seeded.length === 1) {
33
+ // Unique match — resolve silently and continue down the pipeline.
34
+ return nextResolutionStep(`${seeded[0].name}${rest}`, files);
35
+ }
36
+ return { kind: 'open-slash', prefix, rest };
37
+ }
38
+ const filenames = files.map((f) => f.filename);
39
+ const atPrefix = pendingAtTokenPrefix(trimmed, filenames);
40
+ if (atPrefix !== null && files.length > 0) {
41
+ // Always open the picker when the line ends in an unresolved @-token —
42
+ // even when it narrows to a single file. The old single-match shortcut
43
+ // silently auto-resolved AND auto-submitted, so the user never saw which
44
+ // file was attached and couldn't keep typing (e.g. add `--scope`). Showing
45
+ // the palette + inserting-on-pick (App handles that) is the correct flow.
46
+ return { kind: 'open-at', prefix: atPrefix, line: trimmed };
47
+ }
48
+ // Empty workspace: nothing to pick — let the downstream @-resolution
49
+ // produce its normal "no file matching" message.
50
+ return emptyBodyGuard(trimmed);
51
+ }
52
+ /** Substitute the picked command head into the pending line and continue. */
53
+ export function applySlashPick(pickedName, rest, files) {
54
+ const name = pickedName.startsWith('/') ? pickedName : `/${pickedName}`;
55
+ return nextResolutionStep(`${name}${rest}`, files);
56
+ }
57
+ /** Substitute the picked filename into the rightmost @-token and continue. */
58
+ export function applyAtPick(line, pickedFilename, files) {
59
+ return nextResolutionStep(line.replace(/@\S*$/, `@${pickedFilename}`), files);
60
+ }
61
+ /**
62
+ * A SKILL route with an empty body would dispatch an empty prompt the
63
+ * LLM API rejects — prefill `<cmd> ` instead (classic repl.tsx ~1626).
64
+ * Builtins (/help, /exit, /transport …) legitimately take no body and
65
+ * pass straight through.
66
+ */
67
+ function emptyBodyGuard(line) {
68
+ if (line.startsWith('/')) {
69
+ const splitIdx = line.search(/\s/);
70
+ const head = splitIdx === -1 ? line : line.slice(0, splitIdx);
71
+ const body = splitIdx === -1 ? '' : line.slice(splitIdx).trim();
72
+ // Retired-but-redirected name with no body: prefill the NEW name so the
73
+ // user both learns the rename and doesn't dispatch an empty prompt.
74
+ const aliasTarget = SKILL_ALIASES[head.toLowerCase().slice(1)];
75
+ if (aliasTarget !== undefined && body.length === 0) {
76
+ return {
77
+ kind: 'prefill',
78
+ text: `/${aliasTarget} `,
79
+ hint: `↪ ${head.toLowerCase()} is now /${aliasTarget} — type your prompt and press Enter`,
80
+ };
81
+ }
82
+ const isSkill = SKILL_CATALOG.some((s) => `/${s.name}` === head.toLowerCase());
83
+ if (isSkill && body.length === 0) {
84
+ return {
85
+ kind: 'prefill',
86
+ text: `${head} `,
87
+ hint: `↪ ${head} — type your prompt and press Enter`,
88
+ };
89
+ }
90
+ }
91
+ return { kind: 'submit', line };
92
+ }
@@ -6,6 +6,7 @@ const MUTATING_TOOLS = new Set([
6
6
  'sap_set_source', 'sap_update_method', 'sap_delete_object', 'sap_activate',
7
7
  'sap_create_object', 'sap_transport_release', 'sap_message_maintain',
8
8
  'sap_number_range_intervals', 'sap_service_binding_publish',
9
+ 'sap_service_binding_unpublish',
9
10
  ]);
10
11
  export function toTimelineEntry(call) {
11
12
  // args shape depends on the tool; extract defensively.
@@ -0,0 +1,150 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * PasteSafeTextInput — drop-in replacement for `ink-text-input` (D29,
4
+ * "chewed words" during seed-session paste; 2026-06-12).
5
+ *
6
+ * ROOT CAUSE of the chewing, verified by repro test: Ink's App drains ALL
7
+ * pending stdin chunks in one synchronous loop (components/App.js
8
+ * `handleReadable`: `while ((chunk = stdin.read()) !== null) emit('input')`),
9
+ * and a large terminal paste arrives as MULTIPLE chunks. ink-text-input
10
+ * computes `nextValue = value.slice(0, cursor) + input + …` from the
11
+ * `value` PROP of the last committed render — a stale closure for every
12
+ * chunk after the first in the same tick. With chunks A then B, B's
13
+ * handler still sees the pre-A value, so A is silently dropped:
14
+ * `stdin.write('hello '); stdin.write('world')` → value `"world"`.
15
+ *
16
+ * FIX: mirror value + cursor in refs that are updated SYNCHRONOUSLY inside
17
+ * the input handler, so consecutive same-tick chunks compound. The refs
18
+ * re-sync from props on commit, so external value pushes (submit-clear,
19
+ * palette prefill) still win between keystrokes. This also fixes the
20
+ * sibling hazard: an Enter that lands in the same drain as the paste tail
21
+ * now submits the COMPLETE value instead of the stale prop.
22
+ *
23
+ * Rendering, cursor drawing, placeholder, and mask behaviour are vendored
24
+ * verbatim from ink-text-input v6 (MIT, © Vadim Demedes) minus
25
+ * `highlightPastedText` (Footer never used it).
26
+ */
27
+ import { useState, useEffect, useRef } from 'react';
28
+ import { Text, useInput } from 'ink';
29
+ import chalk from 'chalk';
30
+ export function PasteSafeTextInput({ value: originalValue, placeholder = '', focus = true, mask, showCursor = true, onChange, onSubmit, }) {
31
+ const [cursorOffset, setCursorOffset] = useState((originalValue || '').length);
32
+ // The paste-safety core: synchronous mirrors of value + cursor. The
33
+ // input handler reads/writes THESE, never the (possibly stale) props.
34
+ const valueRef = useRef(originalValue);
35
+ const cursorRef = useRef(cursorOffset);
36
+ // Values we've sent through onChange whose prop echo hasn't been
37
+ // observed yet. Needed because passive effects flush LATE: with N
38
+ // same-tick chunks, the echo effect for chunk k can run AFTER the
39
+ // handler for chunk k+1, and blindly assigning `valueRef.current =
40
+ // originalValue` would rewind the mirror to a stale value (observed in
41
+ // the repro as words landing out of order). An echo — even a stale
42
+ // one — must never overwrite the mirror; only EXTERNAL pushes may.
43
+ const pendingEchoes = useRef([]);
44
+ // Re-sync the mirrors on EXTERNAL value pushes only (parent setValue('')
45
+ // after submit, palette prefill). Echoes of our own onChange — current
46
+ // or stale — are recognised via pendingEchoes and skipped: the mirror
47
+ // is already at or ahead of them.
48
+ useEffect(() => {
49
+ const echoIdx = pendingEchoes.current.indexOf(originalValue);
50
+ if (echoIdx !== -1) {
51
+ pendingEchoes.current.splice(0, echoIdx + 1);
52
+ return;
53
+ }
54
+ pendingEchoes.current = [];
55
+ valueRef.current = originalValue;
56
+ if (cursorRef.current > originalValue.length) {
57
+ cursorRef.current = originalValue.length;
58
+ setCursorOffset(originalValue.length);
59
+ }
60
+ }, [originalValue]);
61
+ // Clamp rendered cursor when the value shrinks under it (vendored
62
+ // semantic from ink-text-input's first effect).
63
+ useEffect(() => {
64
+ if (!focus || !showCursor)
65
+ return;
66
+ setCursorOffset((previous) => previous > originalValue.length ? originalValue.length : previous);
67
+ }, [originalValue, focus, showCursor]);
68
+ const value = mask ? mask.repeat(originalValue.length) : originalValue;
69
+ let renderedValue = value;
70
+ let renderedPlaceholder = placeholder ? chalk.grey(placeholder) : undefined;
71
+ // Fake cursor — vendored rendering from ink-text-input v6.
72
+ if (showCursor && focus) {
73
+ renderedPlaceholder =
74
+ placeholder.length > 0
75
+ ? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1))
76
+ : chalk.inverse(' ');
77
+ renderedValue = value.length > 0 ? '' : chalk.inverse(' ');
78
+ let i = 0;
79
+ for (const char of value) {
80
+ renderedValue += i === cursorOffset ? chalk.inverse(char) : char;
81
+ i++;
82
+ }
83
+ if (value.length > 0 && cursorOffset === value.length) {
84
+ renderedValue += chalk.inverse(' ');
85
+ }
86
+ }
87
+ useInput((input, key) => {
88
+ if (key.upArrow ||
89
+ key.downArrow ||
90
+ (key.ctrl && input === 'c') ||
91
+ key.tab ||
92
+ (key.shift && key.tab)) {
93
+ return;
94
+ }
95
+ if (key.return) {
96
+ // valueRef (not the prop) — so an Enter in the same stdin drain as
97
+ // the paste tail submits the complete line.
98
+ if (onSubmit)
99
+ onSubmit(valueRef.current);
100
+ return;
101
+ }
102
+ const currentValue = valueRef.current;
103
+ let nextCursorOffset = cursorRef.current;
104
+ let nextValue = currentValue;
105
+ if (key.leftArrow) {
106
+ if (showCursor)
107
+ nextCursorOffset--;
108
+ }
109
+ else if (key.rightArrow) {
110
+ if (showCursor)
111
+ nextCursorOffset++;
112
+ }
113
+ else if (key.backspace || key.delete) {
114
+ if (cursorRef.current > 0) {
115
+ nextValue =
116
+ currentValue.slice(0, cursorRef.current - 1) +
117
+ currentValue.slice(cursorRef.current, currentValue.length);
118
+ nextCursorOffset--;
119
+ }
120
+ }
121
+ else {
122
+ nextValue =
123
+ currentValue.slice(0, cursorRef.current) +
124
+ input +
125
+ currentValue.slice(cursorRef.current, currentValue.length);
126
+ nextCursorOffset += input.length;
127
+ }
128
+ if (nextCursorOffset < 0)
129
+ nextCursorOffset = 0;
130
+ if (nextCursorOffset > nextValue.length)
131
+ nextCursorOffset = nextValue.length;
132
+ // Synchronous mirror update FIRST — the next same-tick chunk builds
133
+ // on this, not on the stale render.
134
+ valueRef.current = nextValue;
135
+ cursorRef.current = nextCursorOffset;
136
+ setCursorOffset(nextCursorOffset);
137
+ if (nextValue !== currentValue) {
138
+ // Register the outgoing value so its (possibly late) prop echo is
139
+ // recognised and never rewinds the mirror.
140
+ pendingEchoes.current.push(nextValue);
141
+ onChange(nextValue);
142
+ }
143
+ }, { isActive: focus });
144
+ return (_jsx(Text, { children: placeholder
145
+ ? value.length > 0
146
+ ? renderedValue
147
+ : renderedPlaceholder
148
+ : renderedValue }));
149
+ }
150
+ export default PasteSafeTextInput;