@cspeach/cli 1.1.52 → 1.1.53

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 (44) hide show
  1. package/dist/agent/conversation-prompt.js +1 -0
  2. package/dist/agent/loop.js +101 -13
  3. package/dist/agent/maybe-build-project-context.js +11 -2
  4. package/dist/agent/repair-partial.js +9 -0
  5. package/dist/agent/tool-dispatch.js +6 -2
  6. package/dist/approvals/op-labels.js +6 -3
  7. package/dist/approvals/render.js +3 -2
  8. package/dist/commands/plan-chain.js +2 -2
  9. package/dist/commands/plan-resume.js +2 -2
  10. package/dist/commands/plan-startup.js +3 -3
  11. package/dist/docx/doc-meta.js +1 -0
  12. package/dist/docx/markdown-to-docx.js +2 -0
  13. package/dist/lane/app-chain.js +13 -6
  14. package/dist/one-shot.js +1 -0
  15. package/dist/project-context/conventions.js +8 -1
  16. package/dist/project-context/index-files.js +97 -34
  17. package/dist/project-context/index.js +1 -1
  18. package/dist/project-context/render.js +38 -4
  19. package/dist/projects/answer-blockers.js +107 -16
  20. package/dist/projects/blocker-answers-note.js +89 -0
  21. package/dist/projects/change-options.js +70 -0
  22. package/dist/projects/document-save.js +86 -0
  23. package/dist/projects/extract-design.js +4 -0
  24. package/dist/projects/extract-estimate.js +4 -0
  25. package/dist/projects/extract-spec-gap.js +8 -0
  26. package/dist/projects/index.js +1 -1
  27. package/dist/projects/plan-objects.js +24 -0
  28. package/dist/projects/plan-run.js +12 -2
  29. package/dist/projects/save-command.js +46 -18
  30. package/dist/renderer/change-card.js +14 -0
  31. package/dist/renderer/html-entities.js +25 -0
  32. package/dist/renderer/machine-blocks.js +54 -7
  33. package/dist/renderer/tool-labels.js +2 -0
  34. package/dist/renderer/tool-widget.js +20 -5
  35. package/dist/repl/chat-lane.js +10 -1
  36. package/dist/repl.js +11 -2
  37. package/dist/session/fresh-start.js +4 -0
  38. package/dist/session/user-prompt.js +2 -0
  39. package/dist/tools/approval.js +12 -1
  40. package/dist/tools/project/spec_answers_record.js +190 -0
  41. package/dist/tools/tool-loading.js +2 -0
  42. package/dist/ui/approval-modal.js +2 -1
  43. package/dist/ui/widgets/ask-card.js +171 -29
  44. package/package.json +2 -2
@@ -10,6 +10,10 @@ export function startFreshConversation(session) {
10
10
  // Word export (final-review m-4): the once-per-object save offers and the
11
11
  // export records describe the cleared conversation; they go with it.
12
12
  clearConversationRecords(session);
13
+ // Change options (1.1.53): the last document goes too; a level reply then routes as today.
14
+ session.lastChangeOptions = null;
15
+ // Task 16: a pending blocker-form note describes the cleared conversation.
16
+ delete session.blockerAnswersNote;
13
17
  }
14
18
  /** The one line /new prints. */
