@cspeach/cli 1.1.18 → 1.1.19

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 (224) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/loop.js +143 -25
  3. package/dist/agent/providers/local-provider.js +4 -1
  4. package/dist/agent/repair-partial.js +76 -19
  5. package/dist/agent/tool-dispatch.js +58 -21
  6. package/dist/agent/turn-error-ux.js +77 -26
  7. package/dist/agent/turn-stream.js +25 -3
  8. package/dist/approvals/adt-type.js +27 -0
  9. package/dist/approvals/advisory-prompt.js +48 -0
  10. package/dist/approvals/advisory-render.js +25 -11
  11. package/dist/approvals/approval-prompt.js +74 -24
  12. package/dist/approvals/jwt.js +2 -1
  13. package/dist/approvals/render.js +64 -36
  14. package/dist/approvals/risk-floor.js +53 -2
  15. package/dist/auth/api-key.js +30 -16
  16. package/dist/auth/keychain.js +0 -0
  17. package/dist/auth/me.js +25 -7
  18. package/dist/cli.js +25 -6
  19. package/dist/commands/compact.js +12 -9
  20. package/dist/commands/config-set.js +6 -2
  21. package/dist/commands/config-show.js +7 -5
  22. package/dist/commands/cost.js +41 -34
  23. package/dist/commands/help.js +88 -50
  24. package/dist/commands/login.js +28 -19
  25. package/dist/commands/logout.js +7 -3
  26. package/dist/commands/plan-chain.js +71 -77
  27. package/dist/commands/plan-continue.js +1 -0
  28. package/dist/commands/plan-resume.js +20 -12
  29. package/dist/commands/whoami.js +23 -9
  30. package/dist/config/loader.js +15 -3
  31. package/dist/cost/pricing.js +6 -5
  32. package/dist/doctor/checks/forge-rules.js +13 -3
  33. package/dist/doctor/checks/keychain.js +6 -4
  34. package/dist/doctor/run.js +101 -20
  35. package/dist/index.js +5 -2
  36. package/dist/lib/graceful-exit.js +56 -0
  37. package/dist/lib/piped-prompt.js +65 -0
  38. package/dist/lock-contention.js +2 -2
  39. package/dist/one-shot.js +135 -40
  40. package/dist/projects/answer-blockers.js +6 -1
  41. package/dist/projects/handover-md.js +15 -14
  42. package/dist/projects/plan-run.js +97 -54
  43. package/dist/projects/promote-command.js +11 -2
  44. package/dist/projects/save-command.js +3 -2
  45. package/dist/projects/status.js +2 -1
  46. package/dist/projects/yes-no.js +12 -0
  47. package/dist/renderer/abap-inline.js +18 -14
  48. package/dist/renderer/answer-trim.js +69 -0
  49. package/dist/renderer/banners.js +8 -8
  50. package/dist/renderer/brand-settled.js +19 -0
  51. package/dist/renderer/change-card.js +156 -0
  52. package/dist/renderer/checklist-format.js +130 -0
  53. package/dist/renderer/color-mode.js +45 -0
  54. package/dist/renderer/error-detail.js +104 -0
  55. package/dist/renderer/fence-state.js +46 -0
  56. package/dist/renderer/footer-line.js +96 -0
  57. package/dist/renderer/glyphs.js +25 -0
  58. package/dist/renderer/goodbye.js +38 -0
  59. package/dist/renderer/legacy-palette.js +41 -0
  60. package/dist/renderer/look.js +30 -0
  61. package/dist/renderer/markdown.js +299 -92
  62. package/dist/renderer/notice-log.js +72 -0
  63. package/dist/renderer/notice-shape.js +48 -0
  64. package/dist/renderer/notices.js +40 -8
  65. package/dist/renderer/panel-rows.js +16 -0
  66. package/dist/renderer/pipeline.js +66 -0
  67. package/dist/renderer/progress-chatter.js +23 -17
  68. package/dist/renderer/rendering-mode.js +34 -0
  69. package/dist/renderer/routing-line.js +14 -0
  70. package/dist/renderer/sanitize.js +12 -0
  71. package/dist/renderer/severity.js +2 -7
  72. package/dist/renderer/startup-lines.js +152 -0
  73. package/dist/renderer/status-footer.js +52 -34
  74. package/dist/renderer/steering-echo.js +41 -0
  75. package/dist/renderer/syntax.js +31 -12
  76. package/dist/renderer/tables.js +5 -1
  77. package/dist/renderer/theme.js +44 -0
  78. package/dist/renderer/thinking-heartbeat.js +33 -2
  79. package/dist/renderer/todo-block.js +10 -49
  80. package/dist/renderer/tool-labels.js +238 -0
  81. package/dist/renderer/tool-widget.js +245 -51
  82. package/dist/renderer/trace.js +42 -0
  83. package/dist/renderer/transcript-flow.js +132 -0
  84. package/dist/renderer/tty.js +34 -1
  85. package/dist/renderer/ui-width.js +42 -0
  86. package/dist/renderer/verify-chain.js +4 -2
  87. package/dist/renderer/widget-fallback.js +58 -65
  88. package/dist/repl/bracketed-paste.js +7 -1
  89. package/dist/repl/builtin-commands.js +19 -8
  90. package/dist/repl/credential-handover-gate.js +28 -0
  91. package/dist/repl/early-line-buffer.js +5 -2
  92. package/dist/repl/file-picker.js +54 -12
  93. package/dist/repl/ink-stdin-guard.js +66 -0
  94. package/dist/repl/inquirer-guard.js +59 -16
  95. package/dist/repl/inquirer-theme.js +27 -33
  96. package/dist/repl/paste-marker.js +19 -0
  97. package/dist/repl/plan-turn-end.js +20 -0
  98. package/dist/repl/post-turn-status.js +36 -11
  99. package/dist/repl/reroute-turn.js +21 -0
  100. package/dist/repl/reset-tty-stdin.js +44 -0
  101. package/dist/repl/restore-guard.js +22 -0
  102. package/dist/repl/resume-standalone.js +63 -0
  103. package/dist/repl/rule8-detector.js +11 -3
  104. package/dist/repl/safety-confirm.js +161 -93
  105. package/dist/repl/session-spend-line.js +8 -4
  106. package/dist/repl/themed-prompts.js +19 -0
  107. package/dist/repl/ui-look-command.js +75 -0
  108. package/dist/repl/update-method-preview-hook.js +48 -5
  109. package/dist/repl.js +795 -332
  110. package/dist/rewind/cli.js +3 -2
  111. package/dist/rewind/format.js +14 -9
  112. package/dist/rewind/restore.js +11 -0
  113. package/dist/router/classifier.js +47 -4
  114. package/dist/router/intent-extractor.js +24 -8
  115. package/dist/router/piped-routing.js +23 -0
  116. package/dist/router/resume-routing.js +16 -0
  117. package/dist/router/routing-failure.js +57 -0
  118. package/dist/sap/first-run-choice.js +1 -1
  119. package/dist/sap/onboarding.js +3 -2
  120. package/dist/sap/standalone-onboarding.js +1 -1
  121. package/dist/sap/system-info.js +4 -2
  122. package/dist/sap/unreachable.js +28 -0
  123. package/dist/sap-errors/clean-error-text.js +182 -0
  124. package/dist/session/interrupt-reason.js +39 -0
  125. package/dist/session/recap.js +36 -24
  126. package/dist/session/resume.js +104 -36
  127. package/dist/session/store.js +69 -27
  128. package/dist/session/user-prompt.js +21 -0
  129. package/dist/skill-catalog.js +7 -0
  130. package/dist/skills/bundled-skills.js +76 -69
  131. package/dist/standards/standards-init.js +1 -1
  132. package/dist/test-helpers/answer-any.js +46 -0
  133. package/dist/tools/approval.js +27 -11
  134. package/dist/tools/ask-question.js +47 -5
  135. package/dist/tools/filesystem/file-write.js +19 -2
  136. package/dist/tools/fiori/fe-extend.js +16 -1
  137. package/dist/tools/fiori/fe-scaffold.js +105 -3
  138. package/dist/tools/fiori/html-escapes.js +28 -0
  139. package/dist/tools/fiori/metadata/audit.js +605 -0
  140. package/dist/tools/fiori/metadata/references.js +104 -0
  141. package/dist/tools/fiori/metadata/types.js +8 -0
  142. package/dist/tools/fiori/preview/app-guard.js +215 -0
  143. package/dist/tools/fiori/preview/env.js +193 -0
  144. package/dist/tools/fiori/preview/readiness.js +127 -0
  145. package/dist/tools/fiori/preview/registry.js +412 -0
  146. package/dist/tools/fiori/preview/start.js +469 -0
  147. package/dist/tools/fiori/samples/loader.js +20 -5
  148. package/dist/tools/fiori/scaffold.js +18 -6
  149. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  150. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  151. package/dist/tools/fiori/smoke/assertions.js +161 -24
  152. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  153. package/dist/tools/fiori/smoke/batch.js +273 -0
  154. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  155. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  156. package/dist/tools/fiori/smoke/driver.js +171 -4
  157. package/dist/tools/fiori/smoke/evidence.js +35 -0
  158. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  159. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  160. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  161. package/dist/tools/fiori/tools.js +651 -7
  162. package/dist/tools/fiori/ui5-version.js +16 -0
  163. package/dist/tools/index.js +8 -0
  164. package/dist/tools/local-build.js +6 -0
  165. package/dist/tools/sap-read.js +16 -1
  166. package/dist/tools/sap-write.js +122 -10
  167. package/dist/tools/shell/shell_exec.js +30 -12
  168. package/dist/tools/syntax-state.js +67 -0
  169. package/dist/tools/todo.js +23 -18
  170. package/dist/tools/transport-fit.js +462 -0
  171. package/dist/tools/transport-resolution.js +3 -1
  172. package/dist/tools/transport.js +33 -7
  173. package/dist/tools/verify.js +184 -8
  174. package/dist/ui/app.js +284 -146
  175. package/dist/ui/approval-modal.js +41 -15
  176. package/dist/ui/ask-answer-text.js +17 -0
  177. package/dist/ui/body.js +25 -6
  178. package/dist/ui/card-slot.js +98 -0
  179. package/dist/ui/checklist.js +9 -0
  180. package/dist/ui/coaching-picker-classic.js +8 -5
  181. package/dist/ui/command-palette.js +6 -4
  182. package/dist/ui/confirm-request.js +18 -0
  183. package/dist/ui/context-grid.js +2 -1
  184. package/dist/ui/file-palette.js +6 -4
  185. package/dist/ui/files-card-emitter.js +19 -0
  186. package/dist/ui/footer-line.js +16 -0
  187. package/dist/ui/footer.js +105 -104
  188. package/dist/ui/input-wrap.js +122 -0
  189. package/dist/ui/interrupt.js +103 -0
  190. package/dist/ui/key-burst.js +117 -0
  191. package/dist/ui/line-resolution.js +4 -3
  192. package/dist/ui/live-area.js +71 -0
  193. package/dist/ui/login-banner.js +54 -43
  194. package/dist/ui/rewind-panel.js +76 -24
  195. package/dist/ui/role-props.js +47 -0
  196. package/dist/ui/sap-state-store.js +1 -0
  197. package/dist/ui/session-timeline.js +49 -3
  198. package/dist/ui/skill-picker.js +5 -47
  199. package/dist/ui/text-input.js +128 -24
  200. package/dist/ui/todo-emitter.js +19 -0
  201. package/dist/ui/turn-status-emitter.js +28 -2
  202. package/dist/ui/turn-status.js +16 -19
  203. package/dist/ui/use-card-request.js +44 -0
  204. package/dist/ui/widgets/ask-card.js +521 -0
  205. package/dist/ui/widgets/bar-chart.js +11 -5
  206. package/dist/ui/widgets/coaching-picker.js +50 -55
  207. package/dist/ui/widgets/confirm-card.js +193 -0
  208. package/dist/ui/widgets/dep-graph.js +3 -2
  209. package/dist/ui/widgets/diff-viewer.js +6 -5
  210. package/dist/ui/widgets/files-card.js +97 -0
  211. package/dist/ui/widgets/question-card.js +2 -1
  212. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  213. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  214. package/dist/ui/widgets/stack-frames.js +2 -1
  215. package/dist/upgrade-check.js +39 -8
  216. package/package.json +11 -2
  217. package/test-harness/fe-draft/app.ts +188 -0
  218. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  219. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  220. package/test-harness/fe-draft/routes.ts +224 -0
  221. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  222. package/test-harness/fe-draft/server.ts +225 -0
  223. package/test-harness/fe-draft/variants.ts +196 -0
  224. package/vitest.smoke-e2e.config.ts +20 -0
