@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.
Files changed (210) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +108 -0
  3. package/dist/agent/anthropic-provider.js +59 -0
  4. package/dist/agent/llm-provider.js +1 -0
  5. package/dist/agent/loop.js +709 -0
  6. package/dist/agent/maybe-build-project-context.js +126 -0
  7. package/dist/agent/providers/ai-hub-provider.js +58 -0
  8. package/dist/agent/providers/byok-provider.js +53 -0
  9. package/dist/agent/providers/factory.js +13 -0
  10. package/dist/agent/providers/local-provider.js +125 -0
  11. package/dist/agent/repair-partial.js +31 -0
  12. package/dist/agent/retry-key.js +58 -0
  13. package/dist/agent/retry.js +17 -0
  14. package/dist/agent/sap-connection-adapter.js +82 -0
  15. package/dist/agent/skill-checkpoint.js +119 -0
  16. package/dist/agent/tool-dispatch.js +47 -0
  17. package/dist/agent/turn-assistant-text.js +49 -0
  18. package/dist/agent/turn-error-ux.js +126 -0
  19. package/dist/agent/turn-stream.js +79 -0
  20. package/dist/agent/turn-watchdog.js +71 -0
  21. package/dist/approvals/advisory-prompt.js +40 -0
  22. package/dist/approvals/advisory-render.js +38 -0
  23. package/dist/approvals/approval-prompt.js +100 -0
  24. package/dist/approvals/jwt.js +33 -0
  25. package/dist/approvals/render.js +211 -0
  26. package/dist/approvals/risk-floor.js +26 -0
  27. package/dist/auth/api-key.js +40 -0
  28. package/dist/auth/auth-file.js +59 -0
  29. package/dist/auth/device.js +8 -0
  30. package/dist/auth/me.js +19 -0
  31. package/dist/classifier/client.js +58 -0
  32. package/dist/cli-args.js +38 -0
  33. package/dist/cli.js +148 -0
  34. package/dist/commands/config-set.js +245 -0
  35. package/dist/commands/config-show.js +159 -0
  36. package/dist/commands/help.js +93 -0
  37. package/dist/commands/login.js +122 -0
  38. package/dist/commands/logout.js +17 -0
  39. package/dist/commands/project-context-impact.js +215 -0
  40. package/dist/commands/reroute.js +60 -0
  41. package/dist/commands/spec-gap-status.js +52 -0
  42. package/dist/commands/whoami.js +35 -0
  43. package/dist/config/loader.js +67 -0
  44. package/dist/config/paths.js +20 -0
  45. package/dist/doctor/checks/_http-probe.js +56 -0
  46. package/dist/doctor/checks/auth.js +15 -0
  47. package/dist/doctor/checks/cert.js +24 -0
  48. package/dist/doctor/checks/forge-rules.js +102 -0
  49. package/dist/doctor/checks/keychain-fallback.js +14 -0
  50. package/dist/doctor/checks/keychain.js +23 -0
  51. package/dist/doctor/checks/llm-mode.js +27 -0
  52. package/dist/doctor/checks/proxy.js +13 -0
  53. package/dist/doctor/checks/sap.js +33 -0
  54. package/dist/doctor/checks/skill.js +24 -0
  55. package/dist/doctor/checks/write-mode.js +34 -0
  56. package/dist/doctor/checks/zcspeach.js +76 -0
  57. package/dist/doctor/run.js +46 -0
  58. package/dist/errors/codes.js +12 -0
  59. package/dist/index.js +6 -0
  60. package/dist/lock-contention.js +22 -0
  61. package/dist/one-shot.js +104 -0
  62. package/dist/project-context/conventions.js +309 -0
  63. package/dist/project-context/detect.js +250 -0
  64. package/dist/project-context/domain/abap-cloud.js +26 -0
  65. package/dist/project-context/domain/abapgit.js +177 -0
  66. package/dist/project-context/domain/cap.js +164 -0
  67. package/dist/project-context/domain/fiori.js +326 -0
  68. package/dist/project-context/git.js +115 -0
  69. package/dist/project-context/index-files.js +235 -0
  70. package/dist/project-context/index.js +117 -0
  71. package/dist/project-context/render.js +308 -0
  72. package/dist/project-context/types.js +14 -0
  73. package/dist/projects/build.js +20 -0
  74. package/dist/projects/canonicalize.js +39 -0
  75. package/dist/projects/email-template.js +54 -0
  76. package/dist/projects/extract-cca.js +139 -0
  77. package/dist/projects/extract-design.js +107 -0
  78. package/dist/projects/extract-estimate.js +93 -0
  79. package/dist/projects/extract-modernize.js +130 -0
  80. package/dist/projects/extract-spec-gap.js +101 -0
  81. package/dist/projects/extract-test-coverage.js +137 -0
  82. package/dist/projects/extract-upgrade.js +230 -0
  83. package/dist/projects/filename.js +18 -0
  84. package/dist/projects/index.js +8 -0
  85. package/dist/projects/migration.js +111 -0
  86. package/dist/projects/promote-command.js +96 -0
  87. package/dist/projects/promote.js +107 -0
  88. package/dist/projects/save-command.js +124 -0
  89. package/dist/projects/save.js +21 -0
  90. package/dist/projects/status.js +170 -0
  91. package/dist/projects/types.js +1 -0
  92. package/dist/projects/validate.js +146 -0
  93. package/dist/projects/workspace.js +478 -0
  94. package/dist/renderer/abap-inline.js +121 -0
  95. package/dist/renderer/banners.js +39 -0
  96. package/dist/renderer/highlighters/abap.js +126 -0
  97. package/dist/renderer/highlighters/bdef.js +81 -0
  98. package/dist/renderer/highlighters/cds.js +91 -0
  99. package/dist/renderer/markdown.js +291 -0
  100. package/dist/renderer/pipeline.js +201 -0
  101. package/dist/renderer/progress-chatter.js +237 -0
  102. package/dist/renderer/question-normalizer.js +306 -0
  103. package/dist/renderer/severity.js +61 -0
  104. package/dist/renderer/status-footer.js +50 -0
  105. package/dist/renderer/syntax.js +58 -0
  106. package/dist/renderer/tables.js +55 -0
  107. package/dist/renderer/thinking-heartbeat.js +70 -0
  108. package/dist/renderer/tool-widget.js +199 -0
  109. package/dist/renderer/tty.js +66 -0
  110. package/dist/renderer/widget-extractor.js +87 -0
  111. package/dist/renderer/widget-fallback.js +78 -0
  112. package/dist/renderer/widget-schemas.js +43 -0
  113. package/dist/repl/at-completer.js +64 -0
  114. package/dist/repl/at-picker.js +122 -0
  115. package/dist/repl/bracketed-paste.js +284 -0
  116. package/dist/repl/current-transport.js +46 -0
  117. package/dist/repl/diff-display.js +41 -0
  118. package/dist/repl/file-picker.js +219 -0
  119. package/dist/repl/inquirer-guard.js +130 -0
  120. package/dist/repl/inquirer-theme.js +41 -0
  121. package/dist/repl/rule8-detector.js +99 -0
  122. package/dist/repl/safety-confirm.js +106 -0
  123. package/dist/repl/safety-mode-state.js +36 -0
  124. package/dist/repl/slash-completer.js +59 -0
  125. package/dist/repl/slash-picker.js +124 -0
  126. package/dist/repl/update-method-preview-hook.js +45 -0
  127. package/dist/repl.js +1383 -0
  128. package/dist/router/classifier.js +38 -0
  129. package/dist/router/intent-extractor.js +140 -0
  130. package/dist/router/routing-decision.js +19 -0
  131. package/dist/sap/connection-manager.js +52 -0
  132. package/dist/sap/onboarding.js +178 -0
  133. package/dist/sap/system-info.js +515 -0
  134. package/dist/session/awaiting-answer.js +73 -0
  135. package/dist/session/gc.js +28 -0
  136. package/dist/session/pending.js +37 -0
  137. package/dist/session/resume.js +77 -0
  138. package/dist/session/schema.js +20 -0
  139. package/dist/session/store.js +147 -0
  140. package/dist/session/time-ago.js +41 -0
  141. package/dist/skill-catalog.js +222 -0
  142. package/dist/skills/bundled-skills.js +1 -0
  143. package/dist/skills/canonical.js +12 -0
  144. package/dist/skills/manifest-client.js +93 -0
  145. package/dist/skills/promotion-dispatch.js +24 -0
  146. package/dist/skills/signing-public-key.js +4 -0
  147. package/dist/skills/source-bundled.js +20 -0
  148. package/dist/skills/source-managed.js +26 -0
  149. package/dist/skills/source-manifest.js +26 -0
  150. package/dist/tools/_command-shared.js +110 -0
  151. package/dist/tools/_filesystem-shared.js +81 -0
  152. package/dist/tools/_flag.js +39 -0
  153. package/dist/tools/approval.js +228 -0
  154. package/dist/tools/ask-question.js +205 -0
  155. package/dist/tools/dispatch-skill.js +81 -0
  156. package/dist/tools/filesystem/file-edit.js +140 -0
  157. package/dist/tools/filesystem/file-read.js +89 -0
  158. package/dist/tools/filesystem/file-write.js +128 -0
  159. package/dist/tools/filesystem/glob.js +177 -0
  160. package/dist/tools/filesystem/grep.js +163 -0
  161. package/dist/tools/index.js +32 -0
  162. package/dist/tools/project/convention_get.js +91 -0
  163. package/dist/tools/project/playbook_get.js +132 -0
  164. package/dist/tools/project/project_context_get.js +101 -0
  165. package/dist/tools/sap-read.js +454 -0
  166. package/dist/tools/sap-write.js +746 -0
  167. package/dist/tools/shell/shell_exec.js +209 -0
  168. package/dist/tools/snapshot.js +107 -0
  169. package/dist/tools/subagent/_background-shared.js +133 -0
  170. package/dist/tools/subagent/agent_run.js +186 -0
  171. package/dist/tools/subagent/background_run.js +143 -0
  172. package/dist/tools/subagent/monitor_emit.js +65 -0
  173. package/dist/tools/subagent/schedule_create.js +131 -0
  174. package/dist/tools/transport.js +233 -0
  175. package/dist/tools/update-method-intercept.js +119 -0
  176. package/dist/tools/verify.js +39 -0
  177. package/dist/tools/web/_web-shared.js +251 -0
  178. package/dist/tools/web/web_fetch.js +257 -0
  179. package/dist/tools/web/web_search.js +195 -0
  180. package/dist/tools/write-mode.js +22 -0
  181. package/dist/ui/app.js +95 -0
  182. package/dist/ui/approval-emitter.js +10 -0
  183. package/dist/ui/approval-modal.js +53 -0
  184. package/dist/ui/ascii-chars.js +6 -0
  185. package/dist/ui/body.js +102 -0
  186. package/dist/ui/coaching-picker-classic.js +36 -0
  187. package/dist/ui/coaching-picker-emitter.js +27 -0
  188. package/dist/ui/command-palette.js +34 -0
  189. package/dist/ui/error-emitter.js +21 -0
  190. package/dist/ui/footer.js +103 -0
  191. package/dist/ui/header.js +17 -0
  192. package/dist/ui/ink-classifier-route.js +19 -0
  193. package/dist/ui/login-banner.js +72 -0
  194. package/dist/ui/rich-error-box.js +9 -0
  195. package/dist/ui/sap-state-store.js +65 -0
  196. package/dist/ui/session-timeline.js +31 -0
  197. package/dist/ui/sidebar.js +10 -0
  198. package/dist/ui/skill-picker.js +50 -0
  199. package/dist/ui/status-row.js +12 -0
  200. package/dist/ui/widget-control.js +4 -0
  201. package/dist/ui/widgets/bar-chart.js +15 -0
  202. package/dist/ui/widgets/coaching-picker.js +41 -0
  203. package/dist/ui/widgets/component-registry.js +12 -0
  204. package/dist/ui/widgets/dep-graph.js +9 -0
  205. package/dist/ui/widgets/diff-viewer.js +11 -0
  206. package/dist/ui/widgets/question-card.js +11 -0
  207. package/dist/ui/widgets/stack-frames.js +5 -0
  208. package/dist/upgrade-check.js +28 -0
  209. package/dist/upgrade.js +13 -0
  210. 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
+ }