@cspeach/cli 1.1.34 → 1.1.36

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 (47) hide show
  1. package/.gitattributes +1 -0
  2. package/dist/agent/loop.js +127 -31
  3. package/dist/agent/parallel-write-guard.js +3 -1
  4. package/dist/commands/plan-audit-evidence.js +6 -1
  5. package/dist/commands/plan-gate.js +10 -1
  6. package/dist/commands/plan-resume.js +9 -0
  7. package/dist/lib/graceful-exit.js +17 -0
  8. package/dist/one-shot.js +3 -0
  9. package/dist/projects/answer-blockers.js +25 -12
  10. package/dist/projects/extract-plan.js +6 -0
  11. package/dist/projects/plan-objects.js +1 -1
  12. package/dist/projects/plan-skeleton.js +93 -0
  13. package/dist/projects/save-command.js +17 -0
  14. package/dist/rap/finish-checks.js +389 -0
  15. package/dist/rap/generator-names.js +57 -0
  16. package/dist/rap/skeleton-card.js +31 -0
  17. package/dist/rap/skeleton-rules.js +38 -0
  18. package/dist/rap/skeleton-session.js +21 -0
  19. package/dist/rap/skeleton-store.js +40 -0
  20. package/dist/renderer/change-card.js +28 -4
  21. package/dist/renderer/notices.js +27 -13
  22. package/dist/renderer/progress-chatter.js +3 -1
  23. package/dist/renderer/tool-labels.js +12 -3
  24. package/dist/renderer/verify-chain.js +41 -18
  25. package/dist/renderer/wrap-rows.js +87 -0
  26. package/dist/repl/rule8-detector.js +3 -2
  27. package/dist/repl.js +11 -2
  28. package/dist/sap/connection-manager.js +15 -0
  29. package/dist/sap/generator-fact.js +21 -0
  30. package/dist/sap/session-end.js +66 -0
  31. package/dist/session/audit-export.js +19 -1
  32. package/dist/tools/ask-question.js +1 -1
  33. package/dist/tools/rap/finish-check.js +118 -0
  34. package/dist/tools/rap/generate-objects.js +317 -0
  35. package/dist/tools/rap/skeleton-resolve.js +283 -0
  36. package/dist/tools/sap-read.js +22 -22
  37. package/dist/tools/sap-write.js +249 -18
  38. package/dist/tools/snapshot.js +26 -0
  39. package/dist/tools/tool-loading.js +7 -1
  40. package/dist/tools/write-mode.js +2 -0
  41. package/dist/ui/app.js +19 -9
  42. package/dist/ui/approval-modal.js +37 -5
  43. package/dist/ui/live-area.js +14 -1
  44. package/dist/ui/widgets/ask-card.js +183 -107
  45. package/dist/ui/widgets/coaching-picker.js +22 -1
  46. package/dist/ui/widgets/confirm-card.js +102 -76
  47. package/package.json +2 -2
@@ -19,79 +19,78 @@ import stringWidth from 'string-width';
19
19
  import { roleProps, selectedProps } from '../role-props.js';
20
20
  import { getBoxChars } from '../ascii-chars.js';
21
21
  import { getGlyphs } from '../../renderer/glyphs.js';
22
- import { truncateToWidth } from '../../renderer/ui-width.js';
22
+ import { truncateToWidth, uiWidth } from '../../renderer/ui-width.js';
23
+ import { wrapRows } from '../../renderer/wrap-rows.js';
23
24
  import { sanitizeControlChars } from '../../renderer/sanitize.js';
24
25
  // Q1b's shared hook — the one raw-stdin reclaim for every card (no second copy).
25
26
  import { useReclaimRawStdin } from './reclaim-raw-stdin.js';
26
27
  export const CONFIRM_INPUT_GUARD_MS = 150;