@@ -0,0 +1,521 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * AskCard — the one question card (piece 2, spec §3). Replaces the
4
+ * retired v1 modal and v2 form widgets (both deleted in Q2).
5
+ *
6
+ * NO NEW VALUES (spec invariant) — every resolution is one today's widgets
7
+ * could return:
8
+ * v1 choice → { answer: value } | { answer: null, cancelled: true }
9
+ * v1 multi → { answer: 'v1,v2' } (Enter with nothing ticked → the highlighted row)
10
+ * v1 text → { answer: trimmed } (Enter on empty → cancelled, as today)
11
+ * v2 form → { formId, answers: [{ id, answer, custom? }] } | { formId, answers: [], cancelled: true }
12
+ * v1 choice/multi get NO free-text row. v2 optioned questions keep one —
13
+ * "N. Type something else…", today's Other escape (custom: true).
14
+ *
15
+ * Forms with 2+ questions: a tab row (pending/answered chip per header,
16
+ * then a Submit chip) and, after the last answer, a Review step
17
+ * ("1. Submit answers" / "2. Cancel"). Cancel returns
18
+ * { formId, answers: [], cancelled: true } — exactly today's Esc.
19
+ *
20
+ * Keys: ↑↓ move · 1-9 pick (single) or tick (multi) · Space ticks · Enter
21
+ * chooses · Tab/→ next and Shift+Tab/← previous question (←/→ only while
22
+ * the text input is not focused) · Esc cancels; on the free-text row Esc
23
+ * first returns to the options.
24
+ */
25
+ import { useMemo, useRef, useState } from 'react';
26
+ import { Box, Text, useInput } from 'ink';
27
+ import TextInput from '../text-input.js';
28
+ import { useReclaimRawStdin } from './reclaim-raw-stdin.js';
29
+ import stringWidth from 'string-width';
30
+ import { getGlyphs } from '../../renderer/glyphs.js';
31
+ import { truncateToWidth, uiWidth } from '../../renderer/ui-width.js';
32
+ import { roleProps, selectedProps } from '../role-props.js';
33
+ import { sanitizeControlChars } from '../../renderer/sanitize.js';
34
+ import { answerDisplayText } from '../ask-answer-text.js';
35
+ /** Option rows visible at once — the rest scroll. */
36
+ export const VISIBLE_OPTION_ROWS = 5;
37
+ /**
38
+ * Single-key cards (the Yes/No agent confirm) drop keys received in the first
39
+ * GUARD_MS after mount, so a key typed ahead while output streamed cannot
40
+ * answer the card.
41
+ */
42
+ const GUARD_MS = 150;
43
+ /**
44
+ * Card text is model- or tool-supplied. Tabs and newlines are flattened by
45
+ * truncateToWidth; every OTHER control character is neutralised by the
46
+ * shared sanitizer so it cannot drive the terminal inside the live frame.
47
+ */
48
+ const sanitize = sanitizeControlChars;
49
+ /**
50
+ * Sanitize, then cut to `cells` terminal cells with string-width. Ink's own
51
+ * truncate-end counts some emoji as one cell, so rows are pre-cut here and
52
+ * Ink's truncation is only the safety net.
53
+ */
54
+ function fit(text, cells) {
55
+ return truncateToWidth(sanitize(text), cells);
56
+ }
57
+ /** Round 4 item 8b — the question wraps up to this many rows, then is cut. */
58
+ export const QUESTION_MAX_ROWS = 3;
59
+ /** Context wraps too; it gets at least one row per line and at most this many in all (or its line count, if larger). */
60
+ export const CONTEXT_MAX_ROWS = 6;
61
+ const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
62
+ /** The longest grapheme-whole prefix of `word` that fits `cells`. */
63
+ function takeCells(word, cells) {
64
+ let out = '';
65
+ let used = 0;
66
+ for (const { segment } of GRAPHEMES.segment(word)) {
67
+ const w = stringWidth(segment);
68
+ if (used + w > cells && out.length > 0)
69
+ break;
70
+ out += segment;
71
+ used += w;
72
+ if (used >= cells)
73
+ break;
74
+ }
75
+ return out;
76
+ }
77
+ /**
78
+ * Round 4 item 8b — word-wrap card text to `cells` per row, at most `maxRows`
79
+ * rows (the last row then ends with the `more` glyph). A question cut to one
80
+ * row hid the model's suggestion ("…Sugges…"); it now wraps instead.
81
+ */
82
+ export function wrapCardText(text, cells, maxRows) {
83
+ const clean = sanitize(text).replace(/\t/g, ' ').replace(/\r?\n/g, ' ');
84
+ const width = Math.max(1, cells);
85
+ const indent = /^ */.exec(clean)[0].slice(0, Math.max(0, width - 1));
86
+ const rows = [];
87
+ let cur = indent;
88
+ const curEmpty = () => cur.length === 0 || cur === indent;
89
+ for (let word of clean.trim().split(/ +/).filter((w) => w.length > 0)) {
90
+ while (stringWidth(word) > width) {
91
+ if (!curEmpty()) {
92
+ rows.push(cur);
93
+ cur = '';
94
+ }
95
+ const head = takeCells(word, width - stringWidth(cur));
96
+ rows.push(cur + head);
97
+ cur = '';
98
+ word = word.slice(head.length);
99
+ }
100
+ if (word.length === 0)
101
+ continue;
102
+ if (curEmpty())
103
+ cur += word;
104
+ else if (stringWidth(cur) + 1 + stringWidth(word) <= width)
105
+ cur += ` ${word}`;
106
+ else {
107
+ rows.push(cur);
108
+ cur = word;
109
+ }
110
+ }
111
+ if (!curEmpty() || rows.length === 0)
112
+ rows.push(cur);
113
+ const max = Math.max(1, maxRows);
114
+ if (rows.length <= max)
115
+ return rows;
116
+ const kept = rows.slice(0, max);
117
+ kept[max - 1] = truncateToWidth(`${rows.slice(max - 1).join(' ')} `, width);
118
+ return kept;
119
+ }
120
+ /** The question rows, with the multi-select suffix kept whole on the last row. */
121
+ function questionRows(question, suffix, cells) {
122
+ if (!suffix)
123
+ return wrapCardText(question, cells, QUESTION_MAX_ROWS);
124
+ const rows = wrapCardText(question, cells, QUESTION_MAX_ROWS);
125
+ const last = rows[rows.length - 1];
126
+ if (stringWidth(last) + stringWidth(suffix) <= cells) {
127
+ rows[rows.length - 1] = last + suffix;
128
+ }
129
+ else if (rows.length < QUESTION_MAX_ROWS) {
130
+ rows.push(suffix.trimStart());
131
+ }
132
+ else {
133
+ rows[rows.length - 1] = truncateToWidth(`${last} `, Math.max(1, cells - stringWidth(suffix))) + suffix;
134
+ }
135
+ return rows;
136
+ }
137
+ /** Context rows: every line keeps at least one row; wrapping fills the rest of the budget. */
138
+ function contextRows(context, cells) {
139
+ if (!context)
140
+ return [];
141
+ const lines = context.split('\n');
142
+ let budget = Math.max(lines.length, CONTEXT_MAX_ROWS);
143
+ const out = [];
144
+ lines.forEach((line, i) => {
145
+ const reserve = lines.length - i - 1; // one row for each line still to come
146
+ const rows = wrapCardText(line, cells, Math.max(1, budget - reserve));
147
+ out.push(...rows);
148
+ budget -= rows.length;
149
+ });
150
+ return out;
151
+ }
152
+ export function toCardQuestions(request) {
153
+ if ('questions' in request) {
154
+ return request.questions.map((q) => {
155
+ const freeText = q.freeText || q.options.length === 0;
156
+ return {
157
+ id: q.id,
158
+ question: q.question,
159
+ context: q.context,
160
+ header: q.header ?? q.id,
161
+ multiSelect: !freeText && q.multiSelect,
162
+ freeText,
163
+ options: freeText ? [] : q.options,
164
+ otherRow: !freeText,
165
+ };
166
+ });
167
+ }
168
+ const freeText = request.kind === 'text' || request.choices.length === 0;
169
+ return [{
170
+ id: request.id,
171
+ question: request.question,
172
+ context: request.context,
173
+ header: request.id,
174
+ multiSelect: !freeText && request.kind === 'multi',
175
+ freeText,
176
+ options: freeText ? [] : request.choices,
177
+ otherRow: false,
178
+ defaultValue: request.defaultValue,
179
+ keys: request.keys,
180
+ }];
181
+ }
182
+ export function askCardHint(kind, opts = {}) {
183
+ const nav = opts.multiQuestion ? ['Tab next question'] : [];
184
+ switch (kind) {
185
+ case 'text': return ['Enter submits', ...nav, 'Esc cancels'].join(' · ');
186
+ case 'other': return ['Enter submits', ...nav, 'Esc goes back to the options'].join(' · ');
187
+ case 'multi':
188
+ // Kept under 78 cells (an 80-column card) so it never truncates; the
189
+ // "(choose one or more)" question suffix carries the multi-select cue.
190
+ return ['Space or 1-9 ticks a row', 'Enter confirms', ...nav, 'Esc cancels'].join(' · ');
191
+ default:
192
+ return ['↑↓ move', '1-9 or Enter picks', ...nav, 'Esc cancels'].join(' · ');
193
+ }
194
+ }
195
+ export const REVIEW_HINT = '↑↓ move · 1-2 or Enter chooses · ← back to edit · Esc cancels';
196
+ const REVIEW_CHOICES = ['Submit answers', 'Cancel'];
197
+ const MULTI_SUFFIX = ' (choose one or more)';
198
+ const REC = ' (recommended)';
199
+ /**
200
+ * Upper bound on the rows this card renders — the card's share of the
201
+ * live-area height budget (spec §2; liveBudget decides showDescriptions).
202
+ * Worst case over every question screen, plus the Review screen.
203
+ */
204
+ export function estimateAskCardRows(request, showDescriptions, width = uiWidth()) {
205
+ const qs = toCardQuestions(request);
206
+ const chipRows = qs.length > 1 ? 2 : 0; // tab row + its blank line
207
+ const inner = Math.max(1, width - 2);
208
+ let worst = 0;
209
+ for (const q of qs) {
210
+ const ctx = contextRows(q.context, inner).length;
211
+ const suffix = q.multiSelect && inner - MULTI_SUFFIX.length >= 1 ? MULTI_SUFFIX : '';
212
+ const qRows = questionRows(q.question, suffix, inner).length;
213
+ let body = 2; // margin + the text input row
214
+ if (!q.freeText) {
215
+ const shown = Math.min(q.options.length, VISIBLE_OPTION_ROWS);
216
+ const described = showDescriptions ? Math.min(shown, q.options.filter((o) => o.description).length) : 0;
217
+ const arrows = q.options.length > VISIBLE_OPTION_ROWS ? 2 : 0;
218
+ body = 1 + shown + described + arrows + (q.otherRow ? 1 : 0);
219
+ }
220
+ worst = Math.max(worst, chipRows + qRows + ctx + body + 2); // question + body + margin + hint
221
+ }
222
+ if (qs.length > 1) {
223
+ worst = Math.max(worst, chipRows + 1 + qs.length + 1 + REVIEW_CHOICES.length + 2);
224
+ }
225
+ return worst;
226
+ }
227
+ /**
228
+ * Cut differently-styled segments of ONE row to `cells` in total: whole
229
+ * segments while they fit, then the one that overflows ends with the `more`
230
+ * glyph and the rest are dropped.
231
+ */
232
+ function fitSegments(segs, cells) {
233
+ if (segs.reduce((w, sg) => w + stringWidth(sg.text), 0) <= cells)
234
+ return segs;
235
+ const out = [];
236
+ let used = 0;
237
+ for (const seg of segs) {
238
+ const room = cells - used;
239
+ const w = stringWidth(seg.text);
240
+ if (w < room) {
241
+ out.push(seg);
242
+ used += w;
243
+ continue;
244
+ }
245
+ // The row overflows, so the segment that reaches the edge must show the
246
+ // `more` glyph — the trailing space forces a cut even at an exact fit.
247
+ if (room > 0)
248
+ out.push({ ...seg, text: truncateToWidth(`${seg.text} `, room) });
249
+ break;
250
+ }
251
+ return out;
252
+ }
253
+ export function AskCard({ request, width, showDescriptions, onResolveV1, onResolveForm, onInterruptTurn }) {
254
+ useReclaimRawStdin();
255
+ const g = getGlyphs();
256
+ const dim = roleProps('dim');
257
+ const isForm = 'questions' in request;
258
+ const formId = isForm ? request.formId : '';
259
+ const questions = useMemo(() => toCardQuestions(request), [request]);
260
+ const n = questions.length;
261
+ // Single-key answers (Q3 Yes/No) get a pre-open drop: a `y` typed ahead
262
+ // while output streamed must not answer the card.
263
+ const mountGuard = questions.some((qq) => qq.keys !== undefined);
264
+ const mountedAt = useRef(Date.now());
265
+ const done = useRef(false);
266
+ // Focus slots: 0..n-1 = questions; n = the Review step (2+ questions,
267
+ // reachable only once every question has an answer).
268
+ const [focus, setFocus] = useState(0);
269
+ const [cursors, setCursors] = useState(() => questions.map((qq) => Math.max(0, qq.options.findIndex((o) => o.value === qq.defaultValue))));
270
+ const [ticks, setTicks] = useState(() => questions.map(() => new Set()));
271
+ const [drafts, setDrafts] = useState(() => questions.map(() => ''));
272
+ const [answers, setAnswers] = useState(() => questions.map(() => null));
273
+ const [reviewCursor, setReviewCursor] = useState(0);
274
+ const isReview = n > 1 && focus === n;
275
+ const q = isReview ? undefined : questions[focus];
276
+ const cursor = q ? cursors[focus] : 0;
277
+ const rowCount = q ? q.options.length + (q.otherRow ? 1 : 0) : 0;
278
+ const onOther = q !== undefined && q.otherRow && cursor === q.options.length;
279
+ const inputFocused = q !== undefined && (q.freeText || onOther);
280
+ const allAnswered = answers.every((a) => a !== null);
281
+ const setCursor = (i) => setCursors((prev) => prev.map((c, j) => (j === focus ? i : c)));
282
+ const setDraft = (v) => setDrafts((prev) => prev.map((d, j) => (j === focus ? v : d)));
283
+ const toggle = (value) => setTicks((prev) => prev.map((s, j) => {
284
+ if (j !== focus)
285
+ return s;
286
+ const next = new Set(s);
287
+ if (next.has(value))
288
+ next.delete(value);
289
+ else
290
+ next.add(value);
291
+ return next;
292
+ }));
293
+ const cancel = () => {
294
+ if (done.current)
295
+ return;
296
+ done.current = true;
297
+ if (isForm)
298
+ onResolveForm({ formId, answers: [], cancelled: true });
299
+ else
300
+ onResolveV1({ answer: null, cancelled: true });
301
+ };
302
+ const submit = (final) => {
303
+ if (done.current)
304
+ return;
305
+ done.current = true;
306
+ if (!isForm) {
307
+ const a = final[0];
308
+ onResolveV1(a ? { answer: a.answer } : { answer: null, cancelled: true });
309
+ return;
310
+ }
311
+ onResolveForm({
312
+ formId,
313
+ // Question order, always — regardless of the order answered in.
314
+ answers: questions.map((qq, i) => ({
315
+ id: qq.id,
316
+ answer: final[i]?.answer ?? null,
317
+ ...(final[i]?.custom ? { custom: true } : {}),
318
+ })),
319
+ });
320
+ };
321
+ /**
322
+ * Commit, then move to the next UNANSWERED question (wrapping). None left:
323
+ * one-question cards resolve at once; forms open the Review step.
324
+ */
325
+ const answerAndAdvance = (i, value, custom) => {
326
+ const next = answers.map((a, j) => j === i ? { answer: value, ...(custom ? { custom: true } : {}) } : a);
327
+ // Fix round 3 (item 9c) — "Stop here" / Cancel ends the form now; the
328
+ // later questions come back null instead of being asked anyway.
329
+ const picked = custom ? undefined : questions[i]?.options.find((o) => o.value === value);
330
+ if (picked?.endsForm) {
331
+ submit(next);
332
+ return;
333
+ }
334
+ for (let step = 1; step <= n; step++) {
335
+ const j = (i + step) % n;
336
+ if (next[j] === null) {
337
+ setAnswers(next);
338
+ setFocus(j);
339
+ return;
340
+ }
341
+ }
342
+ if (n === 1) {
343
+ submit(next);
344
+ return;
345
+ }
346
+ setAnswers(next);
347
+ setReviewCursor(0);
348
+ setFocus(n);
349
+ };
350
+ const pickEnter = () => {
351
+ if (!q)
352
+ return;
353
+ const opt = q.options[cursor];
354
+ if (q.multiSelect) {
355
+ const values = q.options.filter((o) => ticks[focus].has(o.value)).map((o) => o.value);
356
+ if (values.length > 0)
357
+ answerAndAdvance(focus, values.join(','));
358
+ else if (opt)
359
+ answerAndAdvance(focus, opt.value); // nothing ticked → the highlighted row
360
+ return;
361
+ }
362
+ if (opt)
363
+ answerAndAdvance(focus, opt.value);
364
+ };
365
+ const onTextSubmit = (v) => {
366
+ const trimmed = v.trim();
367
+ if (trimmed.length === 0) {
368
+ if (!isForm)
369
+ cancel(); // v1 text: Enter on empty cancels (today)
370
+ return; // v2: ignored (today)
371
+ }
372
+ // custom marks the free-text ESCAPE on an optioned question only.
373
+ answerAndAdvance(focus, trimmed, onOther);
374
+ };
375
+ useInput((input, key) => {
376
+ if (mountGuard && Date.now() - mountedAt.current < GUARD_MS)
377
+ return;
378
+ // Ctrl+C cancels outright, the result Esc gives when it cancels (same as
379
+ // ConfirmCard / FilesCard) — even from the text row, where Esc steps back.
380
+ // Round 7 item 4 — and then stops the turn (the model must not go on
381
+ // spending after the user pressed Ctrl+C).
382
+ if (key.ctrl && input === 'c') {
383
+ const first = !done.current;
384
+ cancel();
385
+ if (first)
386
+ onInterruptTurn?.();
387
+ return;
388
+ }
389
+ if (key.escape) {
390
+ if (q && onOther) {
391
+ setCursor(Math.max(0, q.options.length - 1));
392
+ return;
393
+ }
394
+ cancel();
395
+ return;
396
+ }
397
+ if (n > 1) {
398
+ const maxSlot = allAnswered ? n : n - 1;
399
+ if ((key.tab && !key.shift) || (!inputFocused && key.rightArrow)) {
400
+ setFocus((f) => (f >= maxSlot ? 0 : f + 1));
401
+ return;
402
+ }
403
+ if ((key.tab && key.shift) || (!inputFocused && key.leftArrow)) {
404
+ setFocus((f) => (f <= 0 ? maxSlot : f - 1));
405
+ return;
406
+ }
407
+ }
408
+ if (isReview) {
409
+ if (key.upArrow) {
410
+ setReviewCursor(0);
411
+ return;
412
+ }
413
+ if (key.downArrow) {
414
+ setReviewCursor(1);
415
+ return;
416
+ }
417
+ if (input === '1' || (key.return && reviewCursor === 0)) {
418
+ submit(answers);
419
+ return;
420
+ }
421
+ if (input === '2' || (key.return && reviewCursor === 1)) {
422
+ cancel();
423
+ return;
424
+ }
425
+ return;
426
+ }
427
+ if (!q)
428
+ return;
429
+ if (!q.freeText) {
430
+ if (key.upArrow) {
431
+ setCursor(Math.max(0, cursor - 1));
432
+ return;
433
+ }
434
+ if (key.downArrow) {
435
+ setCursor(Math.min(rowCount - 1, cursor + 1));
436
+ return;
437
+ }
438
+ }
439
+ // The text input owns every other key while it has focus.
440
+ if (inputFocused)
441
+ return;
442
+ // Ctrl+Y / Meta+Y are not "y": only a bare key answers.
443
+ const alias = key.ctrl || key.meta ? undefined : q.keys?.[input.toLowerCase()];
444
+ if (alias !== undefined && q.options.some((o) => o.value === alias)) {
445
+ answerAndAdvance(focus, alias);
446
+ return;
447
+ }
448
+ const digit = /^[1-9]$/.test(input) ? Number(input) - 1 : -1;
449
+ if (digit >= 0) {
450
+ if (digit >= rowCount)
451
+ return;
452
+ if (q.otherRow && digit === q.options.length) {
453
+ setCursor(digit);
454
+ return;
455
+ }
456
+ const opt = q.options[digit];
457
+ if (q.multiSelect) {
458
+ toggle(opt.value);
459
+ setCursor(digit);
460
+ return;
461
+ }
462
+ answerAndAdvance(focus, opt.value);
463
+ return;
464
+ }
465
+ if (q.multiSelect && input === ' ') {
466
+ const opt = q.options[cursor];
467
+ if (opt)
468
+ toggle(opt.value);
469
+ return;
470
+ }
471
+ if (key.return)
472
+ pickEnter();
473
+ }, { isActive: true });
474
+ const numbered = (i) => `${i + 1}. `;
475
+ const inner = Math.max(1, width - 2); // paddingX={1} on both sides
476
+ const chipRow = n > 1 ? (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { wrap: "truncate-end", children: fitSegments([
477
+ ...questions.map((qq, i) => ({
478
+ text: `${i > 0 ? ' ' : ''}${answers[i] !== null ? g.answered : g.pending} ${fit(qq.header, inner)}`,
479
+ props: i === focus ? selectedProps() : dim,
480
+ })),
481
+ { text: ` ${g.done} Submit`, props: isReview ? selectedProps() : dim },
482
+ ], inner).map((sg, i) => _jsx(Text, { ...sg.props, children: sg.text }, `chip-${i}`)) }) })) : null;
483
+ if (!q) {
484
+ // Review step (forms with 2+ questions, all answered).
485
+ const headerCol = Math.min(14, Math.max(...questions.map((qq) => stringWidth(fit(qq.header, inner)))) + 2);
486
+ return (_jsxs(Box, { flexDirection: "column", width: width, paddingX: 1, flexShrink: 0, children: [chipRow, _jsx(Text, { bold: true, wrap: "truncate-end", children: fit('Review your answers', inner) }), questions.map((qq, i) => {
487
+ const a = answers[i];
488
+ const head = fit(qq.header, headerCol - 2);
489
+ const cell = ` ${head}${' '.repeat(Math.max(0, headerCol - stringWidth(head)))}`;
490
+ const shown = a ? answerDisplayText(a.answer, qq.options, a.custom === true) : '';
491
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...dim, children: fit(cell, inner) }), fit(shown, Math.max(0, inner - stringWidth(cell)))] }, `rev-${i}`));
492
+ }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: REVIEW_CHOICES.map((label, i) => {
493
+ const cur = i === reviewCursor;
494
+ return (_jsx(Text, { wrap: "truncate-end", ...(cur ? selectedProps() : {}), children: fit(`${cur ? `${g.cursor} ` : ' '}${numbered(i)}${label}`, inner) }, `rc-${i}`));
495
+ }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { ...dim, wrap: "truncate-end", children: fit(REVIEW_HINT, inner) }) })] }));
496
+ }
497
+ const winStart = Math.max(0, Math.min(cursor - 3, q.options.length - VISIBLE_OPTION_ROWS));
498
+ const visible = q.options.slice(winStart, winStart + VISIBLE_OPTION_ROWS);
499
+ const below = Math.max(0, q.options.length - (winStart + VISIBLE_OPTION_ROWS));
500
+ const inputWidth = Math.max(8, width - 2 - 2 - `${rowCount}. `.length - 1);
501
+ const contextLines = contextRows(q.context, inner);
502
+ const hintKind = q.freeText ? 'text' : onOther ? 'other' : q.multiSelect ? 'multi' : 'pick';
503
+ // The multi-select suffix is the card's only multi cue: it stays whole and
504
+ // the question is cut instead (the same rule as "(recommended)" below).
505
+ const suffix = q.multiSelect && inner - MULTI_SUFFIX.length >= 1 ? MULTI_SUFFIX : '';
506
+ const questionLines = questionRows(q.question, suffix, inner);
507
+ return (_jsxs(Box, { flexDirection: "column", width: width, paddingX: 1, flexShrink: 0, children: [chipRow, questionLines.map((line, i) => (_jsx(Text, { bold: true, wrap: "truncate-end", children: line }, `q-${i}`))), contextLines.map((line, i) => (_jsx(Text, { ...dim, wrap: "truncate-end", children: line }, `ctx-${i}`))), q.freeText ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { ...roleProps('sap'), children: `${g.cursor} ` }), _jsx(TextInput, { value: drafts[focus], onChange: setDraft, onSubmit: onTextSubmit, maxDisplayWidth: inputWidth })] })) : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [winStart > 0 && _jsx(Text, { ...dim, wrap: "truncate-end", children: ` ↑ ${winStart} more` }), visible.map((o, vi) => {
508
+ const idx = winStart + vi;
509
+ const cur = !onOther && idx === cursor;
510
+ const rec = o.recommended === true && !/\brecommended\b/i.test(o.label);
511
+ const tick = q.multiSelect ? `${ticks[focus].has(o.value) ? g.tickOn : g.tickOff} ` : '';
512
+ const prefix = `${cur ? `${g.cursor} ` : ' '}${numbered(idx)}${tick}`;
513
+ const avail = inner - stringWidth(prefix);
514
+ const label = sanitize(o.label);
515
+ // (recommended) stays whole: the label is cut first; with no room
516
+ // for both, the label wins and the dim suffix is dropped.
517
+ const showRec = rec && avail - REC.length >= 1;
518
+ const shownLabel = truncateToWidth(label, showRec ? avail - REC.length : avail);
519
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { wrap: "truncate-end", ...(cur ? selectedProps() : {}), children: [prefix, shownLabel, showRec ? _jsx(Text, { ...dim, children: REC }) : null, q.defaultValue !== undefined && o.value === q.defaultValue ? _jsx(Text, { ...dim, children: " (default)" }) : null] }), showDescriptions && o.description ? (_jsx(Text, { ...dim, wrap: "truncate-end", children: fit(` ${o.description}`, inner) })) : null] }, `opt-${idx}`));
520
+ }), below > 0 && _jsx(Text, { ...dim, wrap: "truncate-end", children: ` ↓ ${below} more` }), q.otherRow ? (_jsxs(Box, { children: [_jsxs(Text, { wrap: "truncate-end", ...(onOther ? selectedProps() : {}), children: [onOther ? `${g.cursor} ` : ' ', numbered(q.options.length)] }), onOther ? (_jsx(TextInput, { value: drafts[focus], onChange: setDraft, onSubmit: onTextSubmit, placeholder: `Type something else${g.more}`, maxDisplayWidth: inputWidth })) : (_jsx(Text, { ...dim, wrap: "truncate-end", children: `Type something else${g.more}` }))] })) : null] })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { ...dim, wrap: "truncate-end", children: fit(askCardHint(hintKind, { multiQuestion: n > 1 }), inner) }) })] }));
521
+ }
@@ -1,15 +1,21 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- const VALID_COLORS = ['red', 'yellow', 'cyan', 'green', 'blue', 'magenta', 'white', 'gray'];
4
- function toInkColor(c) {
5
- return VALID_COLORS.includes(c) ? c : 'white';
3
+ import { roleProps } from '../role-props.js';
4
+ // The model names a colour; the bar takes the matching theme role, so it follows
5
+ // NO_COLOR and the light palette. White / unknown → the terminal's own text colour.
6
+ const COLOR_ROLE = {
7
+ red: 'bad', yellow: 'warn', green: 'ok', cyan: 'sap', blue: 'sap', magenta: 'brand', gray: 'dim',
8
+ };
9
+ function barProps(c) {
10
+ const role = c ? COLOR_ROLE[c] : undefined;
11
+ return role ? roleProps(role) : {};
6
12
  }
7
13
  export function BarChart({ data }) {
8
14
  const max = Math.max(...data.map((d) => d.value), 1);
9
15
  const barWidth = 30;
10
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Distribution" }), data.map((item) => {
16
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: roleProps('frame').color, paddingX: 1, children: [_jsx(Text, { ...roleProps('sap', { bold: true }), children: "Distribution" }), data.map((item) => {
11
17
  const filled = Math.round((item.value / max) * barWidth);
12
18
  const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
13
- return (_jsxs(Box, { children: [_jsxs(Text, { children: [item.label.padEnd(28), " "] }), _jsx(Text, { color: toInkColor(item.color), children: bar }), _jsxs(Text, { children: [" ", item.value] })] }, item.label));
19
+ return (_jsxs(Box, { children: [_jsxs(Text, { children: [item.label.padEnd(28), " "] }), _jsx(Text, { ...barProps(item.color), children: bar }), _jsxs(Text, { children: [" ", item.value] })] }, item.label));
14
20
  })] }));
15
21
  }