@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.
- package/dist/agent/conversation-prompt.js +1 -0
- package/dist/agent/loop.js +101 -13
- package/dist/agent/maybe-build-project-context.js +11 -2
- package/dist/agent/repair-partial.js +9 -0
- package/dist/agent/tool-dispatch.js +6 -2
- package/dist/approvals/op-labels.js +6 -3
- package/dist/approvals/render.js +3 -2
- package/dist/commands/plan-chain.js +2 -2
- package/dist/commands/plan-resume.js +2 -2
- package/dist/commands/plan-startup.js +3 -3
- package/dist/docx/doc-meta.js +1 -0
- package/dist/docx/markdown-to-docx.js +2 -0
- package/dist/lane/app-chain.js +13 -6
- package/dist/one-shot.js +1 -0
- package/dist/project-context/conventions.js +8 -1
- package/dist/project-context/index-files.js +97 -34
- package/dist/project-context/index.js +1 -1
- package/dist/project-context/render.js +38 -4
- package/dist/projects/answer-blockers.js +107 -16
- package/dist/projects/blocker-answers-note.js +89 -0
- package/dist/projects/change-options.js +70 -0
- package/dist/projects/document-save.js +86 -0
- package/dist/projects/extract-design.js +4 -0
- package/dist/projects/extract-estimate.js +4 -0
- package/dist/projects/extract-spec-gap.js +8 -0
- package/dist/projects/index.js +1 -1
- package/dist/projects/plan-objects.js +24 -0
- package/dist/projects/plan-run.js +12 -2
- package/dist/projects/save-command.js +46 -18
- package/dist/renderer/change-card.js +14 -0
- package/dist/renderer/html-entities.js +25 -0
- package/dist/renderer/machine-blocks.js +54 -7
- package/dist/renderer/tool-labels.js +2 -0
- package/dist/renderer/tool-widget.js +20 -5
- package/dist/repl/chat-lane.js +10 -1
- package/dist/repl.js +11 -2
- package/dist/session/fresh-start.js +4 -0
- package/dist/session/user-prompt.js +2 -0
- package/dist/tools/approval.js +12 -1
- package/dist/tools/project/spec_answers_record.js +190 -0
- package/dist/tools/tool-loading.js +2 -0
- package/dist/ui/approval-modal.js +2 -1
- package/dist/ui/widgets/ask-card.js +171 -29
- 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
|
/**
|
package/dist/tools/approval.js
CHANGED
|
@@ -579,7 +579,18 @@ registerTool({
|
|
|
579
579
|
const stopD = stoppedBeforeCard(ctx);
|
|
580
580
|
if (stopD)
|
|
581
581
|
return stopD;
|
|
582
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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
|
|
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.
|
|
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.
|
|
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",
|