28
+ /** Facts wrap onto this many rows unless the request says otherwise (fix batch 2026-10-12). */
29
+ export const DEFAULT_WRAP_FACTS = 3;
30
+ /** A title wraps onto at most this many rows. */
31
+ export const TITLE_MAX_ROWS = 2;
32
+ /** A choice label wraps onto at most this many rows. */
33
+ export const LABEL_MAX_ROWS = 2;
34
+ /** `${cursor} ${n}. ` — the same width whether the cursor is on the row or not. */
35
+ const choicePrefixWidth = (i) => 2 + `${i + 1}. `.length;
27
36
  /**
28
- * The card's full rendered height for the live-area budget (F3 liveBudget
29
- * `cardRows`): top edge + title + one row per fact + the gap above the
30
- * choices + one row per choice + the hint + bottom edge. Every row is
31
- * truncate-end, so nothing wraps into extra rows — except the facts of a
32
- * card that asks for `wrapFacts`: with `width` given, each counts the rows
33
- * it is drawn on.
37
+ * A choice's rows. The label wraps under its own text (continuation rows are
38
+ * indented past the number). The dim hint stays beside the last label row
39
+ * when it fits; otherwise it gets a row of its own — a hint such as "DEV
40
+ * systems only" is a safety cue and is never dropped.
34
41
  */
