@cspeach/cli 1.0.0 → 1.1.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 (100) hide show
  1. package/dist/agent/loop.js +22 -9
  2. package/dist/approvals/op-labels.js +124 -0
  3. package/dist/approvals/render.js +42 -36
  4. package/dist/cli.js +15 -0
  5. package/dist/commands/compact.js +28 -2
  6. package/dist/commands/config-set.js +189 -0
  7. package/dist/commands/config-show.js +20 -0
  8. package/dist/commands/export-audit.js +43 -0
  9. package/dist/commands/help.js +5 -0
  10. package/dist/commands/plan-audit-evidence.js +266 -0
  11. package/dist/commands/plan-audit.js +692 -0
  12. package/dist/commands/plan-chain.js +671 -0
  13. package/dist/commands/plan-continue.js +179 -0
  14. package/dist/commands/plan-gate.js +154 -0
  15. package/dist/commands/plan-resume.js +588 -33
  16. package/dist/config/loader.js +128 -4
  17. package/dist/config/model-defaults.js +14 -0
  18. package/dist/cost/pricing.js +27 -1
  19. package/dist/doctor/checks/system-roles.js +41 -0
  20. package/dist/doctor/run.js +2 -0
  21. package/dist/models/resolve.js +61 -0
  22. package/dist/models/server-config.js +155 -0
  23. package/dist/one-shot.js +25 -3
  24. package/dist/projects/extract-cca.js +3 -1
  25. package/dist/projects/extract-modernize.js +3 -1
  26. package/dist/projects/extract-plan.js +60 -6
  27. package/dist/projects/extract-test-coverage.js +3 -1
  28. package/dist/projects/extract-upgrade.js +3 -1
  29. package/dist/projects/handover-md.js +195 -0
  30. package/dist/projects/index.js +1 -1
  31. package/dist/projects/plan-run.js +137 -13
  32. package/dist/projects/plan-schema.js +73 -0
  33. package/dist/projects/run-lease.js +157 -0
  34. package/dist/projects/save-command.js +26 -15
  35. package/dist/renderer/status-footer.js +22 -12
  36. package/dist/renderer/thinking-heartbeat.js +64 -8
  37. package/dist/renderer/todo-block.js +51 -0
  38. package/dist/renderer/tool-widget.js +37 -0
  39. package/dist/repl/bracketed-paste.js +28 -19
  40. package/dist/repl/builtin-commands.js +5 -0
  41. package/dist/repl/current-transport.js +10 -0
  42. package/dist/repl/history.js +86 -0
  43. package/dist/repl/ink-stdin-guard.js +64 -0
  44. package/dist/repl/mode-ceiling.js +16 -0
  45. package/dist/repl/mode-cycle.js +104 -0
  46. package/dist/repl/post-turn-status.js +24 -4
  47. package/dist/repl/slash-completer.js +5 -0
  48. package/dist/repl.js +954 -83
  49. package/dist/rewind/candidates.js +194 -0
  50. package/dist/rewind/cli.js +137 -0
  51. package/dist/rewind/format.js +27 -0
  52. package/dist/rewind/restore.js +245 -0
  53. package/dist/session/audit-export.js +459 -0
  54. package/dist/session/context-report.js +163 -0
  55. package/dist/session/recap.js +160 -0
  56. package/dist/skill-catalog.js +9 -3
  57. package/dist/skills/bundled-skills.js +59 -66
  58. package/dist/tools/approval.js +115 -7
  59. package/dist/tools/ask-question.js +304 -3
  60. package/dist/tools/extend-model/anchored-insert.js +604 -0
  61. package/dist/tools/extend-model/tool.js +162 -10
  62. package/dist/tools/fiori/fe-extend.js +76 -0
  63. package/dist/tools/fiori/fe-scaffold.js +29 -3
  64. package/dist/tools/fiori/floorplan-map.js +19 -0
  65. package/dist/tools/fiori/samples/data/index.json +13602 -0
  66. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  67. package/dist/tools/fiori/samples/loader.js +248 -0
  68. package/dist/tools/fiori/samples/search.js +63 -0
  69. package/dist/tools/fiori/samples/types.js +2 -0
  70. package/dist/tools/fiori/smoke/assertions.js +74 -0
  71. package/dist/tools/fiori/smoke/browser.js +52 -0
  72. package/dist/tools/fiori/smoke/driver.js +89 -0
  73. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  74. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  75. package/dist/tools/fiori/tools.js +328 -3
  76. package/dist/tools/local-build.js +11 -1
  77. package/dist/tools/sap-read.js +79 -11
  78. package/dist/tools/sap-write.js +24 -4
  79. package/dist/tools/snapshot.js +27 -1
  80. package/dist/tools/subagent/agent_run.js +27 -3
  81. package/dist/tools/todo.js +144 -0
  82. package/dist/ui/app.js +372 -19
  83. package/dist/ui/approval-modal.js +49 -16
  84. package/dist/ui/ask-question-emitter.js +14 -0
  85. package/dist/ui/context-grid.js +108 -0
  86. package/dist/ui/footer.js +109 -30
  87. package/dist/ui/header.js +7 -0
  88. package/dist/ui/line-resolution.js +18 -2
  89. package/dist/ui/rewind-emitter.js +10 -0
  90. package/dist/ui/rewind-panel.js +81 -0
  91. package/dist/ui/sap-state-store.js +1 -0
  92. package/dist/ui/status-line.js +43 -0
  93. package/dist/ui/text-input.js +72 -8
  94. package/dist/ui/todo-emitter.js +25 -0
  95. package/dist/ui/todo-panel.js +64 -0
  96. package/dist/ui/turn-status-emitter.js +50 -4
  97. package/dist/ui/turn-status.js +18 -3
  98. package/dist/ui/widgets/ask-form.js +242 -0
  99. package/dist/ui/widgets/ask-question-modal.js +17 -7
  100. package/package.json +4 -1
