@pugi/cli 0.1.0-beta.2 → 0.1.0-beta.20
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/THIRD_PARTY_NOTICES.md +40 -0
- package/assets/pugi-mascot.ansi +15 -40
- package/bin/run.js +33 -1
- package/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +15 -0
- package/dist/core/agent-progress/cleanup.js +134 -0
- package/dist/core/agent-progress/schema.js +144 -0
- package/dist/core/agent-progress/writer.js +101 -0
- package/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +196 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/consensus/diff-capture.js +73 -0
- package/dist/core/context/index.js +7 -0
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/cost/rate-card.js +129 -0
- package/dist/core/cost/tracker.js +221 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +86 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/edits/dispatch.js +218 -2
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/edits/worktree.js +111 -18
- package/dist/core/engine/anvil-client.js +115 -5
- package/dist/core/engine/budgets.js +89 -0
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +744 -210
- package/dist/core/engine/prompts.js +61 -6
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +818 -31
- package/dist/core/file-cache.js +113 -1
- package/dist/core/init/scaffold.js +195 -0
- package/dist/core/lsp/client.js +174 -29
- package/dist/core/mcp/client.js +75 -6
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/permissions/gate.js +187 -0
- package/dist/core/permissions/index.js +18 -0
- package/dist/core/permissions/mode.js +102 -0
- package/dist/core/permissions/state.js +160 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/repl/codebase-survey.js +308 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/init-interview.js +457 -0
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/onboarding-state.js +297 -0
- package/dist/core/repl/session.js +719 -29
- package/dist/core/repl/slash-commands.js +133 -9
- package/dist/core/retry-budget/budget.js +284 -0
- package/dist/core/retry-budget/index.js +5 -0
- package/dist/core/settings.js +71 -0
- package/dist/core/skills/defaults.js +457 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +113 -24
- package/dist/core/subagents/index.js +18 -5
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +1588 -266
- package/dist/runtime/commands/compact.js +296 -0
- package/dist/runtime/commands/cost.js +199 -0
- package/dist/runtime/commands/delegate.js +289 -0
- package/dist/runtime/commands/doctor.js +369 -0
- package/dist/runtime/commands/lsp.js +187 -5
- package/dist/runtime/commands/mcp.js +824 -0
- package/dist/runtime/commands/patch.js +17 -0
- package/dist/runtime/commands/permissions.js +87 -0
- package/dist/runtime/commands/report.js +299 -0
- package/dist/runtime/commands/review-consensus.js +17 -2
- package/dist/runtime/commands/roster.js +117 -0
- package/dist/runtime/commands/status.js +178 -0
- package/dist/runtime/commands/worktree.js +50 -6
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/plan-decompose.js +531 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +206 -0
- package/dist/tools/apply-patch.js +281 -39
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/file-tools.js +85 -14
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/registry.js +22 -2
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/compact-banner.js +54 -0
- package/dist/tui/conversation-pane.js +69 -8
- package/dist/tui/cost-table.js +111 -0
- package/dist/tui/doctor-table.js +31 -0
- package/dist/tui/input-box.js +1 -1
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/repl-render.js +276 -37
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +25 -6
- package/dist/tui/splash.js +1 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/status-table.js +7 -0
- package/dist/tui/tool-stream-pane.js +7 -0
- package/dist/tui/update-banner.js +20 -2
- package/docs/examples/codegraph.mcp.json +10 -0
- package/package.json +9 -6
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web_search tool — β1b T4 (2026-05-26).
|
|
3
|
+
*
|
|
4
|
+
* One-shot web search via the Anvil-proxied Brave Search API. Mirrors
|
|
5
|
+
* the gate + SSRF + sanitize + rate-limit posture of
|
|
6
|
+
* `tools/web-fetch.ts`. Returns the top-N {title, url, snippet}
|
|
7
|
+
* results wrapped in an `<untrusted-search-NONCE>` sentinel so the
|
|
8
|
+
* downstream prompt treats every snippet as data, never as
|
|
9
|
+
* instructions.
|
|
10
|
+
*
|
|
11
|
+
* Why an Anvil proxy and not a direct Brave API call:
|
|
12
|
+
* - The Brave Search subscription key is server-side. Shipping it in
|
|
13
|
+
* a customer CLI bundle would burn the rotation budget the first
|
|
14
|
+
* time the binary lands on a phishing repo.
|
|
15
|
+
* - The Anvil proxy already terminates the customer's `PUGI_API_KEY`
|
|
16
|
+
* for tenancy + per-tier rate-limiting + audit logging. Routing
|
|
17
|
+
* search through the same boundary keeps the security model
|
|
18
|
+
* uniform with `pugi engine` and `pugi sync`.
|
|
19
|
+
* - For local dev / offline testing the env var
|
|
20
|
+
* `PUGI_SEARCH_PROXY_URL` overrides the default proxy endpoint;
|
|
21
|
+
* unit tests inject `_setSearchFetchForTests()` so no network is
|
|
22
|
+
* touched.
|
|
23
|
+
*
|
|
24
|
+
* Gate: the tool refuses unless one of:
|
|
25
|
+
* 1. `settings.web.search.enabled === true` (persistent opt-in)
|
|
26
|
+
* 2. `allowSearch === true` (CLI flag `--allow-search`, single dispatch)
|
|
27
|
+
*
|
|
28
|
+
* SSRF: the proxy URL itself runs through `validateHostnameForFetch`
|
|
29
|
+
* from `web-fetch.ts` so a configured `PUGI_SEARCH_PROXY_URL=
|
|
30
|
+
* http://169.254.169.254/...` cannot ride to cloud metadata. We also
|
|
31
|
+
* apply the β1b #62 DNS-rebinding guard via the shared
|
|
32
|
+
* `pinnedAddressAgent` helper (web-fetch.ts) — the same SSRF window
|
|
33
|
+
* that fetch closes, search closes.
|
|
34
|
+
*
|
|
35
|
+
* Rate limit: in-memory session bucket — 5 requests per rolling 60 s
|
|
36
|
+
* keyed by `sessionId`. Burst above the cap returns
|
|
37
|
+
* `{ ok: false, error: 'rate_limited' }` immediately, no network
|
|
38
|
+
* dispatch. Per-session because a noisy session should not starve a
|
|
39
|
+
* quieter one in the same workstation process.
|
|
40
|
+
*
|
|
41
|
+
* Response size cap: hard 1 MiB ceiling on the proxy body. Anything
|
|
42
|
+
* larger drops the entire response with `oversized_response` — the
|
|
43
|
+
* proxy already normalizes Brave's payload to a small JSON shape, so
|
|
44
|
+
* 1 MiB is generous for the typical 10-result payload (~5 KiB).
|
|
45
|
+
*
|
|
46
|
+
* Sanitization: snippet text is stripped of HTML tags before being
|
|
47
|
+
* surfaced to the model. Brave returns raw HTML in `description` for
|
|
48
|
+
* highlight markup; we keep the visible text and drop every `<...>`
|
|
49
|
+
* span via a conservative regex. Cross-checked by the `script/style`
|
|
50
|
+
* spec which asserts a hostile `<script>alert(1)</script>` snippet
|
|
51
|
+
* lands as the inner text only.
|
|
52
|
+
*
|
|
53
|
+
* Brand voice: brief / dispatch / ship / sentinel only — brandbook §08.
|
|
54
|
+
*/
|
|
55
|
+
import { request, Agent } from 'undici';
|
|
56
|
+
import { randomBytes } from 'node:crypto';
|
|
57
|
+
import { validateHostnameForFetch, validateIpLiteralForFetch } from './web-fetch.js';
|
|
58
|
+
let activeFetch = null;
|
|
59
|
+
/**
|
|
60
|
+
* Test seam — pass `null` to restore the production undici dispatcher.
|
|
61
|
+
* Mirrors the `_setLookupForTests` pattern from `web-fetch.ts`.
|
|
62
|
+
*/
|
|
63
|
+
export function _setSearchFetchForTests(fn) {
|
|
64
|
+
activeFetch = fn;
|
|
65
|
+
}
|
|
66
|
+
const SEARCH_TIMEOUT_MS = 10_000;
|
|
67
|
+
const MAX_RESPONSE_BYTES = 1 * 1024 * 1024; // 1 MiB hard cap
|
|
68
|
+
const MAX_RESULTS = 10;
|
|
69
|
+
const DEFAULT_RESULTS = 10;
|
|
70
|
+
const MAX_QUERY_LEN = 256;
|
|
71
|
+
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
72
|
+
const RATE_LIMIT_MAX = 5;
|
|
73
|
+
const USER_AGENT = 'pugi-cli/0.1 (+https://pugi.dev)';
|
|
74
|
+
const DEFAULT_PROXY_URL = 'https://api.pugi.io/api/pugi/web-search';
|
|
75
|
+
export function isWebSearchEnabled(ctx) {
|
|
76
|
+
if (ctx.allowSearch === true)
|
|
77
|
+
return true;
|
|
78
|
+
return ctx.settings.web?.search?.enabled === true;
|
|
79
|
+
}
|
|
80
|
+
const rateBuckets = new Map();
|
|
81
|
+
/**
|
|
82
|
+
* β1b r1: Map sweep cap so a long-running engine session that recycles
|
|
83
|
+
* sessionIds (or a misconfigured caller that passes per-call random
|
|
84
|
+
* ids) cannot grow `rateBuckets` unbounded. 1024 is well above any
|
|
85
|
+
* realistic concurrent-session count for a single CLI process; when
|
|
86
|
+
* we cross the cap we sweep expired buckets first, and if the Map is
|
|
87
|
+
* still over the cap we evict the oldest-touched bucket. Pure LRU
|
|
88
|
+
* would need another timestamp; oldest-by-insertion (Map iteration
|
|
89
|
+
* order) is close enough for a defense-in-depth memory bound.
|
|
90
|
+
*/
|
|
91
|
+
const RATE_BUCKETS_CAP = 1024;
|
|
92
|
+
/**
|
|
93
|
+
* Returns true when the dispatch is allowed and records the call;
|
|
94
|
+
* returns false when the per-session bucket is at cap. Pure
|
|
95
|
+
* in-process; CLI dispatch is short-lived so a Map is sufficient.
|
|
96
|
+
*
|
|
97
|
+
* Exported for spec reset between test cases.
|
|
98
|
+
*/
|
|
99
|
+
export function _checkRateLimit(sessionId, now = Date.now()) {
|
|
100
|
+
if (!sessionId)
|
|
101
|
+
return true;
|
|
102
|
+
let bucket = rateBuckets.get(sessionId);
|
|
103
|
+
if (!bucket) {
|
|
104
|
+
// β1b r1: sweep before allocating a new bucket so we never grow
|
|
105
|
+
// past the cap. Sweep is O(n) but only runs when we are about to
|
|
106
|
+
// breach the cap, so per-call cost stays effectively constant.
|
|
107
|
+
if (rateBuckets.size >= RATE_BUCKETS_CAP) {
|
|
108
|
+
sweepRateBuckets(now);
|
|
109
|
+
}
|
|
110
|
+
bucket = { timestamps: [] };
|
|
111
|
+
rateBuckets.set(sessionId, bucket);
|
|
112
|
+
}
|
|
113
|
+
const cutoff = now - RATE_LIMIT_WINDOW_MS;
|
|
114
|
+
bucket.timestamps = bucket.timestamps.filter((ts) => ts > cutoff);
|
|
115
|
+
if (bucket.timestamps.length >= RATE_LIMIT_MAX)
|
|
116
|
+
return false;
|
|
117
|
+
bucket.timestamps.push(now);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Drop expired buckets (all timestamps older than the rate-limit
|
|
122
|
+
* window) and, if still over the cap, evict the oldest-inserted
|
|
123
|
+
* buckets until under cap. Memory-bound only — never affects rate
|
|
124
|
+
* limit correctness for live sessions.
|
|
125
|
+
*/
|
|
126
|
+
function sweepRateBuckets(now) {
|
|
127
|
+
const cutoff = now - RATE_LIMIT_WINDOW_MS;
|
|
128
|
+
for (const [id, b] of rateBuckets) {
|
|
129
|
+
if (b.timestamps.every((ts) => ts <= cutoff)) {
|
|
130
|
+
rateBuckets.delete(id);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
while (rateBuckets.size >= RATE_BUCKETS_CAP) {
|
|
134
|
+
const oldest = rateBuckets.keys().next();
|
|
135
|
+
if (oldest.done)
|
|
136
|
+
break;
|
|
137
|
+
rateBuckets.delete(oldest.value);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Test seam — clear rate buckets between specs. */
|
|
141
|
+
export function _resetRateLimitForTests() {
|
|
142
|
+
rateBuckets.clear();
|
|
143
|
+
}
|
|
144
|
+
/* ----------------------- sanitization ---------------------- */
|
|
145
|
+
/**
|
|
146
|
+
* Strip HTML tags + collapse whitespace. Brave's `description` carries
|
|
147
|
+
* highlight markup (`<strong>`); we keep the inner text only so the
|
|
148
|
+
* model never sees raw HTML it could mistake for a directive. The
|
|
149
|
+
* regex is conservative: `<[^>]*>` removes opening + closing tags +
|
|
150
|
+
* self-closing tags + comments + processing instructions. Script
|
|
151
|
+
* BODIES are removed via a second pass that drops the entire
|
|
152
|
+
* `<script>...</script>` and `<style>...</style>` block (case-
|
|
153
|
+
* insensitive) so even a server-rendered snippet cannot leak a
|
|
154
|
+
* `console.log` line into the model context.
|
|
155
|
+
*/
|
|
156
|
+
export function sanitizeSnippet(input) {
|
|
157
|
+
if (!input)
|
|
158
|
+
return '';
|
|
159
|
+
// Drop full script + style blocks (with their bodies). The
|
|
160
|
+
// non-greedy `.*?` handles "..." with embedded newlines via the `s`
|
|
161
|
+
// flag.
|
|
162
|
+
const noScripts = input
|
|
163
|
+
.replace(/<script\b[^>]*>.*?<\/script>/gis, '')
|
|
164
|
+
.replace(/<style\b[^>]*>.*?<\/style>/gis, '');
|
|
165
|
+
// Drop any remaining tag.
|
|
166
|
+
const noTags = noScripts.replace(/<[^>]*>/g, '');
|
|
167
|
+
// Collapse whitespace runs. Brave already trims but defensive.
|
|
168
|
+
return noTags.replace(/\s+/g, ' ').trim();
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* HTML-escape for sentinel body — mirrors `escapeForSentinelBody` from
|
|
172
|
+
* web-fetch.ts. Kept local to avoid coupling the modules tighter than
|
|
173
|
+
* the SSRF helpers already do.
|
|
174
|
+
*/
|
|
175
|
+
function escapeForSentinelBody(input) {
|
|
176
|
+
return input
|
|
177
|
+
.replace(/&/g, '&')
|
|
178
|
+
.replace(/</g, '<')
|
|
179
|
+
.replace(/>/g, '>')
|
|
180
|
+
.replace(/"/g, '"')
|
|
181
|
+
.replace(/'/g, ''');
|
|
182
|
+
}
|
|
183
|
+
/* ----------------------- dispatcher ---------------------- */
|
|
184
|
+
/**
|
|
185
|
+
* Resolve the search proxy URL. `PUGI_SEARCH_PROXY_URL` env override
|
|
186
|
+
* lets local dev point at a fixture server; production hits
|
|
187
|
+
* `api.pugi.io`.
|
|
188
|
+
*/
|
|
189
|
+
function resolveProxyUrl() {
|
|
190
|
+
const env = process.env.PUGI_SEARCH_PROXY_URL;
|
|
191
|
+
if (typeof env === 'string' && env.length > 0)
|
|
192
|
+
return env;
|
|
193
|
+
return DEFAULT_PROXY_URL;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Production fetch via undici with the β1b #62 DNS-rebinding guard
|
|
197
|
+
* applied. We resolve the proxy hostname, validate against the SSRF
|
|
198
|
+
* blocklist, then dispatch via an Agent pinned to the resolved IP so
|
|
199
|
+
* a hostile DNS server cannot answer a different address on the
|
|
200
|
+
* connect(2) than on the lookup.
|
|
201
|
+
*/
|
|
202
|
+
async function productionFetch(url, init) {
|
|
203
|
+
// β1b #62 — pinned-address Dispatcher to close the lookup→connect
|
|
204
|
+
// race. The Agent's `connect.lookup` always returns the address we
|
|
205
|
+
// already validated, so DNS cannot flip between the SSRF guard and
|
|
206
|
+
// the socket creation. Agent is per-call (small object) so process
|
|
207
|
+
// teardown does not need to keep a long-lived dispatcher around.
|
|
208
|
+
const parsed = new URL(url);
|
|
209
|
+
const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
|
|
210
|
+
const dnsGuard = await validateHostnameForFetch(hostname);
|
|
211
|
+
if (dnsGuard) {
|
|
212
|
+
throw new Error(`SSRF refused: ${dnsGuard}`);
|
|
213
|
+
}
|
|
214
|
+
const { lookup: dnsLookup } = await import('node:dns/promises');
|
|
215
|
+
const answers = await dnsLookup(hostname, { all: true, verbatim: true });
|
|
216
|
+
if (answers.length === 0) {
|
|
217
|
+
throw new Error(`DNS returned no answers for ${hostname}`);
|
|
218
|
+
}
|
|
219
|
+
// Pin the first answer of THIS lookup, then re-validate against the
|
|
220
|
+
// SSRF blocklist. β1b r1: closes the DNS rebinding window where a
|
|
221
|
+
// hostile resolver returns public IPs to `validateHostnameForFetch`
|
|
222
|
+
// above and private IPs here. The lookup that feeds the pin is the
|
|
223
|
+
// lookup whose answer the connect(2) will actually use; without a
|
|
224
|
+
// re-check on this literal the original guard's IP set can diverge
|
|
225
|
+
// from the IP we lock into `connect.lookup`.
|
|
226
|
+
const pinned = answers[0];
|
|
227
|
+
if (!pinned) {
|
|
228
|
+
throw new Error(`DNS returned no answers for ${hostname}`);
|
|
229
|
+
}
|
|
230
|
+
const ipCheck = validateIpLiteralForFetch(pinned.address, pinned.family);
|
|
231
|
+
if (ipCheck !== null) {
|
|
232
|
+
throw new Error(`SSRF refused: ssrf_pinned_address_blocked: ${ipCheck}`);
|
|
233
|
+
}
|
|
234
|
+
const agent = new Agent({
|
|
235
|
+
connect: {
|
|
236
|
+
// Force the connect path to use the pre-resolved address. undici
|
|
237
|
+
// accepts the standard Node `dns.LookupFunction` shape here.
|
|
238
|
+
lookup: (_h, _opts, cb) => {
|
|
239
|
+
cb(null, pinned.address, pinned.family);
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
try {
|
|
244
|
+
return await request(url, { ...init, dispatcher: agent });
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
await agent.close().catch(() => {
|
|
248
|
+
/* best-effort */
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async function readBodyWithCap(body, controller) {
|
|
253
|
+
const chunks = [];
|
|
254
|
+
let total = 0;
|
|
255
|
+
try {
|
|
256
|
+
for await (const chunk of body) {
|
|
257
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
258
|
+
total += buf.length;
|
|
259
|
+
if (total > MAX_RESPONSE_BYTES) {
|
|
260
|
+
controller.abort();
|
|
261
|
+
try {
|
|
262
|
+
if (typeof body.destroy === 'function')
|
|
263
|
+
body.destroy();
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
/* ignore — already aborted */
|
|
267
|
+
}
|
|
268
|
+
return { ok: false, error: `oversized_response: > ${MAX_RESPONSE_BYTES} bytes` };
|
|
269
|
+
}
|
|
270
|
+
chunks.push(buf);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
275
|
+
return { ok: false, error: `body_read_failed: ${msg}` };
|
|
276
|
+
}
|
|
277
|
+
return { ok: true, buffer: Buffer.concat(chunks) };
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Validate + clamp the optional `count` arg. Clamp into [1, MAX_RESULTS]
|
|
281
|
+
* and default to DEFAULT_RESULTS when missing. The bridge already
|
|
282
|
+
* validates the type — this enforces the bounds.
|
|
283
|
+
*/
|
|
284
|
+
function resolveCount(raw) {
|
|
285
|
+
if (raw === undefined)
|
|
286
|
+
return DEFAULT_RESULTS;
|
|
287
|
+
if (raw < 1)
|
|
288
|
+
return 1;
|
|
289
|
+
if (raw > MAX_RESULTS)
|
|
290
|
+
return MAX_RESULTS;
|
|
291
|
+
return Math.floor(raw);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Sanitize the operator query before it crosses the proxy boundary.
|
|
295
|
+
* Trim, hard-cap at MAX_QUERY_LEN, reject empty after trim. The proxy
|
|
296
|
+
* also validates, but a CLI-side check keeps the model's mistake
|
|
297
|
+
* visible to the operator (the failed call shows up in
|
|
298
|
+
* `.pugi/events.jsonl`) rather than blamed on a 400 from the proxy.
|
|
299
|
+
*/
|
|
300
|
+
function sanitizeQuery(raw) {
|
|
301
|
+
const trimmed = raw.trim();
|
|
302
|
+
if (trimmed.length === 0) {
|
|
303
|
+
throw new Error('empty_query');
|
|
304
|
+
}
|
|
305
|
+
if (trimmed.length > MAX_QUERY_LEN) {
|
|
306
|
+
return trimmed.slice(0, MAX_QUERY_LEN);
|
|
307
|
+
}
|
|
308
|
+
return trimmed;
|
|
309
|
+
}
|
|
310
|
+
export async function webSearchTool(input, ctx) {
|
|
311
|
+
if (!isWebSearchEnabled(ctx)) {
|
|
312
|
+
return {
|
|
313
|
+
ok: false,
|
|
314
|
+
error: 'web_search disabled. Enable with --allow-search or set web.search.enabled=true in .pugi/settings.json.',
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
let query;
|
|
318
|
+
try {
|
|
319
|
+
query = sanitizeQuery(input.query);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
323
|
+
}
|
|
324
|
+
if (!_checkRateLimit(ctx.sessionId)) {
|
|
325
|
+
return {
|
|
326
|
+
ok: false,
|
|
327
|
+
error: `rate_limited: max ${RATE_LIMIT_MAX} searches per ${RATE_LIMIT_WINDOW_MS / 1000}s per session`,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const count = resolveCount(input.count);
|
|
331
|
+
const proxyUrl = resolveProxyUrl();
|
|
332
|
+
// Validate the proxy URL parses + scheme + SSRF. Even though the
|
|
333
|
+
// env var is operator-controlled, a careless export of
|
|
334
|
+
// `http://localhost:8080` would otherwise let an unrelated local
|
|
335
|
+
// server intercept the call. Default URL is `api.pugi.io` and
|
|
336
|
+
// resolves clean.
|
|
337
|
+
let parsedProxy;
|
|
338
|
+
try {
|
|
339
|
+
parsedProxy = new URL(proxyUrl);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return { ok: false, error: `invalid_proxy_url: ${proxyUrl}` };
|
|
343
|
+
}
|
|
344
|
+
if (parsedProxy.protocol !== 'http:' && parsedProxy.protocol !== 'https:') {
|
|
345
|
+
return { ok: false, error: `unsupported_proxy_scheme: ${parsedProxy.protocol}` };
|
|
346
|
+
}
|
|
347
|
+
const proxyHost = parsedProxy.hostname.replace(/^\[|\]$/g, '');
|
|
348
|
+
// SSRF guard applies to the proxy hostname even when activeFetch is
|
|
349
|
+
// a test stub — keeps the spec contract honest. Tests that exercise
|
|
350
|
+
// the rate limiter use a stub that bypasses productionFetch but the
|
|
351
|
+
// guard still runs at the entry above test stubs.
|
|
352
|
+
const ssrfErr = await validateHostnameForFetch(proxyHost);
|
|
353
|
+
if (ssrfErr) {
|
|
354
|
+
return { ok: false, error: `SSRF refused: ${ssrfErr}` };
|
|
355
|
+
}
|
|
356
|
+
const controller = new AbortController();
|
|
357
|
+
const timer = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS);
|
|
358
|
+
// β1b: lifted PUGI_API_KEY from the runtime credential store would
|
|
359
|
+
// belong here for production — for the proxy call we forward whatever
|
|
360
|
+
// PUGI_API_KEY the operator has exported. Anonymous proxy access is
|
|
361
|
+
// permitted; the proxy treats it as the free-tier search quota.
|
|
362
|
+
const apiKey = process.env.PUGI_API_KEY ?? process.env.PUGI_LOGIN_TOKEN;
|
|
363
|
+
const headers = {
|
|
364
|
+
'user-agent': USER_AGENT,
|
|
365
|
+
'content-type': 'application/json',
|
|
366
|
+
accept: 'application/json',
|
|
367
|
+
};
|
|
368
|
+
if (typeof apiKey === 'string' && apiKey.length > 0) {
|
|
369
|
+
headers.authorization = `Bearer ${apiKey}`;
|
|
370
|
+
}
|
|
371
|
+
let response;
|
|
372
|
+
try {
|
|
373
|
+
const fetcher = activeFetch ?? (async (u, i) => (await productionFetch(u, i)));
|
|
374
|
+
response = (await fetcher(proxyUrl, {
|
|
375
|
+
method: 'POST',
|
|
376
|
+
headers,
|
|
377
|
+
body: JSON.stringify({ query, count }),
|
|
378
|
+
signal: controller.signal,
|
|
379
|
+
}));
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
clearTimeout(timer);
|
|
383
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
384
|
+
return { ok: false, error: `search_failed: ${message}` };
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
clearTimeout(timer);
|
|
388
|
+
}
|
|
389
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
390
|
+
// Drain the body so the socket can be reused / released.
|
|
391
|
+
try {
|
|
392
|
+
if (typeof response.body.destroy === 'function')
|
|
393
|
+
response.body.destroy();
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
/* ignore */
|
|
397
|
+
}
|
|
398
|
+
return { ok: false, error: `proxy_http_${response.statusCode}` };
|
|
399
|
+
}
|
|
400
|
+
const bodyResult = await readBodyWithCap(response.body, controller);
|
|
401
|
+
if (!bodyResult.ok)
|
|
402
|
+
return bodyResult;
|
|
403
|
+
let parsed;
|
|
404
|
+
try {
|
|
405
|
+
parsed = JSON.parse(bodyResult.buffer.toString('utf8'));
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
409
|
+
return { ok: false, error: `proxy_malformed_json: ${msg}` };
|
|
410
|
+
}
|
|
411
|
+
// Expect the proxy to normalize Brave's payload to `{results: [{title, url, snippet}]}`.
|
|
412
|
+
// When the proxy is missing the `results` array (or it is not an array)
|
|
413
|
+
// we surface a clean error instead of returning empty.
|
|
414
|
+
const obj = parsed;
|
|
415
|
+
if (!obj || typeof obj !== 'object') {
|
|
416
|
+
return { ok: false, error: 'proxy_malformed_response: not an object' };
|
|
417
|
+
}
|
|
418
|
+
const rawResults = obj.results;
|
|
419
|
+
if (!Array.isArray(rawResults)) {
|
|
420
|
+
return { ok: false, error: 'proxy_malformed_response: missing results array' };
|
|
421
|
+
}
|
|
422
|
+
const results = [];
|
|
423
|
+
for (const r of rawResults.slice(0, count)) {
|
|
424
|
+
if (!r || typeof r !== 'object')
|
|
425
|
+
continue;
|
|
426
|
+
const row = r;
|
|
427
|
+
const title = typeof row.title === 'string' ? sanitizeSnippet(row.title) : '';
|
|
428
|
+
const url = typeof row.url === 'string' ? row.url : '';
|
|
429
|
+
const snippet = typeof row.snippet === 'string' ? sanitizeSnippet(row.snippet) : '';
|
|
430
|
+
if (!url)
|
|
431
|
+
continue;
|
|
432
|
+
results.push({ title, url, snippet });
|
|
433
|
+
}
|
|
434
|
+
// Per-call nonce for the sentinel. Mirrors `web-fetch.ts` so the
|
|
435
|
+
// downstream prompt's nonce-matching escape logic works uniformly.
|
|
436
|
+
const nonce = randomBytes(8).toString('hex');
|
|
437
|
+
const renderedLines = [`<untrusted-search-${nonce}>`, `Query: ${escapeForSentinelBody(query)}`, ''];
|
|
438
|
+
for (let i = 0; i < results.length; i += 1) {
|
|
439
|
+
const r = results[i];
|
|
440
|
+
if (!r)
|
|
441
|
+
continue;
|
|
442
|
+
renderedLines.push(`${i + 1}. ${escapeForSentinelBody(r.title || r.url)}`);
|
|
443
|
+
renderedLines.push(` ${escapeForSentinelBody(r.url)}`);
|
|
444
|
+
if (r.snippet)
|
|
445
|
+
renderedLines.push(` ${escapeForSentinelBody(r.snippet)}`);
|
|
446
|
+
renderedLines.push('');
|
|
447
|
+
}
|
|
448
|
+
renderedLines.push(`</untrusted-search-${nonce}>`);
|
|
449
|
+
const content_md = renderedLines.join('\n').trimEnd();
|
|
450
|
+
return {
|
|
451
|
+
ok: true,
|
|
452
|
+
query,
|
|
453
|
+
results,
|
|
454
|
+
content_md,
|
|
455
|
+
fetched_at: new Date().toISOString(),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
//# sourceMappingURL=web-search.js.map
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
/** Width of the progress bar in display cells. Tuned to fit comfortably
|
|
4
|
+
* inside an 80-col terminal alongside the percent label. */
|
|
5
|
+
export const PROGRESS_BAR_WIDTH = 24;
|
|
6
|
+
/** Max milestone rows the card renders before collapsing to the footer
|
|
7
|
+
* summary. Matches the CC `/compact` cutoff. */
|
|
8
|
+
export const MAX_VISIBLE_MILESTONES = 5;
|
|
9
|
+
const STATUS_GLYPH = {
|
|
10
|
+
done: '◼',
|
|
11
|
+
active: '▸',
|
|
12
|
+
pending: '◻',
|
|
13
|
+
};
|
|
14
|
+
const STATUS_COLOR = {
|
|
15
|
+
done: 'green',
|
|
16
|
+
active: 'yellow',
|
|
17
|
+
pending: 'gray',
|
|
18
|
+
};
|
|
19
|
+
const HEADER_DOT_COLOR = {
|
|
20
|
+
running: 'cyan',
|
|
21
|
+
completed: 'green',
|
|
22
|
+
failed: 'red',
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Build the unicode progress bar. Exported для тесты — guarantees the
|
|
26
|
+
* filled/empty counts match the percent under all rounding edges.
|
|
27
|
+
*/
|
|
28
|
+
export function renderProgressBarCells(percent, width = PROGRESS_BAR_WIDTH) {
|
|
29
|
+
const safePercent = Math.max(0, Math.min(100, percent));
|
|
30
|
+
const cells = Math.round((safePercent / 100) * width);
|
|
31
|
+
const clamped = Math.max(0, Math.min(width, cells));
|
|
32
|
+
return {
|
|
33
|
+
filled: '▰'.repeat(clamped),
|
|
34
|
+
empty: '▱'.repeat(width - clamped),
|
|
35
|
+
cells: clamped,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Format milliseconds as the CC-style `Hh Mm Ss` / `Mm Ss` / `Ss` label.
|
|
40
|
+
* Mirrors the rule used by status-bar elapsed slot.
|
|
41
|
+
*/
|
|
42
|
+
export function formatElapsed(ms) {
|
|
43
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
44
|
+
const h = Math.floor(total / 3600);
|
|
45
|
+
const m = Math.floor((total % 3600) / 60);
|
|
46
|
+
const s = total % 60;
|
|
47
|
+
if (h > 0)
|
|
48
|
+
return `${h}h ${m}m ${s}s`;
|
|
49
|
+
if (m > 0)
|
|
50
|
+
return `${m}m ${s}s`;
|
|
51
|
+
return `${s}s`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Format a raw token count as `21.7k` / `3.4M` / `812`. Mirrors the
|
|
55
|
+
* formatter in `core/repl/model-pricing.ts` so both surfaces stay
|
|
56
|
+
* visually consistent without coupling.
|
|
57
|
+
*/
|
|
58
|
+
export function formatTokenCount(n) {
|
|
59
|
+
if (n === undefined)
|
|
60
|
+
return undefined;
|
|
61
|
+
if (n < 1_000)
|
|
62
|
+
return `${n}`;
|
|
63
|
+
if (n < 1_000_000) {
|
|
64
|
+
const k = n / 1_000;
|
|
65
|
+
return `${k >= 10 ? k.toFixed(1).replace(/\.0$/, '') : k.toFixed(1)}k`;
|
|
66
|
+
}
|
|
67
|
+
const m = n / 1_000_000;
|
|
68
|
+
return `${m >= 10 ? m.toFixed(1).replace(/\.0$/, '') : m.toFixed(1)}M`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Compute the "… +N pending, M completed" footer counts. When the
|
|
72
|
+
* agent supplied rollups they win; otherwise we derive from the
|
|
73
|
+
* milestone array.
|
|
74
|
+
*/
|
|
75
|
+
export function computeFooterCounts(milestones, visibleCount, rollup) {
|
|
76
|
+
const pending = rollup.pendingCount
|
|
77
|
+
?? milestones.filter((m) => m.status === 'pending').length;
|
|
78
|
+
const completed = rollup.completedCount
|
|
79
|
+
?? milestones.filter((m) => m.status === 'done').length;
|
|
80
|
+
const hidden = Math.max(0, milestones.length - visibleCount);
|
|
81
|
+
return { pending, completed, hidden };
|
|
82
|
+
}
|
|
83
|
+
function MilestoneRow({ milestone }) {
|
|
84
|
+
const glyph = STATUS_GLYPH[milestone.status];
|
|
85
|
+
const color = STATUS_COLOR[milestone.status];
|
|
86
|
+
// Truncate to 64 chars so a verbose label can't wrap and break the
|
|
87
|
+
// grid layout in the watcher.
|
|
88
|
+
const label = milestone.label.length > 64
|
|
89
|
+
? `${milestone.label.slice(0, 63)}…`
|
|
90
|
+
: milestone.label;
|
|
91
|
+
return (_jsxs(Box, { children: [_jsx(Text, { children: ' ' }), _jsx(Text, { color: color, children: glyph }), _jsx(Text, { children: " " }), _jsx(Text, { color: color === 'gray' ? 'gray' : undefined, dimColor: milestone.status === 'pending', children: label })] }));
|
|
92
|
+
}
|
|
93
|
+
export function AgentProgressCard({ progress, nowEpochMs, }) {
|
|
94
|
+
// Re-derive elapsed from the wall clock when the parent supplied it;
|
|
95
|
+
// this is what makes the card tick once a second without the writer
|
|
96
|
+
// re-emitting JSON every tick.
|
|
97
|
+
const elapsed = nowEpochMs !== undefined
|
|
98
|
+
? Math.max(progress.elapsedMs, nowEpochMs - Date.parse(progress.startedAt))
|
|
99
|
+
: progress.elapsedMs;
|
|
100
|
+
const bar = renderProgressBarCells(progress.percentComplete);
|
|
101
|
+
const percentLabel = `${Math.round(Math.max(0, Math.min(100, progress.percentComplete)))}%`;
|
|
102
|
+
const tokensLabel = formatTokenCount(progress.tokensUsed);
|
|
103
|
+
const dotColor = HEADER_DOT_COLOR[progress.status];
|
|
104
|
+
const visibleMilestones = progress.milestones.slice(0, MAX_VISIBLE_MILESTONES);
|
|
105
|
+
const footer = computeFooterCounts(progress.milestones, visibleMilestones.length, { pendingCount: progress.pendingCount, completedCount: progress.completedCount });
|
|
106
|
+
// CC compact pattern: header has a leading `· ` glyph + the task label.
|
|
107
|
+
// We append `…` only while running (matches CC's "Compacting…" verb form).
|
|
108
|
+
const headerVerb = progress.status === 'running' ? '…' : '';
|
|
109
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 0, marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: dotColor, children: '· ' }), _jsx(Text, { bold: true, children: progress.agentType }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [progress.task, headerVerb] }), _jsxs(Text, { dimColor: true, children: [' (', formatElapsed(elapsed), tokensLabel ? ` · ↑ ${tokensLabel} tokens` : '', ')'] })] }), _jsxs(Box, { children: [_jsx(Text, { children: ' ' }), _jsx(Text, { color: "cyan", children: bar.filled }), _jsx(Text, { dimColor: true, children: bar.empty }), _jsxs(Text, { children: [' ', percentLabel] })] }), progress.stepDescription ? (_jsxs(Box, { children: [_jsx(Text, { children: ' ' }), _jsxs(Text, { dimColor: true, children: ["step ", progress.currentStep, "/", progress.totalSteps, ": ", progress.stepDescription] })] })) : null, visibleMilestones.length > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { children: ' ' }), _jsx(Text, { dimColor: true, children: "\u23BF" })] }), visibleMilestones.map((m, i) => (_jsx(MilestoneRow, { milestone: m }, `${m.label}-${i}`))), footer.hidden > 0 ? (_jsxs(Box, { children: [_jsx(Text, { children: ' ' }), _jsxs(Text, { dimColor: true, children: ["\u2026 +", footer.pending, " pending, ", footer.completed, " completed"] })] })) : null] })) : null] }));
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=agent-progress-card.js.map
|
package/dist/tui/agent-tree.js
CHANGED
|
@@ -26,6 +26,13 @@ function statusGlyph(status) {
|
|
|
26
26
|
return '⏳';
|
|
27
27
|
case 'shipped':
|
|
28
28
|
return '✓';
|
|
29
|
+
// 2026-05-26 — `replied` = orchestrator finished talking but no
|
|
30
|
+
// tool/delegate side-effect emitted. Distinct arrow glyph so the
|
|
31
|
+
// operator can tell at a glance that no real work shipped, even
|
|
32
|
+
// though the LLM completed cleanly. Defeats the fake-shipped
|
|
33
|
+
// anti-pattern (memory feedback_no_fake_dispatch_promises).
|
|
34
|
+
case 'replied':
|
|
35
|
+
return '→';
|
|
29
36
|
case 'blocked':
|
|
30
37
|
return '✗';
|
|
31
38
|
case 'failed':
|
|
@@ -40,6 +47,9 @@ function statusColor(status) {
|
|
|
40
47
|
return 'cyan';
|
|
41
48
|
case 'shipped':
|
|
42
49
|
return 'green';
|
|
50
|
+
// Dim/grey-ish — succeeded technically but no work was shipped.
|
|
51
|
+
case 'replied':
|
|
52
|
+
return 'gray';
|
|
43
53
|
case 'blocked':
|
|
44
54
|
return 'yellow';
|
|
45
55
|
case 'failed':
|
package/dist/tui/ask-modal.js
CHANGED
|
@@ -85,7 +85,7 @@ export function AskModal(props) {
|
|
|
85
85
|
setBuffer((prev) => prev + input);
|
|
86
86
|
}
|
|
87
87
|
}, { isActive: props.inert !== true });
|
|
88
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "yellow", children: '? ' }), _jsx(Text, { bold: true, children: 'Need your call before I continue' })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: props.tag.question }) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [props.tag.options.map((opt, idx) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "
|
|
88
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "yellow", children: '? ' }), _jsx(Text, { bold: true, children: 'Need your call before I continue' })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: props.tag.question }) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [props.tag.options.map((opt, idx) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "#3da9fc", bold: true, children: ` ${idx + 1}. ` }), _jsx(Text, { children: opt.label })] }), opt.desc ? (_jsx(Box, { marginLeft: 5, children: _jsx(Text, { dimColor: true, children: opt.desc }) })) : null] }, opt.value))), _jsxs(Box, { children: [_jsx(Text, { color: "#3da9fc", bold: true, children: ` ${props.tag.options.length + 1}. ` }), _jsx(Text, { dimColor: true, children: 'Other (type a custom answer)' })] })] }), mode === 'pick' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: `Press 1-${props.tag.options.length + 1} to choose. Esc cancels.` }) })) : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "#3da9fc", children: '> ' }), _jsx(Text, { children: buffer }), _jsx(Text, { inverse: true, children: ' ' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: 'Type your custom answer. Enter submits. Esc cancels.' }) })] }))] }));
|
|
89
89
|
}
|
|
90
90
|
export function PlanReviewModal(props) {
|
|
91
91
|
const [mode, setMode] = useState('pick');
|
|
@@ -130,7 +130,7 @@ export function PlanReviewModal(props) {
|
|
|
130
130
|
setBuffer((prev) => prev + input);
|
|
131
131
|
}
|
|
132
132
|
}, { isActive: props.inert !== true });
|
|
133
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "magenta", children: '? ' }), _jsx(Text, { bold: true, children: 'Plan review - approve before I execute' })] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: 'Steps:' }), props.tag.steps.map((step, idx) => (_jsxs(Box, { children: [_jsx(Text, { color: "
|
|
133
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "magenta", children: '? ' }), _jsx(Text, { bold: true, children: 'Plan review - approve before I execute' })] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: 'Steps:' }), props.tag.steps.map((step, idx) => (_jsxs(Box, { children: [_jsx(Text, { color: "#3da9fc", bold: true, children: ` ${idx + 1}. ` }), _jsx(Text, { children: step.text })] }, `step-${idx}`)))] }), props.tag.risk ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "red", children: 'Risk:' }), _jsx(Box, { marginLeft: 2, children: _jsx(Text, { children: props.tag.risk }) })] })) : null, mode === 'pick' ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "green", bold: true, children: ' [a] approve ' }), _jsx(Text, { color: "yellow", bold: true, children: '[m] modify ' }), _jsx(Text, { color: "red", bold: true, children: '[c] cancel' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: 'Press a, m, or c. Esc cancels.' }) })] })) : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: 'modify > ' }), _jsx(Text, { children: buffer }), _jsx(Text, { inverse: true, children: ' ' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: 'Type the revision. Enter submits. Esc cancels.' }) })] }))] }));
|
|
134
134
|
}
|
|
135
135
|
/* ------------------------------------------------------------------ */
|
|
136
136
|
/* Verdict serialisation */
|