35
- export function estimateConfirmCardRows(req, width, maxRows) {
36
- const facts = req.facts ?? [];
37
- const wrap = width === undefined ? 1 : fittedWrap(req, width, maxRows);
38
- const factRowCount = width !== undefined && wrap > 1
39
- ? facts.reduce((n, f) => n + factRows(f, Math.max(1, width - 4), wrap).length, 0)
40
- : facts.length;
41
- return 5 + factRowCount + req.choices.length;
42
+ export function choiceRows(label, hint, i, inner, labelRows) {
43
+ const p = choicePrefixWidth(i);
44
+ const rows = wrapRows(label, Math.max(1, inner - p), labelRows);
45
+ if (!hint)
46
+ return { label: rows, hint: '', hintOwnRow: false };
47
+ const room = inner - p - stringWidth(rows[rows.length - 1]);
48
+ const beside = ` ${sanitizeControlChars(hint)}`;
49
+ if (stringWidth(beside) <= room)
50
+ return { label: rows, hint: beside, hintOwnRow: false };
51
+ return { label: rows, hint: truncateToWidth(sanitizeControlChars(hint), Math.max(1, inner - p)), hintOwnRow: true };
52
+ }
53
+ function rowsFor(req, inner, l) {
54
+ const title = wrapRows(req.title, inner, l.titleRows).length;
55
+ const facts = (req.facts ?? []).reduce((n, f) => n + wrapRows(f, inner, l.wrap).length, 0);
56
+ const choices = req.choices.reduce((n, c, i) => {
57
+ const r = choiceRows(c.label, c.hint, i, inner, l.labelRows);
58
+ return n + r.label.length + (r.hintOwnRow ? 1 : 0);
59
+ }, 0);
60
+ // top edge + title + facts + gap + choices + hint + bottom edge
61
+ return 4 + title + facts + choices;
42
62
  }
43
63
  /**
44
- * The rows a long fact may wrap onto so the whole card stays within
45
- * `maxRows` (App: rows − 6, so Ink never reaches its full-clear path): the
46
- * card's own `wrapFacts`, one less each time it would not fit, down to one
47
- * row (every fact cut with …, as on any card).
64
+ * The layout that keeps the whole card within `maxRows` (App: rows − 6, so
65
+ * Ink never reaches its full-clear path). Card content wraps; when it would
66
+ * not fit, the facts give up rows first (one less each time, down to one
67
+ * row cut with …), then the title, then the choice labels.
48
68
  */
49
- export function fittedWrap(req, width, maxRows) {
50
- let wrap = Math.max(1, req.wrapFacts ?? 1);
69
+ export function confirmLayout(req, width, maxRows) {
70
+ const l = { wrap: Math.max(1, req.wrapFacts ?? DEFAULT_WRAP_FACTS), titleRows: TITLE_MAX_ROWS, labelRows: LABEL_MAX_ROWS };
51
71
  if (maxRows === undefined)
52
- return wrap;
53
- const cells = Math.max(1, width - 4);
54
- const rowsAt = (w) => 5 + req.choices.length + (req.facts ?? []).reduce((n, f) => n + factRows(f, cells, w).length, 0);
55
- while (wrap > 1 && rowsAt(wrap) > maxRows)
56
- wrap -= 1;
57
- return wrap;
72
+ return l;
73
+ const inner = Math.max(1, width - 4);
74
+ while (l.wrap > 1 && rowsFor(req, inner, l) > maxRows)
75
+ l.wrap -= 1;
76
+ if (rowsFor(req, inner, l) > maxRows)
77
+ l.titleRows = 1;
78
+ if (rowsFor(req, inner, l) > maxRows)
79
+ l.labelRows = 1;
80
+ return l;
58
81
  }
59
- const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
60
82
  /**
61
- * One fact as the rows it is drawn on. `rows` 1 (the default): one row, cut
62
- * with … — how every card looks. More: wrapped at spaces (a word wider than
63
- * the row is cut where it must be) onto at most `rows` rows, the last one cut
64
- * with … when there is still more. Control characters are neutralised first.
83
+ * The card's full rendered height for the live-area budget (F3 liveBudget
84
+ * `cardRows`): top edge + title rows + every fact row + the gap above the
85
+ * choices + every choice row + the hint + bottom edge. Every wrapped row is
86
+ * counted; `width` defaults to uiWidth().
65
87
  */
66
- export function factRows(text, cells, rows = 1) {
67
- const flat = sanitizeControlChars(text).replace(/\t/g, ' ').replace(/\r?\n/g, ' ');
68
- if (rows <= 1 || stringWidth(flat) <= cells)
69
- return [truncateToWidth(flat, cells)];
70
- const out = [];
71
- let rest = flat;
72
- while (out.length < rows - 1 && stringWidth(rest) > cells) {
73
- let used = 0;
74
- let space = -1;
75
- let end = rest.length;
76
- for (const { segment, index } of graphemes.segment(rest)) {
77
- const w = stringWidth(segment);
78
- if (used + w > cells) {
79
- end = index;
80
- if (segment === ' ')
81
- space = index;
82
- break;
83
- }
84
- used += w;
85
- if (segment === ' ')
86
- space = index;
87
- }
88
- const at = space > 0 ? space : Math.max(1, end);
89
- out.push(rest.slice(0, at).trimEnd());
90
- rest = rest.slice(at).trimStart();
91
- }
92
- if (rest)
93
- out.push(truncateToWidth(rest, cells));
94
- return out;
88
+ export function estimateConfirmCardRows(req, width = uiWidth(), maxRows) {
89
+ return rowsFor(req, Math.max(1, width - 4), confirmLayout(req, width, maxRows));
90
+ }
91
+ /** The rows a long fact may wrap onto so the whole card stays within `maxRows`. */
92
+ export function fittedWrap(req, width, maxRows) {
93
+ return confirmLayout(req, width, maxRows).wrap;
95
94
  }
96
95
  /**
97
96
  * Owner decision (fix-round-3 deferred M1): a digit PICKS only on ordinary
@@ -155,17 +154,43 @@ export function matchAlias(aliases, input, word) {
155
154
  function fit(text, cells) {
156
155
  return truncateToWidth(sanitizeControlChars(text), cells);
157
156
  }
158
- const inkBorder = (b) => ({
159
- topLeft: b.tl, top: b.h, topRight: b.tr, right: b.v,
160
- bottomRight: b.br, bottom: b.h, bottomLeft: b.bl, left: b.v,
161
- });
162
- /** The frame every card in the slot shares: tag chip top edge, footnote bottom edge. */
163
- export function CardFrame({ tag, footnote, width, children }) {
157
+ /**
158
+ * One border object per box-character set, reused on every render. Ink diffs
159
+ * style props by identity: a new borderStyle object on a re-render is sent
160
+ * alone, without borderTop/borderBottom false, and Ink then gives the box a
161
+ * top and bottom border again (two blank rows inside the frame after the
162
+ * first key press). Task 4 found it when AskCard moved into this frame.
163
+ */
164
+ const borderCache = new Map();
165
+ function makeInkBorder(b) {
166
+ return {
167
+ topLeft: b.tl, top: b.h, topRight: b.tr, right: b.v,
168
+ bottomRight: b.br, bottom: b.h, bottomLeft: b.bl, left: b.v,
169
+ };
170
+ }
171
+ const inkBorder = (b) => {
172
+ let out = borderCache.get(b);
173
+ if (!out) {
174
+ out = makeInkBorder(b);
175
+ borderCache.set(b, out);
176
+ }
177
+ return out;
178
+ };
179
+ /**
180
+ * The frame every card in the slot shares: tag chip top edge, footnote bottom
181
+ * edge. `header` (Task 4, e.g. "Blocker 2 of 4") follows the tag on the top
182
+ * edge, dim: "╭─ QUESTION · Blocker 2 of 4 ───╮"; it is cut first when narrow.
183
+ */
184
+ export function CardFrame({ tag, header, footnote, width, children }) {
164
185
  const box = getBoxChars();
165
186
  const frame = roleProps('frame');
166
- const top = edgeSegments([box.tl, box.tr], box.h, tag, width);
187
+ const extra = header ? ` · ${sanitizeControlChars(header).replace(/[\t\n]+/g, ' ')}` : '';
188
+ const top = edgeSegments([box.tl, box.tr], box.h, `${tag}${extra}`, width);
189
+ // The shown label is cut from the end, so the tag (if whole) leads it.
190
+ const tagShown = top.label.startsWith(tag) ? tag : top.label;
191
+ const headerShown = top.label.slice(tagShown.length);
167
192
  const bottom = edgeSegments([box.bl, box.br], box.h, footnote, width);
168
- return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: width, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...frame, children: top.lead }), _jsx(Text, { ...roleProps(TAG_ROLE[tag], { bold: true }), children: top.label }), _jsx(Text, { ...frame, children: top.tail })] }), _jsx(Box, { flexDirection: "column", width: width, paddingX: 1, borderStyle: inkBorder(box), borderTop: false, borderBottom: false, borderColor: frame.color, children: children }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...frame, children: bottom.lead }), _jsx(Text, { ...roleProps('dim'), children: bottom.label }), _jsx(Text, { ...frame, children: bottom.tail })] })] }));
193
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: width, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...frame, children: top.lead }), _jsx(Text, { ...roleProps(TAG_ROLE[tag], { bold: true }), children: tagShown }), headerShown ? _jsx(Text, { ...roleProps('dim'), children: headerShown }) : null, _jsx(Text, { ...frame, children: top.tail })] }), _jsx(Box, { flexDirection: "column", width: width, paddingX: 1, borderStyle: inkBorder(box), borderTop: false, borderBottom: false, borderColor: frame.color, children: children }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...frame, children: bottom.lead }), _jsx(Text, { ...roleProps('dim'), children: bottom.label }), _jsx(Text, { ...frame, children: bottom.tail })] })] }));
169
194
  }