@@ -0,0 +1,64 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ /**
5
+ * TodoPanel — UX Wave 2 / Task 5. The Ctrl+T overlay rendering the
6
+ * model-maintained task list (`todo_set` tool → session.todos). House
7
+ * style follows SessionTimeline: bordered box mounted above the Footer
8
+ * by <App>, closes on Esc / q — plus Ctrl+T itself (toggle; App's
9
+ * open-handler goes inactive while the panel is up, so the chord cannot
10
+ * double-fire).
11
+ *
12
+ * Row glyphs: ▣ completed (dim) · ▶ in_progress (peach, bold) ·
13
+ * □ pending. Every row is wrap="truncate-end" — Wave-1 rule: no
14
+ * dynamic-frame row may ever soft-wrap (wrapped rows break Ink's erase
15
+ * accounting and leak stale rows into scrollback).
16
+ *
17
+ * Lists longer than TODO_VISIBLE_ROWS get a scrolling window
18
+ * (CommandPalette pattern): ↑/↓ move the window, hidden counts show as
19
+ * "↑ N more" / "↓ N more". The initial window anchors the in_progress
20
+ * item ~3 rows from the top so the current step is visible on open.
21
+ */
22
+ const PEACH = '#FFCBA4';
23
+ export const TODO_VISIBLE_ROWS = 12;
24
+ /** Pure header-counts formatter, exported for tests. */
25
+ export function formatTodoCounts(todos) {
26
+ const done = todos.filter((t) => t.status === 'completed').length;
27
+ const active = todos.filter((t) => t.status === 'in_progress').length;
28
+ const open = todos.filter((t) => t.status === 'pending').length;
29
+ return `Tasks — ${done} done · ${active} active · ${open} open`;
30
+ }
31
+ export function TodoPanel({ todos, onClose }) {
32
+ const maxOffset = Math.max(0, todos.length - TODO_VISIBLE_ROWS);
33
+ // Anchor the initial window so the in_progress item sits ~3 rows from
34
+ // the top (CommandPalette's cursor rule, with the active item as cursor).
35
+ const [offset, setOffset] = useState(() => {
36
+ const idx = todos.findIndex((t) => t.status === 'in_progress');
37
+ if (idx <= 3)
38
+ return 0;
39
+ return Math.min(idx - 3, maxOffset);
40
+ });
41
+ useInput((input, key) => {
42
+ if (key.escape || input === 'q' || (key.ctrl && (input === 't' || input === 'T'))) {
43
+ onClose();
44
+ return;
45
+ }
46
+ if (key.upArrow)
47
+ setOffset((o) => Math.max(0, o - 1));
48
+ if (key.downArrow)
49
+ setOffset((o) => Math.min(maxOffset, o + 1));
50
+ });
51
+ const clamped = Math.min(offset, maxOffset);
52
+ const visible = todos.slice(clamped, clamped + TODO_VISIBLE_ROWS);
53
+ const below = Math.max(0, todos.length - (clamped + TODO_VISIBLE_ROWS));
54
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 2, paddingY: 1, width: 72, children: [_jsx(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: formatTodoCounts(todos) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [todos.length === 0 && (_jsx(Text, { dimColor: true, children: "(no tasks yet \u2014 the model maintains this list on multi-step work)" })), clamped > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", clamped, " more"] }), visible.map((t, i) => {
55
+ const key = `${clamped + i}`;
56
+ if (t.status === 'completed') {
57
+ return _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u25A3 ", t.text] }, key);
58
+ }
59
+ if (t.status === 'in_progress') {
60
+ return _jsxs(Text, { color: PEACH, bold: true, wrap: "truncate-end", children: ["\u25B6 ", t.text] }, key);
61
+ }
62
+ return _jsxs(Text, { wrap: "truncate-end", children: ["\u25A1 ", t.text] }, key);
63
+ }), below > 0 && _jsxs(Text, { dimColor: true, children: [" \u2193 ", below, " more"] })] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [todos.length > TODO_VISIBLE_ROWS ? '↑↓ scroll · ' : '', "Esc/q/Ctrl+T to close"] }) })] }));
64
+ }
@@ -12,9 +12,48 @@
12
12
  // listeners, and every call is a no-op.
