@cspeach/cli 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +8 -0
- package/README.md +108 -0
- package/dist/agent/anthropic-provider.js +59 -0
- package/dist/agent/llm-provider.js +1 -0
- package/dist/agent/loop.js +709 -0
- package/dist/agent/maybe-build-project-context.js +126 -0
- package/dist/agent/providers/ai-hub-provider.js +58 -0
- package/dist/agent/providers/byok-provider.js +53 -0
- package/dist/agent/providers/factory.js +13 -0
- package/dist/agent/providers/local-provider.js +125 -0
- package/dist/agent/repair-partial.js +31 -0
- package/dist/agent/retry-key.js +58 -0
- package/dist/agent/retry.js +17 -0
- package/dist/agent/sap-connection-adapter.js +82 -0
- package/dist/agent/skill-checkpoint.js +119 -0
- package/dist/agent/tool-dispatch.js +47 -0
- package/dist/agent/turn-assistant-text.js +49 -0
- package/dist/agent/turn-error-ux.js +126 -0
- package/dist/agent/turn-stream.js +79 -0
- package/dist/agent/turn-watchdog.js +71 -0
- package/dist/approvals/advisory-prompt.js +40 -0
- package/dist/approvals/advisory-render.js +38 -0
- package/dist/approvals/approval-prompt.js +100 -0
- package/dist/approvals/jwt.js +33 -0
- package/dist/approvals/render.js +211 -0
- package/dist/approvals/risk-floor.js +26 -0
- package/dist/auth/api-key.js +40 -0
- package/dist/auth/auth-file.js +59 -0
- package/dist/auth/device.js +8 -0
- package/dist/auth/me.js +19 -0
- package/dist/classifier/client.js +58 -0
- package/dist/cli-args.js +38 -0
- package/dist/cli.js +148 -0
- package/dist/commands/config-set.js +245 -0
- package/dist/commands/config-show.js +159 -0
- package/dist/commands/help.js +93 -0
- package/dist/commands/login.js +122 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/project-context-impact.js +215 -0
- package/dist/commands/reroute.js +60 -0
- package/dist/commands/spec-gap-status.js +52 -0
- package/dist/commands/whoami.js +35 -0
- package/dist/config/loader.js +67 -0
- package/dist/config/paths.js +20 -0
- package/dist/doctor/checks/_http-probe.js +56 -0
- package/dist/doctor/checks/auth.js +15 -0
- package/dist/doctor/checks/cert.js +24 -0
- package/dist/doctor/checks/forge-rules.js +102 -0
- package/dist/doctor/checks/keychain-fallback.js +14 -0
- package/dist/doctor/checks/keychain.js +23 -0
- package/dist/doctor/checks/llm-mode.js +27 -0
- package/dist/doctor/checks/proxy.js +13 -0
- package/dist/doctor/checks/sap.js +33 -0
- package/dist/doctor/checks/skill.js +24 -0
- package/dist/doctor/checks/write-mode.js +34 -0
- package/dist/doctor/checks/zcspeach.js +76 -0
- package/dist/doctor/run.js +46 -0
- package/dist/errors/codes.js +12 -0
- package/dist/index.js +6 -0
- package/dist/lock-contention.js +22 -0
- package/dist/one-shot.js +104 -0
- package/dist/project-context/conventions.js +309 -0
- package/dist/project-context/detect.js +250 -0
- package/dist/project-context/domain/abap-cloud.js +26 -0
- package/dist/project-context/domain/abapgit.js +177 -0
- package/dist/project-context/domain/cap.js +164 -0
- package/dist/project-context/domain/fiori.js +326 -0
- package/dist/project-context/git.js +115 -0
- package/dist/project-context/index-files.js +235 -0
- package/dist/project-context/index.js +117 -0
- package/dist/project-context/render.js +308 -0
- package/dist/project-context/types.js +14 -0
- package/dist/projects/build.js +20 -0
- package/dist/projects/canonicalize.js +39 -0
- package/dist/projects/email-template.js +54 -0
- package/dist/projects/extract-cca.js +139 -0
- package/dist/projects/extract-design.js +107 -0
- package/dist/projects/extract-estimate.js +93 -0
- package/dist/projects/extract-modernize.js +130 -0
- package/dist/projects/extract-spec-gap.js +101 -0
- package/dist/projects/extract-test-coverage.js +137 -0
- package/dist/projects/extract-upgrade.js +230 -0
- package/dist/projects/filename.js +18 -0
- package/dist/projects/index.js +8 -0
- package/dist/projects/migration.js +111 -0
- package/dist/projects/promote-command.js +96 -0
- package/dist/projects/promote.js +107 -0
- package/dist/projects/save-command.js +124 -0
- package/dist/projects/save.js +21 -0
- package/dist/projects/status.js +170 -0
- package/dist/projects/types.js +1 -0
- package/dist/projects/validate.js +146 -0
- package/dist/projects/workspace.js +478 -0
- package/dist/renderer/abap-inline.js +121 -0
- package/dist/renderer/banners.js +39 -0
- package/dist/renderer/highlighters/abap.js +126 -0
- package/dist/renderer/highlighters/bdef.js +81 -0
- package/dist/renderer/highlighters/cds.js +91 -0
- package/dist/renderer/markdown.js +291 -0
- package/dist/renderer/pipeline.js +201 -0
- package/dist/renderer/progress-chatter.js +237 -0
- package/dist/renderer/question-normalizer.js +306 -0
- package/dist/renderer/severity.js +61 -0
- package/dist/renderer/status-footer.js +50 -0
- package/dist/renderer/syntax.js +58 -0
- package/dist/renderer/tables.js +55 -0
- package/dist/renderer/thinking-heartbeat.js +70 -0
- package/dist/renderer/tool-widget.js +199 -0
- package/dist/renderer/tty.js +66 -0
- package/dist/renderer/widget-extractor.js +87 -0
- package/dist/renderer/widget-fallback.js +78 -0
- package/dist/renderer/widget-schemas.js +43 -0
- package/dist/repl/at-completer.js +64 -0
- package/dist/repl/at-picker.js +122 -0
- package/dist/repl/bracketed-paste.js +284 -0
- package/dist/repl/current-transport.js +46 -0
- package/dist/repl/diff-display.js +41 -0
- package/dist/repl/file-picker.js +219 -0
- package/dist/repl/inquirer-guard.js +130 -0
- package/dist/repl/inquirer-theme.js +41 -0
- package/dist/repl/rule8-detector.js +99 -0
- package/dist/repl/safety-confirm.js +106 -0
- package/dist/repl/safety-mode-state.js +36 -0
- package/dist/repl/slash-completer.js +59 -0
- package/dist/repl/slash-picker.js +124 -0
- package/dist/repl/update-method-preview-hook.js +45 -0
- package/dist/repl.js +1383 -0
- package/dist/router/classifier.js +38 -0
- package/dist/router/intent-extractor.js +140 -0
- package/dist/router/routing-decision.js +19 -0
- package/dist/sap/connection-manager.js +52 -0
- package/dist/sap/onboarding.js +178 -0
- package/dist/sap/system-info.js +515 -0
- package/dist/session/awaiting-answer.js +73 -0
- package/dist/session/gc.js +28 -0
- package/dist/session/pending.js +37 -0
- package/dist/session/resume.js +77 -0
- package/dist/session/schema.js +20 -0
- package/dist/session/store.js +147 -0
- package/dist/session/time-ago.js +41 -0
- package/dist/skill-catalog.js +222 -0
- package/dist/skills/bundled-skills.js +1 -0
- package/dist/skills/canonical.js +12 -0
- package/dist/skills/manifest-client.js +93 -0
- package/dist/skills/promotion-dispatch.js +24 -0
- package/dist/skills/signing-public-key.js +4 -0
- package/dist/skills/source-bundled.js +20 -0
- package/dist/skills/source-managed.js +26 -0
- package/dist/skills/source-manifest.js +26 -0
- package/dist/tools/_command-shared.js +110 -0
- package/dist/tools/_filesystem-shared.js +81 -0
- package/dist/tools/_flag.js +39 -0
- package/dist/tools/approval.js +228 -0
- package/dist/tools/ask-question.js +205 -0
- package/dist/tools/dispatch-skill.js +81 -0
- package/dist/tools/filesystem/file-edit.js +140 -0
- package/dist/tools/filesystem/file-read.js +89 -0
- package/dist/tools/filesystem/file-write.js +128 -0
- package/dist/tools/filesystem/glob.js +177 -0
- package/dist/tools/filesystem/grep.js +163 -0
- package/dist/tools/index.js +32 -0
- package/dist/tools/project/convention_get.js +91 -0
- package/dist/tools/project/playbook_get.js +132 -0
- package/dist/tools/project/project_context_get.js +101 -0
- package/dist/tools/sap-read.js +454 -0
- package/dist/tools/sap-write.js +746 -0
- package/dist/tools/shell/shell_exec.js +209 -0
- package/dist/tools/snapshot.js +107 -0
- package/dist/tools/subagent/_background-shared.js +133 -0
- package/dist/tools/subagent/agent_run.js +186 -0
- package/dist/tools/subagent/background_run.js +143 -0
- package/dist/tools/subagent/monitor_emit.js +65 -0
- package/dist/tools/subagent/schedule_create.js +131 -0
- package/dist/tools/transport.js +233 -0
- package/dist/tools/update-method-intercept.js +119 -0
- package/dist/tools/verify.js +39 -0
- package/dist/tools/web/_web-shared.js +251 -0
- package/dist/tools/web/web_fetch.js +257 -0
- package/dist/tools/web/web_search.js +195 -0
- package/dist/tools/write-mode.js +22 -0
- package/dist/ui/app.js +95 -0
- package/dist/ui/approval-emitter.js +10 -0
- package/dist/ui/approval-modal.js +53 -0
- package/dist/ui/ascii-chars.js +6 -0
- package/dist/ui/body.js +102 -0
- package/dist/ui/coaching-picker-classic.js +36 -0
- package/dist/ui/coaching-picker-emitter.js +27 -0
- package/dist/ui/command-palette.js +34 -0
- package/dist/ui/error-emitter.js +21 -0
- package/dist/ui/footer.js +103 -0
- package/dist/ui/header.js +17 -0
- package/dist/ui/ink-classifier-route.js +19 -0
- package/dist/ui/login-banner.js +72 -0
- package/dist/ui/rich-error-box.js +9 -0
- package/dist/ui/sap-state-store.js +65 -0
- package/dist/ui/session-timeline.js +31 -0
- package/dist/ui/sidebar.js +10 -0
- package/dist/ui/skill-picker.js +50 -0
- package/dist/ui/status-row.js +12 -0
- package/dist/ui/widget-control.js +4 -0
- package/dist/ui/widgets/bar-chart.js +15 -0
- package/dist/ui/widgets/coaching-picker.js +41 -0
- package/dist/ui/widgets/component-registry.js +12 -0
- package/dist/ui/widgets/dep-graph.js +9 -0
- package/dist/ui/widgets/diff-viewer.js +11 -0
- package/dist/ui/widgets/question-card.js +11 -0
- package/dist/ui/widgets/stack-frames.js +5 -0
- package/dist/upgrade-check.js +28 -0
- package/dist/upgrade.js +13 -0
- package/package.json +83 -0
|
@@ -0,0 +1,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
|
+
}
|
package/dist/ui/body.js
ADDED
|
@@ -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
|
+
}
|