@cspeach/cli 1.1.33 → 1.1.35

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 (119) hide show
  1. package/dist/agent/anthropic-provider.js +2 -0
  2. package/dist/agent/azure-profile.js +7 -0
  3. package/dist/agent/connection-profile.js +49 -0
  4. package/dist/agent/loop.js +182 -39
  5. package/dist/agent/provider-shape.js +36 -0
  6. package/dist/agent/providers/ai-hub-provider.js +6 -5
  7. package/dist/agent/providers/azure-provider.js +125 -0
  8. package/dist/agent/providers/byok-provider.js +8 -7
  9. package/dist/agent/providers/factory.js +8 -3
  10. package/dist/agent/providers/local-provider.js +18 -22
  11. package/dist/agent/risk-gate.js +202 -0
  12. package/dist/agent/summarise-via-provider.js +55 -8
  13. package/dist/agent/tool-dispatch.js +19 -0
  14. package/dist/agent/turn-error-ux.js +22 -0
  15. package/dist/auth/api-key.js +10 -5
  16. package/dist/auth/auth-file.js +35 -6
  17. package/dist/auth/azure/azure-key.js +21 -0
  18. package/dist/auth/azure/azure-log.js +45 -0
  19. package/dist/auth/azure/cache-store.js +171 -0
  20. package/dist/auth/azure/clear.js +28 -0
  21. package/dist/auth/azure/loopback.js +192 -0
  22. package/dist/auth/azure/msal-client.js +41 -0
  23. package/dist/auth/azure/sign-in.js +219 -0
  24. package/dist/auth/azure/startup-sign-in.js +64 -0
  25. package/dist/auth/azure/token-provider.js +116 -0
  26. package/dist/auth/key-resolver.js +258 -0
  27. package/dist/auth/me.js +11 -5
  28. package/dist/cli.js +21 -2
  29. package/dist/commands/compact.js +12 -1
  30. package/dist/commands/config-set.js +74 -3
  31. package/dist/commands/config-show.js +25 -0
  32. package/dist/commands/cost.js +19 -6
  33. package/dist/commands/help.js +2 -2
  34. package/dist/commands/login-azure.js +33 -0
  35. package/dist/commands/login.js +56 -8
  36. package/dist/commands/logout.js +34 -10
  37. package/dist/commands/plan-boundary.js +4 -4
  38. package/dist/commands/plan-model-tier.js +8 -3
  39. package/dist/commands/usage.js +6 -0
  40. package/dist/commands/whoami.js +113 -9
  41. package/dist/config/loader.js +5 -0
  42. package/dist/connection/azure-auth.js +135 -0
  43. package/dist/connection/azure-errors.js +103 -0
  44. package/dist/connection/azure-facts.js +21 -0
  45. package/dist/connection/azure-gate.js +40 -0
  46. package/dist/connection/azure-last-call.js +5 -0
  47. package/dist/connection/azure-turn-error.js +25 -0
  48. package/dist/connection/cache.js +70 -0
  49. package/dist/connection/fetch.js +33 -0
  50. package/dist/connection/guard.js +94 -0
  51. package/dist/connection/limit-gate.js +26 -0
  52. package/dist/connection/local-usage.js +71 -0
  53. package/dist/connection/login-step.js +62 -0
  54. package/dist/connection/startup.js +67 -0
  55. package/dist/connection/state.js +68 -0
  56. package/dist/connection/types.js +1 -0
  57. package/dist/cost/cost-log.js +3 -1
  58. package/dist/cost/credits-wire.js +1 -1
  59. package/dist/cost/credits.js +4 -0
  60. package/dist/cost/display-mode.js +7 -0
  61. package/dist/cost/router-spend.js +3 -0
  62. package/dist/cost/session-cost.js +4 -0
  63. package/dist/doctor/checks/azure.js +100 -0
  64. package/dist/doctor/checks/credits.js +4 -3
  65. package/dist/doctor/checks/usage-reporting.js +22 -0
  66. package/dist/doctor/run.js +7 -0
  67. package/dist/lib/graceful-exit.js +17 -0
  68. package/dist/licence/licence-check.js +205 -0
  69. package/dist/licence/paths.js +6 -0
  70. package/dist/licence/turn-gate.js +66 -0
  71. package/dist/models/model-identity.js +65 -0
  72. package/dist/models/resolve.js +8 -0
  73. package/dist/models/server-config.js +3 -2
  74. package/dist/one-shot.js +86 -2
  75. package/dist/projects/answer-blockers.js +25 -12
  76. package/dist/renderer/change-card.js +28 -4
  77. package/dist/renderer/footer-line.js +7 -1
  78. package/dist/renderer/notices.js +27 -13
  79. package/dist/renderer/progress-chatter.js +3 -1
  80. package/dist/renderer/status-footer.js +13 -6
  81. package/dist/renderer/tool-labels.js +8 -2
  82. package/dist/renderer/verify-chain.js +41 -18
  83. package/dist/renderer/wrap-rows.js +87 -0
  84. package/dist/repl/ask-or-no.js +18 -0
  85. package/dist/repl/builtin-commands.js +10 -2
  86. package/dist/repl/post-turn-status.js +3 -2
  87. package/dist/repl/safety-confirm.js +47 -2
  88. package/dist/repl/session-spend-line.js +4 -1
  89. package/dist/repl/startup-account.js +51 -0
  90. package/dist/repl.js +217 -48
  91. package/dist/router/byok-classifier.js +41 -14
  92. package/dist/router/classifier.js +5 -16
  93. package/dist/router/router-target.js +41 -0
  94. package/dist/sap/connection-manager.js +15 -0
  95. package/dist/sap/onboarding.js +70 -64
  96. package/dist/sap/session-end.js +66 -0
  97. package/dist/session/audit-export.js +11 -4
  98. package/dist/skills/manifest-client.js +37 -4
  99. package/dist/skills/skills-cache.js +115 -0
  100. package/dist/skills/source-authed.js +276 -0
  101. package/dist/tools/ask-question.js +1 -1
  102. package/dist/tools/filesystem/glob.js +12 -3
  103. package/dist/tools/filesystem/grep.js +14 -3
  104. package/dist/tools/sap-read.js +22 -22
  105. package/dist/tools/sap-write.js +249 -18
  106. package/dist/tools/snapshot.js +26 -0
  107. package/dist/tools/tool-loading.js +9 -10
  108. package/dist/ui/app.js +19 -9
  109. package/dist/ui/approval-modal.js +37 -5
  110. package/dist/ui/live-area.js +14 -1
  111. package/dist/ui/widgets/ask-card.js +183 -107
  112. package/dist/ui/widgets/coaching-picker.js +22 -1
  113. package/dist/ui/widgets/confirm-card.js +102 -76
  114. package/dist/usage/usage-queue.js +323 -0
  115. package/dist/usage/usage-reporter.js +270 -0
  116. package/package.json +4 -3
  117. package/dist/skills/bundled-skills.js +0 -286
  118. package/dist/skills/source-bundled.js +0 -20
  119. package/dist/skills/source-manifest.js +0 -36