13
13
  import { EventEmitter } from 'node:events';
14
14
  class TurnStatusEmitter extends EventEmitter {
15
- state = { active: false, paused: false, label: '', startedAt: 0 };
15
+ state = { active: false, reserved: false, paused: false, label: '', startedAt: 0 };
16
+ /**
17
+ * BUG-3 (2026-07-05 live smoke) — the row vanished WHILE the model still
18
+ * worked. Root cause was the twin-turn overlap (BUG-2): turn 1 finished
19
+ * and its stop() cleared `active` while turn 2 was still running, because
20
+ * start/stop toggled a single boolean with no nesting. A depth counter
21
+ * makes stop() clear the row only when the OUTERMOST turn ends. In the
22
+ * BUG-2-fixed world turns are strictly sequential (depth only ever 0↔1),
23
+ * so this is defense-in-depth: any residual overlap (e.g. the /reroute
24
+ * mini-turn, or a future concurrency path) now degrades gracefully instead
25
+ * of blanking the liveness row.
26
+ */
27
+ depth = 0;
28
+ /**
29
+ * BUG-2b — mark a dispatch in-flight before the async pre-turn work runs.
30
+ * Invisible (no spinner); isBusy() reports it so the submit guard can
31
+ * queue a concurrent line as steering instead of starting a twin turn.
32
+ */
33
+ reserve() {
34
+ if (this.state.reserved)
35
+ return;
36
+ this.state = { ...this.state, reserved: true };
37
+ this.emit('change', this.state);
38
+ }
39
+ /** BUG-2b — release the pre-turn reservation (dispatch lifecycle ended). */
40
+ release() {
41
+ if (!this.state.reserved)
42
+ return;
43
+ this.state = { ...this.state, reserved: false };
44
+ this.emit('change', this.state);
45
+ }
46
+ /** "A turn is running OR imminent" — the predicate the submit/live-trigger
47
+ * guards must use so the async pre-turn window (BUG-2b) is covered. */
48
+ isBusy() {
49
+ return this.state.active || this.state.reserved;
50
+ }
16
51
  start(label = 'thinking') {
17
- this.state = { active: true, paused: false, label, startedAt: Date.now() };
52
+ // BUG-3 — nest instead of replace so an overlapping stop() can't blank
53
+ // the row (see the `depth` field doc). `...this.state` preserves the
54
+ // reserved flag set by reserve() at dispatch start.
55
+ this.depth++;
56
+ this.state = { ...this.state, active: true, paused: false, label, startedAt: Date.now() };
18
57
  this.emit('change', this.state);
19
58
  }
20
59
  /** Update the activity label; ignored when no turn is active. Clears pause —
@@ -40,9 +79,16 @@ class TurnStatusEmitter extends EventEmitter {
40
79
  this.emit('change', this.state);
41
80
  }
42
81
  stop() {
43
- if (!this.state.active)
82
+ // BUG-3 — decrement the nesting depth; only clear the row when the
83
+ // outermost turn ends. Guard on depth (not `active`) and clamp at 0 so a
84
+ // spurious extra stop() is a harmless no-op. `reserved` is intentionally
85
+ // NOT cleared here — release() owns the pre-turn reservation lifecycle.
86
+ if (this.depth === 0)
87
+ return;
88
+ this.depth--;
89
+ if (this.depth > 0)
44
90
  return;
45
- this.state = { active: false, paused: false, label: '', startedAt: 0 };
91
+ this.state = { ...this.state, active: false, paused: false, label: '', startedAt: 0 };
46
92
  this.emit('change', this.state);
47
93
  }
48
94
  snapshot() {
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
3
  import { Text } from 'ink';
4
4
  import { turnStatusEmitter } from './turn-status-emitter.js';
@@ -11,6 +11,17 @@ function fmtElapsed(ms) {
11
11
  return `${s}s`;
12
12
  return `${Math.floor(s / 60)}m ${s % 60}s`;
13
13
  }
14
+ /**
15
+ * Task 5 (ux-wave2) — cap for the in-progress-todo strip appended to the
16
+ * status row. The row already carries spinner + label + elapsed + hint;
17
+ * 40 chars of todo text keeps the whole line comfortably inside a normal
18
+ * terminal (and wrap="truncate-end" clips the rest regardless).
19
+ */
20
+ export const TODO_STRIP_MAX = 40;
21
+ /** ≤40 chars, '…' as the 40th when clipped. Exported for tests. */
22
+ export function truncateTodoStrip(text) {
23
+ return text.length > TODO_STRIP_MAX ? `${text.slice(0, TODO_STRIP_MAX - 1)}…` : text;
24
+ }
14
25
  /**
15
26
  * Persistent turn-status row (2026-06-07) — lives in the DYNAMIC Ink
16
27
  * frame just above the Footer, so it repaints with the frame and
@@ -27,7 +38,7 @@ function fmtElapsed(ms) {
27
38
  * cursor #25 class of bug came from a raw-stdout spinner; this
28
39
  * component cannot reproduce it). Guard: ink-cursor-invariant test.
29
40
  */
30
- export function TurnStatus() {
41
+ export function TurnStatus({ inProgressTodo } = {}) {
31
42
  const [state, setState] = useState(turnStatusEmitter.snapshot());
32
43
  const [frame, setFrame] = useState(0);
33
44
  const [now, setNow] = useState(Date.now());
@@ -55,5 +66,9 @@ export function TurnStatus() {
55
66
  }, [state.active, state.paused]);
56
67
  if (!state.active || state.paused)
57
68
  return null;
58
- return (_jsxs(Text, { children: [_jsxs(Text, { color: PEACH, children: [FRAMES[frame], " "] }), _jsx(Text, { children: state.label }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", fmtElapsed(now - state.startedAt), " \u00B7 ctrl+c to interrupt"] })] }));
69
+ // wrap="truncate-end" (wave1 live-fix, Bug 1): this row sits in the
70
+ // dynamic frame too — a long tool label must clip, never soft-wrap
71
+ // (wrapped dynamic-frame rows break Ink's erase accounting and leak
72
+ // stale rows into scrollback).
73
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: PEACH, children: [FRAMES[frame], " "] }), _jsx(Text, { children: state.label }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", fmtElapsed(now - state.startedAt), " \u00B7 ctrl+c to interrupt"] }), inProgressTodo ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " \u00B7 " }), _jsxs(Text, { color: PEACH, children: ["\u25B6 ", truncateTodoStrip(inProgressTodo)] })] })) : null] }));
59
74
  }
@@ -0,0 +1,242 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * AskForm — UX Wave 2 / Task 2: the multi-question Ink form for
4
+ * ask_question v2 batched requests (the CC-parity flagship interaction).
5
+ *
6
+ * One request = 1..4 questions answered in a single visit. Visual
7
+ * de-clutter pass (2026-07, product-owner review): no border box — the
8
+ * chip row + indentation is the form's identity; the approval modal keeps
9
+ * its border deliberately (danger checkpoint):
10
+ *
11
+ * ✓ env name style ← chips (two-space gaps)
12
+ *
13
+ * Which naming style?
14
+ * Used for every generated object
15
+ * ▸ snake_case lv_/lt_ prefixes
16
+ * CamelCase modern style
17
+ * ✎ Other (type your own)
18
+ * [ Submit ] ← ONLY once all answered
19
+ * Enter pick · Tab next · Esc cancel ← one context-sensitive hint
20
+ *
21
+ * Binding rules (Task-2 brief + de-clutter pass):
22
+ * - Chips: one per question (`header`), two-space gaps (no pipes);
23
+ * current = peach + bold, answered = dim with `✓ ` prefix,
24
+ * unanswered = dim. ONE blank line separates the chip row from the
25
+ * question text. Tab/→ next, Shift+Tab/← previous — all INSIDE the
26
+ * form; App's global handlers (mode-cycle, `?`, `/`, `@`) are already
27
+ * gated off by askQuestionPending.
28
+ * - Options: arrow-key list, label + dim description, CommandPalette-style
29
+ * scroll window; multiSelect ⇒ Space toggles ▣/□, Enter confirms the
30
+ * set; single ⇒ Enter picks. ALWAYS a final `✎ Other (type your own)`
31
+ * row → inline windowed TextInput; a typed answer flags `custom: true`.
32
+ * freeText questions render the input directly (no escape row, and the
33
+ * typed answer is the EXPECTED path — no custom flag, per the Task-1
34
+ * AskFormAnswer contract: custom marks the Other ESCAPE only).
35
+ * - Answering advances to the next UNANSWERED question (wrapping); when
36
+ * none remain, focus lands on [ Submit ]. Enter there resolves ALL
37
+ * answers at once, in question order. Tab back re-answers freely.
38
+ * Single-question forms (n === 1) auto-submit on answer — no extra
39
+ * Enter vs the v1 modal; the Submit affordance is multi-question
40
+ * review only (review round M2).
41
+ * - Esc — DELIBERATE DEVIATION from the brief's "Esc anywhere cancels",
42
+ * reviewer-accepted (review round I1): on an OPTIONED question with
43
+ * the Other input focused, Esc first returns focus to the options
44
+ * list (the typed draft is KEPT in otherDrafts); Esc at options
45
+ * level — and anywhere else, including freeText questions (no inner
46
+ * level) — cancels the WHOLE form → { formId, answers: [],
47
+ * cancelled: true } (v1 cancel contract shape per Task 1).
48
+ * - Dynamic-frame safety (Wave-1 lesson): every row truncates (NOTHING
49
+ * may wrap), options scroll, total height ≤ MAX_FORM_ROWS.
50
+ * - No React state keyed on formId (collision caveat from review) — all
51
+ * per-question state lives in arrays keyed by question INDEX within
52
+ * this request object.
53
+ */
54
+ import { useState } from 'react';
55
+ import { Box, Text, useInput } from 'ink';
56
+ // Wave-1 windowed input — display can never wrap the row (D29 paste-safe).
57
+ import TextInput from '../text-input.js';
58
+ import { askHelpLine, useReclaimRawStdin } from './ask-question-modal.js';
59
+ const PEACH = '#FFCBA4';
60
+ /** Option rows visible at once — the rest scroll (CommandPalette style). */
61
+ export const VISIBLE_OPTION_ROWS = 5;
62
+ /**
63
+ * Hard cap on total rendered rows — pinned by test. Re-derived for the
64
+ * de-clutter pass (border gone = −2 rows; blank chip-gap row = +1; the
65
+ * Submit row only renders once all questions are answered).
66
+ *
67
+ * Worst case — all answered, user Tabbed BACK into a scrolled optioned
68
+ * question for review (Submit row visible below it):
69
+ * chips + blank + question + context + "↑ more" + VISIBLE_OPTION_ROWS
70
+ * + "↓ more" + Other + Submit + help = 14.
71
+ * Pre-completion the Submit row is absent → 13.
72
+ */
73
+ export const MAX_FORM_ROWS = 14;
74
+ /** Display window for the Other/freeText input — the row must never wrap. */
75
+ const OTHER_INPUT_WIDTH = 50;
76
+ export function AskForm({ request, onResolve }) {
77
+ useReclaimRawStdin();
78
+ const questions = request.questions;
79
+ const n = questions.length;
80
+ // Focus slots: 0..n-1 = questions, n = the [ Submit ] row (reachable
81
+ // only once every question has an answer).
82
+ const [focus, setFocus] = useState(0);
83
+ // Per-question state, keyed by question INDEX (never formId): option
84
+ // cursor (0..options.length — the last row is Other), multi-select
85
+ // sets, Other draft text, and the committed answer.
86
+ const [cursors, setCursors] = useState(() => questions.map(() => 0));
87
+ const [multiSel, setMultiSel] = useState(() => questions.map(() => new Set()));
88
+ const [otherDrafts, setOtherDrafts] = useState(() => questions.map(() => ''));
89
+ const [answers, setAnswers] = useState(() => questions.map(() => null));
90
+ const isSubmit = focus === n;
91
+ const q = isSubmit ? undefined : questions[focus];
92
+ const otherRow = q ? q.options.length : 0;
93
+ const cursor = isSubmit ? 0 : cursors[focus];
94
+ // The inline TextInput owns typing whenever the question is freeText or
95
+ // the cursor sits on the Other row. While it does, the form-level
96
+ // handler must NOT interpret space/Enter/←/→ (Ink delivers every key to
97
+ // every active useInput — there is no stopPropagation).
98
+ const otherFocused = q != null && (q.freeText || cursor === otherRow);
99
+ const allAnswered = answers.every((a) => a !== null);
100
+ const cancel = () => onResolve({ formId: request.formId, answers: [], cancelled: true });
101
+ const submit = (finalAnswers) => onResolve({
102
+ formId: request.formId,
103
+ // Question order, always — regardless of the order the user
104
+ // actually answered in.
105
+ answers: questions.map((qq, i) => ({
106
+ id: qq.id,
107
+ answer: finalAnswers[i]?.answer ?? null,
108
+ ...(finalAnswers[i]?.custom ? { custom: true } : {}),
109
+ })),
110
+ });
111
+ /**
112
+ * Commit an answer, then advance to the next UNANSWERED question
113
+ * (wrapping past the end); none left → focus [ Submit ] — except
114
+ * single-question forms, which resolve immediately (M2: no extra Enter
115
+ * vs the v1 modal).
116
+ */
117
+ const answerAndAdvance = (i, value, custom) => {
118
+ const next = answers.map((a, j) => j === i ? { answer: value, ...(custom ? { custom: true } : {}) } : a);
119
+ for (let step = 1; step <= n; step++) {
120
+ const j = (i + step) % n;
121
+ if (next[j] === null) {
122
+ setAnswers(next);
123
+ setFocus(j);
124
+ return;
125
+ }
126
+ }
127
+ if (n === 1) {
128
+ submit(next);
129
+ return;
130
+ }
131
+ setAnswers(next);
132
+ setFocus(n);
133
+ };
134
+ useInput((input, key) => {
135
+ if (key.escape) {
136
+ // Two-level Esc (I1, documented deviation — see file header): the
137
+ // Other input focused on an OPTIONED question → back to the options
138
+ // list, draft kept. freeText has no inner level; the options level
139
+ // cancels the whole form.
140
+ if (q && !q.freeText && otherFocused) {
141
+ setCursors((prev) => prev.map((c, j) => (j === focus ? otherRow - 1 : c)));
142
+ return;
143
+ }
144
+ cancel();
145
+ return;
146
+ }
147
+ // Chip navigation. Tab/Shift+Tab always work (TextInput ignores tab);
148
+ // ←/→ only while the inline text input doesn't own horizontal cursor
149
+ // movement.
150
+ const maxSlot = allAnswered ? n : n - 1;
151
+ if ((key.tab && !key.shift) || (!otherFocused && key.rightArrow)) {
152
+ setFocus((f) => (f >= maxSlot ? 0 : f + 1));
153
+ return;
154
+ }
155
+ if ((key.tab && key.shift) || (!otherFocused && key.leftArrow)) {
156
+ setFocus((f) => (f <= 0 ? maxSlot : f - 1));
157
+ return;
158
+ }
159
+ if (isSubmit) {
160
+ if (key.return && allAnswered)
161
+ submit(answers);
162
+ return;
163
+ }
164
+ if (!q)
165
+ return;
166
+ // ↑/↓ move the option cursor — also FROM the Other row back into the
167
+ // list (TextInput ignores vertical arrows, so no conflict).
168
+ if (!q.freeText) {
169
+ if (key.upArrow) {
170
+ setCursors((prev) => prev.map((c, j) => (j === focus ? Math.max(0, c - 1) : c)));
171
+ return;
172
+ }
173
+ if (key.downArrow) {
174
+ setCursors((prev) => prev.map((c, j) => (j === focus ? Math.min(otherRow, c + 1) : c)));
175
+ return;
176
+ }
177
+ }
178
+ // Everything below is typing territory while the Other input has focus.
179
+ if (otherFocused)
180
+ return;
181
+ if (q.multiSelect && input === ' ') {
182
+ const opt = q.options[cursor];
183
+ if (opt) {
184
+ setMultiSel((prev) => prev.map((s, j) => {
185
+ if (j !== focus)
186
+ return s;
187
+ const nextSet = new Set(s);
188
+ if (nextSet.has(opt.value))
189
+ nextSet.delete(opt.value);
190
+ else
191
+ nextSet.add(opt.value);
192
+ return nextSet;
193
+ }));
194
+ }
195
+ return;
196
+ }
197
+ if (key.return) {
198
+ if (q.multiSelect) {
199
+ const values = q.options
200
+ .filter((o) => multiSel[focus].has(o.value))
201
+ .map((o) => o.value);
202
+ if (values.length === 0)
203
+ return; // nothing toggled yet — toggle or use Other
204
+ answerAndAdvance(focus, values.join(','));
205
+ }
206
+ else {
207
+ const opt = q.options[cursor];
208
+ if (opt)
209
+ answerAndAdvance(focus, opt.value);
210
+ }
211
+ }
212
+ }, { isActive: true });
213
+ // CommandPalette-style scroll window over the options (the Other row is
214
+ // rendered separately and is always visible).
215
+ const opts = q?.options ?? [];
216
+ const winStart = Math.max(0, Math.min(cursor - 3, opts.length - VISIBLE_OPTION_ROWS));
217
+ const visible = opts.slice(winStart, winStart + VISIBLE_OPTION_ROWS);
218
+ const below = Math.max(0, opts.length - (winStart + VISIBLE_OPTION_ROWS));
219
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, flexShrink: 0, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { wrap: "truncate-end", children: questions.map((qq, i) => {
220
+ const cur = i === focus;
221
+ const done = answers[i] !== null;
222
+ return (_jsxs(Text, { color: cur ? PEACH : undefined, bold: cur, dimColor: !cur, children: [i > 0 ? ' ' : '', done ? '✓ ' : '', qq.header ?? qq.id] }, qq.id));
223
+ }) }) }), isSubmit ? (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: "All questions answered \u2014 Tab/\u2190 to review, Enter to submit \u00B7 Esc cancel" })) : q ? (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: q.question }), q.context && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: q.context }), !q.freeText && (_jsxs(_Fragment, { children: [winStart > 0 && _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u2191 ", winStart, " more"] }), visible.map((o, vi) => {
224
+ const idx = winStart + vi;
225
+ const isCur = !otherFocused && idx === cursor;
226
+ const mark = q.multiSelect
227
+ ? (multiSel[focus].has(o.value) ? '▣ ' : '□ ')
228
+ : '';
229
+ // v1 never renders the `recommended` flag — the convention
230
+ // is skills writing "(recommended)" INTO the label. Render
231
+ // the same dim convention only when the label lacks it.
232
+ const rec = o.recommended === true && !/\brecommended\b/i.test(o.label);
233
+ return (_jsxs(Text, { wrap: "truncate-end", color: isCur ? PEACH : undefined, bold: isCur, children: [isCur ? '▸ ' : ' ', mark, o.label, rec && _jsx(Text, { dimColor: true, children: " (recommended)" }), o.description && _jsxs(Text, { dimColor: true, children: [" ", o.description] })] }, o.value));
234
+ }), below > 0 && _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u2193 ", below, " more"] })] })), _jsxs(Box, { children: [q.freeText ? (_jsx(Text, { color: PEACH, wrap: "truncate-end", children: '> ' })) : (_jsxs(Text, { color: otherFocused ? PEACH : undefined, bold: otherFocused, wrap: "truncate-end", children: [otherFocused ? '▸ ' : ' ', "\u270E Other (type your own)", otherFocused ? ': ' : ''] })), otherFocused && (_jsx(TextInput, { value: otherDrafts[focus], onChange: (v) => setOtherDrafts((prev) => prev.map((d, j) => (j === focus ? v : d))), onSubmit: (v) => {
235
+ const trimmed = v.trim();
236
+ if (trimmed.length === 0)
237
+ return; // nothing typed — ignore
238
+ // custom marks the Other ESCAPE on an optioned question;
239
+ // freeText typing is the expected path (Task-1 contract).
240
+ answerAndAdvance(focus, trimmed, !q.freeText);
241
+ }, maxDisplayWidth: OTHER_INPUT_WIDTH }))] })] })) : null, allAnswered && (_jsxs(Text, { wrap: "truncate-end", color: isSubmit ? PEACH : undefined, bold: isSubmit, dimColor: !isSubmit, children: [isSubmit ? '▸ ' : ' ', "[ Submit ]"] })), q && (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: askHelpLine(q.freeText ? 'freeText' : q.multiSelect ? 'multi' : 'pick', { single: n === 1 }) }))] }));
242
+ }
@@ -22,6 +22,13 @@ import { Box, Text, useInput } from 'ink';
22
22
  // pastes (stale-prop closure upstream). See ../text-input.tsx.
