@yanzc/claude-pro 0.0.0-stage → 0.4.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/.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 +488 -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 +300 -0
- package/src/codex/translate-request.js +239 -0
- package/src/codex/translate-stream.js +328 -0
- package/src/commands.js +803 -0
- package/src/config.js +453 -0
- package/src/context.js +109 -0
- package/src/filestate.js +57 -0
- package/src/gateway.js +110 -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 +156 -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 +1450 -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/ui/editor.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// The prompt input box: multi-line editing, history, paste handling and completions.
|
|
2
|
+
|
|
3
|
+
import { c, graphemes, graphemeWidth, stringWidth, truncate, padEnd } from './ansi.js';
|
|
4
|
+
|
|
5
|
+
const PASTE_THRESHOLD_CHARS = 800;
|
|
6
|
+
const PASTE_THRESHOLD_LINES = 8;
|
|
7
|
+
|
|
8
|
+
// "╭─ left ───── right ─╮", the parts cut short to fit (the start of the directory goes first).
|
|
9
|
+
export function topBorder(boxW, color, title) {
|
|
10
|
+
const plain = () => color('╭' + '─'.repeat(boxW - 2) + '╮');
|
|
11
|
+
if (!title || (!title.left && !title.right)) return plain();
|
|
12
|
+
const room = boxW - 6; // the corners, a dash at each end and a space each side of a part
|
|
13
|
+
const right = title.right ? truncate(title.right, Math.max(0, Math.floor(room / 3))).replace(/\x1b\[0m$/, '') : '';
|
|
14
|
+
const rightW = right ? stringWidth(right) + 2 : 0;
|
|
15
|
+
let left = title.left || '';
|
|
16
|
+
const leftRoom = room - rightW - (right && left ? 1 : 0) - 2;
|
|
17
|
+
if (leftRoom < 4) left = '';
|
|
18
|
+
else if (stringWidth(left) > leftRoom) {
|
|
19
|
+
const chars = [...left];
|
|
20
|
+
while (chars.length && stringWidth(chars.join('')) > leftRoom - 1) chars.shift();
|
|
21
|
+
left = '…' + chars.join('');
|
|
22
|
+
}
|
|
23
|
+
if (!left && !right) return plain();
|
|
24
|
+
const leftPart = left ? ` ${left} ` : '';
|
|
25
|
+
const rightPart = right ? ` ${right} ` : '';
|
|
26
|
+
const dashes = boxW - 4 - stringWidth(leftPart) - stringWidth(rightPart);
|
|
27
|
+
if (dashes < 1) return plain();
|
|
28
|
+
return color('╭─') + c.dim(leftPart) + color('─'.repeat(dashes)) + (right ? c.blue(rightPart) : '') + color('─╮');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Start of the line the cursor is on. (lastIndexOf with a negative start still looks at index 0, so a
|
|
32
|
+
// text that begins with a newline made the cursor at 0 appear to be on its second line.)
|
|
33
|
+
function lineStartOf(text, cursor) {
|
|
34
|
+
return cursor <= 0 ? 0 : text.lastIndexOf('\n', cursor - 1) + 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function prevBoundary(text, i) {
|
|
38
|
+
if (i <= 0) return 0;
|
|
39
|
+
const gs = graphemes(text.slice(0, i));
|
|
40
|
+
return i - gs[gs.length - 1].length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function nextBoundary(text, i) {
|
|
44
|
+
if (i >= text.length) return text.length;
|
|
45
|
+
const g = graphemes(text.slice(i, i + 8))[0];
|
|
46
|
+
return i + g.length;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function wordLeft(text, i) {
|
|
50
|
+
let j = i;
|
|
51
|
+
while (j > 0 && /\s/.test(text[j - 1])) j--;
|
|
52
|
+
while (j > 0 && !/\s/.test(text[j - 1])) j--;
|
|
53
|
+
return j;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function wordRight(text, i) {
|
|
57
|
+
let j = i;
|
|
58
|
+
while (j < text.length && /\s/.test(text[j])) j++;
|
|
59
|
+
while (j < text.length && !/\s/.test(text[j])) j++;
|
|
60
|
+
return j;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class Editor {
|
|
64
|
+
constructor({ history = [], onSubmit, getSuggestions, onChange, imageFromPath } = {}) {
|
|
65
|
+
this.text = '';
|
|
66
|
+
this.cursor = 0;
|
|
67
|
+
this.history = history; // oldest first
|
|
68
|
+
this.historyIndex = null;
|
|
69
|
+
this.draft = '';
|
|
70
|
+
this.onSubmit = onSubmit;
|
|
71
|
+
this.getSuggestions = getSuggestions;
|
|
72
|
+
this.onChange = onChange;
|
|
73
|
+
this.suggestions = null;
|
|
74
|
+
this.selected = 0;
|
|
75
|
+
this.pastes = new Map();
|
|
76
|
+
this.pasteCounter = 0;
|
|
77
|
+
this.images = new Map(); // "[Image #1]" -> {media_type, data}
|
|
78
|
+
this.imageCounter = 0;
|
|
79
|
+
this.imageFromPath = imageFromPath; // (path) => image | null, for pasted/dropped image files
|
|
80
|
+
this.pasting = false;
|
|
81
|
+
this.pasteBuf = '';
|
|
82
|
+
this.pendingSubmit = null;
|
|
83
|
+
this.lastEscape = 0;
|
|
84
|
+
this.suggestionsDismissedFor = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get mode() {
|
|
88
|
+
if (this.text.startsWith('!')) return 'bash';
|
|
89
|
+
if (this.text.startsWith('#')) return 'memory';
|
|
90
|
+
return 'prompt';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
set(text, cursor = text.length) {
|
|
94
|
+
this.text = text;
|
|
95
|
+
this.cursor = Math.max(0, Math.min(cursor, text.length));
|
|
96
|
+
this.refreshSuggestions();
|
|
97
|
+
this.onChange?.();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
clear() {
|
|
101
|
+
this.set('');
|
|
102
|
+
this.pastes.clear();
|
|
103
|
+
this.images.clear();
|
|
104
|
+
this.imageCounter = 0;
|
|
105
|
+
this.historyIndex = null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
insertImage(image) {
|
|
109
|
+
const label = `[Image #${++this.imageCounter}]`;
|
|
110
|
+
this.images.set(label, image);
|
|
111
|
+
const before = this.text.slice(0, this.cursor);
|
|
112
|
+
this.insert((before && !/\s$/.test(before) ? ' ' : '') + label + ' ');
|
|
113
|
+
return label;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
insert(s) {
|
|
117
|
+
this.set(this.text.slice(0, this.cursor) + s + this.text.slice(this.cursor), this.cursor + s.length);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
insertPaste(raw) {
|
|
121
|
+
const text = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
122
|
+
// A pasted or drag-and-dropped image file path becomes an image attachment.
|
|
123
|
+
const candidate = text.trim().replace(/^["']|["']$/g, '');
|
|
124
|
+
if (this.imageFromPath && !candidate.includes('\n') && /\.(png|jpe?g|gif|webp)$/i.test(candidate)) {
|
|
125
|
+
const image = this.imageFromPath(candidate);
|
|
126
|
+
if (image) { this.insertImage(image); return; }
|
|
127
|
+
}
|
|
128
|
+
const lines = text.split('\n').length;
|
|
129
|
+
if (text.length > PASTE_THRESHOLD_CHARS || lines > PASTE_THRESHOLD_LINES) {
|
|
130
|
+
const id = ++this.pasteCounter;
|
|
131
|
+
const label = `[Pasted text #${id} +${lines} lines]`;
|
|
132
|
+
this.pastes.set(label, text);
|
|
133
|
+
this.insert(label);
|
|
134
|
+
} else {
|
|
135
|
+
this.insert(text);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Text with paste placeholders expanded.
|
|
140
|
+
expanded() {
|
|
141
|
+
let t = this.text;
|
|
142
|
+
for (const [label, content] of this.pastes) t = t.split(label).join(content);
|
|
143
|
+
return t;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
refreshSuggestions() {
|
|
147
|
+
if (!this.getSuggestions || this.suggestionsDismissedFor === this.text) { this.suggestions = null; return; }
|
|
148
|
+
const s = this.getSuggestions(this.text, this.cursor);
|
|
149
|
+
this.suggestions = s && s.items.length ? s : null;
|
|
150
|
+
if (!this.suggestions || this.selected >= this.suggestions.items.length) this.selected = 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
acceptSuggestion() {
|
|
154
|
+
const s = this.suggestions;
|
|
155
|
+
if (!s) return null;
|
|
156
|
+
const item = s.items[this.selected];
|
|
157
|
+
const before = this.text.slice(0, s.from);
|
|
158
|
+
const after = this.text.slice(s.to);
|
|
159
|
+
const value = item.value + (item.noSpace ? '' : ' ');
|
|
160
|
+
this.set(before + value + after.replace(/^ /, ''), before.length + value.length);
|
|
161
|
+
this.suggestions = null;
|
|
162
|
+
return item;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
submit() {
|
|
166
|
+
const value = this.expanded();
|
|
167
|
+
const raw = this.text;
|
|
168
|
+
// Only images whose placeholder is still in the text are sent.
|
|
169
|
+
const images = [...this.images].filter(([label]) => raw.includes(label)).map(([, img]) => img);
|
|
170
|
+
if (raw.trim() && this.history[this.history.length - 1] !== raw) this.history.push(raw);
|
|
171
|
+
this.clear();
|
|
172
|
+
this.onSubmit?.(value, raw, images);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Returns true when the key was consumed.
|
|
176
|
+
handleKey(str, key = {}) {
|
|
177
|
+
// A return key immediately followed by more input in the same tick is part of a paste.
|
|
178
|
+
if (this.pendingSubmit) {
|
|
179
|
+
clearImmediate(this.pendingSubmit);
|
|
180
|
+
this.pendingSubmit = null;
|
|
181
|
+
this.insert('\n');
|
|
182
|
+
}
|
|
183
|
+
if (key.name === 'paste-start') { this.pasting = true; this.pasteBuf = ''; return true; }
|
|
184
|
+
if (key.name === 'paste-end') { this.pasting = false; this.insertPaste(this.pasteBuf); this.pasteBuf = ''; return true; }
|
|
185
|
+
if (this.pasting) {
|
|
186
|
+
if (key.name === 'return' || key.name === 'enter') this.pasteBuf += '\n';
|
|
187
|
+
else if (str) this.pasteBuf += str;
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const name = key.name;
|
|
192
|
+
if (name === 'return' && !key.meta && !key.shift) {
|
|
193
|
+
if (this.suggestions) {
|
|
194
|
+
const item = this.acceptSuggestion();
|
|
195
|
+
if (item?.submitOnEnter) { this.submit(); return true; }
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
const before = this.text.slice(0, this.cursor);
|
|
199
|
+
if (before.endsWith('\\')) {
|
|
200
|
+
this.set(before.slice(0, -1) + '\n' + this.text.slice(this.cursor), this.cursor);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
this.pendingSubmit = setImmediate(() => { this.pendingSubmit = null; this.submit(); });
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
if ((name === 'return' && (key.meta || key.shift)) || name === 'enter' || key.sequence === '\x1b[13;2u') {
|
|
207
|
+
this.insert('\n');
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
if (name === 'tab' && !key.shift) {
|
|
211
|
+
if (this.suggestions) this.acceptSuggestion();
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
if (name === 'escape') {
|
|
215
|
+
if (this.suggestions) { this.suggestionsDismissedFor = this.text; this.suggestions = null; return true; }
|
|
216
|
+
if (key.meta && this.text) { this.clear(); return true; } // ESC ESC arriving in one chunk
|
|
217
|
+
const now = Date.now();
|
|
218
|
+
if (this.text && now - this.lastEscape < 800) { this.clear(); this.lastEscape = 0; return true; }
|
|
219
|
+
this.lastEscape = now;
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
if (name === 'up' || (key.ctrl && name === 'p')) {
|
|
223
|
+
if (this.suggestions) { this.selected = (this.selected - 1 + this.suggestions.items.length) % this.suggestions.items.length; this.onChange?.(); return true; }
|
|
224
|
+
const lineStart = lineStartOf(this.text, this.cursor);
|
|
225
|
+
if (lineStart > 0) {
|
|
226
|
+
const col = this.cursor - lineStart;
|
|
227
|
+
const prevStart = lineStartOf(this.text, lineStart - 1);
|
|
228
|
+
this.set(this.text, Math.min(prevStart + col, lineStart - 1));
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
return this.historyPrev();
|
|
232
|
+
}
|
|
233
|
+
if (name === 'down' || (key.ctrl && name === 'n')) {
|
|
234
|
+
if (this.suggestions) { this.selected = (this.selected + 1) % this.suggestions.items.length; this.onChange?.(); return true; }
|
|
235
|
+
const lineEnd = this.text.indexOf('\n', this.cursor);
|
|
236
|
+
if (lineEnd >= 0) {
|
|
237
|
+
const lineStart = lineStartOf(this.text, this.cursor);
|
|
238
|
+
const col = this.cursor - lineStart;
|
|
239
|
+
const nextEnd = this.text.indexOf('\n', lineEnd + 1);
|
|
240
|
+
this.set(this.text, Math.min(lineEnd + 1 + col, nextEnd < 0 ? this.text.length : nextEnd));
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
return this.historyNext();
|
|
244
|
+
}
|
|
245
|
+
if (name === 'left' || (key.ctrl && name === 'b')) {
|
|
246
|
+
this.set(this.text, key.ctrl || key.meta ? wordLeft(this.text, this.cursor) : prevBoundary(this.text, this.cursor));
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
if (name === 'right' || (key.ctrl && name === 'f')) {
|
|
250
|
+
this.set(this.text, key.ctrl || key.meta ? wordRight(this.text, this.cursor) : nextBoundary(this.text, this.cursor));
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
if (key.meta && name === 'b') { this.set(this.text, wordLeft(this.text, this.cursor)); return true; }
|
|
254
|
+
if (key.meta && name === 'f') { this.set(this.text, wordRight(this.text, this.cursor)); return true; }
|
|
255
|
+
if (name === 'home' || (key.ctrl && name === 'a')) {
|
|
256
|
+
this.set(this.text, lineStartOf(this.text, this.cursor));
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
if (name === 'end' || (key.ctrl && name === 'e')) {
|
|
260
|
+
const e = this.text.indexOf('\n', this.cursor);
|
|
261
|
+
this.set(this.text, e < 0 ? this.text.length : e);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
if (name === 'backspace') {
|
|
265
|
+
if (key.meta || key.ctrl) return this.handleKey('', { ctrl: true, name: 'w' });
|
|
266
|
+
if (this.cursor === 0) return true;
|
|
267
|
+
// Delete a whole paste/image placeholder at once.
|
|
268
|
+
for (const [map, label] of [...[...this.pastes.keys()].map((l) => [this.pastes, l]), ...[...this.images.keys()].map((l) => [this.images, l])]) {
|
|
269
|
+
if (this.text.slice(0, this.cursor).endsWith(label)) {
|
|
270
|
+
map.delete(label);
|
|
271
|
+
this.set(this.text.slice(0, this.cursor - label.length) + this.text.slice(this.cursor), this.cursor - label.length);
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const p = prevBoundary(this.text, this.cursor);
|
|
276
|
+
this.set(this.text.slice(0, p) + this.text.slice(this.cursor), p);
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
if (name === 'delete' || (key.ctrl && name === 'd' && this.text)) {
|
|
280
|
+
const n = nextBoundary(this.text, this.cursor);
|
|
281
|
+
this.set(this.text.slice(0, this.cursor) + this.text.slice(n), this.cursor);
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
if (key.ctrl && name === 'w') {
|
|
285
|
+
const p = wordLeft(this.text, this.cursor);
|
|
286
|
+
this.set(this.text.slice(0, p) + this.text.slice(this.cursor), p);
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
if (key.ctrl && name === 'u') {
|
|
290
|
+
const s = lineStartOf(this.text, this.cursor);
|
|
291
|
+
this.set(this.text.slice(0, s) + this.text.slice(this.cursor), s);
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
if (key.ctrl && name === 'k') {
|
|
295
|
+
const e = this.text.indexOf('\n', this.cursor);
|
|
296
|
+
this.set(this.text.slice(0, this.cursor) + (e < 0 ? '' : this.text.slice(e)), this.cursor);
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
if (key.ctrl || (key.meta && name && name.length > 1)) return false;
|
|
300
|
+
if (str && !/^[\x00-\x08\x0b-\x1f\x7f]$/.test(str) && !(str.startsWith('\x1b') && str.length > 1)) {
|
|
301
|
+
const text = str.replace(/\r\n?/g, '\n');
|
|
302
|
+
if (text.length > 1 && text.includes('\n')) this.insertPaste(text);
|
|
303
|
+
else this.insert(text);
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
historyPrev() {
|
|
310
|
+
if (!this.history.length) return true;
|
|
311
|
+
if (this.historyIndex === null) { this.draft = this.text; this.historyIndex = this.history.length; }
|
|
312
|
+
if (this.historyIndex > 0) {
|
|
313
|
+
this.historyIndex--;
|
|
314
|
+
this.set(this.history[this.historyIndex]);
|
|
315
|
+
}
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
historyNext() {
|
|
320
|
+
if (this.historyIndex === null) return true;
|
|
321
|
+
this.historyIndex++;
|
|
322
|
+
if (this.historyIndex >= this.history.length) {
|
|
323
|
+
this.historyIndex = null;
|
|
324
|
+
this.set(this.draft);
|
|
325
|
+
} else {
|
|
326
|
+
this.set(this.history[this.historyIndex]);
|
|
327
|
+
}
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Render as a rounded box. Returns {lines, cursor:{row,col}}.
|
|
332
|
+
// `title`: {left, right}, shown in the top border (left: the directory; right: the session's name).
|
|
333
|
+
render(width, { placeholder = '', active = true, borderColor, title = null } = {}) {
|
|
334
|
+
const boxW = Math.max(20, width - 1);
|
|
335
|
+
const textW = boxW - 6;
|
|
336
|
+
const mode = this.mode;
|
|
337
|
+
const color = borderColor || (mode === 'bash' ? c.magenta : mode === 'memory' ? c.blue : c.gray);
|
|
338
|
+
const promptCh = mode === 'bash' ? '!' : mode === 'memory' ? '#' : '>';
|
|
339
|
+
const body = mode === 'prompt' ? this.text : this.text.slice(1);
|
|
340
|
+
const offset = mode === 'prompt' ? 0 : 1;
|
|
341
|
+
const cursorIdx = Math.max(0, this.cursor - offset);
|
|
342
|
+
|
|
343
|
+
const rows = [''];
|
|
344
|
+
const widths = [0];
|
|
345
|
+
let cur = null;
|
|
346
|
+
let idx = 0;
|
|
347
|
+
for (const g of graphemes(body)) {
|
|
348
|
+
if (idx === cursorIdx) cur = { row: rows.length - 1, col: widths[widths.length - 1] };
|
|
349
|
+
if (g === '\n') {
|
|
350
|
+
rows.push(''); widths.push(0);
|
|
351
|
+
} else {
|
|
352
|
+
const w = graphemeWidth(g);
|
|
353
|
+
if (widths[widths.length - 1] + w > textW) { rows.push(''); widths.push(0); if (idx === cursorIdx) cur = { row: rows.length - 1, col: 0 }; }
|
|
354
|
+
rows[rows.length - 1] += g === '\t' ? ' ' : g;
|
|
355
|
+
widths[widths.length - 1] += w;
|
|
356
|
+
}
|
|
357
|
+
idx += g.length;
|
|
358
|
+
}
|
|
359
|
+
if (!cur) {
|
|
360
|
+
cur = { row: rows.length - 1, col: widths[widths.length - 1] };
|
|
361
|
+
if (cur.col >= textW) { rows.push(''); widths.push(0); cur = { row: rows.length - 1, col: 0 }; }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const lines = [topBorder(boxW, color, title)];
|
|
365
|
+
rows.forEach((r, i) => {
|
|
366
|
+
let content = r;
|
|
367
|
+
if (!body && i === 0 && placeholder) content = c.dim(truncate(placeholder, textW));
|
|
368
|
+
const lead = i === 0 ? (mode === 'prompt' ? promptCh : color(promptCh)) + ' ' : ' ';
|
|
369
|
+
lines.push(color('│') + ' ' + lead + padEnd(content, textW) + ' ' + color('│'));
|
|
370
|
+
});
|
|
371
|
+
lines.push(color('╰' + '─'.repeat(boxW - 2) + '╯'));
|
|
372
|
+
return { lines, cursor: active ? { row: cur.row + 1, col: 4 + cur.col } : null };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
renderSuggestions(width) {
|
|
376
|
+
if (!this.suggestions) return [];
|
|
377
|
+
const items = this.suggestions.items;
|
|
378
|
+
const max = 8;
|
|
379
|
+
const start = Math.max(0, Math.min(this.selected - Math.floor(max / 2), items.length - max));
|
|
380
|
+
const shown = items.slice(start, start + max);
|
|
381
|
+
const labelW = Math.min(30, Math.max(...shown.map((i) => stringWidth(i.label))) + 2);
|
|
382
|
+
return shown.map((item, k) => {
|
|
383
|
+
const sel = start + k === this.selected;
|
|
384
|
+
const label = padEnd(truncate(item.label, labelW), labelW);
|
|
385
|
+
const desc = item.description ? truncate(item.description, Math.max(10, width - labelW - 6)) : '';
|
|
386
|
+
return sel ? ' ' + c.permission(label) + c.permission(desc) : ' ' + c.gray(label) + c.dim(desc);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Streaming markdown -> ANSI renderer. Text arrives in small deltas; complete lines are
|
|
2
|
+
// rendered immediately (tables are buffered until they end) and the in-progress line
|
|
3
|
+
// can be previewed separately.
|
|
4
|
+
|
|
5
|
+
import { c, stringWidth, wordWrap, wrapLine, padEnd } from './ansi.js';
|
|
6
|
+
|
|
7
|
+
const KEYWORDS = new Set(('abstract as async await break case catch class const continue debugger def default del delete do elif else enum export extends ' +
|
|
8
|
+
'false final finally fn for from func function go if impl import in instanceof interface is lambda let loop match mod module mut new nil none null ' +
|
|
9
|
+
'package pass private protected pub public raise return self static struct super switch this throw throws trait true try type typeof undefined ' +
|
|
10
|
+
'use var void where while with yield None True False').split(' '));
|
|
11
|
+
|
|
12
|
+
export function highlightCode(line, lang = '') {
|
|
13
|
+
const hashComments = /^(py|python|sh|bash|shell|zsh|rb|ruby|yaml|yml|toml|r|perl|pl|dockerfile|make|makefile|ps1|powershell)$/i.test(lang);
|
|
14
|
+
const out = [];
|
|
15
|
+
const re = hashComments
|
|
16
|
+
? /(#.*$)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|(\b\d[\d_.]*(?:e\d+)?\b)|([A-Za-z_$][\w$]*)|(\s+)|([^\sA-Za-z_$\d"'#]+)/g
|
|
17
|
+
: /(\/\/.*$|\/\*.*?\*\/|<!--.*?-->)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)|(\b\d[\d_.]*(?:e\d+)?\b)|([A-Za-z_$][\w$]*)|(\s+)|([^\sA-Za-z_$\d"'`/]+|\/)/g;
|
|
18
|
+
let m;
|
|
19
|
+
let last = 0;
|
|
20
|
+
while ((m = re.exec(line))) {
|
|
21
|
+
if (m.index > last) out.push(line.slice(last, m.index));
|
|
22
|
+
last = re.lastIndex;
|
|
23
|
+
if (m[1]) out.push(c.gray(m[1]));
|
|
24
|
+
else if (m[2]) out.push(c.green(m[2]));
|
|
25
|
+
else if (m[3]) out.push(c.yellow(m[3]));
|
|
26
|
+
else if (m[4]) out.push(KEYWORDS.has(m[4]) ? c.magenta(m[4]) : /^[A-Z]/.test(m[4]) ? c.cyan(m[4]) : m[4]);
|
|
27
|
+
else out.push(m[0]);
|
|
28
|
+
if (m[0] === '') re.lastIndex++;
|
|
29
|
+
}
|
|
30
|
+
if (last < line.length) out.push(line.slice(last));
|
|
31
|
+
return out.join('');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function renderInline(text) {
|
|
35
|
+
const parts = text.split(/(`+[^`]+?`+)/);
|
|
36
|
+
return parts.map((part, i) => {
|
|
37
|
+
if (i % 2 === 1) return c.code(part.replace(/^`+|`+$/g, ''));
|
|
38
|
+
return part
|
|
39
|
+
.replace(/\[([^\]]+)\]\((\S+?)\)/g, (m, t, u) => (t === u ? c.blue(u) : `${t} ${c.dim(`(${u})`)}`))
|
|
40
|
+
.replace(/\*\*\*([^*]+)\*\*\*/g, (m, t) => c.bold(c.italic(t)))
|
|
41
|
+
.replace(/\*\*([^*]+)\*\*/g, (m, t) => c.bold(t))
|
|
42
|
+
.replace(/(?<![\w_])__([^_]+)__(?![\w_])/g, (m, t) => c.bold(t))
|
|
43
|
+
.replace(/(?<![\w*\\])\*(?!\s)([^*\n]+?)(?<!\s)\*(?![\w*])/g, (m, t) => c.italic(t))
|
|
44
|
+
.replace(/(?<![\w_\\])_(?!\s)([^_\n]+?)(?<!\s)_(?![\w_])/g, (m, t) => c.italic(t))
|
|
45
|
+
.replace(/~~([^~]+)~~/g, (m, t) => c.strike(t));
|
|
46
|
+
}).join('');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseRow(line) {
|
|
50
|
+
let s = line.trim();
|
|
51
|
+
if (s.startsWith('|')) s = s.slice(1);
|
|
52
|
+
if (s.endsWith('|') && !s.endsWith('\\|')) s = s.slice(0, -1);
|
|
53
|
+
const cells = [];
|
|
54
|
+
let cur = '';
|
|
55
|
+
let inCode = false;
|
|
56
|
+
for (let i = 0; i < s.length; i++) {
|
|
57
|
+
const ch = s[i];
|
|
58
|
+
if (ch === '\\' && s[i + 1] === '|') { cur += '|'; i++; continue; }
|
|
59
|
+
if (ch === '`') inCode = !inCode;
|
|
60
|
+
if (ch === '|' && !inCode) { cells.push(cur.trim()); cur = ''; continue; }
|
|
61
|
+
cur += ch;
|
|
62
|
+
}
|
|
63
|
+
cells.push(cur.trim());
|
|
64
|
+
return cells;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const isSeparatorRow = (cells) => cells.length > 0 && cells.every((x) => /^:?-{1,}:?$/.test(x.replace(/\s/g, '')));
|
|
68
|
+
|
|
69
|
+
export function renderTable(lines, width) {
|
|
70
|
+
const rows = lines.map(parseRow);
|
|
71
|
+
let align = [];
|
|
72
|
+
const body = [];
|
|
73
|
+
let header = null;
|
|
74
|
+
for (let i = 0; i < rows.length; i++) {
|
|
75
|
+
if (i === 1 && isSeparatorRow(rows[i])) {
|
|
76
|
+
align = rows[i].map((x) => (x.trim().endsWith(':') ? (x.trim().startsWith(':') ? 'center' : 'right') : 'left'));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (i === 0 && rows.length > 1 && isSeparatorRow(rows[1])) header = rows[i];
|
|
80
|
+
else body.push(rows[i]);
|
|
81
|
+
}
|
|
82
|
+
const all = header ? [header, ...body] : body;
|
|
83
|
+
const n = Math.max(...all.map((r) => r.length));
|
|
84
|
+
const rendered = all.map((r) => Array.from({ length: n }, (_, j) => renderInline(r[j] ?? '')));
|
|
85
|
+
const natural = Array.from({ length: n }, (_, j) => Math.max(3, ...rendered.map((r) => stringWidth(r[j]))));
|
|
86
|
+
const overhead = 3 * n + 1;
|
|
87
|
+
const widths = natural.slice();
|
|
88
|
+
while (widths.reduce((a, b) => a + b, 0) + overhead > width) {
|
|
89
|
+
const max = Math.max(...widths);
|
|
90
|
+
if (max <= 6) break;
|
|
91
|
+
widths[widths.indexOf(max)] = max - 1;
|
|
92
|
+
}
|
|
93
|
+
if (widths.reduce((a, b) => a + b, 0) + overhead > width) return lines.map((l) => renderInline(l));
|
|
94
|
+
const border = (l, m, r) => c.gray(l + widths.map((w) => '─'.repeat(w + 2)).join(m) + r);
|
|
95
|
+
const out = [border('┌', '┬', '┐')];
|
|
96
|
+
rendered.forEach((row, ri) => {
|
|
97
|
+
const wrapped = row.map((cell, j) => wordWrap(cell, widths[j]));
|
|
98
|
+
const height = Math.max(...wrapped.map((w) => w.length));
|
|
99
|
+
for (let k = 0; k < height; k++) {
|
|
100
|
+
const cells = wrapped.map((w, j) => {
|
|
101
|
+
const text = w[k] ?? '';
|
|
102
|
+
const pad = widths[j] - stringWidth(text);
|
|
103
|
+
const a = align[j] || 'left';
|
|
104
|
+
const t = ri === 0 && header ? c.bold(text) : text;
|
|
105
|
+
if (a === 'right') return ' '.repeat(pad) + t;
|
|
106
|
+
if (a === 'center') return ' '.repeat(Math.floor(pad / 2)) + t + ' '.repeat(Math.ceil(pad / 2));
|
|
107
|
+
return t + ' '.repeat(pad);
|
|
108
|
+
});
|
|
109
|
+
out.push(c.gray('│ ') + cells.join(c.gray(' │ ')) + c.gray(' │'));
|
|
110
|
+
}
|
|
111
|
+
if (ri === 0 && header) out.push(border('├', '┼', '┤'));
|
|
112
|
+
});
|
|
113
|
+
out.push(border('└', '┴', '┘'));
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export class MarkdownStream {
|
|
118
|
+
// firstPrefix is printed before the first line (e.g. "⏺ "), indent before the rest.
|
|
119
|
+
constructor({ width, firstPrefix = '', indent = '', style = (s) => s }) {
|
|
120
|
+
this.width = width;
|
|
121
|
+
this.firstPrefix = firstPrefix;
|
|
122
|
+
this.indent = indent;
|
|
123
|
+
this.style = style;
|
|
124
|
+
this.pending = '';
|
|
125
|
+
this.fence = null;
|
|
126
|
+
this.table = [];
|
|
127
|
+
this.emittedAny = false;
|
|
128
|
+
this.lastBlank = true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
get contentWidth() { return Math.max(10, this.width - stringWidth(this.indent)); }
|
|
132
|
+
|
|
133
|
+
prefix(lines) {
|
|
134
|
+
return lines.map((l) => {
|
|
135
|
+
if (!this.emittedAny) {
|
|
136
|
+
this.emittedAny = true;
|
|
137
|
+
return this.firstPrefix + l;
|
|
138
|
+
}
|
|
139
|
+
return this.indent + l;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
write(delta) {
|
|
144
|
+
this.pending += delta;
|
|
145
|
+
const out = [];
|
|
146
|
+
let i;
|
|
147
|
+
while ((i = this.pending.indexOf('\n')) >= 0) {
|
|
148
|
+
const line = this.pending.slice(0, i).replace(/\r$/, '');
|
|
149
|
+
this.pending = this.pending.slice(i + 1);
|
|
150
|
+
out.push(...this.line(line));
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
end() {
|
|
156
|
+
const out = [];
|
|
157
|
+
if (this.pending) { out.push(...this.line(this.pending)); this.pending = ''; }
|
|
158
|
+
out.push(...this.flushTable());
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Rendered preview of the incomplete line (not committed).
|
|
163
|
+
partial() {
|
|
164
|
+
if (!this.pending && !this.table.length) return [];
|
|
165
|
+
const saved = { emittedAny: this.emittedAny };
|
|
166
|
+
const lines = [];
|
|
167
|
+
if (this.table.length) lines.push(...this.table.map((l) => renderInline(l)));
|
|
168
|
+
if (this.pending) {
|
|
169
|
+
const p = this.fence ? highlightCode(this.pending, this.fence.lang) : renderInline(this.pending);
|
|
170
|
+
lines.push(...wordWrap(this.style(p), this.contentWidth));
|
|
171
|
+
}
|
|
172
|
+
const res = lines.map((l, idx) => (!saved.emittedAny && idx === 0 ? this.firstPrefix : this.indent) + l);
|
|
173
|
+
return res;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
flushTable() {
|
|
177
|
+
if (!this.table.length) return [];
|
|
178
|
+
const rows = renderTable(this.table, this.contentWidth);
|
|
179
|
+
this.table = [];
|
|
180
|
+
this.lastBlank = false;
|
|
181
|
+
return this.prefix(rows);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
line(raw) {
|
|
185
|
+
const out = [];
|
|
186
|
+
const w = this.contentWidth;
|
|
187
|
+
if (this.fence) {
|
|
188
|
+
const close = /^\s*(`{3,}|~{3,})\s*$/.exec(raw);
|
|
189
|
+
if (close && close[1][0] === this.fence.marker[0] && close[1].length >= this.fence.marker.length) {
|
|
190
|
+
this.fence = null;
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
const code = raw.replace(/\t/g, ' ');
|
|
194
|
+
out.push(...this.prefix(wrapLine(highlightCode(code, this.fence.lang), w)));
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
const tableLike = /^\s*\|.*\|\s*$/.test(raw) || (this.table.length > 0 && /^\s*\|/.test(raw));
|
|
198
|
+
if (tableLike) { this.table.push(raw); return out; }
|
|
199
|
+
if (this.table.length) out.push(...this.flushTable());
|
|
200
|
+
|
|
201
|
+
const open = /^\s*(`{3,}|~{3,})\s*([\w+#.-]*)/.exec(raw);
|
|
202
|
+
if (open) { this.fence = { marker: open[1], lang: open[2] || '' }; return out; }
|
|
203
|
+
|
|
204
|
+
if (!raw.trim()) {
|
|
205
|
+
if (!this.lastBlank && this.emittedAny) out.push(...this.prefix(['']));
|
|
206
|
+
this.lastBlank = true;
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
this.lastBlank = false;
|
|
210
|
+
|
|
211
|
+
let m;
|
|
212
|
+
if ((m = /^\s*(#{1,6})\s+(.*?)\s*#*\s*$/.exec(raw))) {
|
|
213
|
+
const text = renderInline(m[2]);
|
|
214
|
+
const styled = m[1].length === 1 ? c.bold(c.underline(text)) : m[1].length === 2 ? c.bold(text) : c.bold(c.italic(text));
|
|
215
|
+
out.push(...this.prefix(wordWrap(this.style(styled), w)));
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(raw)) {
|
|
219
|
+
out.push(...this.prefix([c.gray('─'.repeat(Math.min(w, 60)))]));
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
if ((m = /^(\s*)>\s?(.*)$/.exec(raw))) {
|
|
223
|
+
const lines = wordWrap(c.italic(renderInline(m[2])), w - 2).map((l) => c.gray('▎ ') + c.dim(l));
|
|
224
|
+
out.push(...this.prefix(lines));
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
if ((m = /^(\s*)([-*+]|\d+[.)])\s+(\[[ xX]\]\s+)?(.*)$/.exec(raw))) {
|
|
228
|
+
const depth = Math.floor(m[1].replace(/\t/g, ' ').length / 2);
|
|
229
|
+
const ordered = /\d/.test(m[2]);
|
|
230
|
+
let bullet = ordered ? m[2] : depth % 2 === 0 ? '•' : '◦';
|
|
231
|
+
if (m[3]) bullet = /x/i.test(m[3]) ? c.green('☒') : '☐';
|
|
232
|
+
const lead = ' '.repeat(depth) + bullet + ' ';
|
|
233
|
+
const leadW = stringWidth(lead);
|
|
234
|
+
const wrapped = wordWrap(this.style(renderInline(m[4])), Math.max(10, w - leadW));
|
|
235
|
+
out.push(...this.prefix(wrapped.map((l, idx) => (idx === 0 ? lead : ' '.repeat(leadW)) + l)));
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
out.push(...this.prefix(wordWrap(this.style(renderInline(raw.replace(/\t/g, ' '))), w)));
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Render a complete markdown string at once.
|
|
244
|
+
export function renderMarkdown(text, opts) {
|
|
245
|
+
const s = new MarkdownStream(opts);
|
|
246
|
+
return [...s.write(text), ...s.end()];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export { padEnd };
|