@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,195 @@
1
+ // cspeach-cli/src/tools/web/web_search.ts
2
+ /**
3
+ * web_search — Phase 3 / Chunk 3C2 web-search tool (Brave provider).
4
+ *
5
+ * Wraps Brave Search API to let the agent find SAP-relevant pages.
6
+ * Reuses _web-shared.validateUrl + DNS-pinned lookup for defense-in-depth
7
+ * on the api.search.brave.com endpoint — even with a hardcoded endpoint,
8
+ * validateUrl closes the DNS-hijack class of attack at zero extra design
9
+ * complexity.
10
+ *
11
+ * Auth: CSPEACH_BRAVE_SEARCH_KEY env var. Missing → structured setup
12
+ * instruction error, NOT an exception. Key is never logged or echoed
13
+ * in any error envelope.
14
+ *
15
+ * Operational caps:
16
+ * - 10 s wall-clock timeout
17
+ * - 256 KB streamed response cap
18
+ * - Default 10 results, hard max 20 (Brave's per-query limit)
19
+ *
20
+ * Flag-gated: invisible to listTools() unless CSPEACH_TOOL_WEB_SEARCH=on.
21
+ * isMutating: false — reads only.
22
+ */
23
+ import * as https from 'node:https';
24
+ import { registerTool } from '../index.js';
25
+ import { validateUrl, BlockedHostError, BlockedProtocolError, MalformedUrlError, } from './_web-shared.js';
26
+ export const BRAVE_ENDPOINT = 'https://api.search.brave.com/res/v1/web/search';
27
+ export const USER_AGENT = 'cspeach/0.5 (+https://cspeach.dev)';
28
+ const DEFAULT_TIMEOUT_MS = 10_000;
29
+ const MAX_RESPONSE_BYTES = 256_000;
30
+ const DEFAULT_COUNT = 10;
31
+ const MAX_COUNT = 20;
32
+ export async function webSearchHandler(args, _ctx, opts = {}) {
33
+ // Input validation
34
+ if (!args.query || typeof args.query !== 'string' || args.query.trim().length === 0) {
35
+ return { content: 'error: query is required (non-empty string)', is_error: true };
36
+ }
37
+ const apiKey = process.env.CSPEACH_BRAVE_SEARCH_KEY;
38
+ if (!apiKey) {
39
+ return {
40
+ content: 'error: web_search requires CSPEACH_BRAVE_SEARCH_KEY. Get a free key at https://api.search.brave.com/app/keys and set CSPEACH_BRAVE_SEARCH_KEY=<your_key> in your environment.',
41
+ is_error: true,
42
+ };
43
+ }
44
+ const count = Math.min(Math.max(typeof args.count === 'number' && args.count > 0 ? Math.floor(args.count) : DEFAULT_COUNT, 1), MAX_COUNT);
45
+ const timeoutMs = opts._timeoutMs ?? DEFAULT_TIMEOUT_MS;
46
+ // Build the full endpoint URL with query parameters
47
+ const queryUrl = `${BRAVE_ENDPOINT}?q=${encodeURIComponent(args.query.trim())}&count=${count}`;
48
+ // Validate the endpoint URL through _web-shared (HTTPS + DNS pin)
49
+ let validated;
50
+ try {
51
+ validated = await validateUrl(queryUrl, { deps: opts._deps });
52
+ }
53
+ catch (err) {
54
+ if (err instanceof BlockedHostError || err instanceof BlockedProtocolError || err instanceof MalformedUrlError) {
55
+ return { content: `error: ${err.message}`, is_error: true };
56
+ }
57
+ return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
58
+ }
59
+ // Make the request
60
+ const result = await fetchBrave(validated, apiKey, timeoutMs);
61
+ if (result.kind === 'error') {
62
+ // Sanity: scrub the API key from any error message in case it leaked
63
+ // through (shouldn't, but defense-in-depth — the test asserts this).
64
+ const message = result.message.split(apiKey).join('***');
65
+ return { content: `error: ${message}`, is_error: true };
66
+ }
67
+ // Non-2xx status → structured error
68
+ if (result.statusCode < 200 || result.statusCode >= 300) {
69
+ const bodyPreview = truncate(result.body, 1024);
70
+ // Scrub API key from error body (defense-in-depth)
71
+ const safePreview = bodyPreview.split(apiKey).join('***');
72
+ return {
73
+ content: `error: Brave API returned status ${result.statusCode}.\n${safePreview}`,
74
+ is_error: true,
75
+ };
76
+ }
77
+ // Parse the JSON
78
+ let parsed;
79
+ try {
80
+ parsed = JSON.parse(result.body);
81
+ }
82
+ catch (err) {
83
+ return {
84
+ content: `error: failed to parse Brave response JSON — ${err instanceof Error ? err.message : String(err)}`,
85
+ is_error: true,
86
+ };
87
+ }
88
+ const results = parsed.web?.results ?? [];
89
+ if (results.length === 0) {
90
+ return { content: `0 results for query "${args.query}".` };
91
+ }
92
+ // Format the result list for the agent
93
+ const lines = [`${results.length} result(s) for "${args.query}":`, ''];
94
+ for (let i = 0; i < results.length; i++) {
95
+ const r = results[i];
96
+ lines.push(`${i + 1}. ${r.title ?? '(no title)'}`);
97
+ lines.push(` ${r.url ?? '(no url)'}`);
98
+ if (r.description)
99
+ lines.push(` ${truncate(r.description, 200)}`);
100
+ lines.push('');
101
+ }
102
+ return { content: lines.join('\n').trimEnd() };
103
+ }
104
+ function truncate(s, maxLen) {
105
+ if (s.length <= maxLen)
106
+ return s;
107
+ return s.slice(0, maxLen) + '… [truncated]';
108
+ }
109
+ async function fetchBrave(validated, apiKey, timeoutMs) {
110
+ return new Promise((resolve) => {
111
+ let buf = '';
112
+ let bufBytes = 0;
113
+ let truncated = false;
114
+ let settled = false;
115
+ const settle = (r) => {
116
+ if (settled)
117
+ return;
118
+ settled = true;
119
+ resolve(r);
120
+ };
121
+ const req = https.request({
122
+ method: 'GET',
123
+ hostname: validated.url.hostname,
124
+ port: validated.url.port ? Number(validated.url.port) : 443,
125
+ path: `${validated.url.pathname}${validated.url.search}`,
126
+ // https.request `lookup` field is typed `LookupFunction` whose
127
+ // opts parameter is `LookupOptions`; our pinnedLookup uses
128
+ // `LookupOneOptions`. The shapes are structurally compatible at
129
+ // runtime (we never set `all: true` so the single-address contract
130
+ // holds) — the cast bridges the @types/node strictness only.
131
+ lookup: validated.pinnedLookup,
132
+ headers: {
133
+ 'X-Subscription-Token': apiKey,
134
+ Accept: 'application/json',
135
+ 'Accept-Encoding': 'identity',
136
+ 'User-Agent': USER_AGENT,
137
+ },
138
+ }, (res) => {
139
+ res.on('data', (chunk) => {
140
+ if (truncated)
141
+ return;
142
+ const remaining = MAX_RESPONSE_BYTES - bufBytes;
143
+ if (remaining <= 0) {
144
+ truncated = true;
145
+ res.destroy?.();
146
+ settle({ kind: 'response', statusCode: res.statusCode ?? 0, body: buf });
147
+ return;
148
+ }
149
+ if (chunk.length <= remaining) {
150
+ buf += chunk.toString('utf-8');
151
+ bufBytes += chunk.length;
152
+ }
153
+ else {
154
+ buf += chunk.slice(0, remaining).toString('utf-8');
155
+ bufBytes += remaining;
156
+ truncated = true;
157
+ res.destroy?.();
158
+ settle({ kind: 'response', statusCode: res.statusCode ?? 0, body: buf });
159
+ }
160
+ });
161
+ res.on('end', () => {
162
+ settle({ kind: 'response', statusCode: res.statusCode ?? 0, body: buf });
163
+ });
164
+ res.on('error', (err) => {
165
+ settle({ kind: 'error', message: `response stream error: ${err.message}` });
166
+ });
167
+ });
168
+ req.setTimeout(timeoutMs, () => {
169
+ settle({ kind: 'error', message: `timeout after ${timeoutMs} ms — request aborted` });
170
+ req.destroy();
171
+ });
172
+ req.on('error', (err) => {
173
+ settle({ kind: 'error', message: `request error: ${err.message}` });
174
+ });
175
+ req.end();
176
+ });
177
+ }
178
+ registerTool({
179
+ name: 'web_search',
180
+ description: 'Search the web via Brave Search API. Returns title + URL + description per result, up to 20 results. ' +
181
+ 'Requires CSPEACH_BRAVE_SEARCH_KEY environment variable. ' +
182
+ 'Defaults to 10 results; pass count to override (max 20).',
183
+ isMutating: false,
184
+ category: 'web',
185
+ flagGated: true,
186
+ input_schema: {
187
+ type: 'object',
188
+ properties: {
189
+ query: { type: 'string', description: 'Search query string. Required.' },
190
+ count: { type: 'number', description: 'Number of results to return. Default 10, max 20.' },
191
+ },
192
+ required: ['query'],
193
+ },
194
+ handler: (args, ctx) => webSearchHandler(args, ctx),
195
+ });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Tools that mutate SAP-side state. Advisory mode short-circuits these;
3
+ * everything else (reads, quality checks, lock/snapshot control primitives)
4
+ * runs normally.
5
+ *
6
+ * Source of truth: ZCSPEACH SICF pivot spec §6.4 rows marked "W" (write).
7
+ * Does NOT include W-ctrl rows (lock/unlock/snapshot) because those are
8
+ * state-management primitives, not SAP data writes.
9
+ */
10
+ export const WRITE_TOOLS = new Set([
11
+ 'sap_create_object',
12
+ 'sap_set_source',
13
+ 'sap_update_method',
14
+ 'sap_delete_object',
15
+ 'sap_activate',
16
+ 'sap_message_maintain',
17
+ 'sap_service_binding_publish',
18
+ 'sap_transport_release',
19
+ ]);
20
+ export function isWriteTool(name) {
21
+ return WRITE_TOOLS.has(name);
22
+ }
package/dist/ui/app.js ADDED
@@ -0,0 +1,95 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect } from 'react';
3
+ import { Box, useInput } from 'ink';
4
+ import crypto from 'node:crypto';
5
+ import { Header } from './header.js';
6
+ import { Sidebar } from './sidebar.js';
7
+ import { Body } from './body.js';
8
+ import { Footer } from './footer.js';
9
+ import { ApprovalModal } from './approval-modal.js';
10
+ import { CommandPalette } from './command-palette.js';
11
+ import { RichErrorBox } from './rich-error-box.js';
12
+ import { SessionTimeline, toTimelineEntry } from './session-timeline.js';
13
+ import { approvalEmitter } from './approval-emitter.js';
14
+ import { errorEmitter } from './error-emitter.js';
15
+ import { SapStateContext, globalStore } from './sap-state-store.js';
16
+ import { coachingPickerEmitter } from './coaching-picker-emitter.js';
17
+ import { CoachingPicker } from './widgets/coaching-picker.js';
18
+ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger, getToolCalls, writeMode }) {
19
+ // Approval-modal overlay state (H-new-4). Populated by approvalEmitter
20
+ // subscription; rendered as the last child so it overlays correctly.
21
+ const [pendingApproval, setPendingApproval] = useState(null);
22
+ const [coachingPending, setCoachingPending] = useState(null);
23
+ // Task 25: slash palette overlay state.
24
+ const [showPalette, setShowPalette] = useState(false);
25
+ const [paletteResult, setPaletteResult] = useState('');
26
+ // Task 27: error stack overlay state.
27
+ const [errorStack, setErrorStack] = useState([]);
28
+ // Task 29: session timeline overlay state.
29
+ const [showTimeline, setShowTimeline] = useState(false);
30
+ const [footerValue, setFooterValue] = useState('');
31
+ useEffect(() => {
32
+ const onReq = (req) => setPendingApproval(req);
33
+ approvalEmitter.on('request', onReq);
34
+ return () => {
35
+ approvalEmitter.off('request', onReq);
36
+ };
37
+ }, []);
38
+ useEffect(() => {
39
+ const onRequest = (req) => {
40
+ setCoachingPending(req);
41
+ };
42
+ coachingPickerEmitter.on('request', onRequest);
43
+ return () => {
44
+ coachingPickerEmitter.off('request', onRequest);
45
+ };
46
+ }, []);
47
+ useEffect(() => {
48
+ const onError = (err) => {
49
+ const id = crypto.randomUUID();
50
+ setErrorStack((s) => [...s, { ...err, id }]);
51
+ setTimeout(() => {
52
+ setErrorStack((s) => s.filter((e) => e.id !== id));
53
+ }, 5000);
54
+ };
55
+ errorEmitter.on('error', onError);
56
+ return () => { errorEmitter.off('error', onError); };
57
+ }, []);
58
+ // `?` key opens the timeline when the footer is empty and no other overlay
59
+ // is active. Gating ensures TextInput captures `?` as a literal character
60
+ // when the user is typing.
61
+ useInput((input, _key) => {
62
+ if (input === '?' && footerValue === '')
63
+ setShowTimeline(true);
64
+ }, { isActive: !showPalette && !showTimeline && !pendingApproval && !coachingPending });
65
+ const clearApproval = () => setPendingApproval(null);
66
+ const handleSlashTrigger = () => {
67
+ setShowPalette(true);
68
+ onSlashTrigger?.();
69
+ };
70
+ const handleHistoryTrigger = () => {
71
+ setShowTimeline(true);
72
+ onHistoryTrigger?.();
73
+ };
74
+ const timelineEntries = showTimeline && getToolCalls
75
+ ? getToolCalls()
76
+ .filter((c) => c.completed === true)
77
+ .map(toTimelineEntry)
78
+ : [];
79
+ return (_jsx(SapStateContext.Provider, { value: globalStore, children: _jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsx(Header, { writeMode: writeMode }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [_jsx(Body, { chunkEmitter: chunkEmitter }), _jsx(Sidebar, {})] }), _jsx(Footer, { onSubmit: onSubmit, onSlashTrigger: handleSlashTrigger, onHistoryTrigger: handleHistoryTrigger, paletteResult: paletteResult, onValueChange: setFooterValue }), showPalette && (_jsx(Box, { position: "absolute", marginTop: 1, marginLeft: 2, children: _jsx(CommandPalette, { onSelect: (s) => {
80
+ setPaletteResult(s);
81
+ setShowPalette(false);
82
+ }, onCancel: () => setShowPalette(false) }) })), errorStack.map((e, i) => (_jsx(Box, { position: "absolute", marginTop: i * 6, children: _jsx(RichErrorBox, { code: e.code, message: e.body, hint: e.hint, severity: "error" }) }, e.id))), showTimeline && (_jsx(Box, { position: "absolute", marginTop: 1, marginLeft: 2, children: _jsx(SessionTimeline, { entries: timelineEntries, onClose: () => setShowTimeline(false) }) })), coachingPending && (_jsx(Box, { position: "absolute", marginTop: 2, marginLeft: 2, children: _jsx(CoachingPicker, { params: coachingPending.params, onResolve: (result) => {
83
+ coachingPending.resolve(result);
84
+ setCoachingPending(null);
85
+ } }) })), pendingApproval && (_jsx(Box, { position: "absolute", marginTop: 2, marginLeft: 2, children: _jsx(ApprovalModal, { change: pendingApproval.change, effectiveRisk: pendingApproval.effectiveRisk, transport: pendingApproval.transport, onApply: () => {
86
+ pendingApproval.resolve({ approved: true });
87
+ clearApproval();
88
+ }, onSkip: () => {
89
+ pendingApproval.resolve({ approved: false, reason: 'skipped' });
90
+ clearApproval();
91
+ }, onDiff: () => { }, onCancel: () => {
92
+ pendingApproval.resolve({ approved: false, reason: '__cancel_turn__' });
93
+ clearApproval();
94
+ } }) }))] }) }));
95
+ }
@@ -0,0 +1,10 @@
1
+ import { EventEmitter } from 'node:events';
2
+ /**
3
+ * Singleton request channel from `approvals/render.ts` (producer) to `<App>`
4
+ * (consumer). App subscribes in a useEffect and pushes the request into its
5
+ * `pendingApproval` state to render `<ApprovalModal>` as an overlay.
6
+ *
7
+ * Never spawn a sibling Ink root for the modal — that fights the main <App>
8
+ * for stdout and corrupts the Ink frame.
9
+ */
10
+ export const approvalEmitter = new EventEmitter();
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ const LABEL_MAP = {
5
+ delete_op: 'delete op detected',
6
+ prod_namespace: 'PROD namespace',
7
+ shared_transport: 'transport shared by 3+ devs',
8
+ atc_failed_24h: 'ATC failed in last 24h',
9
+ non_prod_override: 'auto-approve policy: never',
10
+ escalated_in_session: '>5 changes this session',
11
+ };
12
+ function opIcon(op) {
13
+ const icons = { create: 'A', modify: 'M', delete: 'D', activate: '>', release: '^' };
14
+ return icons[op] ?? '?';
15
+ }
16
+ function riskColor(level) {
17
+ return level === 'high' ? 'red' : level === 'medium' ? 'yellow' : 'green';
18
+ }
19
+ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSkip, onDiff, onCancel }) {
20
+ const [showingDiff, setShowingDiff] = useState(false);
21
+ useInput((input, key) => {
22
+ if (showingDiff) {
23
+ if (key.escape) {
24
+ setShowingDiff(false);
25
+ }
26
+ return;
27
+ }
28
+ if (input === 'a' || input === 'y') {
29
+ onApply();
30
+ return;
31
+ }
32
+ if (input === 's' || input === 'n') {
33
+ onSkip();
34
+ return;
35
+ }
36
+ if (input === 'd') {
37
+ setShowingDiff(true);
38
+ onDiff();
39
+ return;
40
+ }
41
+ if (input === 'c' || key.escape) {
42
+ onCancel();
43
+ return;
44
+ }
45
+ });
46
+ if (showingDiff && change.diff) {
47
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Diff \\u2014 Esc to return" }), change.diff.split('\n').map((line, i) => (_jsx(Text, { color: line.startsWith('+') ? 'green' : line.startsWith('-') ? 'red' : 'gray', children: line }, i)))] }));
48
+ }
49
+ const raisedLabel = effectiveRisk.raisedBy
50
+ .map((r) => LABEL_MAP[r] ?? r)
51
+ .join(', ');
52
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: riskColor(effectiveRisk.level), paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Approval requested" }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { bold: true, color: riskColor(effectiveRisk.level), children: [" ", opIcon(change.op), " "] }), _jsx(Text, { bold: true, children: change.object }), _jsxs(Text, { color: "gray", children: [" (", change.type, ")"] })] }), _jsxs(Text, { color: riskColor(effectiveRisk.level), children: ["Risk: ", effectiveRisk.level, raisedLabel ? ` (raised from low: ${raisedLabel})` : ''] }), transport && _jsxs(Text, { color: "gray", children: ["Transport: ", transport] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { children: "[a] Apply [d] Show diff" }), _jsx(Text, { children: "[s] Skip [c] Cancel turn" })] })] }));
53
+ }
@@ -0,0 +1,6 @@
1
+ import { shouldUseUnicodeBoxChars } from '../renderer/tty.js';
2
+ export const UNICODE_BOX = { tl: '\u256D', tr: '\u256E', bl: '\u2570', br: '\u256F', h: '\u2500', v: '\u2502' };
3
+ export const ASCII_BOX = { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' };
4
+ export function getBoxChars() {
5
+ return shouldUseUnicodeBoxChars() ? UNICODE_BOX : ASCII_BOX;
6
+ }
@@ -0,0 +1,102 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import React, { useReducer, useEffect } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { WIDGET_COMPONENTS } from './widgets/component-registry.js';
5
+ import { WIDGET_CONTROL } from './widget-control.js';
6
+ // WIDGET_CONTROL is the longer sentinel (`\x00CSPEACH-WIDGET-V3\x00`) — see M2.
7
+ // RegExp escaping: `\x00` works in JS regex source directly.
8
+ const WIDGET_RE = new RegExp(WIDGET_CONTROL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
9
+ '(\\d+):(\\w[\\w-]*):([\\w+/=]+)' +
10
+ WIDGET_CONTROL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
11
+ // Eviction caps (H-new-1).
12
+ const MAX_NODES = 500;
13
+ const MAX_TEXT_LEN = 100_000;
14
+ export const INITIAL_BODY_STATE = { nodes: [], pending: '', nextId: 0, totalTextLen: 0 };
15
+ /**
16
+ * Parse an incremental chunk into a list of new nodes + any unterminated tail
17
+ * that should stay in `pending`. Only the NEW content (pending + chunk) is
18
+ * scanned — previously-parsed nodes are never re-regexed.
19
+ */
20
+ function parseIncremental(pending, chunk) {
21
+ const combined = pending + chunk;
22
+ // If an unterminated WIDGET_CONTROL appears at the tail, defer from that point.
23
+ const lastOpenerIdx = combined.lastIndexOf(WIDGET_CONTROL);
24
+ let scannable = combined;
25
+ let nextPending = '';
26
+ if (lastOpenerIdx !== -1) {
27
+ // Check whether there's a matching closer after it.
28
+ const afterOpener = combined.slice(lastOpenerIdx + WIDGET_CONTROL.length);
29
+ if (!afterOpener.includes(WIDGET_CONTROL)) {
30
+ // Unterminated — buffer from the opener onwards.
31
+ scannable = combined.slice(0, lastOpenerIdx);
32
+ nextPending = combined.slice(lastOpenerIdx);
33
+ }
34
+ }
35
+ const newNodes = [];
36
+ const parts = scannable.split(WIDGET_RE);
37
+ // split with 3 groups produces 4 slots per widget: [text, idx, type, b64, text, ...]
38
+ for (let i = 0; i < parts.length; i++) {
39
+ const mod = i % 4;
40
+ if (mod === 0) {
41
+ if (parts[i])
42
+ newNodes.push({ kind: 'text', value: parts[i] });
43
+ }
44
+ else if (mod === 1) {
45
+ const wType = (parts[i + 1] ?? '');
46
+ const b64 = parts[i + 2] ?? '';
47
+ try {
48
+ const data = JSON.parse(Buffer.from(b64, 'base64').toString());
49
+ if (WIDGET_COMPONENTS[wType]) {
50
+ newNodes.push({ kind: 'widget', widgetType: wType, data });
51
+ }
52
+ else {
53
+ newNodes.push({ kind: 'widget-error' });
54
+ }
55
+ }
56
+ catch {
57
+ newNodes.push({ kind: 'widget-error' });
58
+ }
59
+ i += 2; // skip type + b64; loop's i++ advances past last captured group
60
+ }
61
+ }
62
+ return { newNodes, nextPending };
63
+ }
64
+ export function bodyReducer(state, action) {
65
+ const { newNodes, nextPending } = parseIncremental(state.pending, action.chunk);
66
+ let id = state.nextId;
67
+ let totalTextLen = state.totalTextLen;
68
+ const appended = newNodes.map((n) => {
69
+ const withId = { ...n, id: id++ };
70
+ if (withId.kind === 'text')
71
+ totalTextLen += withId.value.length;
72
+ return withId;
73
+ });
74
+ let nodes = state.nodes.concat(appended);
75
+ // Sliding-window eviction: drop oldest nodes until both caps satisfied.
76
+ while (nodes.length > MAX_NODES || totalTextLen > MAX_TEXT_LEN) {
77
+ const [evicted, ...rest] = nodes;
78
+ if (!evicted)
79
+ break;
80
+ if (evicted.kind === 'text')
81
+ totalTextLen -= evicted.value.length;
82
+ nodes = rest;
83
+ }
84
+ return { nodes, pending: nextPending, nextId: id, totalTextLen };
85
+ }
86
+ export const Body = React.memo(function Body({ chunkEmitter }) {
87
+ const [state, dispatch] = useReducer(bodyReducer, INITIAL_BODY_STATE);
88
+ useEffect(() => {
89
+ const handler = (chunk) => dispatch({ chunk });
90
+ chunkEmitter.on('chunk', handler);
91
+ return () => { chunkEmitter.off('chunk', handler); };
92
+ }, [chunkEmitter]);
93
+ return (_jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: state.nodes.map((n) => {
94
+ if (n.kind === 'text')
95
+ return _jsx(Text, { children: n.value }, n.id);
96
+ if (n.kind === 'widget') {
97
+ const Comp = WIDGET_COMPONENTS[n.widgetType];
98
+ return _jsx(Comp, { data: n.data }, n.id);
99
+ }
100
+ return _jsx(Text, { color: "yellow", children: "\\u26A0 widget render error" }, n.id);
101
+ }) }));
102
+ });
@@ -0,0 +1,36 @@
1
+ import { select } from '@inquirer/prompts';
2
+ import { PICK_CANCELLED } from './skill-picker.js';
3
+ const CANCEL_VALUE = '__cancel__';
4
+ /**
5
+ * Classic-mode coaching picker via inquirer's select prompt. Each choice shows
6
+ * a single-line `name` (skill + confidence %) in the list; the "Does:" and
7
+ * "Pick if:" sub-lines appear under the highlighted entry via `description`.
8
+ * Cancel entry always present at the bottom.
9
+ *
10
+ * Ink-mode has a separate implementation — see src/ui/widgets/coaching-picker.tsx.
11
+ */
12
+ export async function coachingPickerClassic(params) {
13
+ const choices = params.options.map((opt) => ({
14
+ name: `/${opt.skill} · ${Math.round(opt.confidence * 100)}%`,
15
+ value: opt.skill,
16
+ description: ` Does: ${opt.whatItDoes}\n Pick if: ${opt.whenToPick}`,
17
+ }));
18
+ choices.push({
19
+ name: '↩ cancel (go back to prompt)',
20
+ value: CANCEL_VALUE,
21
+ });
22
+ const message = `I'm not sure which skill fits for: "${params.prompt.slice(0, 50)}${params.prompt.length > 50 ? '...' : ''}". Pick one:`;
23
+ const defaultValue = params.preSelect && params.options.length > 0 ? params.options[0].skill : undefined;
24
+ try {
25
+ const chosen = await select({ message, choices, default: defaultValue });
26
+ if (chosen === CANCEL_VALUE)
27
+ return PICK_CANCELLED;
28
+ return chosen;
29
+ }
30
+ catch (err) {
31
+ const name = err?.name;
32
+ if (name === 'ExitPromptError')
33
+ return PICK_CANCELLED;
34
+ throw err;
35
+ }
36
+ }
@@ -0,0 +1,27 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { PICK_CANCELLED } from './skill-picker.js';
3
+ /**
4
+ * EventEmitter subclass with a request() helper that returns a Promise
5
+ * resolved from the overlay's onResolve callback. Deliberately different
6
+ * shape from approvalEmitter (which is a bare EventEmitter with request()
7
+ * living elsewhere) — keeping the request contract self-contained in
8
+ * this file makes the coaching flow easier to reason about cross-callers.
9
+ * See spec §5.2 for rationale.
10
+ */
11
+ class CoachingPickerEmitter extends EventEmitter {
12
+ request(params) {
13
+ return new Promise((resolve) => {
14
+ // Defensive: if App hasn't mounted the overlay listener yet (e.g. pre-
15
+ // render race during startup, or caller invokes outside Ink mode), resolve
16
+ // immediately with PICK_CANCELLED rather than hang forever. Real usage
17
+ // always has exactly one listener attached by App.tsx's useEffect.
18
+ if (this.listenerCount('request') === 0) {
19
+ resolve(PICK_CANCELLED);
20
+ return;
21
+ }
22
+ const request = { params, resolve };
23
+ this.emit('request', request);
24
+ });
25
+ }
26
+ }
27
+ export const coachingPickerEmitter = new CoachingPickerEmitter();
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect, useMemo } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import TextInput from 'ink-text-input';
5
+ import Fuse from 'fuse.js';
6
+ import { SKILL_CATALOG } from '../skill-catalog.js';
7
+ export function CommandPalette({ onSelect, onCancel }) {
8
+ const [query, setQuery] = useState('');
9
+ const [cursor, setCursor] = useState(0);
10
+ const fuse = useMemo(() => new Fuse(SKILL_CATALOG, { keys: ['name', 'description'], threshold: 0.4 }), []);
11
+ const results = useMemo(() => query
12
+ ? fuse.search(query).slice(0, 8).map((r) => r.item)
13
+ : SKILL_CATALOG.slice(0, 8), [query, fuse]);
14
+ useEffect(() => { setCursor(0); }, [query]);
15
+ useInput((_input, key) => {
16
+ if (key.escape) {
17
+ onCancel();
18
+ return;
19
+ }
20
+ if (key.upArrow) {
21
+ setCursor((c) => Math.max(0, c - 1));
22
+ return;
23
+ }
24
+ if (key.downArrow) {
25
+ setCursor((c) => Math.min(results.length - 1, c + 1));
26
+ return;
27
+ }
28
+ if (key.return && results[cursor]) {
29
+ onSelect(`/${results[cursor].name} `);
30
+ return;
31
+ }
32
+ });
33
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 2, paddingY: 1, width: 60, children: [_jsx(Text, { bold: true, color: "cyan", children: "Command palette" }), _jsx(TextInput, { value: query, onChange: setQuery, placeholder: "Type to filter..." }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: results.map((skill, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: i === cursor ? 'cyan' : 'white', bold: i === cursor, children: [i === cursor ? '\u25B8' : ' ', " /", skill.name.padEnd(20)] }), _jsx(Text, { dimColor: true, children: skill.description })] }, skill.name))) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter: insert Esc: cancel" }) })] }));
34
+ }
@@ -0,0 +1,21 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { shouldUseInk } from '../renderer/tty.js';
3
+ import chalk from 'chalk';
4
+ export const errorEmitter = new EventEmitter();
5
+ /**
6
+ * Emit an error. In Ink mode, the main App subscribes and pushes the error
7
+ * into its errorStack state for overlay rendering. In non-TTY / one-shot,
8
+ * we fall back to chalk.red on stderr — never mount a second Ink root.
9
+ */
10
+ export function emitError(err) {
11
+ if (shouldUseInk()) {
12
+ errorEmitter.emit('error', err);
13
+ }
14
+ else {
15
+ console.error(chalk.red(`[${err.code}] ${err.title}`));
16
+ if (err.body)
17
+ console.error(chalk.red(` ${err.body}`));
18
+ if (err.hint)
19
+ console.error(chalk.dim(` ${err.hint}`));
20
+ }
21
+ }