23
23
  import TextInput from '../text-input.js';
24
24
  const PEACH = '#FFCBA4';
25
+ export function askHelpLine(kind, opts) {
26
+ switch (kind) {
27
+ case 'multi': return 'Space toggle · Enter confirm · Esc cancel';
28
+ case 'pick': return opts?.single ? 'Enter pick · Esc cancel' : 'Enter pick · Tab next · Esc cancel';
29
+ default: return opts?.single ? 'Enter submit · Esc cancel' : 'Tab next · Esc cancel';
30
+ }
31
+ }
25
32
  /**
26
33
  * Reclaim raw-mode stdin when an Ink modal mounts (2026-06-08).
27
34
  *
@@ -33,8 +40,11 @@ const PEACH = '#FFCBA4';
33
40
  * answer the question. Re-syncing the OS state to what Ink expects on mount
34
41
  * fixes it. Direct `process.stdin` calls (not Ink's ref-counted setRawMode)
35
42
  * because the whole problem is that Ink's count is stale.
43
+ *
44
+ * Exported (UX Wave 2 / Task 2) so <AskForm> — the v2 multi-question form —
45
+ * reuses the exact same reclaim on mount. v1 rendering is untouched.
36
46
  */
37
- function useReclaimRawStdin() {
47
+ export function useReclaimRawStdin() {
38
48
  useEffect(() => {
39
49
  const stdin = process.stdin;
40
50
  if (stdin.isTTY && typeof stdin.setRawMode === 'function') {
@@ -66,7 +76,7 @@ function TextInputForm({ params, onResolve }) {
66
76
  onResolve({ answer: null, cancelled: true });
67
77
  }
68
78
  }, { isActive: true });
69
- return (_jsxs(Box, { borderStyle: "round", borderColor: PEACH, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [_jsx(Text, { bold: true, children: params.question }), params.context && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: params.context }) })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: PEACH, children: '> ' }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: (v) => {
79
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, flexShrink: 0, children: [_jsx(Text, { bold: true, children: params.question }), params.context && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: params.context }) })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: PEACH, children: '> ' }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: (v) => {
70
80
  const trimmed = v.trim();
71
81
  if (trimmed.length === 0) {
72
82
  onResolve({ answer: null, cancelled: true });
@@ -74,7 +84,7 @@ function TextInputForm({ params, onResolve }) {
74
84
  else {
75
85
  onResolve({ answer: trimmed });
76
86
  }
77
- } })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter to submit \u00B7 Esc to cancel" }) })] }));
87
+ } })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: askHelpLine('freeText', { single: true }) }) })] }));
78
88
  }
