@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,201 @@
1
+ // ────────────────────────────────────────────────────────────────────
2
+ // IMPORTS — all at top of file (ESM)
3
+ // ────────────────────────────────────────────────────────────────────
4
+ import { isTTY, shouldUseInk } from './tty.js'; // UNCHANGED FROM v0.1.1 (shouldUseInk is NEW IN v0.3 use-site)
5
+ import { renderMarkdown } from './markdown.js'; // UNCHANGED FROM v0.1.1
6
+ import { applySeverity } from './severity.js'; // UNCHANGED FROM v0.1.1
7
+ import { applyAbapHighlight } from './abap-inline.js'; // UNCHANGED FROM v0.1.1
8
+ import { renderTables } from './tables.js'; // UNCHANGED FROM v0.1.1
9
+ import { extractWidgets } from './widget-extractor.js'; // NEW IN v0.3
10
+ import { WIDGET_SCHEMAS } from './widget-schemas.js'; // NEW IN v0.3
11
+ import { WIDGET_CONTROL } from '../ui/widget-control.js'; // NEW IN v0.3
12
+ // Question-widget normalizer retired in v0.3.1 hotfix — skills now call the
13
+ // `ask_question` tool for interactive questions (src/tools/ask-question.ts).
14
+ // The normalizer's regex + streaming buffering proved too fragile. Import
15
+ // kept commented-out as a breadcrumb; the file itself is retained in case
16
+ // any other skill still emits a widget-real block we want to catch.
17
+ import { renderQuestionCard } from './widget-fallback.js'; // NEW IN v0.3.1
18
+ export function initialBufferState() {
19
+ return { pending: '', fenceCount: 0, widgetBuffer: '', insideWidget: false, orderedListCounter: 0 };
20
+ }
21
+ // ────────────────────────────────────────────────────────────────────
22
+ // FULL-TEXT RENDER — UNCHANGED FROM v0.1.1
23
+ // Pipeline order: markdown → severity → abap-inline → tables.
24
+ // Non-TTY: strip ANSI so piped output is clean.
25
+ // ────────────────────────────────────────────────────────────────────
26
+ export function render(text) {
27
+ let out = renderMarkdown(text);
28
+ out = applySeverity(out);
29
+ out = applyAbapHighlight(out);
30
+ out = renderTables(out);
31
+ if (!isTTY())
32
+ out = stripAnsi(out);
33
+ return out;
34
+ }
35
+ // ────────────────────────────────────────────────────────────────────
36
+ // STREAMING CHUNK RENDER — NEW OUTER SHAPE IN v0.3 (widget pre-stage),
37
+ // inner passthrough path UNCHANGED FROM v0.1.1 (see renderPassthrough).
38
+ //
39
+ // Order of operations per chunk:
40
+ // 1. If mid-widget (state.insideWidget), prepend state.widgetBuffer.
41
+ // 2. Extract widget segments via extractWidgets(). Buffered-tail returns
42
+ // keep the unterminated fence in state.widgetBuffer and re-enter next chunk.
43
+ // 3. Each passthrough segment goes through renderPassthrough (v0.1.1 logic).
44
+ // 4. Each widget segment goes through renderWidget (validate + emit control
45
+ // string for Ink, or ```json``` fallback for non-TTY).
46
+ //
47
+ // INVARIANT: when no widget fence has ever been seen in this stream,
48
+ // state.insideWidget stays false and the whole chunk is a single
49
+ // passthrough segment — behavior is byte-identical to v0.1.1.
50
+ // ────────────────────────────────────────────────────────────────────
51
+ const WIDGET_OPEN_RE = /^```cspeach-widget:[a-z-]+\s*$/m;
52
+ export function renderChunk(chunk, state) {
53
+ // 1. Prepend any mid-widget buffer from a prior chunk.
54
+ const input = state.insideWidget ? state.widgetBuffer + chunk : chunk;
55
+ // 2. Extract widget segments.
56
+ const segments = extractWidgets(input);
57
+ // 3. If extractor returned `buffered`, tail contains unterminated widget fence.
58
+ const bufferedIdx = segments.findIndex((s) => s.type === 'buffered');
59
+ if (bufferedIdx !== -1) {
60
+ // Render any complete segments before the unterminated fence.
61
+ const completedOutput = renderCompletedSegments(segments.slice(0, bufferedIdx), state);
62
+ // Buffer the unterminated open-fence region for next chunk.
63
+ const match = WIDGET_OPEN_RE.exec(input);
64
+ const nextWidgetBuffer = match ? input.slice(match.index) : input;
65
+ return {
66
+ output: completedOutput,
67
+ state: { ...state, widgetBuffer: nextWidgetBuffer, insideWidget: true },
68
+ };
69
+ }
70
+ // 4. No open widget fence: clear widget buffer and render all segments.
71
+ const nextState = { ...state, widgetBuffer: '', insideWidget: false };
72
+ const output = renderCompletedSegments(segments, nextState);
73
+ return { output, state: nextState };
74
+ }
75
+ function renderCompletedSegments(segments, state) {
76
+ let out = '';
77
+ let widgetIdx = 0;
78
+ for (const seg of segments) {
79
+ if (seg.type === 'passthrough') {
80
+ out += renderPassthrough(seg.rawText, state);
81
+ }
82
+ else if (seg.type === 'widget') {
83
+ out += renderWidget(seg.widgetType, seg.rawJson, widgetIdx++);
84
+ }
85
+ // `buffered` never reaches here — filtered out by caller.
86
+ }
87
+ return out;
88
+ }
89
+ /**
90
+ * renderPassthrough — the v0.1.1 streaming chunk renderer, moved verbatim
91
+ * into a helper. Fence-aware, flushes at \n\n block boundaries, honours
92
+ * state.pending for partial markdown. Mutates `state.pending` and
93
+ * `state.fenceCount` exactly as v0.1.1 did.
94
+ *
95
+ * UNCHANGED FROM v0.1.1 (logic-identical — the body is the former
96
+ * renderChunk body from pipeline.ts lines 59-92 of the v0.1.1 file).
97
+ */
98
+ function renderPassthrough(chunk, state) {
99
+ const combined = state.pending + chunk;
100
+ // Line-anchored ``` only — matches CommonMark and stays consistent with
101
+ // markdown.ts / abap-inline.ts (both /^```/m). UNCHANGED FROM v0.1.1.
102
+ const newFencesInChunk = (chunk.match(/^```/gm) || []).length;
103
+ const projectedFenceCount = state.fenceCount + newFencesInChunk;
104
+ if (projectedFenceCount % 2 !== 0) {
105
+ // Inside an open fence — hold everything until fence closes.
106
+ state.pending = combined;
107
+ state.fenceCount = projectedFenceCount;
108
+ return '';
109
+ }
110
+ // Outside a fence — flush up to last BLANK LINE (\n\n) boundary.
111
+ const lastBlankLine = combined.lastIndexOf('\n\n');
112
+ if (lastBlankLine >= 0) {
113
+ const flushable = combined.slice(0, lastBlankLine + 2);
114
+ const unflushed = combined.slice(lastBlankLine + 2);
115
+ state.pending = unflushed;
116
+ state.fenceCount = projectedFenceCount;
117
+ const rendered = render(flushable);
118
+ return renumberOrderedLists(rendered, state);
119
+ }
120
+ // No blank line yet — hold combined, emit nothing.
121
+ state.pending = combined;
122
+ state.fenceCount = projectedFenceCount;
123
+ return '';
124
+ }
125
+ // ────────────────────────────────────────────────────────────────────
126
+ // ORDERED-LIST RENUMBER — NEW
127
+ // Walks rendered output line-by-line and renumbers consecutive ordered
128
+ // list items across paragraph flushes. See BufferState.orderedListCounter.
129
+ //
130
+ // marked-terminal emits ordered list items as ` 1. <text>` (4-space
131
+ // indent + N + dot + space, then optional ANSI styling on the content).
132
+ // We detect these lines BEFORE any ANSI escapes and rewrite the visible
133
+ // number when we're continuing a list.
134
+ // ────────────────────────────────────────────────────────────────────
135
+ const ANSI_PREFIX_RE = /^(\x1B\[[0-9;]*[mGKHFABCDsuJSTfrl])+/;
136
+ const LIST_ITEM_RE = /^(\s+)(\d+)\.\s/;
137
+ function renumberOrderedLists(rendered, state) {
138
+ if (rendered === '')
139
+ return rendered;
140
+ const lines = rendered.split('\n');
141
+ const out = [];
142
+ for (const line of lines) {
143
+ // Strip leading ANSI escapes so we can match against the visible prefix.
144
+ const ansiMatch = ANSI_PREFIX_RE.exec(line);
145
+ const ansiPrefix = ansiMatch ? ansiMatch[0] : '';
146
+ const visiblePart = line.slice(ansiPrefix.length);
147
+ const m = LIST_ITEM_RE.exec(visiblePart);
148
+ if (m) {
149
+ // It's an ordered list item.
150
+ state.orderedListCounter += 1;
151
+ const replacedVisible = visiblePart.replace(LIST_ITEM_RE, `${m[1]}${state.orderedListCounter}. `);
152
+ out.push(ansiPrefix + replacedVisible);
153
+ continue;
154
+ }
155
+ // Not a list item. Blank lines preserve the counter (inter-item gaps);
156
+ // any other non-blank line resets it (the list ended).
157
+ const visibleStripped = visiblePart.replace(/\x1B\[[0-9;]*[mGKHFABCDsuJSTfrl]/g, '');
158
+ if (visibleStripped.trim() !== '') {
159
+ state.orderedListCounter = 0;
160
+ }
161
+ out.push(line);
162
+ }
163
+ return out.join('\n');
164
+ }
165
+ /**
166
+ * renderWidget — NEW IN v0.3. Validate JSON against the widget's zod
167
+ * schema. On success, emit the WIDGET_CONTROL-wrapped payload (Ink/TTY) or
168
+ * a prose ```json``` block (non-TTY). On validation failure, emit a
169
+ * fenced ```json``` block with a warning comment — never throw.
170
+ */
171
+ function renderWidget(type, rawJson, idx) {
172
+ const schema = WIDGET_SCHEMAS[type];
173
+ let parsed;
174
+ try {
175
+ parsed = JSON.parse(rawJson);
176
+ }
177
+ catch {
178
+ return '```json\n// \u26A0 widget JSON parse failed\n' + rawJson + '\n```\n';
179
+ }
180
+ if (!schema || !schema.safeParse(parsed).success) {
181
+ return '```json\n// \u26A0 widget validation failed (unknown type or schema mismatch)\n' + rawJson + '\n```\n';
182
+ }
183
+ if (!shouldUseInk()) {
184
+ // Non-TTY / CI: question widgets get the heavy-border chalk card.
185
+ // Other widget types fall through to the ```json``` prose block.
186
+ if (type === 'question') {
187
+ return renderQuestionCard(parsed);
188
+ }
189
+ return '```json\n' + rawJson + '\n```\n';
190
+ }
191
+ // REPL+TTY: emit a control string the <Body> component splits on.
192
+ const b64 = Buffer.from(rawJson).toString('base64');
193
+ return `${WIDGET_CONTROL}${idx}:${type}:${b64}${WIDGET_CONTROL}`;
194
+ }
195
+ // ────────────────────────────────────────────────────────────────────
196
+ // ANSI stripper — UNCHANGED FROM v0.1.1
197
+ // ────────────────────────────────────────────────────────────────────
198
+ const ANSI_RE = /\x1B\[[0-9;]*[mGKHFABCDsuJSTfrl]|\x1B\][^\x07]*\x07/g;
199
+ function stripAnsi(s) {
200
+ return s.replace(ANSI_RE, '');
201
+ }
@@ -0,0 +1,237 @@
1
+ import chalk from 'chalk';
2
+ /**
3
+ * Progress chatter — keeps the tool-widget box alive while a tool runs.
4
+ *
5
+ * Rendered as a single animated line inside the widget frame, between the
6
+ * arg rows (painted by renderToolCallTop) and the bottom border (painted
7
+ * by renderToolCallBottom). Uses ANSI cursor-up + erase-line to redraw
8
+ * in place, so the line leaves no trace once the tool returns.
9
+ *
10
+ * Disabled in non-TTY mode (piped output, CI) and in Ink mode (chunkEmitter
11
+ * present) — the animation would fight the surrounding render cycle.
12
+ * In those modes `startProgress()` returns a no-op stop() handle.
13
+ */
14
+ // 8-frame braille spinner — tick every 80ms.
15
+ const SPINNER_CHARS = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'];
16
+ const SPINNER_TICK_MS = 80;
17
+ const MESSAGE_ROTATE_MS = 3_000;
18
+ // BOX_WIDTH must match tool-widget.ts — we could import it, but duplicating
19
+ // the const here keeps progress-chatter.ts decoupled from that module's
20
+ // internals and the value is set once across v0.2.
21
+ const BOX_WIDTH = 64;
22
+ /**
23
+ * Per-tool message pools. Messages may include `{name}` / `{type}` placeholders
24
+ * — substituted from the tool's args at render time. If the arg is absent,
25
+ * the placeholder is dropped together with any surrounding whitespace.
26
+ *
27
+ * Keep messages short (≤ ~50 chars) so they fit inside the widget's
28
+ * BOX_WIDTH content area without truncation.
29
+ */
30
+ const MESSAGES = {
31
+ sap_search_object: [
32
+ 'Hunting for {name} in the repository…',
33
+ 'Asking SAP ADT for matches…',
34
+ 'Scanning the object catalog…',
35
+ ],
36
+ sap_get_source: [
37
+ 'Fetching source for {name}…',
38
+ 'Bytes in transit…',
39
+ 'Reading from SAP…',
40
+ ],
41
+ sap_set_source: [
42
+ 'Writing {name} with safety gates…',
43
+ 'Snapshot captured, applying change…',
44
+ 'Waiting on SAP to ACK the write…',
45
+ ],
46
+ sap_activate: [
47
+ 'Activating {name}…',
48
+ 'Asking the ABAP compiler politely…',
49
+ 'Checking for activation errors…',
50
+ ],
51
+ sap_object_structure: [
52
+ 'Mapping {name}\'s structure…',
53
+ 'Inspecting includes and references…',
54
+ 'Tracing the object shape…',
55
+ ],
56
+ sap_atc_run: [
57
+ 'Running ATC checks on {name}…',
58
+ 'Counting findings — this can take a minute…',
59
+ 'ATC is thorough. Patiently waiting…',
60
+ ],
61
+ sap_syntax_check: [
62
+ 'Checking syntax for {name}…',
63
+ 'Validating ABAP compile…',
64
+ ],
65
+ sap_run_unit_test: [
66
+ 'Running ABAP Unit for {name}…',
67
+ 'Waiting on assertions…',
68
+ ],
69
+ sap_usage_references: [
70
+ 'Tracing callers of {name}…',
71
+ 'Running where-used scan…',
72
+ 'Mapping dependencies…',
73
+ ],
74
+ sap_sql_query: [
75
+ 'Running SQL query…',
76
+ 'Fetching rows…',
77
+ ],
78
+ sap_short_dump: [
79
+ 'Reading short dump…',
80
+ 'Parsing stack trace…',
81
+ 'Looking for root cause…',
82
+ ],
83
+ sap_lock: [
84
+ 'Acquiring lock on {name}…',
85
+ 'Checking for contention…',
86
+ 'Staking claim in SAP…',
87
+ ],
88
+ sap_unlock: [
89
+ 'Releasing lock on {name}…',
90
+ ],
91
+ sap_transport_create: [
92
+ 'Reserving a transport request…',
93
+ 'Asking SAP for a fresh TR…',
94
+ ],
95
+ sap_transport_list: [
96
+ 'Listing open transports…',
97
+ 'Finding modifiable TRs…',
98
+ ],
99
+ sap_transport_release: [
100
+ 'Releasing transport for QA queue…',
101
+ 'This is irreversible — sending now…',
102
+ ],
103
+ sap_create_object: [
104
+ 'Creating {name}…',
105
+ 'Shell object in flight…',
106
+ ],
107
+ sap_delete_object: [
108
+ 'Deleting {name}…',
109
+ 'Marking for removal…',
110
+ ],
111
+ sap_snapshot_take: [
112
+ 'Capturing snapshot of {name}…',
113
+ 'Saving before-state to disk…',
114
+ ],
115
+ sap_snapshot_list: [
116
+ 'Listing snapshots for {name}…',
117
+ 'Walking the snapshot directory…',
118
+ ],
119
+ sap_snapshot_read: [
120
+ 'Reading snapshot file…',
121
+ ],
122
+ sap_snapshot_cleanup: [
123
+ 'Sweeping old snapshots…',
124
+ ],
125
+ sap_diff: [
126
+ 'Computing diff for {name}…',
127
+ ],
128
+ sap_preview_change: [
129
+ 'Previewing change for {name}…',
130
+ ],
131
+ sap_update_method: [
132
+ 'Updating method body in {name}…',
133
+ ],
134
+ sap_class_includes: [
135
+ 'Listing includes of {name}…',
136
+ ],
137
+ sap_inactive_objects: [
138
+ 'Counting inactive objects…',
139
+ 'Asking SAP what\'s still pending…',
140
+ ],
141
+ sap_odata_test: [
142
+ 'Probing OData service…',
143
+ 'Calling $metadata…',
144
+ ],
145
+ sap_badi_list: [
146
+ 'Searching BAdI definitions…',
147
+ 'Walking enhancement spots…',
148
+ ],
149
+ sap_message_maintain: [
150
+ '(stub — short-circuit)',
151
+ ],
152
+ sap_number_range_intervals: [
153
+ '(stub — short-circuit)',
154
+ ],
155
+ sap_service_binding_publish: [
156
+ 'Publishing service binding…',
157
+ 'Asking SAP to expose the OData endpoint…',
158
+ ],
159
+ };
160
+ const FALLBACK_MESSAGES = [
161
+ 'Working on it…',
162
+ 'Processing…',
163
+ 'Patiently waiting for SAP…',
164
+ 'Thinking hard about this…',
165
+ ];
166
+ /**
167
+ * Start animated chatter inside the tool-widget frame. Call immediately after
168
+ * `renderToolCallTop()` and call `.stop()` immediately before
169
+ * `renderToolCallBottom()`.
170
+ */
171
+ export function startProgress(params) {
172
+ const { name, args, chunkEmitter } = params;
173
+ // Skip when we can't redraw in place: piped output (no TTY), Ink mode
174
+ // (emitter owns the render cycle), or dumb terminals.
175
+ const disabled = chunkEmitter !== undefined ||
176
+ !process.stdout.isTTY ||
177
+ process.env['CI'] === '1' ||
178
+ process.env['TERM'] === 'dumb';
179
+ if (disabled) {
180
+ return { stop: () => undefined };
181
+ }
182
+ const pool = MESSAGES[name] ?? FALLBACK_MESSAGES;
183
+ const substituted = pool.map((msg) => substitute(msg, args));
184
+ let spinnerIdx = 0;
185
+ let messageIdx = 0;
186
+ let lastMessageRotate = Date.now();
187
+ let stopped = false;
188
+ const render = () => {
189
+ if (stopped)
190
+ return;
191
+ const now = Date.now();
192
+ if (now - lastMessageRotate >= MESSAGE_ROTATE_MS) {
193
+ messageIdx = (messageIdx + 1) % substituted.length;
194
+ lastMessageRotate = now;
195
+ }
196
+ const spinner = SPINNER_CHARS[spinnerIdx];
197
+ spinnerIdx = (spinnerIdx + 1) % SPINNER_CHARS.length;
198
+ const raw = `${spinner} ${substituted[messageIdx]}`;
199
+ const truncated = raw.length > BOX_WIDTH ? raw.slice(0, BOX_WIDTH - 1) + '…' : raw;
200
+ const padded = truncated.padEnd(BOX_WIDTH);
201
+ const line = chalk.dim('│ ') + chalk.cyan(padded) + chalk.dim(' │');
202
+ // Cursor-up 1, erase current line, rewrite. Trailing \n puts us back below.
203
+ process.stdout.write(`\x1B[1A\x1B[2K${line}\n`);
204
+ };
205
+ // Paint the initial chatter line (no cursor-up — it occupies a fresh line).
206
+ const initialSpinner = SPINNER_CHARS[0];
207
+ const initialRaw = `${initialSpinner} ${substituted[0]}`;
208
+ const initialTruncated = initialRaw.length > BOX_WIDTH ? initialRaw.slice(0, BOX_WIDTH - 1) + '…' : initialRaw;
209
+ const initialPadded = initialTruncated.padEnd(BOX_WIDTH);
210
+ const initialLine = chalk.dim('│ ') + chalk.cyan(initialPadded) + chalk.dim(' │');
211
+ process.stdout.write(initialLine + '\n');
212
+ const interval = setInterval(render, SPINNER_TICK_MS);
213
+ // Don't keep the event loop alive waiting for this timer.
214
+ interval.unref?.();
215
+ return {
216
+ stop: () => {
217
+ if (stopped)
218
+ return;
219
+ stopped = true;
220
+ clearInterval(interval);
221
+ // Erase the chatter line and back up so the bottom border writes
222
+ // adjacent to the args (as if no chatter ever existed).
223
+ process.stdout.write('\x1B[1A\x1B[2K');
224
+ },
225
+ };
226
+ }
227
+ function substitute(template, args) {
228
+ return template.replace(/\{(\w+)\}/g, (_match, key) => {
229
+ const v = args[key];
230
+ if (typeof v === 'string' && v.length > 0)
231
+ return v;
232
+ return '';
233
+ })
234
+ // Collapse any double spaces left by missing substitutions.
235
+ .replace(/\s+/g, ' ')
236
+ .trim();
237
+ }