@cspeach/cli 1.1.34 → 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.
@@ -227,50 +227,140 @@ export async function inkAwareConfirm(q, opts) {
227
227
  return 'n';
228
228
  }
229
229
  /**
230
- * Choices picker for an answer-blocker that carries `options`. Renders the
231
- * native Ink AskCard (the same CC-style picker the ask_question tool
232
- * uses) with the candidate answers plus "Write my own" and "Skip" escapes.
230
+ * The picker for one spec-gap blocker (Task 3, 2026-10-12). It asks through
231
+ * the normal question path: a one-question v2 form, the same shape the
232
+ * ask_question tool sends, drawn by AskCard.
233
233
  *
234
- * - headless → skip (never blocks on stdin)
235
- * - classic (non-Ink) → 'write' so the caller falls back to the free-text box
236
- * - Ink → choice/multi modal; Esc or empty selection → skip
234
+ * - "Blocker 2 of 4" on the card frame's top edge (Task 4); the question is the bare text
235
+ * - Ctrl+C on the card → { kind: 'stop' }: the remaining blockers are not asked
236
+ * - options show in sentence case (the stored answer keeps the words as
237
+ * written), then a plain "Skip for now" option; the card's own inline
238
+ * "Type something else…" row takes a typed answer, so no second text card
239
+ * - a blocker with no options is a free-text form question (Esc skips)
240
+ * - asked under the card-slot focus claim; one scrollback line per answer
241
+ * ("Blocker 2 of 4 → <answer>"), so answered blockers stay on screen
242
+ * - headless → skip (never blocks); classic (non-Ink) → 'write' (free text)
237
243
  *
238
- * The two sentinels are filtered out of any real picked answer; for multi-select
239
- * the modal returns comma-joined values, so Write/Skip win over any co-selected
240
- * options.
244
+ * Option values are index ids (o0, o1…) so an option with a comma survives a
245
+ * multi-select answer (comma-joined values).
241
246
  */
242
- const CHOOSE_WRITE = '__cspeach_write__';
243
247
  const CHOOSE_SKIP = '__cspeach_skip__';