79
89
  // ---------------------------------------------------------------------------
80
90
  // Single choice (kind = 'choice')
@@ -100,10 +110,10 @@ function ChoiceForm({ params, onResolve }) {
100
110
  onResolve({ answer: chosen.value });
101
111
  }
102
112
  }, { isActive: true });
103
- return (_jsxs(Box, { borderStyle: "round", borderColor: PEACH, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [_jsx(Text, { bold: true, children: params.question }), params.context && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: params.context }) })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: params.choices.map((c, i) => {
113
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, flexShrink: 0, children: [_jsx(Text, { bold: true, children: params.question }), params.context && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: params.context }) })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: params.choices.map((c, i) => {
104
114
  const isCursor = i === cursor;
105
115
  return (_jsxs(Text, { color: isCursor ? PEACH : undefined, bold: isCursor, children: [isCursor ? '▸ ' : ' ', c.label] }, c.value));
106
- }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191/\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel" }) })] }));
116
+ }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: askHelpLine('pick', { single: true }) }) })] }));
107
117
  }
108
118
  // ---------------------------------------------------------------------------
109
119
  // Multi choice (kind = 'multi') — space to toggle, Enter to submit
@@ -150,11 +160,11 @@ function MultiForm({ params, onResolve }) {
150
160
  }