15
19
  export function freshStartText(lane) {
@@ -35,6 +35,8 @@ export const INJECTED_CONTEXT_TAGS = [
35
35
  'session_context', 'project_context',
36
36
  // The /export docx conversation list (main, 2026-10-23): one list for both readers.
37
37
  'journey', 'lane_decision', 'system_switch', 'sap_system', 'sap_connection',
38
+ // Task 16 (F-M): the blocker form's note (it rides inside <session_context>).
39
+ 'blocker_answers',
38
40
  'skill',
39
41
  ];
40
42
  /**
@@ -579,7 +579,18 @@ registerTool({
579
579
  const stopD = stoppedBeforeCard(ctx);
580
580
  if (stopD)
581
581
  return stopD;
582
- outcome = await renderPerChangeApprovalV3(c, eff, displayTransport(c.op, args.transport, sessionTransport, c.owningTransport, project));
582
+ // 1.1.53 item 8 (live F-C): the batch risk put another object's reason
583
+ // on this card. Each card shows the risk of its own change, plus the
584
+ // activate of the same object that this card approves with it. One
585
+ // visible change keeps the batch risk, exactly as before. Display only:
586
+ // the gates above and the result fields keep `eff`.
587
+ const cardRisk = visibleCount <= 1 ? eff : effectiveRisk(declared, [
588
+ c,
589
+ ...((c.op === 'modify' || c.op === 'create')
590
+ ? changes.filter((a) => a.op === 'activate' && `${a.type}:${canonicalApprovalObject(a.object)}` === objKey)
591
+ : []),
592
+ ], riskCtx);
593
+ outcome = await renderPerChangeApprovalV3(c, cardRisk, displayTransport(c.op, args.transport, sessionTransport, c.owningTransport, project));
583
594
  }
584
595
  if (outcome.approved) {
585
596
  if (c.op === 'modify' || c.op === 'create')
@@ -0,0 +1,190 @@
1
+ /**
2
+ * spec_answers_record — record blocker answers the developer gives in chat
3
+ * (Task 18, F-O, 2026-10-26).
4
+ *
5
+ * Live: after "ok go ahead" the model ran `node -e` to edit the spec-gap
6
+ * `.cspeach.json` itself, inventing an `answeredBy`, and tripped the risky
7
+ * command card. This tool is the proper way: it runs the blocker form's own
8
+ * save step (saveBlockerAnswers: applyBlockerAnswers with the session's
9
+ * signed-in identity → validateEnvelope → write in place), so an answer
10
+ * recorded here is the same as one typed into the form.
11
+ *
12
+ * Path: resolved like file_read (teamReadTarget), so the folder-relative path
13
+ * the blocker note gives (`.cspeach/<name>.cspeach.json`) opens and saves the
14
+ * same file — in a team folder that is the seat copy. Containment and
15
+ * real-path checks are file_read's. Fix round 1: only a saved result the CLI
16
+ * writes (`.cspeach/<name>.cspeach.json`, isReadOnlyProjectFile), never the
17
+ * shared team `.cspeach` (teamReadTarget's fallback there is read-only), and
18
+ * only a spec-gap envelope.
19
+ *
20
+ * Every refusal is one line and writes nothing: a sub-agent or reader child,
21
+ * a path outside the project, a missing file, a file that is not a spec-gap
22
+ * envelope in `.cspeach/`, the shared team `.cspeach`, an unknown id, a blank
23
+ * answer or one over 2,000 characters, an id given twice, a revision that
24
+ * fails validation. Ruling R18: any question id in the file is accepted.
25
+ *
26
+ * Approvals: like file_write for project files — category 'filesystem', so no
27
+ * card and no Rule 8 batch count (isLocalProjectWork). Never touches SAP, so
28
+ * the chat lane allows it.
29
+ */
30
+ import { promises as fs, realpathSync } from 'node:fs';
31
+ import * as path from 'node:path';
32
+ import { registerTool } from '../index.js';
33
+ import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isReadOnlyProjectFile, } from '../_filesystem-shared.js';
34
+ import { pointsIntoSharedCspeach, SHARED_CSPEACH_REFUSAL, teamReadTarget } from '../_team-work-path.js';
35
+ import { SPEC_ANSWERS_RECORD_TOOL } from '../../projects/blocker-answers-note.js';
36
+ import { readProjectFile } from '../../projects/status.js';
37
+ import { openBlockers, saveBlockerAnswers } from '../../projects/answer-blockers.js';
38
+ export const SPEC_ANSWERS_RECORD = SPEC_ANSWERS_RECORD_TOOL;
39
+ /** Fix round 1 (item 4): the longest answer recorded. */
40
+ export const MAX_ANSWER = 2000;
41
+ const QUESTION_MAX = 80;
42
+ const NOTHING_SAVED = 'Nothing was saved.';
43
+ const fail = (line) => ({ content: `error: ${line}`, is_error: true });
44
+ function clip(text, max) {
45
+ const one = text.replace(/\s*[\r\n]+\s*/g, ' ').trim();
46
+ return one.length > max ? `${one.slice(0, max - 1).trimEnd()}…` : one;
47
+ }
48
+ /** The same identity loop.ts gives the blocker form (getAuthorIdentity). */
49
+ async function sessionIdentity() {
50
+ const { getAuthorIdentity } = await import('../../agent/loop.js');
51
+ return getAuthorIdentity();
52
+ }
53
+ function realOr(p) {
54
+ try {
55
+ return realpathSync.native(p);
56
+ }
57
+ catch {
58
+ return path.resolve(p);
59
+ }
60
+ }
61
+ export async function specAnswersRecordHandler(args, ctx) {
62
+ // Fix round 1 (item 5): only the main conversation records answers (a child could race the form).
63
+ if (ctx.subagent || ctx.readerMode)
64
+ return fail(`answers are recorded in the main conversation only. ${NOTHING_SAVED}`);
65
+ if (typeof args.path !== 'string' || args.path.trim() === '') {
66
+ return fail(`give the path of the spec-gap file, for example .cspeach/<name>.cspeach.json. ${NOTHING_SAVED}`);
67
+ }
68
+ const given = args.path;
69
+ // The path as file_read opens it: in a team folder a saved result is the seat copy.
70
+ const t = teamReadTarget(ctx.cwd, given);
71
+ let real;
72
+ try {
73
+ real = await assertRealPathContained(resolveSafePath(t.root, t.path), t.root);
74
+ }
75
+ catch (err) {
76
+ if (err instanceof PathOutsideRootError)
77
+ return fail(`that path is outside the project folder. ${NOTHING_SAVED}`);
78
+ if (err.code === 'ENOENT')
79
+ return fail(`file not found: ${given}. ${NOTHING_SAVED}`);
80
+ return fail(`${err instanceof Error ? err.message : String(err)}. ${NOTHING_SAVED}`);
81
+ }
82
+ // Fix round 1 (item 2): never the shared team .cspeach — teamReadTarget's
83
+ // fallback to it is for reading only, as for the write tools.
84
+ if (pointsIntoSharedCspeach(ctx.cwd, real))
85
+ return fail(`${SHARED_CSPEACH_REFUSAL} ${NOTHING_SAVED}`);
86
+ // Fix round 1 (item 3): only a saved result the CLI writes (.cspeach/<name>.cspeach.json),
87
+ // the same shape file_read's denylist carve-out opens (lane files excluded).
88
+ if (!isReadOnlyProjectFile(real, t.root) || !/\.cspeach\.json$/i.test(real)) {
89
+ return fail(`${given} is not a spec-gap file. ${NOTHING_SAVED}`);
90
+ }
91
+ if (!(await fs.stat(real)).isFile())
92
+ return fail(`${given} is not a spec-gap file. ${NOTHING_SAVED}`);
93
+ let env;
94
+ try {
95
+ env = readProjectFile(real);
96
+ }
97
+ catch {
98
+ return fail(`${given} is not a spec-gap file. ${NOTHING_SAVED}`);
99
+ }
100
+ if (env.artefactType !== 'spec-gap')
101
+ return fail(`${given} is not a spec-gap file. ${NOTHING_SAVED}`);
102
+ // The answers: every id must be a question in the file (ruling R18: any
103
+ // question, not only blockers), with a non-blank answer of at most MAX_ANSWER.
104
+ const list = Array.isArray(args.answers) ? args.answers : null;
105
+ if (!list || list.length === 0)
106
+ return fail(`give at least one answer as { id, answer }. ${NOTHING_SAVED}`);
107
+ const knownIds = new Set(env.interaction.items.map((it) => it.id));
108
+ const answers = new Map();
109
+ const order = [];
110
+ const problems = [];
111
+ for (const entry of list) {
112
+ const id = entry && typeof entry === 'object' && typeof entry.id === 'string'
113
+ ? entry.id.trim() : '';
114
+ const raw = entry && typeof entry === 'object' ? entry.answer : undefined;
115
+ const answer = typeof raw === 'string' ? raw.trim() : '';
116
+ if (!id) {
117
+ problems.push('an answer has no id');
118
+ continue;
119
+ }
120
+ if (order.includes(id)) {
121
+ problems.push(`${id}: given twice`);
122
+ continue;
123
+ }
124
+ order.push(id);
125
+ if (!knownIds.has(id)) {
126
+ problems.push(`${id}: no question with this id in the file`);
127
+ continue;
128
+ }
129
+ if (!answer) {
130
+ problems.push(`${id}: the answer is blank`);
131
+ continue;
132
+ }
133
+ if (answer.length > MAX_ANSWER) {
134
+ problems.push(`${id}: the answer is longer than 2,000 characters`);
135
+ continue;
136
+ }
137
+ answers.set(id, answer);
138
+ }
139
+ if (problems.length > 0)
140
+ return fail(`${problems.join('; ')}. ${NOTHING_SAVED}`);
141
+ // The form's own save step: apply with the session identity, validate, write in place.
142
+ const saved = saveBlockerAnswers(real, env, answers, await sessionIdentity(), new Date().toISOString());
143
+ if (!saved.ok)
144
+ return fail(`the revised file did not validate (${saved.error}). ${NOTHING_SAVED}`);
145
+ const rel = path.relative(realOr(t.root), real).split(path.sep).join('/');
146
+ const open = openBlockers(saved.revised);
147
+ return {
148
+ content: [
149
+ `Saved in ${rel}.`,
150
+ ...order.map((id) => `${id}: recorded.`),
151
+ open.length === 0
152
+ ? 'No blockers are still open.'
153
+ : `Still open: ${open.map((b) => `${b.id} (${clip(b.text, QUESTION_MAX)})`).join(', ')}.`,
154
+ ].join('\n'),
155
+ };
156
+ }
157
+ registerTool({
158
+ name: SPEC_ANSWERS_RECORD,
159
+ description: 'Record answers the developer gives in chat to spec-gap questions, in the saved spec-gap file '
160
+ + '(.cspeach/<name>.cspeach.json): blockers or any question in the file. The file exists only once the document has been saved, after your turn. Give each question id as in the file '
161
+ + '(for example gap-004) with its answer, at most 2,000 characters. '
162
+ + 'Answering a question that already has an answer replaces it. Nothing is saved if any id or answer is refused. '
163
+ + 'Never edit .cspeach.json files with shell or file-edit tools. No SAP call.',
164
+ isMutating: true,
165
+ category: 'filesystem',
166
+ requiresSap: false,
167
+ input_schema: {
168
+ type: 'object',
169
+ properties: {
170
+ path: {
171
+ type: 'string',
172
+ description: 'Folder-relative path of the spec-gap file, for example .cspeach/<name>.cspeach.json.',
173
+ },
174
+ answers: {
175
+ type: 'array',
176
+ description: 'One entry per question answered.',
177
+ items: {
178
+ type: 'object',
179
+ properties: {
180
+ id: { type: 'string', description: 'The question id as in the file, for example gap-004.' },
181
+ answer: { type: 'string', description: "The developer's answer, in their words." },
182
+ },
183
+ required: ['id', 'answer'],
184
+ },
185
+ },
186
+ },
187
+ required: ['path', 'answers'],
188
+ },
189
+ handler: (args, ctx) => specAnswersRecordHandler(args, ctx),
190
+ });
@@ -63,6 +63,8 @@ export const SKILL_STARTERS = Object.freeze({
63
63
  // Both flag-gated (LOCAL_BUILD_TOOLS / STANDALONE_FILE_TOOLS): skipped when off.
64
64
  'abap-app': ['app_lane_resolve', 'read_document'],
65
65
  'abap-atc-fix': ['sap_atc_run'],
66
+ // Task 18 (F-O): blocker answers given in chat are recorded with this tool, never a file edit.
67
+ 'abap-spec-gap': ['spec_answers_record'],
66
68
  'abap-cca': ['team_estate', 'sap_atc_run', 'sap_usage_references', 'sap_api_state'],
67
69
  'abap-clean-core': ['sap_api_state', 'sap_atc_run', 'sap_usage_references', 'team_findings', 'team_lane_move'],
68
70
  'abap-data-model': ['sap_create_domain', 'sap_create_data_element', ...SESSION_TRANSPORT],
@@ -17,6 +17,7 @@ import { useReclaimRawStdin } from './widgets/reclaim-raw-stdin.js';
17
17
  import { getLook } from '../renderer/look.js';
18
18
  import { roleProps, inkChip, legacyInkColor } from './role-props.js';
19
19
  import { wrapRows } from '../renderer/wrap-rows.js';
20
+ import { decodeDisplayBlock } from '../renderer/html-entities.js';
20
21
  import { uiWidth } from '../renderer/ui-width.js';
21
22
  import { cardBorderStyle } from './ascii-chars.js';
22
23
  /**
@@ -109,7 +110,7 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
109
110
  const backAt = useRef(0);
110
111
  const back = () => { backAt.current = Date.now(); setViewing(false); };
111
112
  const viewLines = viewing && aff && change.diff
112
- ? (aff.key === 'd' ? change.diff.split('\n') : toSourceLines(change.diff))
113
+ ? (aff.key === 'd' ? decodeDisplayBlock(change.diff).split('\n') : toSourceLines(change.diff))
113
114
  : [];
114
115
  const lastTop = Math.max(0, viewLines.length - viewRows);
115
116
  useInput((input, key) => {
@@ -22,7 +22,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
22
22
  * the text input is not focused) · Esc cancels; on the free-text row Esc
23
23
  * first returns to the options.
24
24
  */
25
- import { useMemo, useRef, useState } from 'react';
25
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
26
26
  import { Box, Text, useInput } from 'ink';
27
27
  import TextInput from '../text-input.js';
28
28
  import { useReclaimRawStdin } from './reclaim-raw-stdin.js';
@@ -34,14 +34,31 @@ import { sanitizeControlChars } from '../../renderer/sanitize.js';
34
34
  import { wrapRows } from '../../renderer/wrap-rows.js';
35
35
  import { answerDisplayText } from '../ask-answer-text.js';
36
36
  import { CardFrame } from './confirm-card.js';
37
+ import { emitTrace } from '../../renderer/trace.js';
37
38
  /** Option rows visible at once — the rest scroll. */
38
39
  export const VISIBLE_OPTION_ROWS = 5;
39
40
  /**
40
41
  * Single-key cards (the Yes/No agent confirm) drop keys received in the first
41
42
  * GUARD_MS after mount, so a key typed ahead while output streamed cannot
42
43
  * answer the card.
44
+ *
45
+ * 1.1.53 Task 20 (F-P) — every card, keys or not, also ignores Enter, digit
46
+ * picks and alias keys for GUARD_MS after each screen appears (the card, each
47
+ * next question of a form, the Review step), so the Enter of a "pick, then
48
+ * Enter" habit cannot answer the card that comes next. Typing into the
49
+ * typed-answer row is never dropped.
43
50
  */
44
51
  const GUARD_MS = 150;
52
+ /** Task 20 — the per-screen pick window; GUARD_MS outside tests. */
53
+ let pickGuardMs = GUARD_MS;
54
+ /**
55
+ * Test seam only: interaction tests that drive keys faster than a person can
56
+ * type set 0; undefined restores GUARD_MS. The window itself is pinned by
57
+ * ask-card-stray-enter.test.tsx. The y/n mount guard is not affected.
58
+ */
59
+ export function setAskPickGuardMsForTest(ms) {
60
+ pickGuardMs = ms ?? GUARD_MS;
61
+ }
45
62
  /**
46
63
  * Card text is model- or tool-supplied. Tabs and newlines are flattened by
47
64
  * truncateToWidth; every OTHER control character is neutralised by the
@@ -56,7 +73,12 @@ const sanitize = sanitizeControlChars;
56
73
  function fit(text, cells) {
57
74
  return truncateToWidth(sanitize(text), cells);
58
75
  }
59
- /** Round 4 item 8b — the question wraps up to this many rows, then is cut. */
76
+ /**
77
+ * Round 4 item 8b — the question's base rows. 1.1.53 Task 14: no longer the
78
+ * cap. With a row budget (App) the current question grows past it to as many
79
+ * rows as it needs while the card still fits (askCardLayout); without one it
80
+ * shows whole. Only a question that still does not fit is cut.
81
+ */
60
82
  export const QUESTION_MAX_ROWS = 3;
61
83
  /** Context wraps too; it gets at least one row per line and at most this many in all (or its line count, if larger). */
62
84
  export const CONTEXT_MAX_ROWS = 6;
@@ -70,6 +92,8 @@ const DESCRIPTION_INDENT = ' ';
70
92
  export const CONTEXT_FOLDED_ROWS = 3;
71
93
  /** …and then the question to this many. */
72
94
  export const QUESTION_FOLDED_ROWS = 2;
95
+ /** 1.1.53 Task 14 — the dim last row of a cut question; the whole question is printed in the scrollback above. */
96
+ export const FULL_QUESTION_NOTE = 'The full question is printed above.';
73
97
  /** The question rows (at most `maxRows`), with the multi-select suffix kept whole on the last row. */
74
98
  function questionRows(question, suffix, cells, maxRows = QUESTION_MAX_ROWS) {
75
99
  if (!suffix)
@@ -87,6 +111,58 @@ function questionRows(question, suffix, cells, maxRows = QUESTION_MAX_ROWS) {
87
111
  }
88
112
  return rows;
89
113
  }
114
+ /**
115
+ * 1.1.53 Task 14 — the question as plain wrapped text for the scrollback:
116
+ * each line of it wrapped at `cells`, nothing cut. Printed above the card
117
+ * when the card has to cut the question, so the person can read it whole.
118
+ */
119
+ export function fullQuestionText(question, cells) {
120
+ return question.split(/\r?\n/).map((line) => wrapRows(line, cells, Number.MAX_SAFE_INTEGER).join('\n')).join('\n');
121
+ }
122
+ /** The multi-select suffix, when the width has room for it (kept whole on the question's last row). */
123
+ function questionSuffix(q, cells) {
124
+ return q.multiSelect && cells - MULTI_SUFFIX.length >= 1 ? MULTI_SUFFIX : '';
125
+ }
126
+ /** Rows the whole question needs (suffix included). */
127
+ function questionNeed(q, cells) {
128
+ // The layout asks this many times per render (folds, growth, estimate): memoised.
129
+ const key = `${cells}|${q.multiSelect ? 1 : 0}|${q.question}`;
130
+ let need = needCache.get(key);
131
+ if (need === undefined) {
132
+ need = questionRows(q.question, questionSuffix(q, cells), cells, Number.MAX_SAFE_INTEGER).length;
133
+ if (needCache.size >= 64)
134
+ needCache.clear();
135
+ needCache.set(key, need);
136
+ }
137
+ return need;
138
+ }
139
+ const needCache = new Map();
140
+ /** Fix round 2 (I-1) — a cut question gets the pointer row only from this many rows; below, the old cut rows. */
141
+ const POINTER_MIN_ROWS = 3;
142
+ /**
143
+ * 1.1.53 Task 14 — the question as drawn in at most `cap` rows. It fits: the
144
+ * same rows as before. It does not: with `cap` of POINTER_MIN_ROWS or more it
145
+ * is cut to `cap - 1` rows and the last row is the FULL_QUESTION_NOTE pointer;
146
+ * with fewer (the short-screen fold) it is cut to `cap` rows exactly as
147
+ * before, no pointer. Either way the block is min(need, cap) rows tall, so
148
+ * the height math is today's.
149
+ */
150
+ function questionBlock(q, cells, cap) {
151
+ // Memoised like questionNeed: the fold and growth loops ask for many caps.
152
+ const key = `${cells}|${q.multiSelect ? 1 : 0}|${cap}|${q.question}`;
153
+ const hit = blockCache.get(key);
154
+ if (hit)
155
+ return hit;
156
+ const suffix = questionSuffix(q, cells);
157
+ const block = questionNeed(q, cells) <= cap
158
+ ? { lines: questionRows(q.question, suffix, cells, cap), cut: false }
159
+ : { lines: questionRows(q.question, suffix, cells, cap >= POINTER_MIN_ROWS ? cap - 1 : cap), cut: true };
160
+ if (blockCache.size >= 256)
161
+ blockCache.clear();
162
+ blockCache.set(key, block);
163
+ return block;
164
+ }
165
+ const blockCache = new Map();
90
166
  /**
91
167
  * Context rows: every line keeps at least one row; wrapping fills the rest of
92
168
  * the budget. Folded (`maxRows` below CONTEXT_MAX_ROWS, Task 4) the context
@@ -108,6 +184,8 @@ function contextRows(context, cells, maxRows = CONTEXT_MAX_ROWS) {
108
184
  });
109
185
  return out;
110
186
  }
187
+ /** The rows question `i` may take at `layout`. */
188
+ const questionCap = (layout, i) => layout.questionRowsFor?.[i] ?? layout.questionRows;
111
189
  /**
112
190
  * Lay out one option at `inner` cells. The label wraps onto LABEL_MAX_ROWS
113
191
  * under its own text; the (recommended) / (default) suffix stays whole on
@@ -151,6 +229,17 @@ export function optionRows(o, idx, q, inner, layout) {
151
229
  }
152
230
  /** Rows one option takes on the card. */
153
231
  const optionHeight = (r) => r.label.length + r.description.length;
232
+ /**
233
+ * 1.1.53 R16 — the digit 1-9 a key chunk stands for, or 0. Remote desktops,
234
+ * VDI, ConPTY and key drivers can deliver a digit and Enter as one chunk
235
+ * ("2\r"): it counts as the digit alone, and the newline goes with it (never
236
+ * on to a following card). Any other multi-byte chunk ("22", an arrow plus a
237
+ * digit) is no digit.
238
+ */
239
+ function digitKey(input) {
240
+ const m = /^([1-9])[\r\n]*$/.exec(input);
241
+ return m ? Number(m[1]) : 0;
242
+ }
154
243
  export function toCardQuestions(request) {
155
244
  if ('questions' in request) {
156
245
  return request.questions.map((q) => {
@@ -212,29 +301,31 @@ const suffixText = (r) => (r.rec ? REC : '') + (r.def ? DEFAULT_SUFFIX : '');
212
301
  const askInner = (width) => Math.max(1, width - 4);
213
302
  /** The frame's top and bottom edges. */
214
303
  const FRAME_ROWS = 2;
304
+ /** The rows of one question's screen when its question may take `cap` rows. */
305
+ function screenRows(q, chipRows, inner, layout, cap) {
306
+ const ctx = contextRows(q.context, inner, layout.contextRows).length;
307
+ const block = questionBlock(q, inner, cap);
308
+ const qRows = block.lines.length + (block.cut && cap >= POINTER_MIN_ROWS ? 1 : 0); // + the pointer row
309
+ let body = 2; // margin + the text input row
310
+ if (!q.freeText) {
311
+ // Every wrapped label and description row counts; with more options
312
+ // than the window shows, the tallest window of VISIBLE_OPTION_ROWS.
313
+ const heights = q.options.map((o, i) => optionHeight(optionRows(o, i, q, inner, layout)));
314
+ const span = Math.min(heights.length, VISIBLE_OPTION_ROWS);
315
+ let tallest = 0;
316
+ for (let start = 0; start + span <= heights.length; start++) {
317
+ tallest = Math.max(tallest, heights.slice(start, start + span).reduce((x, y) => x + y, 0));
318
+ }
319
+ const arrows = q.options.length > VISIBLE_OPTION_ROWS ? 2 : 0;
320
+ body = 1 + tallest + arrows + (q.otherRow ? 1 : 0);
321
+ }
322
+ return chipRows + qRows + ctx + body + 1 + FRAME_ROWS; // question + body + hint + frame
323
+ }
215
324
  /** The card's rows at `layout` — worst case over every question screen, plus the Review screen. */
216
325
  function askRowsFor(qs, inner, layout) {
217
326
  const chipRows = qs.length > 1 ? 2 : 0; // tab row + its blank line
218
327
  let worst = 0;
219
- for (const q of qs) {
220
- const ctx = contextRows(q.context, inner, layout.contextRows).length;
221
- const suffix = q.multiSelect && inner - MULTI_SUFFIX.length >= 1 ? MULTI_SUFFIX : '';
222
- const qRows = questionRows(q.question, suffix, inner, layout.questionRows).length;
223
- let body = 2; // margin + the text input row
224
- if (!q.freeText) {
225
- // Every wrapped label and description row counts; with more options
226
- // than the window shows, the tallest window of VISIBLE_OPTION_ROWS.
227
- const heights = q.options.map((o, i) => optionHeight(optionRows(o, i, q, inner, layout)));
228
- const span = Math.min(heights.length, VISIBLE_OPTION_ROWS);
229
- let tallest = 0;
230
- for (let start = 0; start + span <= heights.length; start++) {
231
- tallest = Math.max(tallest, heights.slice(start, start + span).reduce((x, y) => x + y, 0));
232
- }
233
- const arrows = q.options.length > VISIBLE_OPTION_ROWS ? 2 : 0;
234
- body = 1 + tallest + arrows + (q.otherRow ? 1 : 0);
235
- }
236
- worst = Math.max(worst, chipRows + qRows + ctx + body + 1 + FRAME_ROWS); // question + body + hint + frame
237
- }
328
+ qs.forEach((q, i) => { worst = Math.max(worst, screenRows(q, chipRows, inner, layout, questionCap(layout, i))); });
238
329
  if (qs.length > 1) {
239
330
  // title + one row per answer + margin + choices + hint + frame
240
331
  worst = Math.max(worst, chipRows + 1 + qs.length + 1 + REVIEW_CHOICES.length + 1 + FRAME_ROWS);
@@ -256,10 +347,12 @@ export function askCardLayout(request, showDescriptions, width = uiWidth(), maxR
256
347
  contextRows: CONTEXT_MAX_ROWS,
257
348
  questionRows: QUESTION_MAX_ROWS,
258
349
  };
259
- if (maxRows === undefined)
260
- return layout;
261
350
  const qs = toCardQuestions(request);
262
351
  const inner = askInner(width);
352
+ // 1.1.53 Task 14 — no budget: every question shows whole.
353
+ if (maxRows === undefined) {
354
+ return { ...layout, questionRowsFor: qs.map((q) => Math.max(layout.questionRows, questionNeed(q, inner))) };
355
+ }
263
356
  const folds = [
264
357
  (l) => { l.labelRows = 1; },
265
358
  (l) => { l.descriptionRows = Math.min(l.descriptionRows, 1); },
@@ -272,6 +365,21 @@ export function askCardLayout(request, showDescriptions, width = uiWidth(), maxR
272
365
  break;
273
366
  fold(layout);
274
367
  }
368
+ // 1.1.53 Task 14 — then each question grows to the rows it needs while its
369
+ // screen still fits maxRows. What still does not fit stays cut (the AskCard
370
+ // prints it whole above). The question block is min(need, cap) rows tall
371
+ // (questionBlock), so the rest of the screen is measured once at the base
372
+ // and the cap follows directly (fix round 2, I-2 — no per-row re-wrap).
373
+ const chipRows = qs.length > 1 ? 2 : 0;
374
+ const base = layout.questionRows;
375
+ layout.questionRowsFor = qs.map((q) => {
376
+ const need = questionNeed(q, inner);
377
+ if (need <= base)
378
+ return base;
379
+ const rest = screenRows(q, chipRows, inner, layout, base) - base; // everything but the question
380
+ const room = maxRows - rest;
381
+ return need <= room ? need : Math.max(base, room);
382
+ });
275
383
  return layout;
276
384
  }
277
385
  /**
@@ -320,6 +428,9 @@ export function AskCard({ request, width, showDescriptions, maxRows, onResolveV1
320
428
  // while output streamed must not answer the card.
321
429
  const mountGuard = questions.some((qq) => qq.keys !== undefined);
322
430
  const mountedAt = useRef(Date.now());
431
+ // Task 20 — when the current screen appeared (reset on every focus change).
432
+ const screenAt = useRef(Date.now());
433
+ const pickGuarded = () => Date.now() - screenAt.current < pickGuardMs;
323
434
  const done = useRef(false);
324
435
  // Focus slots: 0..n-1 = questions; n = the Review step (2+ questions,
325
436
  // reachable only once every question has an answer).
@@ -329,6 +440,16 @@ export function AskCard({ request, width, showDescriptions, maxRows, onResolveV1
329
440
  const [drafts, setDrafts] = useState(() => questions.map(() => ''));
330
441
  const [answers, setAnswers] = useState(() => questions.map(() => null));
331
442
  const [reviewCursor, setReviewCursor] = useState(0);
443
+ const firstScreen = useRef(true);
444
+ useLayoutEffect(() => {
445
+ if (firstScreen.current) {
446
+ firstScreen.current = false;
447
+ return;
448
+ }
449
+ screenAt.current = Date.now();
450
+ }, [focus]);
451
+ // 1.1.53 Task 14 — questions already printed whole to the scrollback.
452
+ const printedFull = useRef(new Set());
332
453
  const isReview = n > 1 && focus === n;
333
454
  const q = isReview ? undefined : questions[focus];
334
455
  const cursor = q ? cursors[focus] : 0;
@@ -421,6 +542,8 @@ export function AskCard({ request, width, showDescriptions, maxRows, onResolveV1
421
542
  answerAndAdvance(focus, opt.value);
422
543
  };
423
544
  const onTextSubmit = (v) => {
545
+ if (pickGuarded())
546
+ return; // Task 20 — a stray Enter does not submit the row
424
547
  const trimmed = v.trim();
425
548
  if (trimmed.length === 0) {
426
549
  if (!isForm)
@@ -483,11 +606,15 @@ export function AskCard({ request, width, showDescriptions, maxRows, onResolveV1
483
606
  setReviewCursor(1);
484
607
  return;
485
608
  }
486
- if (input === '1' || (key.return && reviewCursor === 0)) {
609
+ // R16 — "1\r" / "2\n" in one chunk act like the bare digit.
610
+ const reviewDigit = digitKey(input);
611
+ if (pickGuarded() && (reviewDigit > 0 || key.return))
612
+ return; // Task 20
613
+ if (reviewDigit === 1 || (key.return && reviewCursor === 0)) {
487
614
  submit(answers);
488
615
  return;
489
616
  }
490
- if (input === '2' || (key.return && reviewCursor === 1)) {
617
+ if (reviewDigit === 2 || (key.return && reviewCursor === 1)) {
491
618
  cancel();
492
619
  return;
493
620
  }
@@ -510,11 +637,14 @@ export function AskCard({ request, width, showDescriptions, maxRows, onResolveV1
510
637
  return;
511
638
  // Ctrl+Y / Meta+Y are not "y": only a bare key answers.
512
639
  const alias = key.ctrl || key.meta ? undefined : q.keys?.[input.toLowerCase()];
640
+ // Task 20 — Enter, digit picks and alias keys wait out the screen's window.
641
+ if (pickGuarded() && (alias !== undefined || digitKey(input) > 0 || key.return))
642
+ return;
513
643
  if (alias !== undefined && q.options.some((o) => o.value === alias)) {
514
644
  answerAndAdvance(focus, alias);
515
645
  return;
516
646
  }
517
- const digit = /^[1-9]$/.test(input) ? Number(input) - 1 : -1;
647
+ const digit = digitKey(input) - 1;
518
648
  if (digit >= 0) {
519
649
  if (digit >= rowCount)
520
650
  return;
@@ -545,6 +675,19 @@ export function AskCard({ request, width, showDescriptions, maxRows, onResolveV1
545
675
  const frameHeader = isForm ? request.frameHeader : undefined;
546
676
  // The same fold as estimateAskCardRows, so the drawn card is the estimated card.
547
677
  const layout = askCardLayout(request, showDescriptions, width, maxRows);
678
+ // 1.1.53 Task 14 — a question the card has to cut is printed whole, once,
679
+ // to the scrollback just above the card (where answered questions land),
680
+ // when it becomes the current question. A question that fits prints
681
+ // nothing. Classic / headless have no trace emitter: emitTrace is a no-op.
682
+ const block = q ? questionBlock(q, inner, questionCap(layout, focus)) : undefined;
683
+ const questionCut = block?.cut === true;
684
+ const showPointer = questionCut && questionCap(layout, focus) >= POINTER_MIN_ROWS;
685
+ useEffect(() => {
686
+ if (!q || !questionCut || printedFull.current.has(focus))
687
+ return;
688
+ printedFull.current.add(focus);
689
+ emitTrace(fullQuestionText(q.question, inner));
690
+ }, [q, questionCut, focus, inner]);
548
691
  const chipRow = n > 1 ? (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { wrap: "truncate-end", children: fitSegments([
549
692
  ...questions.map((qq, i) => ({
550
693
  text: `${i > 0 ? ' ' : ''}${answers[i] !== null ? g.answered : g.pending} ${fit(qq.header, inner)}`,
@@ -574,8 +717,7 @@ export function AskCard({ request, width, showDescriptions, maxRows, onResolveV1
574
717
  const hintKind = q.freeText ? 'text' : onOther ? 'other' : q.multiSelect ? 'multi' : 'pick';
575
718
  // The multi-select suffix is the card's only multi cue: it stays whole and
576
719
  // the question is cut instead (the same rule as "(recommended)" below).
577
- const suffix = q.multiSelect && inner - MULTI_SUFFIX.length >= 1 ? MULTI_SUFFIX : '';
578
- const questionLines = questionRows(q.question, suffix, inner, layout.questionRows);
720
+ const questionLines = block.lines;
579
721
  const hint = askCardHint(hintKind, {
580
722
  multiQuestion: n > 1,
581
723
  count: rowCount,
@@ -583,7 +725,7 @@ export function AskCard({ request, width, showDescriptions, maxRows, onResolveV1
583
725
  });
584
726
  // Task 4 — the same frame as ConfirmCard: QUESTION tag (plus the optional
585
727
  // header) on the top edge, the hint right under the options.
586
- 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) => {
728
+ 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}`))), showPointer ? _jsx(Text, { ...dim, wrap: "truncate-end", children: fit(FULL_QUESTION_NOTE, inner) }) : null, 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) => {
587
729
  const idx = winStart + vi;
588
730
  const cur = !onOther && idx === cursor;
589
731
  const tick = q.multiSelect ? `${ticks[focus].has(o.value) ? g.tickOn : g.tickOff} ` : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.52",
3
+ "version": "1.1.53",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, signed skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",
@@ -54,7 +54,7 @@
54
54
  "@anthropic-ai/sdk": "^0.90.0",
55
55
  "@azure/msal-node": "6.0.1",
56
56
  "@cspeach/register-core": "0.4.2",
57
- "@cspeach/sap-client": "0.1.30",
57
+ "@cspeach/sap-client": "0.1.31",
58
58
  "@iarna/toml": "^2.2.5",
59
59
  "@inkjs/ui": "^2.0.0",
60
60
  "@inquirer/prompts": "^7.0.0",