248
+ const SKIP_LABEL = 'Skip for now';
249
+ const SKIP_DESCRIPTION = 'Stays open. Answer it later at cspeach.dev/edit.';
250
+ const SKIPPED_TRACE = 'skipped for now';
251
+ const STOPPED_TRACE = 'stopped';
252
+ /**
253
+ * A first word that is a name, not prose (Task 4, m1): it holds _ / digits /
254
+ * a slash (zcl_foo, s4hk903359, zfin/pkg), is all capitals (ZTEST_LAS, ERDAT)
255
+ * or has a capital after its first letter (eTag). Such a word is never recased.
256
+ */
257
+ const Z_Y_ENGLISH = new Set(['yes', 'you', 'your', 'yours', 'yet', 'year', 'years', 'yearly', 'yield', 'zero', 'zone', 'zones', 'zoom']);
258
+ function looksLikeIdentifier(word) {
259
+ if (/[_\d/]/.test(word))
260
+ return true;
261
+ // A Z/Y customer-namespace name (zfin, ytools) unless it is a common English word.
262
+ if (/^[zy][a-z0-9_]{2,}$/i.test(word) && !Z_Y_ENGLISH.has(word.toLowerCase()))
263
+ return true;
264
+ if (/[A-Z]/.test(word.slice(1)))
265
+ return true;
266
+ return word.length > 1 && /[A-Z]/.test(word) && word === word.toUpperCase();
267
+ }
268
+ /** First letter upper case when it is a lower-case letter; the rest as written. Identifiers stay as written. */
269
+ function sentenceCase(label) {
270
+ const first = label.charAt(0);
271
+ if (first === first.toUpperCase())
272
+ return label;
273
+ if (looksLikeIdentifier(label.split(/\s/, 1)[0] ?? ''))
274
+ return label;
275
+ return first.toUpperCase() + label.slice(1);
276
+ }
244
277
  export async function inkAwareChoose(a) {
245
278
  const { shouldUseInk, isHeadless } = await import('../renderer/tty.js');
246
279
  if (isHeadless())
247
280
  return { kind: 'skip' };
248
281
  if (!shouldUseInk())
249
282
  return { kind: 'write' }; // classic REPL → free-text fallback
250
- const choices = [
251
- ...a.options.map((o) => ({ value: o, label: o })),
252
- { value: CHOOSE_WRITE, label: '✏️ Write my own…' },
253
- { value: CHOOSE_SKIP, label: '↷ Skip (leave for the viewer)' },
283
+ const header = `Blocker ${a.index + 1} of ${a.total}`;
284
+ const freeText = a.options.length === 0;
285
+ const options = freeText ? [] : [
286
+ ...a.options.map((o, i) => ({ value: `o${i}`, label: sentenceCase(o) })),
287
+ { value: CHOOSE_SKIP, label: SKIP_LABEL, description: SKIP_DESCRIPTION },
254
288
  ];
255
289
  const { askQuestionEmitter } = await import('../ui/ask-question-emitter.js');
256
- const res = await askQuestionEmitter.request({
257
- id: 'answer-blocker',
258
- question: `Blocker ${a.index + 1}/${a.total}: ${a.question}`,
259
- context: a.multiSelect ? 'Space to toggle one or more · Enter to submit' : undefined,
260
- kind: a.multiSelect ? 'multi' : 'choice',
261
- choices,
262
- });
263
- if (res.cancelled || res.answer == null)
264
- return { kind: 'skip' };
265
- const values = res.answer.split(',');
266
- if (values.includes(CHOOSE_WRITE))
267
- return { kind: 'write' };
268
- if (values.includes(CHOOSE_SKIP))
290
+ const { withFocusClaim } = await import('../ui/card-slot.js');
291
+ const { emitTrace, formatAnswerTrace } = await import('../renderer/trace.js');
292
+ const { uiWidth } = await import('../renderer/ui-width.js');
293
+ let stopped = false;
294
+ const res = await withFocusClaim(() => askQuestionEmitter.requestForm({
295
+ formId: 'answer-blocker',
296
+ // Task 4 (m4): the counter sits on the frame's top edge.
297
+ frameHeader: header,
298
+ // Task 4 (m2): Ctrl+C stops the remaining blocker cards.
299
+ onInterrupt: () => { stopped = true; },
300
+ questions: [{
301
+ id: 'answer-blocker',
302
+ question: a.question,
303
+ ...(freeText ? { context: 'Esc skips it for now' } : {}),
304
+ header,
305
+ multiSelect: !freeText && a.multiSelect,
306
+ freeText,
307
+ options,
308
+ }],
309
+ }));
310
+ const skip = () => {
311
+ emitTrace(formatAnswerTrace(header, SKIPPED_TRACE, uiWidth()));
269
312
  return { kind: 'skip' };
270
- const real = values.filter((v) => v !== CHOOSE_WRITE && v !== CHOOSE_SKIP);
271
- if (real.length === 0)
272
- return { kind: 'skip' };
273
- return { kind: 'picked', answer: real.join(', ') };
313
+ };
314
+ if (stopped) {
315
+ emitTrace(formatAnswerTrace(header, STOPPED_TRACE, uiWidth()));
316
+ return { kind: 'stop' };
317
+ }
318
+ const ans = res.cancelled ? undefined : res.answers[0];
319
+ if (!ans || ans.answer == null)
320
+ return skip();
321
+ let answer;
322
+ let shown;
323
+ if (freeText) {
324
+ answer = ans.answer.trim();
325
+ shown = answer;
326
+ }
327
+ else if (ans.custom) {
328
+ // Task 4 (m3): a multi-select typed answer arrives as the ticked labels
329
+ // (in option order), then the typed words. The labels go back to the
330
+ // options as written; a ticked "Skip for now" gives way to the answer.
331
+ let rest = ans.answer.trim();
332
+ const parts = [];
333
+ const shownParts = [];
334
+ if (a.multiSelect) {
335
+ for (const o of options) {
336
+ if (!rest.startsWith(`${o.label}, `))
337
+ continue;
338
+ rest = rest.slice(o.label.length + 2);
339
+ const i = /^o(\d+)$/.exec(o.value);
340
+ if (i) {
341
+ parts.push(a.options[Number(i[1])]);
342
+ shownParts.push(o.label);
343
+ }
344
+ }
345
+ }
346
+ answer = [...parts, rest].join(', ');
347
+ shown = [...shownParts, rest].join(', ');
348
+ }
349
+ else {
350
+ const values = ans.answer.split(',');
351
+ if (values.includes(CHOOSE_SKIP))
352
+ return skip();
353
+ const picked = values
354
+ .map((v) => /^o(\d+)$/.exec(v))
355
+ .map((m) => (m ? a.options[Number(m[1])] : undefined))
356
+ .filter((o) => o !== undefined);
357
+ answer = picked.join(', ');
358
+ shown = picked.map(sentenceCase).join(', ');
359
+ }
360
+ if (answer.length === 0)
361
+ return skip();
362
+ emitTrace(formatAnswerTrace(header, shown, uiWidth()));
363
+ return { kind: 'picked', answer };
274
364
  }
275
365
  export async function maybeOfferSave(p) {
276
366
  if (p.assistantText.trim().length === 0)
@@ -31,6 +31,20 @@ function call(fn, self) {
31
31
  return Promise.reject(e);
32
32
  }
33
33
  }
34
+ /**
35
+ * 2026-10-13 — async work that must run on the way out, before the sockets
36
+ * close (ending the stateful SAP session, so its locks leave SM12). Capped as
37
+ * a whole; a task that throws or hangs never blocks the exit.
38
+ */
39
+ const exitTasks = new Set();
40
+ /** Register an async exit task. Returns an unregister. */
41
+ export function registerExitTask(task) {
42
+ exitTasks.add(task);
43
+ return () => { exitTasks.delete(task); };
44
+ }
45
+ export function __resetExitTasksForTests() {
46
+ exitTasks.clear();
47
+ }
34
48
  export async function gracefulExit(code, opts = {}) {
35
49
  const exit = opts.exit ?? ((c) => process.exit(c));
36
50
  const graceMs = opts.graceMs ?? 250;
@@ -38,6 +52,9 @@ export async function gracefulExit(code, opts = {}) {
38
52
  const guard = () => { };
39
53
  process.on('unhandledRejection', guard);
40
54
  try {
55
+ if (exitTasks.size > 0) {
56
+ await capped(Promise.allSettled([...exitTasks].map((t) => call(t, {}))), opts.exitTaskMs ?? 3_500);
57
+ }
41
58
  const dispatcher = globalThis[UNDICI_DISPATCHER];
42
59
  if (dispatcher) {
43
60
  let done = false;
@@ -59,6 +59,9 @@ export function applyBlockerAnswers(env, answers, answerer, nowIso) {
59
59
  lastEditedBy: answerer,
60
60
  };
61
61
  }
62
+ function plural(n, word) {
63
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
64
+ }
62
65
  function affirmative(reply) {
63
66
  const v = reply.trim().toLowerCase();
64
67
  return v === 'y' || v === 'yes';
@@ -83,7 +86,9 @@ export async function offerAnswerBlockers(args) {
83
86
  if (blockers.length === 0)
84
87
  return null;
85
88
  const reply = await askYesNo({
86
- question: `Answer the ${blockers.length} unanswered blocker(s) now?`,
89
+ question: blockers.length === 1
90
+ ? 'Answer the open blocker now?'
91
+ : `Answer the ${blockers.length} open blockers now?`,
87
92
  confirm: args.confirm,
88
93
  prompt: args.prompt,
89
94
  });
@@ -92,16 +97,15 @@ export async function offerAnswerBlockers(args) {
92
97
  const answers = new Map();
93
98
  for (let i = 0; i < blockers.length; i++) {
94
99
  const b = blockers[i];
95
- // 2026-09-17 — the "Blocker i/n: …" line is printed only on the free-text
96
- // path below. When a picker handles the question it shows the same text
97
- // itself, and printing it here too showed every blocker twice.
98
- // Choices first, where the question has them and a picker is available.
100
+ // The "Blocker i of n: …" line is printed only on the free-text fallback
101
+ // below. The picker shows the question itself and leaves its own trace.
102
+ // The picker first, for every blocker (options or free text).
99
103
  // 'write' falls through to the text box; 'skip' leaves the blocker open.
100
104
  let askText = true;
101
- if (b.options && b.options.length > 0 && args.choose) {
105
+ if (args.choose) {
102
106
  const c = await args.choose({
103
107
  question: b.text,
104
- options: b.options,
108
+ options: b.options ?? [],
105
109
  multiSelect: !!b.multiSelect,
106
110
  index: i,
107
111
  total: blockers.length,
@@ -114,19 +118,26 @@ export async function offerAnswerBlockers(args) {
114
118
  else if (c.kind === 'skip') {
115
119
  askText = false;
116
120
  }
121
+ else if (c.kind === 'stop') {
122
+ // Ctrl+C (Task 4, m2): ask none of the rest. Answers already given
123
+ // are kept and saved below; the others stay open.
124
+ break;
125
+ }
117
126
  // c.kind === 'write' → askText stays true
118
127
  }
119
128
  if (askText) {
120
129
  args.log('');
121
- args.log(`Blocker ${i + 1}/${blockers.length}: ${b.text}`);
122
- const a = (await args.prompt(' Your answer (Enter to skip): ')).trim();
130
+ args.log(`Blocker ${i + 1} of ${blockers.length}: ${b.text}`);
131
+ const a = (await args.prompt('Your answer (Enter to skip): ')).trim();
123
132
  if (a.length > 0)
124
133
  answers.set(b.id, a);
125
134
  }
126
135
  }
127
136
  if (answers.size === 0) {
128
137
  args.log('');
129
- args.log('No answers entered — all blockers left open for the viewer.');
138
+ args.log(blockers.length === 1
139
+ ? 'No answer entered. The blocker stays open. Answer it later at cspeach.dev/edit.'
140
+ : `No answers entered. The ${blockers.length} blockers stay open. Answer them later at cspeach.dev/edit.`);
130
141
  return null;
131
142
  }
132
143
  const nowIso = (args.now ?? (() => new Date().toISOString()))();
@@ -139,7 +150,9 @@ export async function offerAnswerBlockers(args) {
139
150
  writeFileSync(args.path, JSON.stringify(revised, null, 2), 'utf8');
140
151
  const stillOpen = blockers.length - answers.size;
141
152
  args.log('');
142
- args.log(`Recorded ${answers.size} answer(s) in the file`
143
- + (stillOpen > 0 ? ` — ${stillOpen} blocker(s) still open for the viewer.` : '.'));
153
+ args.log(`Recorded ${plural(answers.size, 'answer')} in the file.`
154
+ + (stillOpen === 0 ? ''
155
+ : stillOpen === 1 ? ' 1 blocker is still open. Answer it later at cspeach.dev/edit.'
156
+ : ` ${stillOpen} blockers are still open. Answer them later at cspeach.dev/edit.`));
144
157
  return answers.size;
145
158
  }
@@ -6,10 +6,11 @@
6
6
  import { diffLines } from 'diff';
7
7
  import { paint } from './theme.js';
8
8
  import { getBoxChars } from '../ui/ascii-chars.js';
9
+ import { ERROR_DETAIL_ROWS, SNAPSHOT_DETAIL_ROWS } from './verify-chain.js';
9
10
  import { ANSI_RE } from './tool-labels.js';
11
+ import { wrapRows } from './wrap-rows.js';
10
12
  export const MIN_CARD_WIDTH = 40;
11
13
  const DIFF_LINES = 6;
12
- const DETAIL_MAX = 60;
13
14
  /** Myers diff is O(N·D) and synchronous: past this budget we show no diff
14
15
  * at all (object + footer only), never a partial one. */
15
16
  const DIFF_TIMEOUT_MS = 200;
@@ -33,7 +34,7 @@ function footerParts(input) {
33
34
  else if (input.snapshot === 'new_object')
34
35
  parts.push(paint('dim', '– snapshot (new object)'));
35
36
  else if (input.snapshot === 'source_read_failed')
36
- parts.push(paint('dim', `– snapshot (no source read${input.snapshotDetail ? ': ' + cut(flat(clean(input.snapshotDetail)), 40) : ''})`));
37
+ parts.push(paint('dim', '– snapshot (no source read)'));
37
38
  else
38
39
  parts.push(paint('dim', '– snapshot (skipped)'));
39
40
  parts.push(input.written ? `${paint('ok', '✓')} written` : `${paint('bad', '✗')} written`);
@@ -54,15 +55,35 @@ function footerParts(input) {
54
55
  else if (a.state === 'unverified')
55
56
  parts.push(paint('warn', '· activation unverified'));
56
57
  else
57
- parts.push(`${paint('bad', '✗')} activation${a.detail ? ' ' + paint('bad', `(${cut(flat(clean(a.detail)), DETAIL_MAX)})`) : ''}`);
58
+ parts.push(`${paint('bad', '✗')} activation`);
58
59
  }
59
60
  else {
60
61
  // Activation is blocked — say so; a pending step would lie.
61
- parts.push(`${paint('bad', '✗')} syntax ${paint('bad', `(${cut(flat(clean(input.syntax.errors[0] ?? 'syntax errors')), DETAIL_MAX)})`)}`);
62
+ parts.push(`${paint('bad', '✗')} syntax`);
62
63
  parts.push(paint('dim', '· activation blocked'));
63
64
  }
64
65
  return parts;
65
66
  }
67
+ /**
68
+ * Fix batch 2026-10-12 (Task 2): the reasons behind a failed step — the
69
+ * snapshot read failure, the first syntax error, SAP's activation refusal —
70
+ * are what the user needs, so they are rows of their own (wrapped at word
71
+ * breaks to `cells`, up to the verify-chain caps), never cut on the footer.
72
+ */
73
+ function detailLines(input, cells) {
74
+ const out = [];
75
+ const wrap = (text, rows) => wrapRows(flat(clean(text)), Math.max(1, cells), rows);
76
+ if (input.snapshot === 'source_read_failed' && input.snapshotDetail) {
77
+ out.push(...wrap(`No source read: ${input.snapshotDetail}`, SNAPSHOT_DETAIL_ROWS).map((r) => paint('dim', r)));
78
+ }
79
+ if (input.syntax !== 'not_checked' && !input.syntax.ok) {
80
+ out.push(...wrap(input.syntax.errors[0] ?? 'syntax errors', ERROR_DETAIL_ROWS).map((r) => paint('bad', r)));
81
+ }
82
+ else if (input.syntax !== 'not_checked' && input.activation?.state === 'failed' && input.activation.detail) {
83
+ out.push(...wrap(input.activation.detail, ERROR_DETAIL_ROWS).map((r) => paint('bad', r)));
84
+ }
85
+ return out;
86
+ }
66
87
  /** Changed lines for the card. Exported for tests only. */
67
88
  export function diffBody(card, differ = defaultDiffer) {
68
89
  if (card.oldSource === undefined || card.newSource === undefined)
@@ -99,6 +120,7 @@ export function buildChangeCard(input, width, box = getBoxChars()) {
99
120
  // cut to the width so nothing wraps (colour dropped only on a cut part).
100
121
  for (const p of parts)
101
122
  out.push(visibleLen(p) > width ? cut(p.replace(ANSI, ''), width) : p);
123
+ out.push(...detailLines(input, width));
102
124
  if (card.transport)
103
125
  out.push(paint('dim', cut(`· ${clean(card.transport)}`, width)));
104
126
  return out.join('\n');
@@ -117,6 +139,8 @@ export function buildChangeCard(input, width, box = getBoxChars()) {
117
139
  lines.push(row(paint(l.startsWith('+') ? 'ok' : 'bad', cut(l, inner))));
118
140
  if (body.hidden > 0)
119
141
  lines.push(row(paint('dim', ` … +${body.hidden} lines`)));
142
+ for (const d of detailLines(input, inner))
143
+ lines.push(row(d));
120
144
  lines.push(...footerLines(parts, chain, transportText, width, b, row));
121
145
  return lines.join('\n');
122
146
  }
@@ -36,13 +36,18 @@ import stringWidth from 'string-width';
36
36
  import { getLook } from './look.js';
37
37
  import { getGlyphs } from './glyphs.js';
38
38
  import { truncateToWidth, uiWidth } from './ui-width.js';
39
+ import { wrapRows } from './wrap-rows.js';
40
+ import { paint } from './theme.js';
41
+ import chalk from 'chalk';
39
42
  import { getNoticeLogPath, recordFullNotice } from './notice-log.js';
40
43
  import { getEffectiveBodyWidth, isDeclaredOneShot } from './tty.js';
41
44
  import { EventEmitter } from 'node:events';
45
+ /** Fix batch 2026-10-12 — a panels notice wraps onto at most this many rows. */
46
+ export const NOTICE_MAX_ROWS = 3;
42
47
  /**
43
- * Format one notice as a single terminal line (trailing newline included).
44
- * Multi-line messages are collapsed to one line — notices are glanceable
45
- * margin notes, not documents; anything long belongs in the tool result.
48
+ * Format one notice (trailing newline included). Multi-line messages are
49
+ * collapsed to one line of text; in the panels look that text then wraps
50
+ * onto up to NOTICE_MAX_ROWS screen rows (fix batch 2026-10-12).
46
51
  *
47
52
  * Piece 2 §9: warn → the attention shape ("! message"), info → the info
48
53
  * shape ("· message", dim). Same glyphs as every other notice.
@@ -50,10 +55,11 @@ import { EventEmitter } from 'node:events';
50
55
  export function formatNoticeLine(level, message) {
51
56
  // Live fix 2026-09-26: a SAP HTML error page must never leak tags/CSS here.
52
57
  const readable = looksLikeMarkup(message) ? cleanErrorText(message, 400) : message;
53
- let oneLine = readable.replace(/\s*\r?\n\s*/g, ' ').trim();
54
- // Live fix 2026-09-26 (panels): never wrap mid-word — cut to one screen
55
- // line ("<glyph> " + text ≤ uiWidth) and keep the full text in the
56
- // session's notice log. Legacy stays byte-identical.
58
+ const oneLine = readable.replace(/\s*\r?\n\s*/g, ' ').trim();
59
+ // Live fix 2026-09-26 (panels): never let the terminal wrap mid-word —
60
+ // the text is pre-wrapped at word breaks inside uiWidth (below) and a cut
61
+ // notice keeps its full text in the session's notice log. Legacy stays
62
+ // byte-identical.
57
63
  // Review I3: only when a screen is there to wrap (stdout is a TTY) or the
58
64
  // full text has somewhere to go (a notice log). One-shot / piped output
59
65
  // keeps the whole single line — nothing else would record it.
@@ -61,15 +67,23 @@ export function formatNoticeLine(level, message) {
61
67
  // keeps the whole line too.
62
68
  const wrapsOnScreen = process.stdout.isTTY === true && !isDeclaredOneShot();
63
69
  if (getLook() === 'panels' && (wrapsOnScreen || getNoticeLogPath() !== undefined)) {
70
+ // Fix batch 2026-10-12 (Task 2): a notice may carry what the user must
71
+ // act on (a transport number, SAP's reason), so it wraps at word breaks
72
+ // onto up to NOTICE_MAX_ROWS rows — further rows indented two spaces, the
73
+ // notice shape — instead of being cut to one. Only a longer notice is
74
+ // cut; its full text goes to the notice log and the screen says so.
64
75
  const g = getGlyphs();
65
76
  const room = Math.min(uiWidth(), getEffectiveBodyWidth()) - stringWidth(level === 'warn' ? g.attention : g.info) - 1;
66
- const cut = truncateToWidth(oneLine, room);
67
- // Review M10: truncateToWidth turns tabs into two spaces — compare against
68
- // the same normalisation so only a real cut is logged.
69
- if (cut !== oneLine.replace(/\t/g, ' ')) {
77
+ const rows = wrapRows(oneLine, room, NOTICE_MAX_ROWS);
78
+ const cut = wrapRows(oneLine, room, NOTICE_MAX_ROWS + 1).length > NOTICE_MAX_ROWS;
79
+ const logPath = getNoticeLogPath();
80
+ if (cut)
70
81
  recordFullNotice(level, message);
71
- oneLine = cut;
72
- }
82
+ const head = level === 'warn' ? formatAttention({ text: rows[0] }) : formatInfo(rows[0]);
83
+ const more = rows.slice(1).map((r) => (level === 'warn' ? ` ${chalk.bold(r)}` : paint('dim', ` ${r}`)));
84
+ if (cut && logPath)
85
+ more.push(paint('dim', truncateToWidth(` Full text: ${logPath}`, room + 2)));
86
+ return head + more.map((r) => `${r}\n`).join('');
73
87
  }
74
88
  return level === 'warn' ? formatAttention({ text: oneLine }) : formatInfo(oneLine);
75
89
  }
@@ -165,7 +165,9 @@ const MESSAGES = {
165
165
  'Walking enhancement spots…',
166
166
  ],
167
167
  sap_message_maintain: [
168
- '(stub — short-circuit)',
168
+ 'Reading the message class…',
169
+ 'Writing the messages…',
170
+ 'Reading the messages back…',
169
171
  ],
170
172
  sap_number_range_intervals: [
171
173
  '(stub — short-circuit)',
@@ -177,7 +177,13 @@ const RULES = {
177
177
  sap_unlock: sapObject('Unlocked'),
178
178
  sap_service_binding_publish: sapObject('Published'),
179
179
  sap_service_binding_unpublish: sapObject('Unpublished'),
180
- sap_message_maintain: (a) => ({ verb: 'Message', object: shortRight([str(a['messageClass']), str(a['messageNumber'])].filter(Boolean).join(' ')) }),
180
+ sap_message_maintain: (a) => {
181
+ const n = Array.isArray(a['messages']) ? a['messages'].length : 0;
182
+ if (n > 0) {
183
+ return { verb: 'Messages', object: shortRight(str(a['messageClass']) ?? ''), detail: n === 1 ? '1 message' : `${n} messages` };
184
+ }
185
+ return { verb: 'Messages', object: shortRight([str(a['messageClass']), str(a['messageNumber'])].filter(Boolean).join(' ')) };
186
+ },
181
187
  sap_number_range_intervals: (a) => ({ verb: 'Number range', object: shortRight(str(a['nrObject']) ?? '') }),
182
188
  ask_question: (a) => ({ verb: 'Question', object: shortRight(str(a['question']) ?? '') }),
183
189
  todo_set: () => ({ verb: 'Updated todos', object: '' }),
@@ -257,7 +263,7 @@ const PRESENT = {
257
263
  'Created transport': 'Creating transport', 'Transport for': 'Finding transport for',
258
264
  'Listed transports': 'Listing transports', Released: 'Releasing', 'Created domain': 'Creating domain',
259
265
  'Created data element': 'Creating data element', Locked: 'Locking', Unlocked: 'Unlocking',
260
- Published: 'Publishing', Unpublished: 'Unpublishing', Message: 'Maintaining message',
266
+ Published: 'Publishing', Unpublished: 'Unpublishing', Messages: 'Maintaining messages in',
261
267
  'Number range': 'Maintaining number range', Question: 'Asking', 'Updated todos': 'Updating todos',
262
268
  'Ran skill': 'Running skill', 'Read document': 'Reading document', Fetched: 'Fetching',
263
269
  'Searched web': 'Searching web', 'Read project context': 'Reading project context',
@@ -8,13 +8,15 @@
8
8
  * never synthesized. States render truthfully:
9
9
  * - snapshot 'new_object' → "① snapshot – (new object)" (genuine ADT 404 —
10
10
  * nothing to save; NEVER used for other read failures)
11
- * - snapshot 'source_read_failed' → "① snapshot – (no source read: <msg>)" —
11
+ * - snapshot 'source_read_failed' → "① snapshot – (no source read)" and the
12
+ * reason on its own row under the chain —
12
13
  * the pre-write read failed for a non-404 reason; the object may exist
13
14
  * but there is no recovery copy. Hard-gate tools block instead of
14
15
  * emitting this; it only appears on soft (best-effort) gates.
15
16
  * - snapshot 'best_effort_failed' → "① snapshot – (skipped)" (write tools
16
17
  * where the gate is soft, e.g. class includes)
17
- * - syntax fail → "③ syntax ✗ (L12: MESSAGE …)" AND no ④ at all —
18
+ * - syntax fail → "③ syntax ✗", the first error whole on the rows under
19
+ * the chain (fix batch 2026-10-12: no 60-character cut) AND no ④ at all —
18
20
  * activation is blocked, so we don't show a fake pending step
19
21
  * - syntax 'not_checked' → "③ syntax – (checked at activation)" —
20
22
  * sap_set_class_include has no standalone syntax gate
@@ -28,21 +30,43 @@
28
30
  * result row in both renderers.
29
31
  */
30
32
  import chalk from 'chalk';
33
+ import { wrapRows } from './wrap-rows.js';
34
+ import { uiWidth } from './ui-width.js';
31
35
  const pass = (s) => `${s} ${chalk.green('✓')}`;
32
36
  const fail = (s, detail) => `${s} ${chalk.red('✗')}${detail ? ' ' + chalk.red(`(${detail})`) : ''}`;
33
37
  const skip = (s, why) => chalk.dim(`${s} – (${why})`);
34
- const ERROR_DETAIL_MAX = 60;
35
- const SNAPSHOT_DETAIL_MAX = 40;
36
- const truncate = (s, max) => {
37
- const flat = s.replace(/\s+/g, ' ').trim();
38
- return flat.length > max ? flat.substring(0, max - 1) + '…' : flat;
39
- };
38
+ /**
39
+ * Fix batch 2026-10-12 (Task 2): the SAP error is what the user needs, so it
40
+ * is never cut to a few characters. Details go on their own rows under the
41
+ * chain, wrapped at word breaks inside the width, up to these many rows (the
42
+ * last ends with … only when even that is not enough; the model gets the
43
+ * full text in the tool result).
44
+ */
45
+ export const ERROR_DETAIL_ROWS = 4;
46
+ export const SNAPSHOT_DETAIL_ROWS = 2;
47
+ /** Detail rows sit under the chain: its two-space indent plus two. */
48
+ const DETAIL_INDENT = ' ';
49
+ const flatDetail = (s) => s.replace(/\s+/g, ' ').trim();
50
+ /** Detail rows: indented, wrapped inside `width`, at most `maxRows`. */
51
+ export function detailRows(text, width, maxRows) {
52
+ return wrapRows(flatDetail(text), Math.max(1, width - DETAIL_INDENT.length), maxRows).map((r) => `${DETAIL_INDENT}${r}`);
53
+ }
54
+ /**
55
+ * Detail-row width: the UI width less the Ink body's 4-cell border overhead
56
+ * (tty.ts BORDER_OVERHEAD), so a row never reaches the body edge in either
57
+ * renderer. Pure (no tty import): many tool tests mock tty.js partially.
58
+ */
59
+ export function chainWidth() {
60
+ return Math.max(20, uiWidth() - 4);
61
+ }
40
62
  /**
41
63
  * Build the chain line (no trailing newline). Returns the full line
42
- * including the two-space indent that aligns with the ⎿ result row.
64
+ * including the two-space indent that aligns with the ⎿ result row, then
65
+ * any detail rows (snapshot read failure, first syntax error) under it.
43
66
  */
44
- export function buildWriteChainLine(input) {
67
+ export function buildWriteChainLine(input, width = chainWidth()) {
45
68
  const parts = [];
69
+ const details = [];
46
70
  // ① snapshot
47
71
  if (input.snapshot === 'taken')
48
72
  parts.push(pass('① snapshot'));
@@ -51,10 +75,10 @@ export function buildWriteChainLine(input) {
51
75
  else if (input.snapshot === 'source_read_failed') {
52
76
  // Honest: the read failed — we do NOT know the object is new, and there
53
77
  // is no recovery copy. Never render this as "(new object)".
54
- const why = input.snapshotDetail
55
- ? `no source read: ${truncate(input.snapshotDetail, SNAPSHOT_DETAIL_MAX)}`
56
- : 'no source read';
57
- parts.push(skip('① snapshot', why));
78
+ parts.push(skip('① snapshot', 'no source read'));
79
+ if (input.snapshotDetail) {
80
+ details.push(...detailRows(`No source read: ${input.snapshotDetail}`, width, SNAPSHOT_DETAIL_ROWS).map((r) => chalk.dim(r)));
81
+ }
58
82
  }
59
83
  else
60
84
  parts.push(skip('① snapshot', 'skipped'));
@@ -70,10 +94,9 @@ export function buildWriteChainLine(input) {
70
94
  parts.push(chalk.dim('④ activate pending'));
71
95
  }
72
96
  else {
73
- const first = (input.syntax.errors[0] ?? 'syntax errors').replace(/\s+/g, ' ').trim();
74
- const detail = first.length > ERROR_DETAIL_MAX ? first.substring(0, ERROR_DETAIL_MAX - 1) + '…' : first;
75
- parts.push(fail('③ syntax', detail));
97
+ parts.push(fail('③ syntax'));
98
+ details.push(...detailRows(input.syntax.errors[0] ?? 'syntax errors', width, ERROR_DETAIL_ROWS).map((r) => chalk.red(r)));
76
99
  // No ④ — activation is blocked; rendering a pending step would lie.
77
100
  }
78
- return ' ' + parts.join(' ');
101
+ return [' ' + parts.join(' '), ...details].join('\n');
79
102
  }