151
161
  }
152
162
  }, { isActive: true });
153
- return (_jsxs(Box, { borderStyle: "round", borderColor: PEACH, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [_jsx(Text, { bold: true, children: params.question }), params.context && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: params.context }) })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: params.choices.map((c, i) => {
163
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, flexShrink: 0, children: [_jsx(Text, { bold: true, children: params.question }), params.context && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: params.context }) })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: params.choices.map((c, i) => {
154
164
  const isCursor = i === cursor;
155
165
  const isSelected = selected.has(c.value);
156
166
  const mark = isSelected ? '◉' : '○';
157
167
  const prefix = isCursor ? '▸ ' : ' ';
158
168
  return (_jsxs(Text, { color: isCursor ? PEACH : undefined, bold: isCursor, children: [prefix, mark, " ", c.label] }, c.value));
159
- }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191/\u2193 navigate \u00B7 Space toggle \u00B7 Enter submit \u00B7 Esc cancel" }) })] }));
169
+ }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: askHelpLine('multi') }) })] }));
160
170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",
@@ -41,6 +41,7 @@
41
41
  "smoke:all": "pnpm zcspeach:smoke && pnpm adt:smoke",
42
42
  "zcspeach:smoke": "tsx scripts/zcspeach-smoke.ts",
43
43
  "bench:models": "tsx scripts/bench-models.ts",