@@ -31,7 +31,9 @@ import { getGlyphs } from '../../renderer/glyphs.js';
31
31
  import { truncateToWidth, uiWidth } from '../../renderer/ui-width.js';
32
32
  import { roleProps, selectedProps } from '../role-props.js';
33
33
  import { sanitizeControlChars } from '../../renderer/sanitize.js';
34
+ import { wrapRows } from '../../renderer/wrap-rows.js';
34
35
  import { answerDisplayText } from '../ask-answer-text.js';
36
+ import { CardFrame } from './confirm-card.js';
35
37
  /** Option rows visible at once — the rest scroll. */
36
38
  export const VISIBLE_OPTION_ROWS = 5;
37
39
  /**
@@ -58,75 +60,26 @@ function fit(text, cells) {
58
60
  export const QUESTION_MAX_ROWS = 3;
59
61
  /** 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
62
  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) {
63
+ /** Fix batch 2026-10-12 — an option label wraps onto at most this many rows. */
64
+ export const LABEL_MAX_ROWS = 2;
65
+ /** …and its description too. */
66
+ export const DESCRIPTION_MAX_ROWS = 2;
67
+ /** Descriptions sit under the label text: paddingX + "❯ 1. ". */
68
+ const DESCRIPTION_INDENT = ' ';
69
+ /** Task 4 — over budget the context folds to this many rows… */
70
+ export const CONTEXT_FOLDED_ROWS = 3;
71
+ /** …and then the question to this many. */
72
+ export const QUESTION_FOLDED_ROWS = 2;
73
+ /** The question rows (at most `maxRows`), with the multi-select suffix kept whole on the last row. */
74
+ function questionRows(question, suffix, cells, maxRows = QUESTION_MAX_ROWS) {
122
75
  if (!suffix)
123
- return wrapCardText(question, cells, QUESTION_MAX_ROWS);
124
- const rows = wrapCardText(question, cells, QUESTION_MAX_ROWS);
76
+ return wrapRows(question, cells, maxRows);
77
+ const rows = wrapRows(question, cells, maxRows);
125
78
  const last = rows[rows.length - 1];
126
79
  if (stringWidth(last) + stringWidth(suffix) <= cells) {
127
80
  rows[rows.length - 1] = last + suffix;
128
81
  }
129
- else if (rows.length < QUESTION_MAX_ROWS) {
82
+ else if (rows.length < maxRows) {
130
83
  rows.push(suffix.trimStart());
131
84
  }
132
85
  else {
@@ -134,21 +87,70 @@ function questionRows(question, suffix, cells) {
134
87
  }
135
88
  return rows;
136
89
  }
137
- /** Context rows: every line keeps at least one row; wrapping fills the rest of the budget. */
138
- function contextRows(context, cells) {
90
+ /**
91
+ * Context rows: every line keeps at least one row; wrapping fills the rest of
92
+ * the budget. Folded (`maxRows` below CONTEXT_MAX_ROWS, Task 4) the context
93
+ * takes that many rows at most: more lines than rows run together.
94
+ */
95
+ function contextRows(context, cells, maxRows = CONTEXT_MAX_ROWS) {
139
96
  if (!context)
140
97
  return [];
141
- const lines = context.split('\n');
142
- let budget = Math.max(lines.length, CONTEXT_MAX_ROWS);
98
+ let lines = context.split('\n');
99
+ if (maxRows < CONTEXT_MAX_ROWS && lines.length > maxRows)
100
+ lines = [lines.join(' ')];
101
+ let budget = maxRows < CONTEXT_MAX_ROWS ? maxRows : Math.max(lines.length, CONTEXT_MAX_ROWS);
143
102
  const out = [];
144
103
  lines.forEach((line, i) => {
145
104
  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));
105
+ const rows = wrapRows(line, cells, Math.max(1, budget - reserve));
147
106
  out.push(...rows);
148
107
  budget -= rows.length;
149
108
  });
150
109
  return out;
151
110
  }
111
+ /**
112
+ * Lay out one option at `inner` cells. The label wraps onto LABEL_MAX_ROWS
113
+ * under its own text; the (recommended) / (default) suffix stays whole on
114
+ * the last row (a new row when there is room for one, else the label is cut
115
+ * to make room; with no room at all the label wins and the suffix drops).
116
+ * The description wraps onto DESCRIPTION_MAX_ROWS dim rows under the label.
117
+ */
118
+ export function optionRows(o, idx, q, inner, layout) {
119
+ const prefixWidth = 2 + `${idx + 1}. `.length + (q.multiSelect ? stringWidth(`${getGlyphs().tickOff} `) : 0);
120
+ const avail = Math.max(1, inner - prefixWidth);
121
+ const wantRec = o.recommended === true && !/\brecommended\b/i.test(o.label);
122
+ const def = q.defaultValue !== undefined && o.value === q.defaultValue;
123
+ const suffix = (wantRec ? REC : '') + (def ? DEFAULT_SUFFIX : '');
124
+ let label = wrapRows(o.label, avail, layout.labelRows);
125
+ let rec = wantRec;
126
+ let showDef = def;
127
+ if (suffix) {
128
+ // The suffix words are wrapped WITH the label; when they land whole at
129
+ // the end, they come off the text again and are drawn dim on that row.
130
+ const joined = wrapRows(`${sanitizeControlChars(o.label)}${suffix}`, avail, layout.labelRows);
131
+ const last = joined[joined.length - 1];
132
+ const bare = suffix.trimStart();
133
+ if (last.endsWith(bare) && !last.endsWith(getGlyphs().more)) {
134
+ const text = last.slice(0, last.length - bare.length).trimEnd();
135
+ label = [...joined.slice(0, -1), text];
136
+ }
137
+ else if (avail - stringWidth(suffix) >= 1) {
138
+ // Too long for the rows: the label is cut so the suffix stays whole.
139
+ const lastLabel = label[label.length - 1];
140
+ label = [...label.slice(0, -1), truncateToWidth(`${lastLabel} `, avail - stringWidth(suffix))];
141
+ }
142
+ else {
143
+ rec = false;
144
+ showDef = false; // no room at all: the label wins
145
+ }
146
+ }
147
+ const description = layout.descriptionRows > 0 && o.description
148
+ ? wrapRows(o.description, Math.max(1, inner - DESCRIPTION_INDENT.length), layout.descriptionRows).map((r) => `${DESCRIPTION_INDENT}${r}`)
149
+ : [];
150
+ return { label, rec, def: showDef, description, prefixWidth };
151
+ }
152
+ /** Rows one option takes on the card. */
153
+ const optionHeight = (r) => r.label.length + r.description.length;
152
154
  export function toCardQuestions(request) {
153
155
  if ('questions' in request) {
154
156
  return request.questions.map((q) => {
@@ -179,51 +181,106 @@ export function toCardQuestions(request) {
179
181
  keys: request.keys,
180
182
  }];
181
183
  }
184
+ /**
185
+ * The footer. Task 4: the same words as ConfirmCard ("1-N picks · ↑↓ move ·
186
+ * Enter chooses · Esc cancels"), with the real N (rows a digit reaches, at
187
+ * most 9). Multi-select says digits and Space tick; single-key answers add
188
+ * "keys y/n" as ConfirmCard does. Text rows keep "Enter submits".
189
+ */
182
190
  export function askCardHint(kind, opts = {}) {
183
191
  const nav = opts.multiQuestion ? ['Tab next question'] : [];
192
+ const digits = `1-${Math.max(1, Math.min(9, opts.count ?? 9))}`;
193
+ const keys = opts.keys && opts.keys.length > 0 ? [`keys ${opts.keys.join('/')}`] : [];
184
194
  switch (kind) {
185
195
  case 'text': return ['Enter submits', ...nav, 'Esc cancels'].join(' · ');
186
196
  case 'other': return ['Enter submits', ...nav, 'Esc goes back to the options'].join(' · ');
187
197
  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(' · ');
198
+ // The "(choose one or more)" question suffix carries the multi-select cue.
199
+ return [`${digits} or Space ticks`, '↑↓ move', 'Enter chooses', ...nav, 'Esc cancels', ...keys].join(' · ');
191
200
  default:
192
- return ['↑↓ move', '1-9 or Enter picks', ...nav, 'Esc cancels'].join(' · ');
201
+ return [`${digits} picks`, '↑↓ move', 'Enter chooses', ...nav, 'Esc cancels', ...keys].join(' · ');
193
202
  }
194
203
  }
195
- export const REVIEW_HINT = '↑↓ move · 1-2 or Enter chooses · ← back to edit · Esc cancels';
204
+ export const REVIEW_HINT = '1-2 picks · ↑↓ move · Enter chooses · ← back to edit · Esc cancels';
196
205
  const REVIEW_CHOICES = ['Submit answers', 'Cancel'];
197
206
  const MULTI_SUFFIX = ' (choose one or more)';
198
207
  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);
208
+ const DEFAULT_SUFFIX = ' (default)';
209
+ const suffixText = (r) => (r.rec ? REC : '') + (r.def ? DEFAULT_SUFFIX : '');
210
+ /** Content width: the card width less the frame's side and paddingX on both sides (Task 4). */
211
+ const askInner = (width) => Math.max(1, width - 4);
212
+ /** The frame's top and bottom edges. */
213
+ const FRAME_ROWS = 2;
214
+ /** The card's rows at `layout` — worst case over every question screen, plus the Review screen. */
215
+ function askRowsFor(qs, inner, layout) {
206
216
  const chipRows = qs.length > 1 ? 2 : 0; // tab row + its blank line
207
- const inner = Math.max(1, width - 2);
208
217
  let worst = 0;
209
218
  for (const q of qs) {
210
- const ctx = contextRows(q.context, inner).length;
219
+ const ctx = contextRows(q.context, inner, layout.contextRows).length;
211
220
  const suffix = q.multiSelect && inner - MULTI_SUFFIX.length >= 1 ? MULTI_SUFFIX : '';
212
- const qRows = questionRows(q.question, suffix, inner).length;
221
+ const qRows = questionRows(q.question, suffix, inner, layout.questionRows).length;
213
222
  let body = 2; // margin + the text input row
214
223
  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;
224
+ // Every wrapped label and description row counts; with more options
225
+ // than the window shows, the tallest window of VISIBLE_OPTION_ROWS.
226
+ const heights = q.options.map((o, i) => optionHeight(optionRows(o, i, q, inner, layout)));
227
+ const span = Math.min(heights.length, VISIBLE_OPTION_ROWS);
228
+ let tallest = 0;
229
+ for (let start = 0; start + span <= heights.length; start++) {
230
+ tallest = Math.max(tallest, heights.slice(start, start + span).reduce((x, y) => x + y, 0));
231
+ }
217
232
  const arrows = q.options.length > VISIBLE_OPTION_ROWS ? 2 : 0;
218
- body = 1 + shown + described + arrows + (q.otherRow ? 1 : 0);
233
+ body = 1 + tallest + arrows + (q.otherRow ? 1 : 0);
219
234
  }
220
- worst = Math.max(worst, chipRows + qRows + ctx + body + 2); // question + body + margin + hint
235
+ worst = Math.max(worst, chipRows + qRows + ctx + body + 1 + FRAME_ROWS); // question + body + hint + frame
221
236
  }
222
237
  if (qs.length > 1) {
223
- worst = Math.max(worst, chipRows + 1 + qs.length + 1 + REVIEW_CHOICES.length + 2);
238
+ // title + one row per answer + margin + choices + hint + frame
239
+ worst = Math.max(worst, chipRows + 1 + qs.length + 1 + REVIEW_CHOICES.length + 1 + FRAME_ROWS);
224
240
  }
225
241
  return worst;
226
242
  }
243
+ /**
244
+ * The one place the card's height budget is decided (fix round 1, review
245
+ * I1). Labels and descriptions wrap to their full rows; when the card would
246
+ * be taller than `maxRows` (App: the live area's room for a card), it folds
247
+ * in this order until it fits: labels to 1 row, descriptions to 1 row, no
248
+ * descriptions, then (Task 4) the context to 3 rows and the question to 2.
249
+ * `showDescriptions` false (liveBudget) starts with none. Frame rows count.
250
+ */
251
+ export function askCardLayout(request, showDescriptions, width = uiWidth(), maxRows) {
252
+ const layout = {
253
+ labelRows: LABEL_MAX_ROWS,
254
+ descriptionRows: showDescriptions ? DESCRIPTION_MAX_ROWS : 0,
255
+ contextRows: CONTEXT_MAX_ROWS,
256
+ questionRows: QUESTION_MAX_ROWS,
257
+ };
258
+ if (maxRows === undefined)
259
+ return layout;
260
+ const qs = toCardQuestions(request);
261
+ const inner = askInner(width);
262
+ const folds = [
263
+ (l) => { l.labelRows = 1; },
264
+ (l) => { l.descriptionRows = Math.min(l.descriptionRows, 1); },
265
+ (l) => { l.descriptionRows = 0; },
266
+ (l) => { l.contextRows = CONTEXT_FOLDED_ROWS; },
267
+ (l) => { l.questionRows = QUESTION_FOLDED_ROWS; },
268
+ ];
269
+ for (const fold of folds) {
270
+ if (askRowsFor(qs, inner, layout) <= maxRows)
271
+ break;
272
+ fold(layout);
273
+ }
274
+ return layout;
275
+ }
276
+ /**
277
+ * Upper bound on the rows this card renders — the card's share of the
278
+ * live-area height budget (spec §2; liveBudget decides showDescriptions).
279
+ * With `maxRows`, the height after askCardLayout's folds.
280
+ */
281
+ export function estimateAskCardRows(request, showDescriptions, width = uiWidth(), maxRows) {
282
+ return askRowsFor(toCardQuestions(request), askInner(width), askCardLayout(request, showDescriptions, width, maxRows));
283
+ }
227
284
  /**
228
285
  * Cut differently-styled segments of ONE row to `cells` in total: whole
229
286
  * segments while they fit, then the one that overflows ends with the `more`
@@ -250,7 +307,7 @@ function fitSegments(segs, cells) {
250
307
  }
251
308
  return out;
252
309
  }
253
- export function AskCard({ request, width, showDescriptions, onResolveV1, onResolveForm, onInterruptTurn }) {
310
+ export function AskCard({ request, width, showDescriptions, maxRows, onResolveV1, onResolveForm, onInterruptTurn }) {
254
311
  useReclaimRawStdin();
255
312
  const g = getGlyphs();
256
313
  const dim = roleProps('dim');
@@ -370,6 +427,13 @@ export function AskCard({ request, width, showDescriptions, onResolveV1, onResol
370
427
  return; // v2: ignored (today)
371
428
  }
372
429
  // custom marks the free-text ESCAPE on an optioned question only.
430
+ // Task 4 (m3): on a multi-select question the ticked options are kept.
431
+ // The answer is their labels, then the typed words (all typed text).
432
+ if (onOther && q?.multiSelect) {
433
+ const ticked = q.options.filter((o) => ticks[focus].has(o.value)).map((o) => o.label);
434
+ answerAndAdvance(focus, [...ticked, trimmed].join(', '), true);
435
+ return;
436
+ }
373
437
  answerAndAdvance(focus, trimmed, onOther);
374
438
  };
375
439
  useInput((input, key) => {
@@ -382,8 +446,12 @@ export function AskCard({ request, width, showDescriptions, onResolveV1, onResol
382
446
  if (key.ctrl && input === 'c') {
383
447
  const first = !done.current;
384
448
  cancel();
385
- if (first)
449
+ if (first) {
450
+ // Task 4 (m2): a caller asking a series (the spec-gap blockers) stops the rest.
451
+ if (isForm)
452
+ request.onInterrupt?.();
386
453
  onInterruptTurn?.();
454
+ }
387
455
  return;
388
456
  }
389
457
  if (key.escape) {
@@ -472,7 +540,10 @@ export function AskCard({ request, width, showDescriptions, onResolveV1, onResol
472
540
  pickEnter();
473
541
  }, { isActive: true });
474
542
  const numbered = (i) => `${i + 1}. `;
475
- const inner = Math.max(1, width - 2); // paddingX={1} on both sides
543
+ const inner = askInner(width); // the frame's sides and paddingX={1}
544
+ const frameHeader = isForm ? request.frameHeader : undefined;
545
+ // The same fold as estimateAskCardRows, so the drawn card is the estimated card.
546
+ const layout = askCardLayout(request, showDescriptions, width, maxRows);
476
547
  const chipRow = n > 1 ? (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { wrap: "truncate-end", children: fitSegments([
477
548
  ...questions.map((qq, i) => ({
478
549
  text: `${i > 0 ? ' ' : ''}${answers[i] !== null ? g.answered : g.pending} ${fit(qq.header, inner)}`,
@@ -483,7 +554,7 @@ export function AskCard({ request, width, showDescriptions, onResolveV1, onResol
483
554
  if (!q) {
484
555
  // Review step (forms with 2+ questions, all answered).
485
556
  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) => {
557
+ return (_jsxs(CardFrame, { tag: "QUESTION", header: frameHeader, width: width, children: [chipRow, _jsx(Text, { bold: true, wrap: "truncate-end", children: fit('Review your answers', inner) }), questions.map((qq, i) => {
487
558
  const a = answers[i];
488
559
  const head = fit(qq.header, headerCol - 2);
489
560
  const cell = ` ${head}${' '.repeat(Math.max(0, headerCol - stringWidth(head)))}`;
@@ -492,30 +563,35 @@ export function AskCard({ request, width, showDescriptions, onResolveV1, onResol
492
563
  }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: REVIEW_CHOICES.map((label, i) => {
493
564
  const cur = i === reviewCursor;
494
565
  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) }) })] }));
566
+ }) }), _jsx(Text, { ...dim, wrap: "truncate-end", children: fit(REVIEW_HINT, inner) })] }));
496
567
  }
497
568
  const winStart = Math.max(0, Math.min(cursor - 3, q.options.length - VISIBLE_OPTION_ROWS));
498
569
  const visible = q.options.slice(winStart, winStart + VISIBLE_OPTION_ROWS);
499
570
  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);
571
+ const inputWidth = Math.max(8, inner - 2 - `${rowCount}. `.length - 1);
572
+ const contextLines = contextRows(q.context, inner, layout.contextRows);
502
573
  const hintKind = q.freeText ? 'text' : onOther ? 'other' : q.multiSelect ? 'multi' : 'pick';
503
574
  // The multi-select suffix is the card's only multi cue: it stays whole and
504
575
  // the question is cut instead (the same rule as "(recommended)" below).
505
576
  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) => {
577
+ const questionLines = questionRows(q.question, suffix, inner, layout.questionRows);
578
+ const hint = askCardHint(hintKind, {
579
+ multiQuestion: n > 1,
580
+ count: rowCount,
581
+ keys: Object.keys(q.keys ?? {}).filter((k) => k.length === 1),
582
+ });
583
+ // Task 4 — the same frame as ConfirmCard: QUESTION tag (plus the optional
584
+ // header) on the top edge, the hint right under the options.
585
+ return (_jsxs(CardFrame, { tag: "QUESTION", header: frameHeader, width: width, 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
586
  const idx = winStart + vi;
509
587
  const cur = !onOther && idx === cursor;
510
- const rec = o.recommended === true && !/\brecommended\b/i.test(o.label);
511
588
  const tick = q.multiSelect ? `${ticks[focus].has(o.value) ? g.tickOn : g.tickOff} ` : '';
512
589
  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) }) })] }));
590
+ const r = optionRows(o, idx, q, inner, layout);
591
+ const pad = ' '.repeat(r.prefixWidth);
592
+ const lastRow = r.label.length - 1;
593
+ return (_jsxs(Box, { flexDirection: "column", children: [r.label.map((row, j) => (_jsxs(Text, { wrap: "truncate-end", ...(cur ? selectedProps() : {}), children: [j === 0 ? prefix : pad, row, j === lastRow && (r.rec || r.def) ? (
594
+ // A suffix that wrapped onto a row of its own sits under the label text, no leading blank.
595
+ _jsx(Text, { ...dim, children: row === '' ? suffixText(r).trimStart() : suffixText(r) })) : null] }, `l-${j}`))), r.description.map((row, j) => (_jsx(Text, { ...dim, wrap: "truncate-end", children: row }, `d-${j}`)))] }, `opt-${idx}`));
596
+ }), 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(Text, { ...dim, wrap: "truncate-end", children: fit(hint, inner) })] }));
521
597
  }
@@ -7,7 +7,26 @@ import { getGlyphs } from '../../renderer/glyphs.js';
7
7
  import { roleProps, selectedProps } from '../role-props.js';
8
8
  import { truncateToWidth, uiWidth } from '../../renderer/ui-width.js';
9
9
  import { sanitizeControlChars } from '../../renderer/sanitize.js';
10
+ import { wrapRows } from '../../renderer/wrap-rows.js';
10
11
  export const COACHING_HINT = '↑↓ move · 1-9/Enter picks · Enter with nothing selected cancels · Esc cancels';
12
+ /** Fix batch 2026-10-12 — "Pick if:" tells the skills apart, so it wraps onto this many rows. */
13
+ export const PICK_IF_MAX_ROWS = 2;
14
+ const PICK_IF_INDENT = ' ';
15
+ /** The highlighted option's "Pick if:" rows at content width `inner`, each carrying its indent. */
16
+ export function pickIfRows(whenToPick, inner) {
17
+ return wrapRows(`Pick if: ${whenToPick}`, Math.max(1, inner - PICK_IF_INDENT.length), PICK_IF_MAX_ROWS)
18
+ .map((r) => `${PICK_IF_INDENT}${r}`);
19
+ }
20
+ /**
21
+ * The card's rendered height (live-area budget): title + gap + one row per
22
+ * option + the tallest "Pick if:" (only the highlighted option shows one) +
23
+ * gap + hint.
24
+ */
25
+ export function estimateCoachingPickerRows(params, width = uiWidth()) {
26
+ const inner = Math.max(1, width - 2);
27
+ const pickIf = Math.max(0, ...params.options.map((o) => (o.whenToPick ? pickIfRows(o.whenToPick, inner).length : 0)));
28
+ return 4 + params.options.length + pickIf;
29
+ }
11
30
  /**
12
31
  * Sanitize, then cut to `cells` terminal cells (same rule as the ask card):
13
32
  * the prompt is user text and must not drive the terminal; rows are pre-cut
@@ -78,6 +97,8 @@ export function CoachingPicker({ params, onResolve, width }) {
78
97
  return (_jsxs(Box, { flexDirection: "column", width: w, paddingX: 1, flexShrink: 0, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: fit(`Pick a skill for "${params.prompt}"`, inner) }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: params.options.map((opt, i) => {
79
98
  const cur = i === cursor;
80
99
  const head = fit(`${cur ? `${g.cursor} ` : ' '}${i + 1}. ${`/${opt.skill}`.padEnd(nameWidth, ' ')}`, inner);
81
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { wrap: "truncate-end", ...(cur ? selectedProps() : {}), children: [head, _jsx(Text, { ...(cur ? {} : dim), children: fit(opt.whatItDoes, inner - stringWidth(head)) })] }), cur && opt.whenToPick ? (_jsx(Text, { ...dim, wrap: "truncate-end", children: fit(` Pick if: ${opt.whenToPick}`, inner) })) : null] }, opt.skill));
100
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { wrap: "truncate-end", ...(cur ? selectedProps() : {}), children: [head, _jsx(Text, { ...(cur ? {} : dim), children: fit(opt.whatItDoes, inner - stringWidth(head)) })] }), cur && opt.whenToPick
101
+ ? pickIfRows(opt.whenToPick, inner).map((row, j) => (_jsx(Text, { ...dim, wrap: "truncate-end", children: row }, `pick-if-${j}`)))
102
+ : null] }, opt.skill));
82
103
  }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { ...dim, wrap: "truncate-end", children: fit(COACHING_HINT, inner) }) })] }));
83
104
  }