@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,199 @@
1
+ import chalk from 'chalk';
2
+ import { PEACH } from './banners.js';
3
+ /**
4
+ * Tool-dispatch renderer — CC-style two-line format with peach spinner.
5
+ *
6
+ * ⏺ tool_name(arg-summary) ← printed at dispatch start
7
+ * ⠋ slicing peach… ← spinner, in place, while dispatch runs
8
+ * ⎿ ✓ result-summary · 1.2s ← overwrites the spinner on completion
9
+ *
10
+ * Top line (renderToolCallTop) prints at content_block_stop, before the
11
+ * dispatch fires. The spinner is started immediately after and runs until
12
+ * the bottom line (renderToolCallBottom) replaces it. The bottom line
13
+ * uses ANSI cursor-up + erase-line to consume the spinner row, leaving
14
+ * just the dispatch + result rows in scrollback.
15
+ *
16
+ * Color discipline:
17
+ * - Peach (PEACH) for the identity glyphs (⏺ dispatch, ⎿ result, spinner).
18
+ * - Default white/bold for the tool name.
19
+ * - Dim grey for arg-summary inside parens, timing, the spinner's verb.
20
+ * - Green (✓) on success, red (✗ + body) on error.
21
+ * - No cyan, no rainbow, no boxes.
22
+ */
23
+ const peach = (s) => chalk.hex(PEACH)(s);
24
+ const ARG_SUMMARY_MAX = 64;
25
+ const RESULT_SUMMARY_MAX = 96;
26
+ /** Braille spinner — 8 frames at 80ms each gives a smooth rotation that
27
+ * reads as "still working" without being distracting. */
28
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
29
+ const SPINNER_TICK_MS = 80;
30
+ /** Vibe verbs — picked once per dispatch so the indicator feels alive
31
+ * without thrashing. Default is `slicing peach` per the brand bit. */
32
+ const SPINNER_VERBS = ['slicing peach', 'mashing pulp', 'pitting', 'pressing', 'simmering', 'plating'];
33
+ /** LLM-think verbs — different pool from tool-dispatch so the user can
34
+ * tell at a glance whether we're waiting on the model or running a
35
+ * tool. Tool dispatch = peach-cooking metaphor; thinking = peach-growing
36
+ * metaphor (a beat slower in pacing). */
37
+ const THINKING_VERBS = ['thinking', 'ripening', 'steeping', 'marinating', 'pondering', 'considering'];
38
+ /**
39
+ * Module-level reference to the currently-running spinner's stop function.
40
+ * Set by startToolSpinner; cleared on stop. Lets unrelated subsystems
41
+ * (e.g. inquirer-guard, when an inquirer prompt is about to open) silence
42
+ * the spinner so it doesn't fight inquirer for stdout/cursor.
43
+ */
44
+ let activeSpinnerStop = null;
45
+ export function clearActiveSpinner() {
46
+ if (activeSpinnerStop) {
47
+ activeSpinnerStop();
48
+ activeSpinnerStop = null;
49
+ }
50
+ }
51
+ export function renderToolCallTop(params) {
52
+ const { name, args, chunkEmitter } = params;
53
+ const write = (s) => {
54
+ if (chunkEmitter)
55
+ chunkEmitter.emit('chunk', s);
56
+ else
57
+ process.stdout.write(s);
58
+ };
59
+ const summary = argSummary(name, args);
60
+ const wrapped = summary ? chalk.gray('(') + chalk.gray(summary) + chalk.gray(')') : '';
61
+ const line = `${peach('⏺')} ${chalk.bold(name)}${wrapped}`;
62
+ write('\n' + line + '\n');
63
+ }
64
+ /**
65
+ * Animated peach-themed indicator while we wait on the LLM (between
66
+ * `provider.createStream` and the first content event). Same visual
67
+ * shape as the tool spinner but pulls from THINKING_VERBS so the user
68
+ * can distinguish "model is thinking" from "tool is running" at a
69
+ * glance. Wired from agent/loop.ts immediately before the await on
70
+ * createStream; stopped on the first content_block_start (or on
71
+ * error). Also stopped by clearActiveSpinner (e.g. when an inquirer
72
+ * prompt opens, same as tool spinner).
73
+ */
74
+ export function startThinkingSpinner(params) {
75
+ return startSpinnerWithVerbs(params, THINKING_VERBS, 'thinking');
76
+ }
77
+ export function startToolSpinner(params) {
78
+ return startSpinnerWithVerbs(params, SPINNER_VERBS, 'slicing peach');
79
+ }
80
+ function startSpinnerWithVerbs(params, verbs, defaultVerb) {
81
+ const { chunkEmitter } = params;
82
+ const disabled = chunkEmitter !== undefined ||
83
+ !process.stdout.isTTY ||
84
+ process.env['CI'] === '1' ||
85
+ process.env['TERM'] === 'dumb';
86
+ if (disabled)
87
+ return { stop: () => undefined };
88
+ // Pick one verb per dispatch so the indicator doesn't churn.
89
+ const verb = verbs[Math.floor(Math.random() * verbs.length)] ?? defaultVerb;
90
+ let frameIdx = 0;
91
+ let stopped = false;
92
+ const paint = () => {
93
+ if (stopped)
94
+ return;
95
+ const frame = SPINNER_FRAMES[frameIdx];
96
+ frameIdx = (frameIdx + 1) % SPINNER_FRAMES.length;
97
+ // Erase current line + reprint. First call appends a fresh line; later
98
+ // calls cursor-up to the spinner row and overwrite in place.
99
+ const line = ` ${peach(frame)} ${chalk.dim(verb + '…')}`;
100
+ process.stdout.write(`\x1B[2K\r${line}`);
101
+ };
102
+ // Hide the terminal cursor while the spinner animates so it doesn't blink
103
+ // mid-frame and confuse the user about whether their typing went through.
104
+ process.stdout.write('\x1B[?25l');
105
+ // Print the initial spinner line.
106
+ process.stdout.write('\r');
107
+ paint();
108
+ const interval = setInterval(paint, SPINNER_TICK_MS);
109
+ interval.unref?.();
110
+ const stopFn = () => {
111
+ if (stopped)
112
+ return;
113
+ stopped = true;
114
+ clearInterval(interval);
115
+ // Erase the spinner line so the result row paints in its place.
116
+ // \r → start of line, \x1B[2K → erase entire line. No newline so the
117
+ // bottom renderer's \n keeps the spacing correct.
118
+ // Also re-show the cursor (\x1B[?25h) — paired with the hide above.
119
+ process.stdout.write('\r\x1B[2K\x1B[?25h');
120
+ // Clear the global handle if it's still pointing at us. (If a newer
121
+ // spinner already replaced us, leave its registration alone.)
122
+ if (activeSpinnerStop === stopFn)
123
+ activeSpinnerStop = null;
124
+ };
125
+ // Defensive: if a previous spinner is somehow still active when we start
126
+ // (shouldn't happen — agent loop stops the previous one before next
127
+ // dispatch — but easy to guarantee here), stop it before claiming the slot.
128
+ if (activeSpinnerStop)
129
+ activeSpinnerStop();
130
+ activeSpinnerStop = stopFn;
131
+ return { stop: stopFn };
132
+ }
133
+ export function renderToolCallBottom(params) {
134
+ const { durationMs, isError, resultSummary, chunkEmitter } = params;
135
+ const write = (s) => {
136
+ if (chunkEmitter)
137
+ chunkEmitter.emit('chunk', s);
138
+ else
139
+ process.stdout.write(s);
140
+ };
141
+ const icon = isError ? chalk.red('✗') : chalk.green('✓');
142
+ const detailRaw = resultSummary ? truncate(resultSummary, RESULT_SUMMARY_MAX) : '';
143
+ const detail = detailRaw
144
+ ? (isError ? chalk.red(detailRaw) : chalk.gray(detailRaw))
145
+ : '';
146
+ const timing = chalk.dim(formatDuration(durationMs));
147
+ const sep = chalk.dim('·');
148
+ let line = ` ${peach('⎿')} ${icon}`;
149
+ if (detail)
150
+ line += ` ${detail} ${sep} ${timing}`;
151
+ else
152
+ line += ` ${timing}`;
153
+ write(line + '\n');
154
+ }
155
+ /**
156
+ * Compact one-liner describing the most informative parts of the dispatch
157
+ * arguments. Per-tool aware: pulls LLM-supplied summary first, then the
158
+ * common (name, type, package) triple, then specific keys for known tools.
159
+ * Falls back to a truncated JSON dump.
160
+ */
161
+ function argSummary(toolName, args) {
162
+ if (!args || Object.keys(args).length === 0)
163
+ return '';
164
+ const a = args;
165
+ // request_approval / ask_question — surface the LLM-supplied summary text.
166
+ if (typeof a['summary'] === 'string') {
167
+ return truncate(a['summary'], ARG_SUMMARY_MAX);
168
+ }
169
+ if (typeof a['question'] === 'string') {
170
+ return truncate(a['question'], ARG_SUMMARY_MAX);
171
+ }
172
+ // Class.method shorthand for sap_update_method.
173
+ if (typeof a['class_name'] === 'string' && typeof a['method_name'] === 'string') {
174
+ return truncate(`${a['class_name']}.${a['method_name']}`, ARG_SUMMARY_MAX);
175
+ }
176
+ // Common (name, type, package) for SAP object tools.
177
+ const parts = [];
178
+ if (typeof a['name'] === 'string')
179
+ parts.push(a['name']);
180
+ if (typeof a['type'] === 'string' && a['type'] !== a['name'])
181
+ parts.push(a['type']);
182
+ if (typeof a['package'] === 'string')
183
+ parts.push(`in ${a['package']}`);
184
+ if (parts.length > 0)
185
+ return truncate(parts.join(' '), ARG_SUMMARY_MAX);
186
+ // Search query / SQL.
187
+ if (typeof a['query'] === 'string')
188
+ return truncate(a['query'], ARG_SUMMARY_MAX);
189
+ // Fallback — short JSON.
190
+ return truncate(JSON.stringify(args), ARG_SUMMARY_MAX);
191
+ }
192
+ function truncate(s, max) {
193
+ return s.length > max ? s.substring(0, max - 1) + '…' : s;
194
+ }
195
+ function formatDuration(ms) {
196
+ if (ms < 1000)
197
+ return `${ms}ms`;
198
+ return `${(ms / 1000).toFixed(1)}s`;
199
+ }
@@ -0,0 +1,66 @@
1
+ // C:\Users\Admin\ClaudeProjects\ABAPForge\cspeach-cli\src\renderer\tty.ts
2
+ /**
3
+ * TTY / mode detection helpers.
4
+ */
5
+ import * as os from 'node:os';
6
+ /** True when stdout is an interactive terminal (not piped / redirected). */
7
+ export function isTTY() {
8
+ return process.stdout.isTTY === true;
9
+ }
10
+ /**
11
+ * True when the CLI was invoked with a positional argument (one-shot mode)
12
+ * or with the --one-shot flag.
13
+ * e.g. `cspeach "/radar ZTTT_GAME"` vs interactive REPL.
14
+ */
15
+ export function isOneShot() {
16
+ const args = process.argv.slice(2);
17
+ return args.some((a) => a === '--one-shot') || args.some((a) => !a.startsWith('-'));
18
+ }
19
+ let renderingOverride = 'classic';
20
+ /** Set the rendering mode override. Call once at startup after loadConfig(). */
21
+ export function setRenderingOverride(mode) {
22
+ renderingOverride = mode;
23
+ }
24
+ /** Get the current rendering override (for debugging / status display). */
25
+ export function getRenderingOverride() {
26
+ return renderingOverride;
27
+ }
28
+ /**
29
+ * True when the Ink UI frame should be mounted.
30
+ *
31
+ * Decision order:
32
+ * 1. If override === 'classic', never use Ink (explicit opt-out)
33
+ * 2. If override === 'ink', use Ink when a TTY is available; otherwise
34
+ * fall back — Ink cannot render on a pipe regardless of user preference
35
+ * 3. If override === 'auto' (default), use Ink when interactive TTY +
36
+ * not one-shot + CI=unset
37
+ *
38
+ * v0.3+: pipeline.ts and approvals/render.ts branch on this.
39
+ */
40
+ export function shouldUseInk() {
41
+ if (renderingOverride === 'classic')
42
+ return false;
43
+ if (renderingOverride === 'ink')
44
+ return isTTY();
45
+ // auto
46
+ return isTTY() && !isOneShot() && !process.env['CI'];
47
+ }
48
+ /**
49
+ * Stub for v0.3 Windows ConPTY / CI TTY hinting.
50
+ *
51
+ * On Windows, ConPTY sets WT_SESSION; GitHub Actions sets CI=true.
52
+ * Returns undefined when no hint is available.
53
+ */
54
+ export function ciTtyHint() {
55
+ return undefined;
56
+ }
57
+ /** True when running on Windows without a capable terminal (no WT or iTerm/wezterm). */
58
+ export function isWindowsLegacyTerminal() {
59
+ return (os.platform() === 'win32' &&
60
+ !process.env['WT_SESSION'] &&
61
+ !process.env['TERM_PROGRAM']);
62
+ }
63
+ /** Use full Unicode box-drawing chars only when NOT on a legacy Windows terminal. */
64
+ export function shouldUseUnicodeBoxChars() {
65
+ return !isWindowsLegacyTerminal();
66
+ }
@@ -0,0 +1,87 @@
1
+ const OPEN_RE = /^```cspeach-widget:(\S+)\s*$/;
2
+ // CLOSE_RE matches a bare ``` on a line — which means a widget payload
3
+ // containing ``` (e.g. a diff-viewer unified diff inside JSON strings) would
4
+ // prematurely close the fence. LLMs emit widget JSON as single-line payloads
5
+ // per the widget-emit contract (spec §4.3) — if this invariant breaks, the
6
+ // renderer will need a more sophisticated parser (stack-based with string-
7
+ // literal awareness inside JSON).
8
+ const CLOSE_RE = /^```\s*$/;
9
+ const SENTINEL = '<!-- widget-real -->';
10
+ export function extractWidgets(text) {
11
+ const segments = [];
12
+ let pos = 0;
13
+ let i = 0;
14
+ // Index of first line NOT yet emitted as passthrough. Any line between this
15
+ // index and the current position that isn't a widget-open needs to surface as
16
+ // passthrough so the pipeline can still render non-widget markdown.
17
+ let passStart = 0;
18
+ const lines = text.split('\n');
19
+ const flushPassthrough = (upToExclusive) => {
20
+ if (passStart >= upToExclusive)
21
+ return;
22
+ // Reassemble the raw text for these lines. We lost the original line-break
23
+ // info when we split, but rebuilding with `\n` between lines and a trailing
24
+ // `\n` if the region extends to end-of-input matches how renderChunk's
25
+ // passthrough helper already expects its input.
26
+ const slice = lines.slice(passStart, upToExclusive);
27
+ const tail = upToExclusive < lines.length ? '\n' : '';
28
+ const rawText = slice.join('\n') + tail;
29
+ if (rawText.length === 0) {
30
+ passStart = upToExclusive;
31
+ return;
32
+ }
33
+ const spanStart = pos;
34
+ const spanEnd = spanStart + rawText.length;
35
+ pos += rawText.length;
36
+ segments.push({ type: 'passthrough', rawText, span: [spanStart, spanEnd] });
37
+ passStart = upToExclusive;
38
+ };
39
+ while (i < lines.length) {
40
+ const m = OPEN_RE.exec(lines[i]);
41
+ if (!m) {
42
+ i++;
43
+ continue;
44
+ }
45
+ // Found opening fence — first flush any preceding passthrough region.
46
+ flushPassthrough(i);
47
+ const openLine = i;
48
+ const widgetType = m[1];
49
+ const bodyLines = [];
50
+ i++;
51
+ let closed = false;
52
+ while (i < lines.length) {
53
+ if (CLOSE_RE.test(lines[i])) {
54
+ closed = true;
55
+ i++;
56
+ break;
57
+ }
58
+ bodyLines.push(lines[i]);
59
+ i++;
60
+ }
61
+ if (!closed) {
62
+ // Stream-incomplete: return buffered sentinel so caller waits.
63
+ return [...segments, { type: 'buffered' }];
64
+ }
65
+ // Compute raw span in original string (approximate by line offset).
66
+ const spanStart = pos;
67
+ const blockText = lines.slice(openLine, i).join('\n') + '\n';
68
+ const spanEnd = spanStart + blockText.length;
69
+ pos += blockText.length;
70
+ passStart = i;
71
+ // Sentinel check — compare on trimmed values so leading/trailing whitespace
72
+ // around `<!-- widget-real -->` is tolerated.
73
+ const firstContent = bodyLines.find((l) => l.trim() !== '');
74
+ if (firstContent?.trim() !== SENTINEL) {
75
+ segments.push({ type: 'passthrough', rawText: blockText, span: [spanStart, spanEnd] });
76
+ continue;
77
+ }
78
+ // Strip the sentinel line AND any empty lines before the JSON (again, trim-aware).
79
+ const jsonLines = bodyLines.filter((l) => l.trim() !== '' && l.trim() !== SENTINEL);
80
+ const rawJson = jsonLines.join('\n');
81
+ segments.push({ type: 'widget', widgetType, rawJson, span: [spanStart, spanEnd] });
82
+ }
83
+ // Flush trailing passthrough (lines after the last widget or the entire input
84
+ // if no widget fence was ever opened).
85
+ flushPassthrough(lines.length);
86
+ return segments;
87
+ }
@@ -0,0 +1,78 @@
1
+ import chalk from 'chalk';
2
+ // Inner content width — the box becomes content+4 total width.
3
+ const CONTENT_WIDTH = 55;
4
+ const TOTAL_WIDTH = CONTENT_WIDTH + 4; // left "┃ " + content + " ┃"
5
+ function padLine(s) {
6
+ // Pad to CONTENT_WIDTH visible chars, preserve ANSI codes if any
7
+ const ANSI = /\x1B\[[0-9;]*[mGKHFABCDsuJSTfrl]/g;
8
+ const visible = s.replace(ANSI, '');
9
+ const padding = Math.max(0, CONTENT_WIDTH - visible.length);
10
+ return s + ' '.repeat(padding);
11
+ }
12
+ function wrap(text, width) {
13
+ if (text.length <= width)
14
+ return [text];
15
+ const words = text.split(/\s+/);
16
+ const lines = [];
17
+ let current = '';
18
+ for (const word of words) {
19
+ if (current.length === 0) {
20
+ current = word;
21
+ }
22
+ else if (current.length + 1 + word.length <= width) {
23
+ current += ' ' + word;
24
+ }
25
+ else {
26
+ lines.push(current);
27
+ current = word;
28
+ }
29
+ }
30
+ if (current.length > 0)
31
+ lines.push(current);
32
+ return lines;
33
+ }
34
+ /**
35
+ * Render a question widget as a heavy-bordered chalk card for classic-mode
36
+ * REPL output. Called by the pipeline's widget-fallback path when
37
+ * shouldUseInk() is false OR when we're rendering the default text
38
+ * fallback for the widget system.
39
+ *
40
+ * Uses heavy box-drawing chars (┏ ┓ ┗ ┛ ━ ┃) so the question card
41
+ * visually distinguishes from the light-bordered tool-widget boxes.
42
+ */
43
+ export function renderQuestionCard(widget) {
44
+ const border = chalk.cyan;
45
+ const dim = chalk.dim;
46
+ const top = border('┏━ ' + chalk.cyan.bold(`Question: ${widget.id}`) + ' ' + '━'.repeat(Math.max(0, CONTENT_WIDTH - widget.id.length - 12)) + '━┓');
47
+ const bottom = border('┗' + '━'.repeat(TOTAL_WIDTH - 2) + '┛');
48
+ const lines = [top];
49
+ lines.push(border('┃ ') + padLine('') + border(' ┃'));
50
+ // Question text (wrapped)
51
+ for (const line of wrap(widget.question, CONTENT_WIDTH)) {
52
+ lines.push(border('┃ ') + padLine(chalk.bold(line)) + border(' ┃'));
53
+ }
54
+ // Optional context
55
+ if (widget.context) {
56
+ lines.push(border('┃ ') + padLine('') + border(' ┃'));
57
+ for (const line of wrap(`Context: ${widget.context}`, CONTENT_WIDTH)) {
58
+ lines.push(border('┃ ') + padLine(dim(line)) + border(' ┃'));
59
+ }
60
+ }
61
+ // Choice list for choice/multi kinds
62
+ if (widget.kind === 'choice' || widget.kind === 'multi') {
63
+ lines.push(border('┃ ') + padLine('') + border(' ┃'));
64
+ const header = widget.kind === 'choice' ? 'Pick one:' : 'Pick any (comma-separate):';
65
+ lines.push(border('┃ ') + padLine(chalk.bold(header)) + border(' ┃'));
66
+ const choices = widget.choices ?? [];
67
+ for (let i = 0; i < choices.length; i++) {
68
+ const choice = choices[i];
69
+ const line = `[${i + 1}] ${choice.label}`;
70
+ for (const wrapped of wrap(line, CONTENT_WIDTH)) {
71
+ lines.push(border('┃ ') + padLine(wrapped) + border(' ┃'));
72
+ }
73
+ }
74
+ }
75
+ lines.push(border('┃ ') + padLine('') + border(' ┃'));
76
+ lines.push(bottom);
77
+ return '\n' + lines.join('\n') + '\n';
78
+ }
@@ -0,0 +1,43 @@
1
+ import { z } from 'zod';
2
+ export const depGraphSchema = z.object({
3
+ root: z.string(),
4
+ children: z.array(z.object({
5
+ name: z.string(),
6
+ type: z.string(),
7
+ relation: z.string(),
8
+ })),
9
+ });
10
+ export const barChartSchema = z.array(z.object({
11
+ label: z.string(),
12
+ value: z.number(),
13
+ color: z.string().optional(),
14
+ }));
15
+ export const stackFramesSchema = z.array(z.object({
16
+ frame: z.number(),
17
+ program: z.string(),
18
+ line: z.number(),
19
+ code: z.string(),
20
+ type: z.enum(['error', 'context']),
21
+ }));
22
+ export const diffSchema = z.object({ unified: z.string() });
23
+ export const questionSchema = z
24
+ .object({
25
+ id: z.string(),
26
+ question: z.string(),
27
+ context: z.string().optional(),
28
+ kind: z.enum(['text', 'choice', 'multi']),
29
+ choices: z
30
+ .array(z.object({
31
+ value: z.string(),
32
+ label: z.string(),
33
+ }))
34
+ .optional(),
35
+ })
36
+ .refine((data) => data.kind === 'text' || (data.choices !== undefined && data.choices.length > 0), { message: 'choice/multi kinds require non-empty choices[]' });
37
+ export const WIDGET_SCHEMAS = {
38
+ 'dep-graph': depGraphSchema,
39
+ 'bar-chart': barChartSchema,
40
+ 'stack-frames': stackFramesSchema,
41
+ 'diff-viewer': diffSchema,
42
+ question: questionSchema, // NEW
43
+ };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Tab-completion for `@<filename>` tokens in the REPL.
3
+ *
4
+ * UX contract:
5
+ * - User types `@` then Tab → all .cspeach.json files in workspace
6
+ * - User types `@b9b` then Tab → 1 match auto-completes; many lists
7
+ * - Mid-line: `/abap-design --status @b9b<Tab>`
8
+ * → completes only the @-token, line tail preserved
9
+ * - No `@` on the line → atCompleter returns null; caller
10
+ * falls through to the next completer
11
+ *
12
+ * Backed by readline's native `completer`, like slashCompleter — same
13
+ * `[string[], string]` shape so a thin wrapper can dispatch between the two.
14
+ *
15
+ * Performance: the workspace folder is read on every call but cached for
16
+ * a few seconds so per-keystroke listing doesn't thrash the filesystem.
17
+ * The cache TTL is short enough that newly-saved files appear within a
18
+ * few seconds of their save without manual refresh.
19
+ */
20
+ import { ensureWorkspaceSync, listProjectFilesSync } from '../projects/workspace.js';
21
+ const CACHE_TTL_MS = 3000;
22
+ let cache = null;
23
+ function getCachedFiles() {
24
+ const now = Date.now();
25
+ if (!cache || now - cache.at > CACHE_TTL_MS) {
26
+ try {
27
+ const ws = ensureWorkspaceSync();
28
+ cache = { files: listProjectFilesSync(ws), at: now };
29
+ }
30
+ catch {
31
+ cache = { files: [], at: now };
32
+ }
33
+ }
34
+ return cache.files;
35
+ }
36
+ /**
37
+ * readline-style completer for `@<token>`. Returns `null` when the cursor
38
+ * is NOT inside an @-token, so the caller knows to delegate.
39
+ *
40
+ * `line` is the full input up to the cursor (Node's readline contract).
41
+ * We anchor on the rightmost `@<word>` because the @-token always sits
42
+ * at the cursor end during typing.
43
+ */
44
+ export function atCompleter(line) {
45
+ const m = /@(\S*)$/.exec(line);
46
+ if (!m)
47
+ return null;
48
+ const token = m[1] ?? '';
49
+ const lineBefore = line.slice(0, m.index);
50
+ const lower = token.toLowerCase();
51
+ const files = getCachedFiles();
52
+ const matched = files.filter((f) => lower === '' || f.filename.toLowerCase().includes(lower));
53
+ if (matched.length === 0) {
54
+ // Return an empty hit list (not null) so readline knows we OWNED this
55
+ // completion — emitting "no matches" beep instead of falling through.
56
+ return [[], line];
57
+ }
58
+ const hits = matched.map((f) => `${lineBefore}@${f.filename}`);
59
+ return [hits, line];
60
+ }
61
+ /** Test-only: invalidate the cache so unit tests see fresh fs state. */
62
+ export function _resetAtCompleterCache() {
63
+ cache = null;
64
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Live picker for `@<filename>` workspace tokens. Opens when the user
3
+ * presses Enter on a line that ends in a partial / unresolved @-token —
4
+ * mirrors openSlashPicker but for files instead of slash commands.
5
+ *
6
+ * UX contract:
7
+ * - User types `... @` then Enter → picker shows ALL workspace files
8
+ * - User types `... @b9b` then Enter → picker pre-filtered to those
9
+ * files matching `b9b`. User can refine further or pick.
10
+ * - User types `... @<exact-filename>` then Enter → no picker; the line
11
+ * is treated as already-resolved (existing @-token resolution path).
12
+ * - User cancels (Esc / Ctrl-C inside picker) → returns null; caller
13
+ * restores the original line so the user can edit it.
14
+ *
15
+ * Pairs with at-completer.ts (Tab completion for the same tokens).
16
+ * Tab vs Enter is the user's choice: Tab when they want a quick list
17
+ * without screen takeover, Enter when they want a full search-as-you-type
18
+ * picker. Same backing data, different UI.
19
+ */
20
+ import { search } from '@inquirer/prompts';
21
+ import { withInquirer } from './inquirer-guard.js';
22
+ import { inquirerTheme } from './inquirer-theme.js';
23
+ import { ensureWorkspaceSync, listProjectFilesSync } from '../projects/workspace.js';
24
+ const CANCEL_VALUE = '__cspeach_cancel__';
25
+ /**
26
+ * Open the at-picker pre-filtered to `prefix`. Returns the picked
27
+ * filename (no leading @), or null on cancel / empty-workspace.
28
+ *
29
+ * `prefix` is whatever the user typed AFTER the @ — e.g. for line
30
+ * `/abap-rap --from @b9b<Enter>` the prefix is `b9b`. Empty prefix
31
+ * (bare `@`) seeds the picker with all workspace files.
32
+ */
33
+ export async function openAtPicker(prefix) {
34
+ let allFiles;
35
+ try {
36
+ const ws = ensureWorkspaceSync();
37
+ allFiles = listProjectFilesSync(ws).map((f) => ({ filename: f.filename, mtimeMs: f.mtimeMs }));
38
+ }
39
+ catch {
40
+ allFiles = [];
41
+ }
42
+ if (allFiles.length === 0) {
43
+ return null;
44
+ }
45
+ const lowerPrefix = prefix.trim().toLowerCase();
46
+ const seeded = lowerPrefix.length === 0
47
+ ? allFiles
48
+ : allFiles.filter((f) => f.filename.toLowerCase().includes(lowerPrefix));
49
+ // Single unique match — short-circuit, save the user a keystroke. Same
50
+ // shortcut openSlashPicker uses.
51
+ if (seeded.length === 1) {
52
+ return seeded[0].filename;
53
+ }
54
+ // Empty filter result → fall back to all files so the user can correct
55
+ // their prefix rather than seeing an empty picker.
56
+ const choicesForPicker = seeded.length === 0 ? allFiles : seeded;
57
+ try {
58
+ const picked = await withInquirer(() => search({
59
+ message: 'Pick a workspace file',
60
+ theme: inquirerTheme,
61
+ source: async (input) => {
62
+ const filter = (input ?? '').toLowerCase();
63
+ const filtered = filter.length === 0
64
+ ? choicesForPicker
65
+ : choicesForPicker.filter((f) => f.filename.toLowerCase().includes(filter));
66
+ const cancelChoice = {
67
+ name: '← Cancel — keep my typed text and let me edit it',
68
+ value: CANCEL_VALUE,
69
+ short: 'cancel',
70
+ };
71
+ const choices = filtered.map((f) => ({
72
+ name: f.filename,
73
+ value: f.filename,
74
+ short: f.filename,
75
+ }));
76
+ return [cancelChoice, ...choices];
77
+ },
78
+ }));
79
+ if (picked === CANCEL_VALUE)
80
+ return null;
81
+ return typeof picked === 'string' ? picked : null;
82
+ }
83
+ catch {
84
+ // Ctrl+C inside the picker — treat as cancellation.
85
+ return null;
86
+ }
87
+ }
88
+ /**
89
+ * Quick check: does `trimmed` end with an @-token whose value (the part
90
+ * after @) doesn't already exactly match a workspace file? Used by the
91
+ * REPL to decide between "pass through to existing @-token resolution"
92
+ * and "open picker for ambiguous input".
93
+ *
94
+ * Returns the rightmost @-token's prefix (the chars typed after @), or
95
+ * null when the line doesn't end with an @-token or the token already
96
+ * resolves exactly.
97
+ */
98
+ export function pendingAtTokenPrefix(trimmed) {
99
+ // Match the rightmost @<word> at the end of the line. \S+ means
100
+ // non-whitespace only — `@filename with space` is not auto-detected
101
+ // (user can quote with `@"name with space"` for those, handled by
102
+ // the existing parseFromFlag / resolveAtToken paths).
103
+ const m = /@(\S*)$/.exec(trimmed);
104
+ if (!m)
105
+ return null;
106
+ const prefix = m[1] ?? '';
107
+ // If the prefix already exactly matches a workspace filename, the
108
+ // existing @-resolution will pick it up — don't open the picker.
109
+ if (prefix.length > 0) {
110
+ try {
111
+ const ws = ensureWorkspaceSync();
112
+ const files = listProjectFilesSync(ws);
113
+ const exact = files.some((f) => f.filename === prefix);
114
+ if (exact)
115
+ return null;
116
+ }
117
+ catch {
118
+ // Fall through — open picker as a safe default.
119
+ }
120
+ }
121
+ return prefix;
122
+ }