44
+ "bench:audit": "tsx scripts/bench-audit-task.ts",
44
45
  "fiori": "tsx src/tools/fiori/bin.ts",
45
46
  "prepare": "husky",
46
47
  "prepublishOnly": "node -e \"const d=(require('./package.json').dependencies||{})['@cspeach/sap-client']||''; if(d.startsWith('file:')||d.startsWith('link:')){console.error('publish blocked: @cspeach/sap-client is a local link ('+d+') — point it at the published version before npm publish'); process.exit(1)}\""
@@ -56,6 +57,7 @@
56
57
  "@inquirer/prompts": "^7.0.0",
57
58
  "@noble/ed25519": "2.1.0",
58
59
  "@noble/hashes": "1.4.0",
60
+ "@sap-ux/fe-fpm-writer": "0.43.7",
59
61
  "@sap-ux/fiori-elements-writer": "^3.0.39",
60
62
  "@sap-ux/fiori-freestyle-writer": "3.0.12",
61
63
  "chalk": "^5.3.0",
@@ -77,6 +79,7 @@
77
79
  "mem-fs-editor": "9.4.0",
78
80
  "node-machine-id": "^1.1.12",
79
81
  "openai": "4.67.3",
82
+ "puppeteer-core": "24.10.2",
80
83
  "react": "^18.3.0",
81
84
  "turndown": "^7.2.0",
82
85
  "unpdf": "^0.12.1",