@cspeach/cli 0.6.0
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/LICENSE +8 -0
- package/README.md +108 -0
- package/dist/agent/anthropic-provider.js +59 -0
- package/dist/agent/llm-provider.js +1 -0
- package/dist/agent/loop.js +709 -0
- package/dist/agent/maybe-build-project-context.js +126 -0
- package/dist/agent/providers/ai-hub-provider.js +58 -0
- package/dist/agent/providers/byok-provider.js +53 -0
- package/dist/agent/providers/factory.js +13 -0
- package/dist/agent/providers/local-provider.js +125 -0
- package/dist/agent/repair-partial.js +31 -0
- package/dist/agent/retry-key.js +58 -0
- package/dist/agent/retry.js +17 -0
- package/dist/agent/sap-connection-adapter.js +82 -0
- package/dist/agent/skill-checkpoint.js +119 -0
- package/dist/agent/tool-dispatch.js +47 -0
- package/dist/agent/turn-assistant-text.js +49 -0
- package/dist/agent/turn-error-ux.js +126 -0
- package/dist/agent/turn-stream.js +79 -0
- package/dist/agent/turn-watchdog.js +71 -0
- package/dist/approvals/advisory-prompt.js +40 -0
- package/dist/approvals/advisory-render.js +38 -0
- package/dist/approvals/approval-prompt.js +100 -0
- package/dist/approvals/jwt.js +33 -0
- package/dist/approvals/render.js +211 -0
- package/dist/approvals/risk-floor.js +26 -0
- package/dist/auth/api-key.js +40 -0
- package/dist/auth/auth-file.js +59 -0
- package/dist/auth/device.js +8 -0
- package/dist/auth/me.js +19 -0
- package/dist/classifier/client.js +58 -0
- package/dist/cli-args.js +38 -0
- package/dist/cli.js +148 -0
- package/dist/commands/config-set.js +245 -0
- package/dist/commands/config-show.js +159 -0
- package/dist/commands/help.js +93 -0
- package/dist/commands/login.js +122 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/project-context-impact.js +215 -0
- package/dist/commands/reroute.js +60 -0
- package/dist/commands/spec-gap-status.js +52 -0
- package/dist/commands/whoami.js +35 -0
- package/dist/config/loader.js +67 -0
- package/dist/config/paths.js +20 -0
- package/dist/doctor/checks/_http-probe.js +56 -0
- package/dist/doctor/checks/auth.js +15 -0
- package/dist/doctor/checks/cert.js +24 -0
- package/dist/doctor/checks/forge-rules.js +102 -0
- package/dist/doctor/checks/keychain-fallback.js +14 -0
- package/dist/doctor/checks/keychain.js +23 -0
- package/dist/doctor/checks/llm-mode.js +27 -0
- package/dist/doctor/checks/proxy.js +13 -0
- package/dist/doctor/checks/sap.js +33 -0
- package/dist/doctor/checks/skill.js +24 -0
- package/dist/doctor/checks/write-mode.js +34 -0
- package/dist/doctor/checks/zcspeach.js +76 -0
- package/dist/doctor/run.js +46 -0
- package/dist/errors/codes.js +12 -0
- package/dist/index.js +6 -0
- package/dist/lock-contention.js +22 -0
- package/dist/one-shot.js +104 -0
- package/dist/project-context/conventions.js +309 -0
- package/dist/project-context/detect.js +250 -0
- package/dist/project-context/domain/abap-cloud.js +26 -0
- package/dist/project-context/domain/abapgit.js +177 -0
- package/dist/project-context/domain/cap.js +164 -0
- package/dist/project-context/domain/fiori.js +326 -0
- package/dist/project-context/git.js +115 -0
- package/dist/project-context/index-files.js +235 -0
- package/dist/project-context/index.js +117 -0
- package/dist/project-context/render.js +308 -0
- package/dist/project-context/types.js +14 -0
- package/dist/projects/build.js +20 -0
- package/dist/projects/canonicalize.js +39 -0
- package/dist/projects/email-template.js +54 -0
- package/dist/projects/extract-cca.js +139 -0
- package/dist/projects/extract-design.js +107 -0
- package/dist/projects/extract-estimate.js +93 -0
- package/dist/projects/extract-modernize.js +130 -0
- package/dist/projects/extract-spec-gap.js +101 -0
- package/dist/projects/extract-test-coverage.js +137 -0
- package/dist/projects/extract-upgrade.js +230 -0
- package/dist/projects/filename.js +18 -0
- package/dist/projects/index.js +8 -0
- package/dist/projects/migration.js +111 -0
- package/dist/projects/promote-command.js +96 -0
- package/dist/projects/promote.js +107 -0
- package/dist/projects/save-command.js +124 -0
- package/dist/projects/save.js +21 -0
- package/dist/projects/status.js +170 -0
- package/dist/projects/types.js +1 -0
- package/dist/projects/validate.js +146 -0
- package/dist/projects/workspace.js +478 -0
- package/dist/renderer/abap-inline.js +121 -0
- package/dist/renderer/banners.js +39 -0
- package/dist/renderer/highlighters/abap.js +126 -0
- package/dist/renderer/highlighters/bdef.js +81 -0
- package/dist/renderer/highlighters/cds.js +91 -0
- package/dist/renderer/markdown.js +291 -0
- package/dist/renderer/pipeline.js +201 -0
- package/dist/renderer/progress-chatter.js +237 -0
- package/dist/renderer/question-normalizer.js +306 -0
- package/dist/renderer/severity.js +61 -0
- package/dist/renderer/status-footer.js +50 -0
- package/dist/renderer/syntax.js +58 -0
- package/dist/renderer/tables.js +55 -0
- package/dist/renderer/thinking-heartbeat.js +70 -0
- package/dist/renderer/tool-widget.js +199 -0
- package/dist/renderer/tty.js +66 -0
- package/dist/renderer/widget-extractor.js +87 -0
- package/dist/renderer/widget-fallback.js +78 -0
- package/dist/renderer/widget-schemas.js +43 -0
- package/dist/repl/at-completer.js +64 -0
- package/dist/repl/at-picker.js +122 -0
- package/dist/repl/bracketed-paste.js +284 -0
- package/dist/repl/current-transport.js +46 -0
- package/dist/repl/diff-display.js +41 -0
- package/dist/repl/file-picker.js +219 -0
- package/dist/repl/inquirer-guard.js +130 -0
- package/dist/repl/inquirer-theme.js +41 -0
- package/dist/repl/rule8-detector.js +99 -0
- package/dist/repl/safety-confirm.js +106 -0
- package/dist/repl/safety-mode-state.js +36 -0
- package/dist/repl/slash-completer.js +59 -0
- package/dist/repl/slash-picker.js +124 -0
- package/dist/repl/update-method-preview-hook.js +45 -0
- package/dist/repl.js +1383 -0
- package/dist/router/classifier.js +38 -0
- package/dist/router/intent-extractor.js +140 -0
- package/dist/router/routing-decision.js +19 -0
- package/dist/sap/connection-manager.js +52 -0
- package/dist/sap/onboarding.js +178 -0
- package/dist/sap/system-info.js +515 -0
- package/dist/session/awaiting-answer.js +73 -0
- package/dist/session/gc.js +28 -0
- package/dist/session/pending.js +37 -0
- package/dist/session/resume.js +77 -0
- package/dist/session/schema.js +20 -0
- package/dist/session/store.js +147 -0
- package/dist/session/time-ago.js +41 -0
- package/dist/skill-catalog.js +222 -0
- package/dist/skills/bundled-skills.js +1 -0
- package/dist/skills/canonical.js +12 -0
- package/dist/skills/manifest-client.js +93 -0
- package/dist/skills/promotion-dispatch.js +24 -0
- package/dist/skills/signing-public-key.js +4 -0
- package/dist/skills/source-bundled.js +20 -0
- package/dist/skills/source-managed.js +26 -0
- package/dist/skills/source-manifest.js +26 -0
- package/dist/tools/_command-shared.js +110 -0
- package/dist/tools/_filesystem-shared.js +81 -0
- package/dist/tools/_flag.js +39 -0
- package/dist/tools/approval.js +228 -0
- package/dist/tools/ask-question.js +205 -0
- package/dist/tools/dispatch-skill.js +81 -0
- package/dist/tools/filesystem/file-edit.js +140 -0
- package/dist/tools/filesystem/file-read.js +89 -0
- package/dist/tools/filesystem/file-write.js +128 -0
- package/dist/tools/filesystem/glob.js +177 -0
- package/dist/tools/filesystem/grep.js +163 -0
- package/dist/tools/index.js +32 -0
- package/dist/tools/project/convention_get.js +91 -0
- package/dist/tools/project/playbook_get.js +132 -0
- package/dist/tools/project/project_context_get.js +101 -0
- package/dist/tools/sap-read.js +454 -0
- package/dist/tools/sap-write.js +746 -0
- package/dist/tools/shell/shell_exec.js +209 -0
- package/dist/tools/snapshot.js +107 -0
- package/dist/tools/subagent/_background-shared.js +133 -0
- package/dist/tools/subagent/agent_run.js +186 -0
- package/dist/tools/subagent/background_run.js +143 -0
- package/dist/tools/subagent/monitor_emit.js +65 -0
- package/dist/tools/subagent/schedule_create.js +131 -0
- package/dist/tools/transport.js +233 -0
- package/dist/tools/update-method-intercept.js +119 -0
- package/dist/tools/verify.js +39 -0
- package/dist/tools/web/_web-shared.js +251 -0
- package/dist/tools/web/web_fetch.js +257 -0
- package/dist/tools/web/web_search.js +195 -0
- package/dist/tools/write-mode.js +22 -0
- package/dist/ui/app.js +95 -0
- package/dist/ui/approval-emitter.js +10 -0
- package/dist/ui/approval-modal.js +53 -0
- package/dist/ui/ascii-chars.js +6 -0
- package/dist/ui/body.js +102 -0
- package/dist/ui/coaching-picker-classic.js +36 -0
- package/dist/ui/coaching-picker-emitter.js +27 -0
- package/dist/ui/command-palette.js +34 -0
- package/dist/ui/error-emitter.js +21 -0
- package/dist/ui/footer.js +103 -0
- package/dist/ui/header.js +17 -0
- package/dist/ui/ink-classifier-route.js +19 -0
- package/dist/ui/login-banner.js +72 -0
- package/dist/ui/rich-error-box.js +9 -0
- package/dist/ui/sap-state-store.js +65 -0
- package/dist/ui/session-timeline.js +31 -0
- package/dist/ui/sidebar.js +10 -0
- package/dist/ui/skill-picker.js +50 -0
- package/dist/ui/status-row.js +12 -0
- package/dist/ui/widget-control.js +4 -0
- package/dist/ui/widgets/bar-chart.js +15 -0
- package/dist/ui/widgets/coaching-picker.js +41 -0
- package/dist/ui/widgets/component-registry.js +12 -0
- package/dist/ui/widgets/dep-graph.js +9 -0
- package/dist/ui/widgets/diff-viewer.js +11 -0
- package/dist/ui/widgets/question-card.js +11 -0
- package/dist/ui/widgets/stack-frames.js +5 -0
- package/dist/upgrade-check.js +28 -0
- package/dist/upgrade.js +13 -0
- package/package.json +83 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming-safe question-widget normalizer.
|
|
3
|
+
*
|
|
4
|
+
* LLMs emit the `cspeach-widget:question` payload inconsistently — sometimes
|
|
5
|
+
* with the proper code fence, sometimes as an indented code block, sometimes
|
|
6
|
+
* bare JSON with no sentinel and no fence at all (the streaming path often
|
|
7
|
+
* strips markers unpredictably). The widget extractor only matches the
|
|
8
|
+
* canonical fenced form, so everything else falls through and renders as raw
|
|
9
|
+
* JSON to the user — ugly and confusing.
|
|
10
|
+
*
|
|
11
|
+
* This normalizer runs before the widget extractor and aggressively
|
|
12
|
+
* rewrites anything that looks like a question-widget payload into clean
|
|
13
|
+
* markdown prose. Two detection modes:
|
|
14
|
+
*
|
|
15
|
+
* 1. SENTINEL MODE — `<!-- widget-real -->` followed by a balanced JSON
|
|
16
|
+
* object. The canonical path; also handles most fenced-but-mangled
|
|
17
|
+
* outputs because the sentinel survives even when the fence doesn't.
|
|
18
|
+
*
|
|
19
|
+
* 2. BARE-JSON MODE — any balanced JSON object containing `"question":`
|
|
20
|
+
* AND `"kind":"choice|multi|text"`. Catches the streaming edge case
|
|
21
|
+
* where the sentinel got stripped upstream (markdown renderer,
|
|
22
|
+
* streaming chunk boundary, etc). Safe because the payload shape is
|
|
23
|
+
* specific enough that we won't chew on unrelated JSON.
|
|
24
|
+
*
|
|
25
|
+
* Non-question widgets (dep-graph, bar-chart, stack-frames, diff-viewer)
|
|
26
|
+
* are left alone in both modes — their payloads don't have both `question`
|
|
27
|
+
* and `kind:choice|multi|text`.
|
|
28
|
+
*/
|
|
29
|
+
const SENTINEL = '<!-- widget-real -->';
|
|
30
|
+
const QUESTION_KEY = '"question"';
|
|
31
|
+
/**
|
|
32
|
+
* Find the start of a balanced JSON object at or after `from`. Returns the
|
|
33
|
+
* index of the opening `{` and the index ONE PAST the matching closing `}`.
|
|
34
|
+
* Returns null if no complete JSON object is found.
|
|
35
|
+
*/
|
|
36
|
+
function findBalancedJson(text, from) {
|
|
37
|
+
const start = text.indexOf('{', from);
|
|
38
|
+
if (start === -1)
|
|
39
|
+
return null;
|
|
40
|
+
let depth = 0;
|
|
41
|
+
let inString = false;
|
|
42
|
+
let escape = false;
|
|
43
|
+
for (let i = start; i < text.length; i++) {
|
|
44
|
+
const c = text[i];
|
|
45
|
+
if (escape) {
|
|
46
|
+
escape = false;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (inString) {
|
|
50
|
+
if (c === '\\')
|
|
51
|
+
escape = true;
|
|
52
|
+
else if (c === '"')
|
|
53
|
+
inString = false;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (c === '"') {
|
|
57
|
+
inString = true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (c === '{')
|
|
61
|
+
depth++;
|
|
62
|
+
else if (c === '}') {
|
|
63
|
+
depth--;
|
|
64
|
+
if (depth === 0)
|
|
65
|
+
return { start, end: i + 1 };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Walk backward from the `{` at `jsonStart` to find the enclosing block
|
|
72
|
+
* start. Pulls back over a ```cspeach-widget... fence line, over a
|
|
73
|
+
* `<!-- widget-real -->` sentinel line, and over any whitespace-only lines.
|
|
74
|
+
* Returns the offset where the block begins in `text`.
|
|
75
|
+
*/
|
|
76
|
+
function findBlockStart(text, jsonStart) {
|
|
77
|
+
let pos = jsonStart;
|
|
78
|
+
// Walk backward over preceding lines that are empty, the sentinel, or
|
|
79
|
+
// a fence-open marker. Stop on any line that has other content.
|
|
80
|
+
while (pos > 0) {
|
|
81
|
+
const prevNewline = text.lastIndexOf('\n', pos - 1);
|
|
82
|
+
const lineStart = prevNewline === -1 ? 0 : prevNewline + 1;
|
|
83
|
+
const line = text.slice(lineStart, pos).trim();
|
|
84
|
+
if (line === '' ||
|
|
85
|
+
line === SENTINEL ||
|
|
86
|
+
/^```(?:cspeach-widget:\S+)?$/.test(line) ||
|
|
87
|
+
/^````(?:cspeach-widget:\S+)?$/.test(line)) {
|
|
88
|
+
pos = lineStart;
|
|
89
|
+
if (prevNewline === -1)
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return pos;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Walk forward from `jsonEnd` (one past the closing `}`) to include a
|
|
100
|
+
* trailing close-fence line if present.
|
|
101
|
+
*/
|
|
102
|
+
function findBlockEnd(text, jsonEnd) {
|
|
103
|
+
// Skip trailing whitespace on current line
|
|
104
|
+
let pos = jsonEnd;
|
|
105
|
+
while (pos < text.length && text[pos] !== '\n')
|
|
106
|
+
pos++;
|
|
107
|
+
if (pos < text.length)
|
|
108
|
+
pos++; // consume newline
|
|
109
|
+
// Check next line for a close fence
|
|
110
|
+
const nextNewline = text.indexOf('\n', pos);
|
|
111
|
+
const nextLineEnd = nextNewline === -1 ? text.length : nextNewline;
|
|
112
|
+
const nextLine = text.slice(pos, nextLineEnd).trim();
|
|
113
|
+
if (nextLine === '```' || nextLine === '````') {
|
|
114
|
+
pos = nextNewline === -1 ? text.length : nextNewline + 1;
|
|
115
|
+
}
|
|
116
|
+
return pos;
|
|
117
|
+
}
|
|
118
|
+
/** Render a parsed question payload as clean markdown prose. */
|
|
119
|
+
function proseFromPayload(p) {
|
|
120
|
+
const lines = [];
|
|
121
|
+
lines.push('');
|
|
122
|
+
if (p.question)
|
|
123
|
+
lines.push(`**${p.question}**`);
|
|
124
|
+
if (p.context) {
|
|
125
|
+
lines.push('');
|
|
126
|
+
lines.push(`*${p.context}*`);
|
|
127
|
+
}
|
|
128
|
+
if (p.choices && p.choices.length > 0) {
|
|
129
|
+
lines.push('');
|
|
130
|
+
const header = p.kind === 'multi' ? 'Pick any (comma-separate your answers):' : 'Pick one:';
|
|
131
|
+
lines.push(header);
|
|
132
|
+
for (let i = 0; i < p.choices.length; i++) {
|
|
133
|
+
const c = p.choices[i];
|
|
134
|
+
lines.push(` ${i + 1}. ${c.label}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
lines.push('');
|
|
138
|
+
return lines.join('\n');
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* True if the parsed JSON looks like a question-widget payload (has a
|
|
142
|
+
* string `question` and a valid `kind`).
|
|
143
|
+
*/
|
|
144
|
+
function isQuestionPayload(obj) {
|
|
145
|
+
if (!obj || typeof obj !== 'object')
|
|
146
|
+
return false;
|
|
147
|
+
const p = obj;
|
|
148
|
+
return (typeof p['question'] === 'string' &&
|
|
149
|
+
(p['kind'] === 'text' || p['kind'] === 'choice' || p['kind'] === 'multi'));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Main entry. Scan `text` for question-widget payloads (via sentinel OR
|
|
153
|
+
* bare JSON shape) and replace each with prose. Streaming-safe: if a
|
|
154
|
+
* sentinel or `"question":` key is present but the following JSON is
|
|
155
|
+
* incomplete, return `tailFrom` so the caller can buffer and retry.
|
|
156
|
+
*/
|
|
157
|
+
export function normalizeQuestionBlocks(text) {
|
|
158
|
+
let out = '';
|
|
159
|
+
let cursor = 0;
|
|
160
|
+
while (cursor < text.length) {
|
|
161
|
+
// Find the next CANDIDATE — either a sentinel or a "question" key.
|
|
162
|
+
// Whichever comes first wins.
|
|
163
|
+
const sentinelIdx = text.indexOf(SENTINEL, cursor);
|
|
164
|
+
const questionKeyIdx = text.indexOf(QUESTION_KEY, cursor);
|
|
165
|
+
let candidateIdx;
|
|
166
|
+
let mode;
|
|
167
|
+
if (sentinelIdx === -1 && questionKeyIdx === -1) {
|
|
168
|
+
out += text.slice(cursor);
|
|
169
|
+
return { normalized: out, tailFrom: null };
|
|
170
|
+
}
|
|
171
|
+
else if (sentinelIdx === -1) {
|
|
172
|
+
candidateIdx = questionKeyIdx;
|
|
173
|
+
mode = 'bare';
|
|
174
|
+
}
|
|
175
|
+
else if (questionKeyIdx === -1) {
|
|
176
|
+
candidateIdx = sentinelIdx;
|
|
177
|
+
mode = 'sentinel';
|
|
178
|
+
}
|
|
179
|
+
else if (sentinelIdx < questionKeyIdx) {
|
|
180
|
+
candidateIdx = sentinelIdx;
|
|
181
|
+
mode = 'sentinel';
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
candidateIdx = questionKeyIdx;
|
|
185
|
+
mode = 'bare';
|
|
186
|
+
}
|
|
187
|
+
// In bare mode, the `{` is BEFORE the question key. Scan backward to
|
|
188
|
+
// find the enclosing `{`. In sentinel mode, `{` is AFTER the sentinel.
|
|
189
|
+
// `bufferAnchor` is the position we pull the streaming tail back to if
|
|
190
|
+
// the JSON is incomplete. Critically, the anchor must be pulled back
|
|
191
|
+
// FURTHER to also include any preceding ```cspeach-widget:question
|
|
192
|
+
// fence-open line — otherwise the downstream widget-extractor sees the
|
|
193
|
+
// dangling fence in `normalized` and OVERRIDES our buffer with its
|
|
194
|
+
// own (losing the sentinel + partial JSON).
|
|
195
|
+
let json;
|
|
196
|
+
let bufferAnchor;
|
|
197
|
+
if (mode === 'sentinel') {
|
|
198
|
+
json = findBalancedJson(text, candidateIdx + SENTINEL.length);
|
|
199
|
+
bufferAnchor = pullAnchorOverFence(text, candidateIdx);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
const braceStart = findEnclosingBrace(text, candidateIdx);
|
|
203
|
+
if (braceStart === -1) {
|
|
204
|
+
// No enclosing `{` found — buffer from the question key with the
|
|
205
|
+
// same fence-pullback treatment so any preceding fence-open line
|
|
206
|
+
// gets bundled.
|
|
207
|
+
const anchor = pullAnchorOverFence(text, candidateIdx);
|
|
208
|
+
out += text.slice(cursor, anchor);
|
|
209
|
+
return { normalized: out, tailFrom: anchor };
|
|
210
|
+
}
|
|
211
|
+
json = findBalancedJson(text, braceStart);
|
|
212
|
+
bufferAnchor = pullAnchorOverFence(text, braceStart);
|
|
213
|
+
}
|
|
214
|
+
if (!json) {
|
|
215
|
+
// JSON incomplete — emit everything up to the buffer anchor and ask
|
|
216
|
+
// the caller to buffer from the anchor. Critical: bufferAnchor must
|
|
217
|
+
// be at or before the opening `{`; otherwise the `{` is lost and
|
|
218
|
+
// the next chunk can't balance the braces. Also must be before any
|
|
219
|
+
// preceding fence-open line so widget-extractor doesn't fight us.
|
|
220
|
+
out += text.slice(cursor, bufferAnchor);
|
|
221
|
+
return { normalized: out, tailFrom: bufferAnchor };
|
|
222
|
+
}
|
|
223
|
+
// Try to parse.
|
|
224
|
+
let payload;
|
|
225
|
+
try {
|
|
226
|
+
payload = JSON.parse(text.slice(json.start, json.end));
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// Malformed JSON. Skip past it — emit verbatim and advance cursor so
|
|
230
|
+
// we don't loop forever on the same candidate.
|
|
231
|
+
out += text.slice(cursor, json.end);
|
|
232
|
+
cursor = json.end;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!isQuestionPayload(payload)) {
|
|
236
|
+
// Not a question payload (other widget types end up here). Emit
|
|
237
|
+
// verbatim so the widget extractor or markdown renderer handles it,
|
|
238
|
+
// and advance past the JSON.
|
|
239
|
+
out += text.slice(cursor, json.end);
|
|
240
|
+
cursor = json.end;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
// Replace the whole block (including any preceding fence+sentinel
|
|
244
|
+
// lines and trailing close fence) with prose.
|
|
245
|
+
const blockStart = findBlockStart(text, json.start);
|
|
246
|
+
const blockEnd = findBlockEnd(text, json.end);
|
|
247
|
+
out += text.slice(cursor, blockStart);
|
|
248
|
+
out += proseFromPayload(payload);
|
|
249
|
+
cursor = blockEnd;
|
|
250
|
+
}
|
|
251
|
+
return { normalized: out, tailFrom: null };
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Given an anchor position (e.g. sentinel or `{`), walk backward to the
|
|
255
|
+
* start of the line containing the anchor. If the line immediately
|
|
256
|
+
* preceding that is a ```cspeach-widget:... fence opener (or any bare
|
|
257
|
+
* ``` fence), pull the anchor further back to the start of THAT fence
|
|
258
|
+
* line. This ensures the buffered tail includes any dangling fence-open
|
|
259
|
+
* line, preventing the widget-extractor from seeing an orphan fence in
|
|
260
|
+
* `normalized` and overriding our buffer with its own.
|
|
261
|
+
*/
|
|
262
|
+
function pullAnchorOverFence(text, anchor) {
|
|
263
|
+
const prevNewline = text.lastIndexOf('\n', anchor - 1);
|
|
264
|
+
const lineStart = prevNewline === -1 ? 0 : prevNewline + 1;
|
|
265
|
+
if (prevNewline === -1)
|
|
266
|
+
return lineStart;
|
|
267
|
+
// Check the line BEFORE `anchor`'s line.
|
|
268
|
+
const priorPrevNewline = text.lastIndexOf('\n', prevNewline - 1);
|
|
269
|
+
const priorLineStart = priorPrevNewline === -1 ? 0 : priorPrevNewline + 1;
|
|
270
|
+
const priorLine = text.slice(priorLineStart, prevNewline).trim();
|
|
271
|
+
if (/^```(?:cspeach-widget:\S+)?$/.test(priorLine) || /^````(?:cspeach-widget:\S+)?$/.test(priorLine)) {
|
|
272
|
+
return priorLineStart;
|
|
273
|
+
}
|
|
274
|
+
return lineStart;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Walk backward from `pos` to find the nearest `{` that is NOT inside a
|
|
278
|
+
* string. This is the enclosing JSON object's opening brace when `pos`
|
|
279
|
+
* points at a key-level position. Returns -1 if no such `{` exists in the
|
|
280
|
+
* text before `pos`.
|
|
281
|
+
*
|
|
282
|
+
* This is a best-effort scan — to be fully correct we'd need forward
|
|
283
|
+
* string-state tracking from the start of the text. For our purposes
|
|
284
|
+
* (locating an object containing `"question":`), we do a simpler check:
|
|
285
|
+
* walk back, counting unmatched `{`/`}`, stopping at the first `{` that
|
|
286
|
+
* brings us to depth -1 (i.e. one more open than close on the way back).
|
|
287
|
+
*
|
|
288
|
+
* Limitation: if the `"question"` string itself contains unbalanced braces
|
|
289
|
+
* in its value (e.g. `"question": "count { elements"`), this can pick the
|
|
290
|
+
* wrong brace. Acceptable risk — real question texts don't contain raw
|
|
291
|
+
* braces.
|
|
292
|
+
*/
|
|
293
|
+
function findEnclosingBrace(text, pos) {
|
|
294
|
+
let depth = 0;
|
|
295
|
+
for (let i = pos - 1; i >= 0; i--) {
|
|
296
|
+
const c = text[i];
|
|
297
|
+
if (c === '}')
|
|
298
|
+
depth++;
|
|
299
|
+
else if (c === '{') {
|
|
300
|
+
if (depth === 0)
|
|
301
|
+
return i;
|
|
302
|
+
depth--;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return -1;
|
|
306
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// C:\Users\Admin\ClaudeProjects\ABAPForge\cspeach-cli\src\renderer\severity.ts
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
const PILLS = {
|
|
4
|
+
CRITICAL: (s) => chalk.bgRedBright.white.bold(s),
|
|
5
|
+
HIGH: (s) => chalk.bgRed.white(s),
|
|
6
|
+
MEDIUM: (s) => chalk.bgYellow.black(s),
|
|
7
|
+
LOW: (s) => chalk.bgGreen.black(s),
|
|
8
|
+
};
|
|
9
|
+
const FENCE_RE = /^```/;
|
|
10
|
+
const ANSI_STRIP_RE = /\x1B\[[0-9;]*[mGKHFABCDsuJSTfrl]|\x1B\][^\x07]*\x07/g;
|
|
11
|
+
// A line that is part of a rendered cli-table3 block always starts with one of
|
|
12
|
+
// these box-drawing chars (after any ANSI codes). Severity pills in those
|
|
13
|
+
// cells are applied inside markdown.ts's custom table renderer BEFORE
|
|
14
|
+
// cli-table3 computes column widths — re-applying here would add ` HIGH `
|
|
15
|
+
// (6 visible chars) over an already-styled 4-char "HIGH", pushing every
|
|
16
|
+
// following `│` border right by 2 cells and breaking the layout.
|
|
17
|
+
const TABLE_LINE_RE = /^[┌│└├┤┬┴]/;
|
|
18
|
+
function isTableLine(line) {
|
|
19
|
+
return TABLE_LINE_RE.test(line.replace(ANSI_STRIP_RE, '').trimStart());
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Replace bare severity words (CRITICAL / HIGH / MEDIUM / LOW) with
|
|
23
|
+
* chalk-styled pills. Words inside fenced code blocks or inside rendered
|
|
24
|
+
* cli-table3 rows are left untouched.
|
|
25
|
+
*
|
|
26
|
+
* Operates line-by-line so fence tracking is exact and table rows (styled
|
|
27
|
+
* upstream by markdown.ts) aren't double-wrapped.
|
|
28
|
+
*/
|
|
29
|
+
export function applySeverity(text) {
|
|
30
|
+
const lines = text.split('\n');
|
|
31
|
+
let insideFence = false;
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
if (FENCE_RE.test(line)) {
|
|
35
|
+
insideFence = !insideFence;
|
|
36
|
+
out.push(line);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (insideFence) {
|
|
40
|
+
out.push(line);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (isTableLine(line)) {
|
|
44
|
+
// Already styled inside the table renderer — skip to avoid layout drift.
|
|
45
|
+
out.push(line);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
out.push(replacePills(line));
|
|
49
|
+
}
|
|
50
|
+
return out.join('\n');
|
|
51
|
+
}
|
|
52
|
+
/** Exposed so markdown.ts can apply per-cell severity BEFORE cli-table3 lays out the table. */
|
|
53
|
+
export function applySeverityToCell(cell) {
|
|
54
|
+
return replacePills(cell);
|
|
55
|
+
}
|
|
56
|
+
function replacePills(line) {
|
|
57
|
+
return line.replace(/\b(CRITICAL|HIGH|MEDIUM|LOW)\b/g, (match) => {
|
|
58
|
+
const fn = PILLS[match];
|
|
59
|
+
return fn ? fn(` ${match} `) : match;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
const RULE_WIDTH = 72;
|
|
3
|
+
const RULE = chalk.dim('─'.repeat(RULE_WIDTH));
|
|
4
|
+
/**
|
|
5
|
+
* Prints a status footer after each turn ends.
|
|
6
|
+
*
|
|
7
|
+
* Format (spec §3.7, v0.2 — cost-free variant):
|
|
8
|
+
* ────────────────────────────────────────────────────────────────────
|
|
9
|
+
* session 870ed8cc │ S4H-DEV │ /abap-radar │ 3 turns │ 6,533 tok this turn │ 2,100 cached │ 4.2s
|
|
10
|
+
* ────────────────────────────────────────────────────────────────────
|
|
11
|
+
*
|
|
12
|
+
* H1 — `tokens` is the per-turn delta (input + output consumed THIS turn),
|
|
13
|
+
* computed by the REPL as `after − before` around the `runTurn` call against
|
|
14
|
+
* the session-lifetime `session.usage`. `cachedTokens` remains lifetime
|
|
15
|
+
* (cache reads are rare per-turn; the session running count is more useful).
|
|
16
|
+
*
|
|
17
|
+
* M9 — Dollar display removed in v0.2: accurate pricing requires splitting
|
|
18
|
+
* input / output / cached-read / cached-write tokens against four different
|
|
19
|
+
* per-MTok prices, which the footer does not have access to post-turn.
|
|
20
|
+
* A blended `$30/M * totalTokens` formula is wrong by up to 6× on cache-heavy
|
|
21
|
+
* turns (cached reads are $0.30/M, not $30/M) and misleads rather than
|
|
22
|
+
* informs. v0.3 adds proper usage breakdown via
|
|
23
|
+
* `session.tokens_by_category` and restores a provably correct cost line.
|
|
24
|
+
*
|
|
25
|
+
* v0.3 transition: this chalk status-footer-after-turn is replaced (not
|
|
26
|
+
* augmented) by the Ink persistent footer region in Plan 4c.
|
|
27
|
+
*/
|
|
28
|
+
export function printStatusFooter(params) {
|
|
29
|
+
const { sessionId, sapAlias, skill, turnCount, tokens, cachedTokens, elapsedMs } = params;
|
|
30
|
+
const shortSession = sessionId.slice(0, 8);
|
|
31
|
+
const skillDisplay = skill.startsWith('/') ? skill : `/${skill}`;
|
|
32
|
+
const turns = `${turnCount} turn${turnCount === 1 ? '' : 's'}`;
|
|
33
|
+
// H1 — "this turn" makes it explicit the token count is per-turn, not
|
|
34
|
+
// session-lifetime. The REPL passes the pre/post-runTurn delta.
|
|
35
|
+
const tokStr = tokens.toLocaleString() + ' tok this turn';
|
|
36
|
+
const cachedStr = cachedTokens > 0 ? `${cachedTokens.toLocaleString()} cached` : '';
|
|
37
|
+
const elapsed = elapsedMs > 0 ? `${(elapsedMs / 1000).toFixed(1)}s` : '';
|
|
38
|
+
const parts = [
|
|
39
|
+
chalk.dim(`session ${shortSession}`),
|
|
40
|
+
chalk.dim(sapAlias),
|
|
41
|
+
chalk.dim(skillDisplay),
|
|
42
|
+
chalk.dim(turns),
|
|
43
|
+
chalk.dim(tokStr),
|
|
44
|
+
...(cachedStr ? [chalk.dim(cachedStr)] : []),
|
|
45
|
+
...(elapsed ? [chalk.dim(elapsed)] : []),
|
|
46
|
+
];
|
|
47
|
+
console.log('\n' + RULE);
|
|
48
|
+
console.log(' ' + parts.join(chalk.dim(' │ ')));
|
|
49
|
+
console.log(RULE + '\n');
|
|
50
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syntax dispatcher — reads the fenced-code-block language hint and routes
|
|
3
|
+
* to the correct cli-highlight grammar. Called by renderer/pipeline.ts
|
|
4
|
+
* for every fenced block it extracts.
|
|
5
|
+
*
|
|
6
|
+
* Returns a chalk-escaped string. In non-TTY mode the highlight() call
|
|
7
|
+
* still runs but the terminal won't render colours — tty.ts callers are
|
|
8
|
+
* responsible for stripping ANSI if required.
|
|
9
|
+
*
|
|
10
|
+
* M3 — cli-highlight's public API does NOT accept a `languages: {...}` map
|
|
11
|
+
* in the `highlight()` options; grammars must be registered against the
|
|
12
|
+
* underlying highlight.js namespace via `hljs.registerLanguage(name, fn)`.
|
|
13
|
+
* cli-highlight depends on and re-uses the same `highlight.js` module
|
|
14
|
+
* singleton, so registering there makes the grammars discoverable from
|
|
15
|
+
* cli-highlight's `highlight()` call. We register at module-load time so
|
|
16
|
+
* every subsequent `highlightCodeBlock` call just dispatches.
|
|
17
|
+
*
|
|
18
|
+
* If a future version of cli-highlight vends its own isolated hljs
|
|
19
|
+
* instance (check `node_modules/cli-highlight/package.json` `main` entry
|
|
20
|
+
* and its actual `require('highlight.js')` source), swap the import to
|
|
21
|
+
* `import hljs from 'cli-highlight'` and call `hljs.registerLanguage` on
|
|
22
|
+
* that object — the shape of the API is the same.
|
|
23
|
+
*/
|
|
24
|
+
import { highlight } from 'cli-highlight';
|
|
25
|
+
import hljs from 'highlight.js';
|
|
26
|
+
import abapGrammar from './highlighters/abap.js';
|
|
27
|
+
import cdsGrammar from './highlighters/cds.js';
|
|
28
|
+
import bdefGrammar from './highlighters/bdef.js';
|
|
29
|
+
// Module-load-time registration. The grammar modules export plain objects
|
|
30
|
+
// (shape matches highlight.js `Language` definition). Cast is safe — the
|
|
31
|
+
// grammars match the runtime contract even when strict typings drift.
|
|
32
|
+
hljs.registerLanguage('abap', () => abapGrammar);
|
|
33
|
+
hljs.registerLanguage('cds', () => cdsGrammar);
|
|
34
|
+
hljs.registerLanguage('bdef', () => bdefGrammar);
|
|
35
|
+
const SUPPORTED = new Set(['abap', 'cds', 'bdef']);
|
|
36
|
+
/**
|
|
37
|
+
* Highlight a code block body (without fence markers).
|
|
38
|
+
*
|
|
39
|
+
* @param code Raw source text between the opening and closing ``` fences.
|
|
40
|
+
* @param lang Language hint from the opening fence (e.g. 'abap', 'cds').
|
|
41
|
+
* Empty string or unrecognised lang → returns `code` unchanged.
|
|
42
|
+
* @returns ANSI-highlighted string or the original code if no grammar.
|
|
43
|
+
*/
|
|
44
|
+
export function highlightCodeBlock(code, lang) {
|
|
45
|
+
const normalised = lang.trim().toLowerCase();
|
|
46
|
+
if (!SUPPORTED.has(normalised))
|
|
47
|
+
return code;
|
|
48
|
+
try {
|
|
49
|
+
return highlight(code, {
|
|
50
|
+
language: normalised,
|
|
51
|
+
ignoreIllegals: true,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Grammar error should never surface to the user.
|
|
56
|
+
return code;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// C:\Users\Admin\ClaudeProjects\ABAPForge\cspeach-cli\src\renderer\tables.ts
|
|
2
|
+
import Table from 'cli-table3';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { applySeverity } from './severity.js';
|
|
5
|
+
/** Regex that matches a markdown pipe-table row (at least two | delimiters). */
|
|
6
|
+
const PIPE_ROW_RE = /^\|(.+\|)+$/;
|
|
7
|
+
/** Separator row: | --- | or | :---: | etc. */
|
|
8
|
+
const SEP_ROW_RE = /^\|[\s|:-]+\|$/;
|
|
9
|
+
/**
|
|
10
|
+
* Scan `text` for contiguous blocks of markdown pipe-table rows,
|
|
11
|
+
* replace each with a cli-table3 rendered string.
|
|
12
|
+
* Non-table lines are passed through unchanged.
|
|
13
|
+
*/
|
|
14
|
+
export function renderTables(text) {
|
|
15
|
+
const lines = text.split('\n');
|
|
16
|
+
const out = [];
|
|
17
|
+
let tableLines = [];
|
|
18
|
+
const flushTable = () => {
|
|
19
|
+
if (tableLines.length === 0)
|
|
20
|
+
return;
|
|
21
|
+
out.push(buildTable(tableLines));
|
|
22
|
+
tableLines = [];
|
|
23
|
+
};
|
|
24
|
+
for (const line of lines) {
|
|
25
|
+
if (PIPE_ROW_RE.test(line.trim())) {
|
|
26
|
+
tableLines.push(line);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
flushTable();
|
|
30
|
+
out.push(line);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
flushTable();
|
|
34
|
+
return out.join('\n');
|
|
35
|
+
}
|
|
36
|
+
function parseRow(line) {
|
|
37
|
+
return line
|
|
38
|
+
.split('|')
|
|
39
|
+
.slice(1, -1) // drop leading/trailing empty segments
|
|
40
|
+
.map((c) => c.trim());
|
|
41
|
+
}
|
|
42
|
+
function buildTable(lines) {
|
|
43
|
+
// First non-separator row is the header.
|
|
44
|
+
const rows = lines.filter((l) => !SEP_ROW_RE.test(l.trim()));
|
|
45
|
+
if (rows.length === 0)
|
|
46
|
+
return lines.join('\n');
|
|
47
|
+
const [headerRow, ...dataRows] = rows;
|
|
48
|
+
const headers = parseRow(headerRow).map((h) => chalk.cyan.bold(h));
|
|
49
|
+
const table = new Table({ head: headers });
|
|
50
|
+
for (const row of dataRows) {
|
|
51
|
+
const cells = parseRow(row).map((c) => applySeverity(c));
|
|
52
|
+
table.push(cells);
|
|
53
|
+
}
|
|
54
|
+
return table.toString();
|
|
55
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thinking heartbeat — guaranteed-visible "model is alive" indicator.
|
|
3
|
+
*
|
|
4
|
+
* The animated spinner in tool-widget.ts uses `\r` cursor manipulation to
|
|
5
|
+
* paint frames in place. That's nice on a real ANSI-capable terminal but
|
|
6
|
+
* fails on:
|
|
7
|
+
*
|
|
8
|
+
* - Windows PowerShell where process.stdout.isTTY returns false in some
|
|
9
|
+
* configurations (the spinner self-disables).
|
|
10
|
+
* - Terminals where the readline prompt redraw races with the spinner's
|
|
11
|
+
* `\r\x1B[2K` and overwrites it before the user sees anything.
|
|
12
|
+
* - CI / piped output where ANSI codes are stripped.
|
|
13
|
+
*
|
|
14
|
+
* Live test on PowerShell showed a 45-second blank-cursor wait during a
|
|
15
|
+
* resumed turn. The heartbeat solves this by printing FRESH LINES at
|
|
16
|
+
* elapsed-time thresholds — no cursor manipulation, no overwriting,
|
|
17
|
+
* works on the dumbest terminal:
|
|
18
|
+
*
|
|
19
|
+
* 3s → ⠂ thinking…
|
|
20
|
+
* 10s → ⠂ thinking… (10s)
|
|
21
|
+
* 30s → ⠂ thinking… (30s)
|
|
22
|
+
* 60s → ⠂ thinking… (60s)
|
|
23
|
+
* 90s → ⠂ thinking… (90s — long wait, hold on)
|
|
24
|
+
* 120s → ⠂ thinking… (2m — provider unusually slow, may want to interrupt)
|
|
25
|
+
*
|
|
26
|
+
* Stops as soon as content arrives. Designed to complement, not replace,
|
|
27
|
+
* the animated spinner — both can run; the spinner gives a smooth in-place
|
|
28
|
+
* pulse on capable terminals, the heartbeat is the "definitely visible"
|
|
29
|
+
* fallback.
|
|
30
|
+
*/
|
|
31
|
+
import chalk from 'chalk';
|
|
32
|
+
const THRESHOLDS_MS = [
|
|
33
|
+
{ at: 3_000, label: 'thinking…' },
|
|
34
|
+
{ at: 10_000, label: 'thinking… (10s)' },
|
|
35
|
+
{ at: 30_000, label: 'thinking… (30s)' },
|
|
36
|
+
{ at: 60_000, label: 'thinking… (60s)' },
|
|
37
|
+
{ at: 90_000, label: 'thinking… (90s — long wait, hold on)' },
|
|
38
|
+
{ at: 120_000, label: 'thinking… (2m — provider unusually slow, Ctrl+C to interrupt)' },
|
|
39
|
+
{ at: 180_000, label: 'thinking… (3m — Ctrl+C is recommended; the stream may have stalled)' },
|
|
40
|
+
{ at: 300_000, label: 'thinking… (5m — Ctrl+C and try again)' },
|
|
41
|
+
];
|
|
42
|
+
export function startThinkingHeartbeat() {
|
|
43
|
+
const startedAt = Date.now();
|
|
44
|
+
let nextIdx = 0;
|
|
45
|
+
let stopped = false;
|
|
46
|
+
const tick = () => {
|
|
47
|
+
if (stopped)
|
|
48
|
+
return;
|
|
49
|
+
const elapsed = Date.now() - startedAt;
|
|
50
|
+
while (nextIdx < THRESHOLDS_MS.length && THRESHOLDS_MS[nextIdx].at <= elapsed) {
|
|
51
|
+
const t = THRESHOLDS_MS[nextIdx];
|
|
52
|
+
// Plain newline-terminated line — survives any terminal mode.
|
|
53
|
+
// Indent by 2 spaces to align with tool-call rows.
|
|
54
|
+
process.stdout.write(chalk.dim(` ${t.label}\n`));
|
|
55
|
+
nextIdx++;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
// Check every second — granular enough to fire each threshold within a
|
|
59
|
+
// second of crossing it without producing noisy intermediate output.
|
|
60
|
+
const interval = setInterval(tick, 1000);
|
|
61
|
+
interval.unref?.();
|
|
62
|
+
return {
|
|
63
|
+
stop: () => {
|
|
64
|
+
if (stopped)
|
|
65
|
+
return;
|
|
66
|
+
stopped = true;
|
|
67
|
+
clearInterval(interval);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|