@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.
- package/dist/agent/loop.js +121 -31
- package/dist/lib/graceful-exit.js +17 -0
- package/dist/projects/answer-blockers.js +25 -12
- package/dist/renderer/change-card.js +28 -4
- package/dist/renderer/notices.js +27 -13
- package/dist/renderer/progress-chatter.js +3 -1
- package/dist/renderer/tool-labels.js +8 -2
- package/dist/renderer/verify-chain.js +41 -18
- package/dist/renderer/wrap-rows.js +87 -0
- package/dist/repl.js +6 -0
- package/dist/sap/connection-manager.js +15 -0
- package/dist/sap/session-end.js +66 -0
- package/dist/tools/ask-question.js +1 -1
- package/dist/tools/sap-read.js +22 -22
- package/dist/tools/sap-write.js +249 -18
- package/dist/tools/snapshot.js +26 -0
- package/dist/ui/app.js +19 -9
- package/dist/ui/approval-modal.js +37 -5
- package/dist/ui/live-area.js +14 -1
- package/dist/ui/widgets/ask-card.js +183 -107
- package/dist/ui/widgets/coaching-picker.js +22 -1
- package/dist/ui/widgets/confirm-card.js +102 -76
- package/package.json +2 -2
package/dist/agent/loop.js
CHANGED
|
@@ -227,50 +227,140 @@ export async function inkAwareConfirm(q, opts) {
|
|
|
227
227
|
return 'n';
|
|
228
228
|
}
|
|
229
229
|
/**
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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
|
-
* -
|
|
235
|
-
* -
|
|
236
|
-
* -
|
|
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
|
-
*
|
|
239
|
-
*
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
{ value:
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
271
|
-
if (
|
|
272
|
-
|
|
273
|
-
|
|
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:
|
|
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
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
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 (
|
|
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}
|
|
122
|
-
const a = (await args.prompt('
|
|
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(
|
|
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
|
|
143
|
-
+ (stillOpen
|
|
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',
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/renderer/notices.js
CHANGED
|
@@ -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
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
|
|
54
|
-
// Live fix 2026-09-26 (panels): never wrap mid-word —
|
|
55
|
-
//
|
|
56
|
-
// session's notice log. Legacy stays
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (cut
|
|
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
|
-
|
|
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
|
-
'
|
|
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) =>
|
|
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',
|
|
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
|
|
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 ✗
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
:
|
|
57
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
}
|