170
195
  export function ConfirmCard({ request, width, maxRows, onResolve, onInterruptTurn }) {
171
196
  // Re-assert raw stdin on mount: an earlier inquirer prompt can leave Ink's
@@ -235,7 +260,7 @@ export function ConfirmCard({ request, width, maxRows, onResolve, onInterruptTur
235
260
  finish({ value: hit });
236
261
  });
237
262
  const inner = Math.max(1, width - 4);
238
- const wrap = fittedWrap(request, width, maxRows);
263
+ const layout = confirmLayout(request, width, maxRows);
239
264
  const dim = roleProps('dim');
240
265
  const titleProps = request.tag === 'CANNOT BE UNDONE' ? roleProps('bad', { bold: true }) : { bold: true };
241
266
  const letters = Object.keys(request.aliases ?? {}).filter((k) => k.length === 1);
@@ -244,13 +269,14 @@ export function ConfirmCard({ request, width, maxRows, onResolve, onInterruptTur
244
269
  ? `1-${digits} picks · ↑↓ move · Enter chooses · Esc cancels`
245
270
  : `Enter chooses · Esc cancels · ↑↓ or 1-${digits} move`)
246
271
  + (letters.length > 0 ? ` · keys ${letters.join('/')}` : '');
247
- return (_jsxs(CardFrame, { tag: request.tag, footnote: request.footnote, width: width, children: [_jsx(Text, { wrap: "truncate-end", ...titleProps, children: fit(request.title, inner) }), (request.facts ?? []).flatMap((f, i) => (wrap > 1
248
- ? factRows(f, inner, wrap).map((row, j) => (_jsx(Text, { wrap: "truncate-end", ...dim, children: row }, `fact-${i}-${j}`)))
249
- : [_jsx(Text, { wrap: "truncate-end", ...dim, children: fit(f, inner) }, `fact-${i}`)])), _jsx(Box, { flexDirection: "column", marginTop: 1, children: request.choices.map((c, i) => {
272
+ return (_jsxs(CardFrame, { tag: request.tag, footnote: request.footnote, width: width, children: [wrapRows(request.title, inner, layout.titleRows).map((row, j) => (_jsx(Text, { wrap: "truncate-end", ...titleProps, children: row }, `title-${j}`))), (request.facts ?? []).flatMap((f, i) => wrapRows(f, inner, layout.wrap).map((row, j) => (_jsx(Text, { wrap: "truncate-end", ...dim, children: row }, `fact-${i}-${j}`)))), _jsx(Box, { flexDirection: "column", marginTop: 1, children: request.choices.flatMap((c, i) => {
250
273
  const on = i === cursor;
251
- const main = fit(`${on ? g.cursor : ' '} ${i + 1}. ${c.label}`, inner);
252
- const room = inner - stringWidth(main);
253
- const hintText = c.hint && room > 2 ? fit(` ${c.hint}`, room) : '';
254
- return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...(on ? selectedProps() : {}), children: main }), hintText ? _jsx(Text, { ...dim, children: hintText }) : null] }, c.value));
274
+ const r = choiceRows(c.label, c.hint, i, inner, layout.labelRows);
275
+ const pad = ' '.repeat(choicePrefixWidth(i));
276
+ const sel = on ? selectedProps() : {};
277
+ const out = r.label.map((row, j) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...sel, children: j === 0 ? `${on ? g.cursor : ' '} ${i + 1}. ${row}` : `${pad}${row}` }), j === r.label.length - 1 && r.hint && !r.hintOwnRow ? _jsx(Text, { ...dim, children: r.hint }) : null] }, `${c.value}-${j}`)));
278
+ if (r.hintOwnRow)
279
+ out.push(_jsx(Text, { wrap: "truncate-end", ...dim, children: `${pad}${r.hint}` }, `${c.value}-hint`));
280
+ return out;
255
281
  }) }), _jsx(Text, { wrap: "truncate-end", ...dim, children: fit(hint, inner) })] }));
256
282
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.34",
3
+ "version": "1.1.36",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, signed skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",
@@ -54,7 +54,7 @@
54
54
  "@anthropic-ai/sdk": "^0.90.0",
55
55
  "@azure/msal-node": "6.0.1",
56
56
  "@cspeach/register-core": "0.4.0",
57
- "@cspeach/sap-client": "0.1.18",
57
+ "@cspeach/sap-client": "0.1.21",
58
58
  "@iarna/toml": "^2.2.5",
59
59
  "@inkjs/ui": "^2.0.0",
60
60
  "@inquirer/prompts": "^7.0.0",