@yanzc/claude-pro 0.0.0-stage → 0.4.1
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/.env.example +41 -0
- package/LICENSE +21 -0
- package/README.md +314 -3
- package/bin/claude-pro.js +32 -0
- package/package.json +40 -6
- package/src/agent.js +752 -0
- package/src/agents.js +186 -0
- package/src/api.js +529 -0
- package/src/cache.js +150 -0
- package/src/checkpoints.js +184 -0
- package/src/claude-code.js +60 -0
- package/src/codex/client.js +307 -0
- package/src/codex/translate-request.js +239 -0
- package/src/codex/translate-stream.js +328 -0
- package/src/commands.js +839 -0
- package/src/config.js +453 -0
- package/src/context.js +109 -0
- package/src/filestate.js +57 -0
- package/src/gateway.js +143 -0
- package/src/hooks.js +81 -0
- package/src/main.js +193 -0
- package/src/mcp.js +290 -0
- package/src/modes.js +154 -0
- package/src/oauth.js +287 -0
- package/src/peers.js +157 -0
- package/src/print.js +106 -0
- package/src/prompts.js +179 -0
- package/src/ratelimit.js +244 -0
- package/src/restart.js +115 -0
- package/src/runtime.js +162 -0
- package/src/selfcheck.js +71 -0
- package/src/session.js +139 -0
- package/src/supervisor.js +110 -0
- package/src/tasks.js +76 -0
- package/src/tools/bash.js +227 -0
- package/src/tools/effort.js +41 -0
- package/src/tools/files.js +352 -0
- package/src/tools/index.js +52 -0
- package/src/tools/interaction.js +157 -0
- package/src/tools/notebook.js +91 -0
- package/src/tools/peers.js +45 -0
- package/src/tools/restart.js +50 -0
- package/src/tools/search.js +458 -0
- package/src/tools/skill.js +32 -0
- package/src/tools/task.js +139 -0
- package/src/tools/web.js +283 -0
- package/src/ui/ansi.js +228 -0
- package/src/ui/dialogs.js +122 -0
- package/src/ui/editor.js +389 -0
- package/src/ui/markdown.js +249 -0
- package/src/ui/render.js +155 -0
- package/src/ui/repl.js +1460 -0
- package/src/ui/screen.js +259 -0
- package/src/updater.js +93 -0
- package/src/utils/clipboard.js +197 -0
- package/src/utils/diff.js +95 -0
- package/src/utils/paths.js +37 -0
- package/src/utils/shell.js +251 -0
package/src/tools/web.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// WebFetch (fetch a page, convert to markdown-ish text, answer a prompt about it with the
|
|
2
|
+
// small model) and WebSearch (DuckDuckGo HTML endpoint with a Bing fallback).
|
|
3
|
+
|
|
4
|
+
import { quickQuery } from '../api.js';
|
|
5
|
+
|
|
6
|
+
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';
|
|
7
|
+
const cache = new Map(); // url -> {at, text, status, bytes}; only successful responses
|
|
8
|
+
|
|
9
|
+
// Limits, in one object so tests can shrink them.
|
|
10
|
+
export const webLimits = {
|
|
11
|
+
timeoutMs: 30_000, // for a whole page, headers and body
|
|
12
|
+
searchTimeoutMs: 20_000,
|
|
13
|
+
maxBodyBytes: 10 * 1024 * 1024, // the rest of a bigger response is never downloaded
|
|
14
|
+
cacheMs: 15 * 60_000,
|
|
15
|
+
cacheEntries: 40,
|
|
16
|
+
cacheBytes: 60 * 1024 * 1024,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Content that is not text: a page read as text would be a wall of garbage for the model.
|
|
20
|
+
const BINARY_TYPE = /^(?:image\/(?!svg)|audio\/|video\/|font\/)|^application\/(?:pdf|zip|gzip|x-gzip|x-tar|x-7z-compressed|x-rar-compressed|octet-stream|msword|wasm|vnd\.ms-|vnd\.openxmlformats-)/i;
|
|
21
|
+
|
|
22
|
+
// Decode a response body using the charset from the Content-Type header, a byte order mark or a
|
|
23
|
+
// <meta> tag, in that order of preference (pages in GBK, Big5 or Shift_JIS are common enough).
|
|
24
|
+
export function decodeBody(buf, contentType = '') {
|
|
25
|
+
let label = /charset\s*=\s*["']?([\w.:-]+)/i.exec(contentType)?.[1];
|
|
26
|
+
if (buf[0] === 0xFF && buf[1] === 0xFE) label = 'utf-16le';
|
|
27
|
+
else if (buf[0] === 0xFE && buf[1] === 0xFF) label = 'utf-16be';
|
|
28
|
+
else if (!label) label = /<meta[^>]+charset\s*=\s*["']?([\w.:-]+)/i.exec(buf.subarray(0, 4096).toString('latin1'))?.[1];
|
|
29
|
+
try {
|
|
30
|
+
return new TextDecoder(label || 'utf-8').decode(buf); // an unknown label throws
|
|
31
|
+
} catch {
|
|
32
|
+
return buf.toString('utf8');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Read at most `max` bytes of a response body and stop downloading after that.
|
|
37
|
+
async function readCapped(res, max) {
|
|
38
|
+
const reader = res.body?.getReader();
|
|
39
|
+
if (!reader) return Buffer.from(await res.arrayBuffer()).subarray(0, max);
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let size = 0;
|
|
42
|
+
while (size < max) {
|
|
43
|
+
const { done, value } = await reader.read();
|
|
44
|
+
if (done) break;
|
|
45
|
+
chunks.push(value);
|
|
46
|
+
size += value.length;
|
|
47
|
+
}
|
|
48
|
+
try { await reader.cancel(); } catch { /* already finished */ }
|
|
49
|
+
return Buffer.concat(chunks).subarray(0, max);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function remember(url, entry) {
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
for (const [key, old] of cache) if (now - old.at >= webLimits.cacheMs) cache.delete(key);
|
|
55
|
+
cache.set(url, entry);
|
|
56
|
+
let bytes = 0;
|
|
57
|
+
for (const e of cache.values()) bytes += e.text.length;
|
|
58
|
+
// Oldest first (Map keeps insertion order); the page just fetched always stays.
|
|
59
|
+
for (const key of cache.keys()) {
|
|
60
|
+
if (cache.size <= 1 || (cache.size <= webLimits.cacheEntries && bytes <= webLimits.cacheBytes)) break;
|
|
61
|
+
bytes -= cache.get(key).text.length;
|
|
62
|
+
cache.delete(key);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–', hellip: '…', copy: '©', reg: '®', rsquo: '’', lsquo: '‘', rdquo: '”', ldquo: '“' };
|
|
67
|
+
|
|
68
|
+
export function decodeEntities(s) {
|
|
69
|
+
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
|
|
70
|
+
if (e[0] === '#') {
|
|
71
|
+
const cp = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
|
|
72
|
+
try { return String.fromCodePoint(cp); } catch { return m; }
|
|
73
|
+
}
|
|
74
|
+
return ENTITIES[e.toLowerCase()] ?? m;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function htmlToMarkdown(html, baseUrl) {
|
|
79
|
+
let s = html;
|
|
80
|
+
const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(s)?.[1];
|
|
81
|
+
s = s.replace(/<!--[\s\S]*?-->/g, '')
|
|
82
|
+
.replace(/<(script|style|noscript|svg|iframe|template|head)[\s\S]*?<\/\1>/gi, '')
|
|
83
|
+
.replace(/<(nav|footer)[\s\S]*?<\/\1>/gi, '');
|
|
84
|
+
const abs = (href) => { try { return new URL(decodeEntities(href), baseUrl).href; } catch { return href; } };
|
|
85
|
+
s = s.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, (m, code) => `\n\`\`\`\n${decodeEntities(code.replace(/<[^>]+>/g, ''))}\n\`\`\`\n`)
|
|
86
|
+
.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi, (m, n, t) => `\n\n${'#'.repeat(+n)} ${t.replace(/<[^>]+>/g, '').trim()}\n\n`)
|
|
87
|
+
.replace(/<a\s[^>]*href\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (m, href, t) => {
|
|
88
|
+
const text = t.replace(/<[^>]+>/g, '').trim();
|
|
89
|
+
return text ? `[${text}](${abs(href)})` : '';
|
|
90
|
+
})
|
|
91
|
+
.replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, '**$2**')
|
|
92
|
+
.replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1>/gi, '*$2*')
|
|
93
|
+
.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, '`$1`')
|
|
94
|
+
.replace(/<li[^>]*>/gi, '\n- ')
|
|
95
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
96
|
+
.replace(/<\/(p|div|section|article|ul|ol|table|tr|blockquote|h\d)>/gi, '\n\n')
|
|
97
|
+
.replace(/<(td|th)[^>]*>/gi, ' | ')
|
|
98
|
+
.replace(/<[^>]+>/g, '');
|
|
99
|
+
s = decodeEntities(s)
|
|
100
|
+
.replace(/[ \t\f\v]+/g, ' ')
|
|
101
|
+
.replace(/ *\n */g, '\n')
|
|
102
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
103
|
+
.trim();
|
|
104
|
+
return (title ? `# ${decodeEntities(title.trim())}\n\n` : '') + s;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function fetchText(url, signal) {
|
|
108
|
+
const hit = cache.get(url);
|
|
109
|
+
if (hit && Date.now() - hit.at < webLimits.cacheMs) return hit;
|
|
110
|
+
const ctrl = new AbortController();
|
|
111
|
+
// A timeout must be reported as one, and must not look like the user's interrupt.
|
|
112
|
+
let timedOut = false;
|
|
113
|
+
const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, webLimits.timeoutMs);
|
|
114
|
+
const onAbort = () => ctrl.abort();
|
|
115
|
+
if (signal?.aborted) ctrl.abort(); else signal?.addEventListener('abort', onAbort, { once: true });
|
|
116
|
+
try {
|
|
117
|
+
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'text/html,text/markdown,text/plain,application/json;q=0.9,*/*;q=0.8' }, redirect: 'follow', signal: ctrl.signal });
|
|
118
|
+
const buf = await readCapped(res, webLimits.maxBodyBytes);
|
|
119
|
+
const type = res.headers.get('content-type') || '';
|
|
120
|
+
const base = { at: Date.now(), status: res.status, statusText: res.statusText, bytes: buf.length, finalUrl: res.url, type };
|
|
121
|
+
// Binary content (a PDF, an image, an archive) is not shown as text.
|
|
122
|
+
if (BINARY_TYPE.test(type) || buf.subarray(0, 8000).includes(0)) return { ...base, text: '', binary: true };
|
|
123
|
+
let text = decodeBody(buf, type);
|
|
124
|
+
if (/html/i.test(type) || /^\s*<(!doctype|html)/i.test(text)) text = htmlToMarkdown(text, res.url);
|
|
125
|
+
const r = { ...base, text };
|
|
126
|
+
// Only a successful page is remembered: a 503 or a 404 must not be served for the next 15 minutes
|
|
127
|
+
// after the server has recovered.
|
|
128
|
+
if (res.ok) remember(url, r);
|
|
129
|
+
return r;
|
|
130
|
+
} catch (e) {
|
|
131
|
+
if (timedOut && !signal?.aborted) throw new Error(`timed out after ${Number((webLimits.timeoutMs / 1000).toFixed(1))}s`);
|
|
132
|
+
throw e;
|
|
133
|
+
} finally {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
signal?.removeEventListener('abort', onAbort);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function makeWebFetchTool(config) {
|
|
140
|
+
return {
|
|
141
|
+
name: 'WebFetch',
|
|
142
|
+
readOnly: true,
|
|
143
|
+
description: `- Fetches content from a specified URL (any scheme the network allows, including localhost and private addresses), converts HTML to markdown, and answers a prompt about it using a small, fast model
|
|
144
|
+
- Takes a URL and a prompt as input
|
|
145
|
+
- Set raw: true to get the converted page text itself instead of an answer: use it when you need the exact content, code, tables or the whole document (very long pages are saved to a file and previewed; read the file for the rest)
|
|
146
|
+
- Use this tool when you need to retrieve and analyze web content
|
|
147
|
+
|
|
148
|
+
Usage notes:
|
|
149
|
+
- The URL must be a fully-formed valid URL, used exactly as given
|
|
150
|
+
- The prompt should describe what information you want to extract from the page; it is optional with raw: true
|
|
151
|
+
- This tool is read-only and does not modify any files
|
|
152
|
+
- Includes a self-cleaning 15-minute cache for faster responses when repeatedly accessing the same URL`,
|
|
153
|
+
input_schema: {
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: {
|
|
156
|
+
url: { type: 'string', format: 'uri', description: 'The URL to fetch content from' },
|
|
157
|
+
prompt: { type: 'string', description: 'The prompt to run on the fetched content' },
|
|
158
|
+
raw: { type: 'boolean', description: 'Return the converted page text itself instead of an answer from the small model' },
|
|
159
|
+
},
|
|
160
|
+
required: ['url'],
|
|
161
|
+
},
|
|
162
|
+
summarize(input) { return input.url; },
|
|
163
|
+
domain(input) { try { return new URL(input.url).hostname; } catch { return null; } },
|
|
164
|
+
async call(input, ctx) {
|
|
165
|
+
let url;
|
|
166
|
+
try { url = new URL(input.url); } catch { return { isError: true, content: `Invalid URL: ${input.url}` }; }
|
|
167
|
+
let page;
|
|
168
|
+
try { page = await fetchText(url.href, ctx.signal); } catch (e) {
|
|
169
|
+
if (ctx.signal?.aborted) throw e;
|
|
170
|
+
return { isError: true, content: `Failed to fetch ${url.href}: ${e.cause?.message || e.message}` };
|
|
171
|
+
}
|
|
172
|
+
const size = page.bytes > 1024 ? `${(page.bytes / 1024).toFixed(1)}KB` : `${page.bytes}B`;
|
|
173
|
+
const display = { summary: `Received ${size} (${page.status} ${page.statusText || ''})`.trim() };
|
|
174
|
+
if (page.status >= 400) return { isError: true, content: `HTTP ${page.status} ${page.statusText} fetching ${url.href}\n\n${page.text.slice(0, 2000)}`, display };
|
|
175
|
+
if (page.binary) {
|
|
176
|
+
return { isError: true, content: `${page.finalUrl || url.href} is not text (${page.type || 'binary data'}, ${size}), so WebFetch cannot read it. If you need the file, download it with curl in Bash.`, display };
|
|
177
|
+
}
|
|
178
|
+
if (input.raw) {
|
|
179
|
+
const text = page.text.length > 1_000_000 ? `${page.text.slice(0, 1_000_000)}\n\n[content truncated at 1,000,000 characters]` : page.text;
|
|
180
|
+
return { content: text || '(the page has no text content)', display };
|
|
181
|
+
}
|
|
182
|
+
const content = page.text.length > 400_000 ? page.text.slice(0, 400_000) + '\n\n[content truncated]' : page.text;
|
|
183
|
+
try {
|
|
184
|
+
const answer = await quickQuery(config, {
|
|
185
|
+
model: config.smallModel,
|
|
186
|
+
system: 'You extract information from web pages. Answer only from the provided content. Be complete and precise: include every relevant detail, exact quotes, numbers and code. Keep code blocks intact.',
|
|
187
|
+
prompt: `Web page content from ${page.finalUrl || url.href}:\n---\n${content}\n---\n\n${input.prompt || 'Summarize this page, keeping all important details, code and numbers.'}\n\nRespond based only on the content above.`,
|
|
188
|
+
maxTokens: 16_000,
|
|
189
|
+
signal: ctx.signal,
|
|
190
|
+
// Nobody is told about a pause that happens inside a tool call, so the summary is skipped
|
|
191
|
+
// instead (the page text below is returned as it is) and the main agent does the waiting.
|
|
192
|
+
waitForRateLimit: false,
|
|
193
|
+
});
|
|
194
|
+
return { content: answer || content.slice(0, 50_000), display };
|
|
195
|
+
} catch (e) {
|
|
196
|
+
if (ctx.signal?.aborted) throw e;
|
|
197
|
+
return { content: content.slice(0, 100_000), display };
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// A search engine that never answers must not hold the tool call for minutes.
|
|
204
|
+
const withTimeout = (signal) => AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(webLimits.searchTimeoutMs)]);
|
|
205
|
+
|
|
206
|
+
async function searchDuckDuckGo(query, signal) {
|
|
207
|
+
const res = await fetch('https://html.duckduckgo.com/html/', {
|
|
208
|
+
method: 'POST',
|
|
209
|
+
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
210
|
+
body: new URLSearchParams({ q: query }).toString(),
|
|
211
|
+
signal: withTimeout(signal),
|
|
212
|
+
});
|
|
213
|
+
const html = await res.text();
|
|
214
|
+
const results = [];
|
|
215
|
+
const re = /<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?(?:<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>)?/g;
|
|
216
|
+
let m;
|
|
217
|
+
while ((m = re.exec(html)) && results.length < 10) {
|
|
218
|
+
let href = decodeEntities(m[1]);
|
|
219
|
+
const uddg = /[?&]uddg=([^&]+)/.exec(href);
|
|
220
|
+
if (uddg) href = decodeURIComponent(uddg[1]);
|
|
221
|
+
if (href.startsWith('//')) href = 'https:' + href;
|
|
222
|
+
if (/duckduckgo\.com\/y\.js/.test(href)) continue; // ads
|
|
223
|
+
results.push({ title: decodeEntities(m[2].replace(/<[^>]+>/g, '')).trim(), url: href, snippet: decodeEntities((m[3] || '').replace(/<[^>]+>/g, '')).trim() });
|
|
224
|
+
}
|
|
225
|
+
return results;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function searchBing(query, signal) {
|
|
229
|
+
const res = await fetch(`https://www.bing.com/search?q=${encodeURIComponent(query)}&setlang=en`, { headers: { 'User-Agent': UA }, signal: withTimeout(signal) });
|
|
230
|
+
const html = await res.text();
|
|
231
|
+
const results = [];
|
|
232
|
+
for (const block of html.split('<li class="b_algo"').slice(1)) {
|
|
233
|
+
const a = /<h2[^>]*>\s*<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/.exec(block);
|
|
234
|
+
if (!a) continue;
|
|
235
|
+
const p = /<p[^>]*>([\s\S]*?)<\/p>/.exec(block);
|
|
236
|
+
results.push({ title: decodeEntities(a[2].replace(/<[^>]+>/g, '')).trim(), url: decodeEntities(a[1]), snippet: p ? decodeEntities(p[1].replace(/<[^>]+>/g, '')).trim() : '' });
|
|
237
|
+
if (results.length >= 10) break;
|
|
238
|
+
}
|
|
239
|
+
return results;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export const WebSearchTool = {
|
|
243
|
+
name: 'WebSearch',
|
|
244
|
+
readOnly: true,
|
|
245
|
+
description: `- Allows searching the web and using the results to inform responses
|
|
246
|
+
- Provides up-to-date information for current events and recent data
|
|
247
|
+
- Returns search results (title, URL, snippet) for the query
|
|
248
|
+
- Use this tool for accessing information beyond your knowledge cutoff
|
|
249
|
+
- Use WebFetch afterwards to read a promising result in full
|
|
250
|
+
- After answering from search results, include a "Sources:" section listing the relevant URLs as markdown links`,
|
|
251
|
+
input_schema: {
|
|
252
|
+
type: 'object',
|
|
253
|
+
properties: {
|
|
254
|
+
query: { type: 'string', minLength: 2, description: 'The search query to use' },
|
|
255
|
+
allowed_domains: { type: 'array', items: { type: 'string' }, description: 'Only include results from these domains' },
|
|
256
|
+
blocked_domains: { type: 'array', items: { type: 'string' }, description: 'Never include results from these domains' },
|
|
257
|
+
},
|
|
258
|
+
required: ['query'],
|
|
259
|
+
},
|
|
260
|
+
summarize(input) { return `"${input.query}"`; },
|
|
261
|
+
async call(input, ctx) {
|
|
262
|
+
let results = [];
|
|
263
|
+
const errors = [];
|
|
264
|
+
for (const engine of [searchDuckDuckGo, searchBing]) {
|
|
265
|
+
try {
|
|
266
|
+
results = await engine(input.query, ctx.signal);
|
|
267
|
+
if (results.length) break;
|
|
268
|
+
} catch (e) {
|
|
269
|
+
if (ctx.signal?.aborted) throw e;
|
|
270
|
+
errors.push(e.cause?.message || e.message);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const host = (u) => { try { return new URL(u).hostname; } catch { return ''; } };
|
|
274
|
+
const match = (h, d) => h === d || h.endsWith('.' + d);
|
|
275
|
+
if (input.allowed_domains?.length) results = results.filter((r) => input.allowed_domains.some((d) => match(host(r.url), d)));
|
|
276
|
+
if (input.blocked_domains?.length) results = results.filter((r) => !input.blocked_domains.some((d) => match(host(r.url), d)));
|
|
277
|
+
if (!results.length) {
|
|
278
|
+
return { isError: !!errors.length, content: errors.length ? `Web search failed: ${errors.join('; ')}` : `No results found for "${input.query}"`, display: { summary: 'No results' } };
|
|
279
|
+
}
|
|
280
|
+
const text = results.map((r, i) => `${i + 1}. [${r.title}](${r.url})\n ${r.snippet}`).join('\n\n');
|
|
281
|
+
return { content: `Web search results for query: "${input.query}"\n\n${text}`, display: { summary: `Found ${results.length} results` } };
|
|
282
|
+
},
|
|
283
|
+
};
|
package/src/ui/ansi.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Terminal primitives: colors, ANSI-aware string width (CJK / emoji aware), wrapping, truncation.
|
|
2
|
+
|
|
3
|
+
const out = process.stdout;
|
|
4
|
+
const depth = process.env.NO_COLOR ? 1 : (out.isTTY && out.getColorDepth ? out.getColorDepth() : (process.env.FORCE_COLOR ? 24 : 1));
|
|
5
|
+
|
|
6
|
+
const sgr = (open, close) => (s) => (depth <= 1 ? String(s) : `\x1b[${open}m${s}\x1b[${close}m`);
|
|
7
|
+
|
|
8
|
+
function rgbTo256(r, g, b) {
|
|
9
|
+
if (r === g && g === b) {
|
|
10
|
+
if (r < 8) return 16;
|
|
11
|
+
if (r > 248) return 231;
|
|
12
|
+
return Math.round(((r - 8) / 247) * 24) + 232;
|
|
13
|
+
}
|
|
14
|
+
const c = (v) => Math.round((v / 255) * 5);
|
|
15
|
+
return 16 + 36 * c(r) + 6 * c(g) + c(b);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function rgbTo16(r, g, b) {
|
|
19
|
+
const bright = Math.max(r, g, b) > 170 ? 60 : 0;
|
|
20
|
+
const code = (b > 127 ? 4 : 0) | (g > 127 ? 2 : 0) | (r > 127 ? 1 : 0);
|
|
21
|
+
return 30 + code + bright;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hex(h, bg = false) {
|
|
25
|
+
const n = parseInt(h.slice(1), 16);
|
|
26
|
+
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
|
|
27
|
+
if (depth <= 1) return (s) => String(s);
|
|
28
|
+
if (depth >= 24) return sgr(`${bg ? 48 : 38};2;${r};${g};${b}`, bg ? 49 : 39);
|
|
29
|
+
if (depth >= 8) return sgr(`${bg ? 48 : 38};5;${rgbTo256(r, g, b)}`, bg ? 49 : 39);
|
|
30
|
+
const c = rgbTo16(r, g, b);
|
|
31
|
+
return sgr(bg ? c + 10 : c, bg ? 49 : 39);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const c = {
|
|
35
|
+
bold: sgr(1, 22),
|
|
36
|
+
dim: sgr(2, 22),
|
|
37
|
+
italic: sgr(3, 23),
|
|
38
|
+
underline: sgr(4, 24),
|
|
39
|
+
inverse: sgr(7, 27),
|
|
40
|
+
strike: sgr(9, 29),
|
|
41
|
+
red: hex('#ff6b80'),
|
|
42
|
+
green: hex('#4eba65'),
|
|
43
|
+
yellow: hex('#ffc107'),
|
|
44
|
+
blue: hex('#7aa2f7'),
|
|
45
|
+
cyan: hex('#56b6c2'),
|
|
46
|
+
magenta: hex('#c678dd'),
|
|
47
|
+
gray: hex('#8a8a8a'),
|
|
48
|
+
white: hex('#ffffff'),
|
|
49
|
+
claude: hex('#d77757'), // the orange brand accent
|
|
50
|
+
permission: hex('#b1b9f9'),
|
|
51
|
+
plan: hex('#48968c'),
|
|
52
|
+
edits: hex('#af87ff'),
|
|
53
|
+
code: hex('#b1b9f9'),
|
|
54
|
+
diffAdd: hex('#225c2b', true),
|
|
55
|
+
diffDel: hex('#7a2936', true),
|
|
56
|
+
diffAddWord: hex('#38a660', true),
|
|
57
|
+
diffDelWord: hex('#b3596b', true),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const colorEnabled = depth > 1;
|
|
61
|
+
|
|
62
|
+
// eslint-disable-next-line no-control-regex
|
|
63
|
+
const ANSI_RE = /[\u001b\u009b][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g;
|
|
64
|
+
|
|
65
|
+
export function stripAnsi(s) {
|
|
66
|
+
return String(s).replace(ANSI_RE, '');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const WIDE = [
|
|
70
|
+
[0x1100, 0x115f], [0x231a, 0x231b], [0x2329, 0x232a], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3],
|
|
71
|
+
[0x25fd, 0x25fe], [0x2614, 0x2615], [0x2648, 0x2653], [0x267f, 0x267f], [0x2693, 0x2693], [0x26a1, 0x26a1],
|
|
72
|
+
[0x26aa, 0x26ab], [0x26bd, 0x26be], [0x26c4, 0x26c5], [0x26ce, 0x26ce], [0x26d4, 0x26d4], [0x26ea, 0x26ea],
|
|
73
|
+
[0x26f2, 0x26f3], [0x26f5, 0x26f5], [0x26fa, 0x26fa], [0x26fd, 0x26fd], [0x2705, 0x2705], [0x270a, 0x270b],
|
|
74
|
+
[0x2728, 0x2728], [0x274c, 0x274c], [0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797],
|
|
75
|
+
[0x27b0, 0x27b0], [0x27bf, 0x27bf], [0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55],
|
|
76
|
+
[0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xa000, 0xa4cf], [0xa960, 0xa97f],
|
|
77
|
+
[0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe10, 0xfe19], [0xfe30, 0xfe6f], [0xff00, 0xff60], [0xffe0, 0xffe6],
|
|
78
|
+
[0x16fe0, 0x16fe4], [0x17000, 0x18cff], [0x1b000, 0x1b2ff], [0x1f004, 0x1f004], [0x1f0cf, 0x1f0cf],
|
|
79
|
+
[0x1f18e, 0x1f18e], [0x1f191, 0x1f19a], [0x1f200, 0x1f251], [0x1f300, 0x1f64f], [0x1f680, 0x1f6ff],
|
|
80
|
+
[0x1f7e0, 0x1f7eb], [0x1f90c, 0x1f9ff], [0x1fa70, 0x1faff], [0x20000, 0x3fffd],
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const ZERO = [
|
|
84
|
+
[0x0300, 0x036f], [0x0483, 0x0489], [0x0591, 0x05bd], [0x0610, 0x061a], [0x064b, 0x065f], [0x0e31, 0x0e31],
|
|
85
|
+
[0x0e34, 0x0e3a], [0x0e47, 0x0e4e], [0x1ab0, 0x1aff], [0x1dc0, 0x1dff], [0x200b, 0x200f], [0x2028, 0x202e],
|
|
86
|
+
[0x2060, 0x2064], [0x20d0, 0x20ff], [0xfe00, 0xfe0f], [0xfe20, 0xfe2f], [0xfeff, 0xfeff], [0xe0100, 0xe01ef],
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
function inRanges(cp, ranges) {
|
|
90
|
+
let lo = 0, hi = ranges.length - 1;
|
|
91
|
+
while (lo <= hi) {
|
|
92
|
+
const mid = (lo + hi) >> 1;
|
|
93
|
+
if (cp < ranges[mid][0]) hi = mid - 1;
|
|
94
|
+
else if (cp > ranges[mid][1]) lo = mid + 1;
|
|
95
|
+
else return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function codePointWidth(cp) {
|
|
101
|
+
if (cp < 32 || (cp >= 0x7f && cp < 0xa0)) return 0;
|
|
102
|
+
if (inRanges(cp, ZERO)) return 0;
|
|
103
|
+
if (inRanges(cp, WIDE)) return 2;
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
108
|
+
|
|
109
|
+
export function graphemes(s) {
|
|
110
|
+
const res = [];
|
|
111
|
+
for (const { segment } of segmenter.segment(s)) res.push(segment);
|
|
112
|
+
return res;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function graphemeWidth(g) {
|
|
116
|
+
if (g === '\t') return 4;
|
|
117
|
+
const cp = g.codePointAt(0);
|
|
118
|
+
let w = codePointWidth(cp);
|
|
119
|
+
// Emoji presentation selector or ZWJ sequences render wide.
|
|
120
|
+
if (g.length > 1 && /[️]/.test(g) && w === 1) w = 2;
|
|
121
|
+
return w;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function stringWidth(s) {
|
|
125
|
+
s = stripAnsi(s);
|
|
126
|
+
let w = 0;
|
|
127
|
+
// fast path for ASCII
|
|
128
|
+
if (/^[\x20-\x7e]*$/.test(s)) return s.length;
|
|
129
|
+
for (const g of graphemes(s)) w += graphemeWidth(g);
|
|
130
|
+
return w;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Tokenize a string into ANSI escape tokens and grapheme tokens.
|
|
134
|
+
function tokenize(s) {
|
|
135
|
+
const tokens = [];
|
|
136
|
+
let last = 0;
|
|
137
|
+
ANSI_RE.lastIndex = 0;
|
|
138
|
+
let m;
|
|
139
|
+
while ((m = ANSI_RE.exec(s))) {
|
|
140
|
+
if (m.index > last) for (const g of graphemes(s.slice(last, m.index))) tokens.push({ t: g, w: graphemeWidth(g) });
|
|
141
|
+
tokens.push({ t: m[0], w: 0, ansi: true });
|
|
142
|
+
last = m.index + m[0].length;
|
|
143
|
+
}
|
|
144
|
+
if (last < s.length) for (const g of graphemes(s.slice(last))) tokens.push({ t: g, w: graphemeWidth(g) });
|
|
145
|
+
return tokens;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Hard-wrap a (possibly ANSI colored) single line to the given width. Returns array of lines.
|
|
149
|
+
export function wrapLine(line, width) {
|
|
150
|
+
width = Math.max(1, width);
|
|
151
|
+
if (stringWidth(line) <= width) return [line];
|
|
152
|
+
const lines = [];
|
|
153
|
+
let cur = '', curW = 0;
|
|
154
|
+
for (const tok of tokenize(line)) {
|
|
155
|
+
if (tok.ansi) { cur += tok.t; continue; }
|
|
156
|
+
const tw = tok.t === '\t' ? 4 : tok.w;
|
|
157
|
+
const text = tok.t === '\t' ? ' ' : tok.t;
|
|
158
|
+
if (curW + tw > width && curW > 0) {
|
|
159
|
+
lines.push(cur);
|
|
160
|
+
cur = '';
|
|
161
|
+
curW = 0;
|
|
162
|
+
}
|
|
163
|
+
cur += text;
|
|
164
|
+
curW += tw;
|
|
165
|
+
}
|
|
166
|
+
lines.push(cur);
|
|
167
|
+
return lines;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Word-aware wrap for prose; falls back to hard wrap for long words.
|
|
171
|
+
export function wordWrap(line, width) {
|
|
172
|
+
width = Math.max(1, width);
|
|
173
|
+
if (stringWidth(line) <= width) return [line];
|
|
174
|
+
const words = line.split(/(\s+)/);
|
|
175
|
+
const lines = [];
|
|
176
|
+
let cur = '', curW = 0;
|
|
177
|
+
for (const word of words) {
|
|
178
|
+
if (!word) continue;
|
|
179
|
+
const w = stringWidth(word);
|
|
180
|
+
if (curW + w <= width) { cur += word; curW += w; continue; }
|
|
181
|
+
if (/^\s+$/.test(word)) { lines.push(cur); cur = ''; curW = 0; continue; }
|
|
182
|
+
if (curW > 0) { lines.push(cur); cur = ''; curW = 0; }
|
|
183
|
+
if (w > width) {
|
|
184
|
+
const parts = wrapLine(word, width);
|
|
185
|
+
lines.push(...parts.slice(0, -1));
|
|
186
|
+
cur = parts[parts.length - 1];
|
|
187
|
+
curW = stringWidth(cur);
|
|
188
|
+
} else { cur = word; curW = w; }
|
|
189
|
+
}
|
|
190
|
+
lines.push(cur);
|
|
191
|
+
return lines;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function truncate(s, width, ellipsis = '…') {
|
|
195
|
+
if (stringWidth(s) <= width) return s;
|
|
196
|
+
let res = '', w = 0;
|
|
197
|
+
const ew = stringWidth(ellipsis);
|
|
198
|
+
for (const tok of tokenize(s)) {
|
|
199
|
+
if (tok.ansi) { res += tok.t; continue; }
|
|
200
|
+
if (w + tok.w > width - ew) break;
|
|
201
|
+
res += tok.t;
|
|
202
|
+
w += tok.w;
|
|
203
|
+
}
|
|
204
|
+
return res + ellipsis + (colorEnabled ? '\x1b[0m' : '');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function padEnd(s, width) {
|
|
208
|
+
const w = stringWidth(s);
|
|
209
|
+
return w >= width ? s : s + ' '.repeat(width - w);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function termWidth() {
|
|
213
|
+
return Math.max(20, (process.stdout.columns || 100));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export const ESC = {
|
|
217
|
+
hideCursor: '\x1b[?25l',
|
|
218
|
+
showCursor: '\x1b[?25h',
|
|
219
|
+
clearDown: '\x1b[0J',
|
|
220
|
+
clearLine: '\x1b[2K',
|
|
221
|
+
up: (n) => (n > 0 ? `\x1b[${n}A` : ''),
|
|
222
|
+
down: (n) => (n > 0 ? `\x1b[${n}B` : ''),
|
|
223
|
+
right: (n) => (n > 0 ? `\x1b[${n}C` : ''),
|
|
224
|
+
col0: '\r',
|
|
225
|
+
bracketedPasteOn: '\x1b[?2004h',
|
|
226
|
+
bracketedPasteOff: '\x1b[?2004l',
|
|
227
|
+
clearScreen: '\x1b[2J\x1b[3J\x1b[H',
|
|
228
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Modal dialogs rendered in the live region: selection menus (permission prompts,
|
|
2
|
+
// plan approval, questions, session picker) and a free-text prompt.
|
|
3
|
+
|
|
4
|
+
import { c, padEnd, stringWidth, truncate, wordWrap } from './ansi.js';
|
|
5
|
+
import { Editor } from './editor.js';
|
|
6
|
+
|
|
7
|
+
function box(lines, width, color) {
|
|
8
|
+
const w = Math.max(20, width - 1);
|
|
9
|
+
const inner = w - 4;
|
|
10
|
+
const out = [color('╭' + '─'.repeat(w - 2) + '╮')];
|
|
11
|
+
for (const l of lines) {
|
|
12
|
+
for (const part of stringWidth(l) > inner ? wordWrap(l, inner) : [l]) {
|
|
13
|
+
out.push(color('│') + ' ' + padEnd(part, inner) + ' ' + color('│'));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
out.push(color('╰' + '─'.repeat(w - 2) + '╯'));
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class SelectDialog {
|
|
21
|
+
// options: [{label, value, description?}]
|
|
22
|
+
constructor({ title, body = [], question, options, color = c.permission, multi = false, footer, onResolve, cancelValue = null, otherOption = false }) {
|
|
23
|
+
this.title = title;
|
|
24
|
+
this.body = body;
|
|
25
|
+
this.question = question;
|
|
26
|
+
this.options = otherOption ? [...options, { label: 'Other (type your own answer)', value: '__other__' }] : options;
|
|
27
|
+
this.color = color;
|
|
28
|
+
this.multi = multi;
|
|
29
|
+
this.footer = footer;
|
|
30
|
+
this.onResolve = onResolve;
|
|
31
|
+
this.cancelValue = cancelValue;
|
|
32
|
+
this.selected = 0;
|
|
33
|
+
this.checked = new Set();
|
|
34
|
+
this.other = null; // Editor when typing a custom answer
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
resolve(value) {
|
|
38
|
+
const cb = this.onResolve;
|
|
39
|
+
this.onResolve = null;
|
|
40
|
+
cb?.(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
handleKey(str, key = {}) {
|
|
44
|
+
if (this.other) {
|
|
45
|
+
if (key.name === 'escape') { this.other = null; return true; }
|
|
46
|
+
if (key.name === 'return' && !key.meta) {
|
|
47
|
+
const text = this.other.text.trim();
|
|
48
|
+
if (text) this.resolve(this.multi ? [...[...this.checked].map((i) => this.options[i].value), text] : text);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return this.other.handleKey(str, key);
|
|
52
|
+
}
|
|
53
|
+
const n = this.options.length;
|
|
54
|
+
if (key.name === 'up' || (key.ctrl && key.name === 'p') || str === 'k') { this.selected = (this.selected - 1 + n) % n; return true; }
|
|
55
|
+
if (key.name === 'down' || (key.ctrl && key.name === 'n') || str === 'j' || (key.name === 'tab' && !key.shift)) { this.selected = (this.selected + 1) % n; return true; }
|
|
56
|
+
if (key.name === 'escape') { this.resolve(this.cancelValue); return true; }
|
|
57
|
+
if (/^[1-9]$/.test(str || '') && +str <= n) {
|
|
58
|
+
this.selected = +str - 1;
|
|
59
|
+
if (!this.multi) return this.choose();
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
if (this.multi && str === ' ') {
|
|
63
|
+
if (this.options[this.selected].value === '__other__') return this.choose();
|
|
64
|
+
if (this.checked.has(this.selected)) this.checked.delete(this.selected); else this.checked.add(this.selected);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
if (key.name === 'return') return this.choose();
|
|
68
|
+
return true; // modal: swallow everything else
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
choose() {
|
|
72
|
+
const opt = this.options[this.selected];
|
|
73
|
+
if (opt.value === '__other__') {
|
|
74
|
+
this.other = new Editor();
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if (this.multi) {
|
|
78
|
+
const values = this.checked.size ? [...this.checked].sort().map((i) => this.options[i].value) : [opt.value];
|
|
79
|
+
this.resolve(values);
|
|
80
|
+
} else {
|
|
81
|
+
this.resolve(opt.value);
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
render(width, maxHeight = 40) {
|
|
87
|
+
const lines = [];
|
|
88
|
+
if (this.title) lines.push(c.bold(this.color(this.title)), '');
|
|
89
|
+
lines.push(...this.body);
|
|
90
|
+
if (this.body.length) lines.push('');
|
|
91
|
+
if (this.question) lines.push(this.question);
|
|
92
|
+
const inner = Math.max(20, width - 1) - 4;
|
|
93
|
+
// Show a window of options around the selection when they don't all fit.
|
|
94
|
+
const perOption = this.options.some((o) => o.description) ? 2 : 1;
|
|
95
|
+
const room = Math.max(3, Math.floor((maxHeight - lines.length - 4 - (this.other ? 5 : 0)) / perOption));
|
|
96
|
+
let first = 0;
|
|
97
|
+
if (this.options.length > room) first = Math.min(Math.max(0, this.selected - Math.floor(room / 2)), this.options.length - room);
|
|
98
|
+
const last = Math.min(this.options.length, first + room);
|
|
99
|
+
if (first > 0) lines.push(c.dim(` ↑ ${first} more`));
|
|
100
|
+
for (let i = first; i < last; i++) {
|
|
101
|
+
const o = this.options[i];
|
|
102
|
+
const sel = i === this.selected;
|
|
103
|
+
const mark = this.multi ? (this.checked.has(i) ? c.green('[✔] ') : '[ ] ') : '';
|
|
104
|
+
const label = `${sel ? '❯' : ' '} ${i + 1}. ${mark}${o.label}`;
|
|
105
|
+
lines.push(sel ? this.color(truncate(label, inner)) : truncate(label, inner));
|
|
106
|
+
if (o.description) lines.push(c.dim(' ' + truncate(o.description, inner - 6)));
|
|
107
|
+
}
|
|
108
|
+
if (last < this.options.length) lines.push(c.dim(` ↓ ${this.options.length - last} more`));
|
|
109
|
+
let cursor = null;
|
|
110
|
+
if (this.other) {
|
|
111
|
+
lines.push('');
|
|
112
|
+
const r = this.other.render(width - 4, { placeholder: 'Type your answer, Enter to submit, Esc to go back' });
|
|
113
|
+
const top = lines.length + 1; // +1 for the box's top border
|
|
114
|
+
lines.push(...r.lines.map((l) => truncate(l, inner)));
|
|
115
|
+
if (r.cursor) cursor = { row: top + r.cursor.row, col: 2 + r.cursor.col };
|
|
116
|
+
}
|
|
117
|
+
const out = box(lines, width, this.color);
|
|
118
|
+
const hint = this.footer ?? (this.multi ? 'Space to toggle · Enter to confirm · Esc to cancel' : 'Enter to confirm · Esc to cancel');
|
|
119
|
+
out.push(c.dim(' ' + hint));
|
|
120
|
+
return { lines: out, cursor };
|
|
121
|
+
}
|
|
122
|
+
}
|