@code4bug/jarvis-agent 1.2.1 → 1.3.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/README.md +1 -1
- package/dist/agents/jarvis.md +1 -1
- package/dist/components/AnimatedStatusText.d.ts +10 -0
- package/dist/components/AnimatedStatusText.js +17 -0
- package/dist/components/ComposerPane.d.ts +25 -0
- package/dist/components/ComposerPane.js +10 -0
- package/dist/components/FooterPane.d.ts +9 -0
- package/dist/components/FooterPane.js +22 -0
- package/dist/components/InputTextView.d.ts +11 -0
- package/dist/components/InputTextView.js +44 -0
- package/dist/components/MessageList.d.ts +9 -0
- package/dist/components/MessageList.js +8 -0
- package/dist/components/MessageViewport.d.ts +21 -0
- package/dist/components/MessageViewport.js +11 -0
- package/dist/components/MultilineInput.js +62 -344
- package/dist/components/StreamingDraft.d.ts +11 -0
- package/dist/components/StreamingDraft.js +14 -0
- package/dist/components/inputEditing.d.ts +20 -0
- package/dist/components/inputEditing.js +48 -0
- package/dist/hooks/useMultilineInputStream.d.ts +17 -0
- package/dist/hooks/useMultilineInputStream.js +141 -0
- package/dist/hooks/useTerminalCursorSync.d.ts +8 -0
- package/dist/hooks/useTerminalCursorSync.js +44 -0
- package/dist/hooks/useTerminalSize.d.ts +7 -0
- package/dist/hooks/useTerminalSize.js +21 -0
- package/dist/screens/repl.js +39 -28
- package/dist/services/api/llm.js +3 -1
- package/dist/skills/index.js +10 -3
- package/dist/terminal/cursor.d.ts +6 -0
- package/dist/terminal/cursor.js +21 -0
- package/dist/tools/writeFile.js +63 -2
- package/package.json +1 -1
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { jsx as _jsx
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect, useRef } from 'react';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import { useInput, useStdin } from 'ink';
|
|
4
|
+
import { disableBracketedPaste, enableBracketedPaste } from '../terminal/cursor.js';
|
|
5
|
+
import { useMultilineInputStream } from '../hooks/useMultilineInputStream.js';
|
|
6
|
+
import { useTerminalCursorSync } from '../hooks/useTerminalCursorSync.js';
|
|
7
|
+
import InputTextView from './InputTextView.js';
|
|
8
|
+
import { expandPlaceholders as expandPastedPlaceholders, findPlaceholderBeforeCursor, getCursorRowCol, insertTextAtCursor, removeTextRange, rowColToOffset, } from './inputEditing.js';
|
|
6
9
|
/** 粘贴检测的时间窗口(ms),在此窗口内连续到达的数据视为一次粘贴 */
|
|
7
10
|
const PASTE_DETECT_WINDOW_MS = 8;
|
|
8
11
|
/**
|
|
@@ -31,20 +34,12 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
|
|
|
31
34
|
// 粘贴内容存储
|
|
32
35
|
const pasteCountRef = useRef(0);
|
|
33
36
|
const pastedChunksRef = useRef(new Map());
|
|
34
|
-
// bracketed paste 缓冲
|
|
35
|
-
const pasteBufferRef = useRef(null);
|
|
36
|
-
// 时间窗口粘贴检测:收集短时间内连续到达的数据块
|
|
37
|
-
const batchBufferRef = useRef('');
|
|
38
|
-
const batchTimerRef = useRef(null);
|
|
39
37
|
// IME composing 缓冲:积累拼音字母,等 composing 结束后再一次性更新
|
|
40
38
|
// imeBufferRef: 当前正在 composing 的拼音字母
|
|
41
39
|
// imeTimerRef: composing 超时定时器,超时后将拼音作为普通文本提交
|
|
42
40
|
// imeInsertedLen: 已经临时插入到 value 中的 composing 文本长度(用于替换)
|
|
43
41
|
const imeBufferRef = useRef('');
|
|
44
|
-
const imeTimerRef = useRef(null);
|
|
45
42
|
const imeInsertedLenRef = useRef(0);
|
|
46
|
-
// 终端光标重定位定时器(用于 IME composing 位置修正)
|
|
47
|
-
const cursorRelocTimerRef = useRef(null);
|
|
48
43
|
// 启用 bracketed paste mode,并在激活时清空 stdin 缓冲区
|
|
49
44
|
useEffect(() => {
|
|
50
45
|
if (!stdin || !isActive)
|
|
@@ -53,9 +48,9 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
|
|
|
53
48
|
if (typeof stdin.read === 'function') {
|
|
54
49
|
while (stdin.read() !== null) { /* drain */ }
|
|
55
50
|
}
|
|
56
|
-
|
|
51
|
+
enableBracketedPaste();
|
|
57
52
|
return () => {
|
|
58
|
-
|
|
53
|
+
disableBracketedPaste();
|
|
59
54
|
};
|
|
60
55
|
}, [stdin, isActive]);
|
|
61
56
|
// 标记内部触发的 value 变更,避免 useEffect 覆盖光标位置
|
|
@@ -98,24 +93,6 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
|
|
|
98
93
|
onSlashMenuCloseRef.current = onSlashMenuClose;
|
|
99
94
|
const onTabFillPlaceholderRef = useRef(onTabFillPlaceholder);
|
|
100
95
|
onTabFillPlaceholderRef.current = onTabFillPlaceholder;
|
|
101
|
-
// 辅助:根据光标偏移计算所在行号和行内列号
|
|
102
|
-
const getCursorRowCol = (text, pos) => {
|
|
103
|
-
const before = text.slice(0, pos);
|
|
104
|
-
const row = (before.match(/\n/g) || []).length;
|
|
105
|
-
const lastNewline = before.lastIndexOf('\n');
|
|
106
|
-
const col = lastNewline === -1 ? pos : pos - lastNewline - 1;
|
|
107
|
-
return { row, col };
|
|
108
|
-
};
|
|
109
|
-
// 辅助:根据行号和列号计算偏移
|
|
110
|
-
const rowColToOffset = (text, row, col) => {
|
|
111
|
-
const lines = text.split('\n');
|
|
112
|
-
let offset = 0;
|
|
113
|
-
for (let i = 0; i < row && i < lines.length; i++) {
|
|
114
|
-
offset += lines[i].length + 1;
|
|
115
|
-
}
|
|
116
|
-
const targetLine = lines[Math.min(row, lines.length - 1)] ?? '';
|
|
117
|
-
return offset + Math.min(col, targetLine.length);
|
|
118
|
-
};
|
|
119
96
|
/** 将粘贴的多行内容折叠为占位符,插入到当前光标位置 */
|
|
120
97
|
const insertPaste = (pastedText) => {
|
|
121
98
|
const v = valueRef.current;
|
|
@@ -124,40 +101,22 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
|
|
|
124
101
|
const lineCount = cleaned.split('\n').length;
|
|
125
102
|
// 单行粘贴直接插入,不折叠
|
|
126
103
|
if (lineCount <= 1) {
|
|
127
|
-
const
|
|
128
|
-
emitChange(
|
|
129
|
-
setCursor(
|
|
104
|
+
const result = insertTextAtCursor(v, c, cleaned);
|
|
105
|
+
emitChange(result.value);
|
|
106
|
+
setCursor(result.cursor);
|
|
130
107
|
return;
|
|
131
108
|
}
|
|
132
109
|
// 多行 → 生成占位符
|
|
133
110
|
const id = ++pasteCountRef.current;
|
|
134
111
|
pastedChunksRef.current.set(id, { id, content: cleaned, lineCount });
|
|
135
112
|
const tag = `[Pasted text #${id} +${lineCount} lines]`;
|
|
136
|
-
const
|
|
137
|
-
emitChange(
|
|
138
|
-
setCursor(
|
|
113
|
+
const result = insertTextAtCursor(v, c, tag);
|
|
114
|
+
emitChange(result.value);
|
|
115
|
+
setCursor(result.cursor);
|
|
139
116
|
};
|
|
140
117
|
/** 展开 value 中所有占位符为真实内容 */
|
|
141
118
|
const expandPlaceholders = (text) => {
|
|
142
|
-
return text
|
|
143
|
-
const id = parseInt(idStr, 10);
|
|
144
|
-
const chunk = pastedChunksRef.current.get(id);
|
|
145
|
-
return chunk ? chunk.content : match;
|
|
146
|
-
});
|
|
147
|
-
};
|
|
148
|
-
/** 检测光标前是否紧邻一个占位符 */
|
|
149
|
-
const findPlaceholderBeforeCursor = (text, pos) => {
|
|
150
|
-
if (pos === 0 || text[pos - 1] !== ']')
|
|
151
|
-
return null;
|
|
152
|
-
const before = text.slice(0, pos);
|
|
153
|
-
const idx = before.lastIndexOf('[Pasted text #');
|
|
154
|
-
if (idx === -1)
|
|
155
|
-
return null;
|
|
156
|
-
const sub = before.slice(idx);
|
|
157
|
-
const m = sub.match(/^\[Pasted text #\d+ \+\d+ lines\]$/);
|
|
158
|
-
if (m)
|
|
159
|
-
return { start: idx, end: pos };
|
|
160
|
-
return null;
|
|
119
|
+
return expandPastedPlaceholders(text, (id) => pastedChunksRef.current.get(id)?.content);
|
|
161
120
|
};
|
|
162
121
|
/** 处理单个普通输入字符/按键(非粘贴) */
|
|
163
122
|
const handleNormalInput = (raw) => {
|
|
@@ -165,9 +124,9 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
|
|
|
165
124
|
const c = cursorRef.current;
|
|
166
125
|
// Alt+Enter → 插入换行
|
|
167
126
|
if (raw === '\x1B\r' || raw === '\x1B\n') {
|
|
168
|
-
const
|
|
169
|
-
emitChange(
|
|
170
|
-
setCursor(
|
|
127
|
+
const result = insertTextAtCursor(v, c, '\n');
|
|
128
|
+
emitChange(result.value);
|
|
129
|
+
setCursor(result.cursor);
|
|
171
130
|
return;
|
|
172
131
|
}
|
|
173
132
|
// Enter → 提交(展开占位符)
|
|
@@ -189,14 +148,14 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
|
|
|
189
148
|
const match = v.slice(ph.start, ph.end).match(/\[Pasted text #(\d+)/);
|
|
190
149
|
if (match)
|
|
191
150
|
pastedChunksRef.current.delete(parseInt(match[1], 10));
|
|
192
|
-
const
|
|
193
|
-
emitChange(
|
|
194
|
-
setCursor(
|
|
151
|
+
const result = removeTextRange(v, ph.start, ph.end);
|
|
152
|
+
emitChange(result.value);
|
|
153
|
+
setCursor(result.cursor);
|
|
195
154
|
}
|
|
196
155
|
else {
|
|
197
|
-
const
|
|
198
|
-
emitChange(
|
|
199
|
-
setCursor(
|
|
156
|
+
const result = removeTextRange(v, c - 1, c);
|
|
157
|
+
emitChange(result.value);
|
|
158
|
+
setCursor(result.cursor);
|
|
200
159
|
}
|
|
201
160
|
}
|
|
202
161
|
return;
|
|
@@ -278,73 +237,12 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
|
|
|
278
237
|
if (raw.startsWith('\x1B[') || raw.startsWith('\x1B]') || raw.startsWith('\x1BO'))
|
|
279
238
|
return;
|
|
280
239
|
// 可打印字符 → 在光标位置插入
|
|
281
|
-
const
|
|
282
|
-
emitChange(
|
|
283
|
-
setCursor(
|
|
284
|
-
};
|
|
285
|
-
/**
|
|
286
|
-
* 处理批量缓冲区中积累的数据。
|
|
287
|
-
* 如果缓冲区包含换行(多行),视为粘贴;否则逐字符处理。
|
|
288
|
-
*/
|
|
289
|
-
const flushBatchBuffer = () => {
|
|
290
|
-
const buf = batchBufferRef.current;
|
|
291
|
-
batchBufferRef.current = '';
|
|
292
|
-
batchTimerRef.current = null;
|
|
293
|
-
if (!buf)
|
|
294
|
-
return;
|
|
295
|
-
const cleaned = buf.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
296
|
-
// 多行 → 粘贴
|
|
297
|
-
if (cleaned.includes('\n') && cleaned.length > 1) {
|
|
298
|
-
insertPaste(cleaned);
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
// 检测是否为 IME composing 输入(连续小写字母 = 拼音)
|
|
302
|
-
if (isAsciiLowerAlpha(buf)) {
|
|
303
|
-
// 可能是拼音 composing,进入 IME 缓冲模式
|
|
304
|
-
imeBufferRef.current += buf;
|
|
305
|
-
// 不更新 value/不触发 re-render,只重置 IME 超时
|
|
306
|
-
if (imeTimerRef.current !== null) {
|
|
307
|
-
clearTimeout(imeTimerRef.current);
|
|
308
|
-
}
|
|
309
|
-
imeTimerRef.current = setTimeout(flushImeBuffer, IME_COMPOSE_WINDOW_MS);
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
// 收到非 ASCII 字符(中文等):如果有 IME 缓冲,说明 composing 结束
|
|
313
|
-
if (hasNonAscii(buf) && imeBufferRef.current.length > 0) {
|
|
314
|
-
// 丢弃之前积累的拼音字母(它们只是 composing 中间态),
|
|
315
|
-
// 回退已临时插入的 composing 文本
|
|
316
|
-
if (imeTimerRef.current !== null) {
|
|
317
|
-
clearTimeout(imeTimerRef.current);
|
|
318
|
-
imeTimerRef.current = null;
|
|
319
|
-
}
|
|
320
|
-
const insertedLen = imeInsertedLenRef.current;
|
|
321
|
-
imeBufferRef.current = '';
|
|
322
|
-
imeInsertedLenRef.current = 0;
|
|
323
|
-
if (insertedLen > 0) {
|
|
324
|
-
// 回退之前临时插入的拼音
|
|
325
|
-
const v = valueRef.current;
|
|
326
|
-
const c = cursorRef.current;
|
|
327
|
-
const newVal = v.slice(0, c - insertedLen) + v.slice(c);
|
|
328
|
-
emitChange(newVal);
|
|
329
|
-
setCursor(c - insertedLen);
|
|
330
|
-
}
|
|
331
|
-
// 插入最终的中文字符
|
|
332
|
-
handleNormalInput(buf);
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
// 收到非拼音的 ASCII 可打印字符,且有 IME 缓冲:先提交 IME 缓冲
|
|
336
|
-
if (imeBufferRef.current.length > 0) {
|
|
337
|
-
commitImeBuffer();
|
|
338
|
-
}
|
|
339
|
-
// 单字符或单行短文本,按普通输入处理
|
|
340
|
-
handleNormalInput(buf);
|
|
240
|
+
const result = insertTextAtCursor(v, c, raw);
|
|
241
|
+
emitChange(result.value);
|
|
242
|
+
setCursor(result.cursor);
|
|
341
243
|
};
|
|
342
244
|
/** 提交 IME 缓冲区中的拼音为普通文本(composing 超时或被打断时调用) */
|
|
343
245
|
const commitImeBuffer = () => {
|
|
344
|
-
if (imeTimerRef.current !== null) {
|
|
345
|
-
clearTimeout(imeTimerRef.current);
|
|
346
|
-
imeTimerRef.current = null;
|
|
347
|
-
}
|
|
348
246
|
const buf = imeBufferRef.current;
|
|
349
247
|
const insertedLen = imeInsertedLenRef.current;
|
|
350
248
|
imeBufferRef.current = '';
|
|
@@ -359,223 +257,43 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
|
|
|
359
257
|
};
|
|
360
258
|
/** IME composing 超时:将积累的拼音作为普通文本提交 */
|
|
361
259
|
const flushImeBuffer = () => {
|
|
362
|
-
imeTimerRef.current = null;
|
|
363
260
|
commitImeBuffer();
|
|
364
261
|
};
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
if (
|
|
262
|
+
const clearImeBufferWithInsertedLenRollback = () => {
|
|
263
|
+
const insertedLen = imeInsertedLenRef.current;
|
|
264
|
+
imeBufferRef.current = '';
|
|
265
|
+
imeInsertedLenRef.current = 0;
|
|
266
|
+
if (insertedLen <= 0)
|
|
370
267
|
return;
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const endIdx = raw.indexOf('\x1B[201~');
|
|
377
|
-
if (endIdx !== -1) {
|
|
378
|
-
insertPaste(raw.slice(startIdx, endIdx));
|
|
379
|
-
}
|
|
380
|
-
else {
|
|
381
|
-
pasteBufferRef.current = raw.slice(startIdx);
|
|
382
|
-
}
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
if (pasteBufferRef.current !== null) {
|
|
386
|
-
const endIdx = raw.indexOf('\x1B[201~');
|
|
387
|
-
if (endIdx !== -1) {
|
|
388
|
-
pasteBufferRef.current += raw.slice(0, endIdx);
|
|
389
|
-
insertPaste(pasteBufferRef.current);
|
|
390
|
-
pasteBufferRef.current = null;
|
|
391
|
-
}
|
|
392
|
-
else {
|
|
393
|
-
pasteBufferRef.current += raw;
|
|
394
|
-
}
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
// === 处理 ESC 等待状态:上一次收到了单独的 ESC,现在看后续字符 ===
|
|
398
|
-
if (escTimerRef.current !== null) {
|
|
399
|
-
clearTimeout(escTimerRef.current);
|
|
400
|
-
escTimerRef.current = null;
|
|
401
|
-
if (raw === '\r' || raw === '\n') {
|
|
402
|
-
// ESC + Enter = Alt+Enter → 插入换行
|
|
403
|
-
handleNormalInput('\x1B\r');
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
// ESC + 其他字符:先处理 ESC 本身,再处理当前字符
|
|
407
|
-
handleNormalInput('\x1B');
|
|
408
|
-
// 继续往下处理当前 raw
|
|
409
|
-
}
|
|
410
|
-
// === 非 bracketed paste:用时间窗口检测 ===
|
|
411
|
-
// 控制字符和转义序列不参与批量缓冲,直接处理
|
|
412
|
-
const isSingleControl = raw === '\r' || raw === '\n' ||
|
|
413
|
-
raw === '\x7F' || raw === '\x08' ||
|
|
414
|
-
raw === '\t' || raw === '\x1B' ||
|
|
415
|
-
raw === '\x1B\r' || raw === '\x1B\n' ||
|
|
416
|
-
raw.startsWith('\x1B[') || raw.startsWith('\x1B]') || raw.startsWith('\x1BO');
|
|
417
|
-
// Ctrl+C → 不拦截,让上层 useInput 处理双击退出
|
|
418
|
-
if (raw === '\x03')
|
|
419
|
-
return;
|
|
420
|
-
// Ctrl+O → 不拦截,让上层 useInput 处理详情展开/折叠
|
|
421
|
-
if (raw === '\x0F')
|
|
422
|
-
return;
|
|
423
|
-
// Ctrl+L → 拦截,不穿透到终端(避免清屏)
|
|
424
|
-
if (raw === '\x0C')
|
|
425
|
-
return;
|
|
426
|
-
// 单独的 ESC:进入等待状态,看后续是否跟着 Enter(Alt+Enter 组合)
|
|
427
|
-
if (raw === '\x1B') {
|
|
428
|
-
escTimerRef.current = setTimeout(() => {
|
|
429
|
-
escTimerRef.current = null;
|
|
430
|
-
handleNormalInput('\x1B');
|
|
431
|
-
}, ESC_WAIT_MS);
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
if (isSingleControl && batchBufferRef.current === '') {
|
|
435
|
-
// 没有正在积累的缓冲,直接处理控制字符
|
|
436
|
-
// 如果有 IME 缓冲,先提交
|
|
437
|
-
if (imeBufferRef.current.length > 0) {
|
|
438
|
-
commitImeBuffer();
|
|
439
|
-
}
|
|
440
|
-
handleNormalInput(raw);
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
if (isSingleControl && batchBufferRef.current !== '') {
|
|
444
|
-
// 有缓冲在积累中,控制字符也追加进去(可能是粘贴内容中的 \r)
|
|
445
|
-
batchBufferRef.current += raw;
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
// 可打印字符:追加到批量缓冲,重置定时器
|
|
449
|
-
batchBufferRef.current += raw;
|
|
450
|
-
if (batchTimerRef.current !== null) {
|
|
451
|
-
clearTimeout(batchTimerRef.current);
|
|
452
|
-
}
|
|
453
|
-
batchTimerRef.current = setTimeout(flushBatchBuffer, PASTE_DETECT_WINDOW_MS);
|
|
454
|
-
};
|
|
455
|
-
stdin.prependListener('data', onData);
|
|
456
|
-
return () => {
|
|
457
|
-
stdin.off('data', onData);
|
|
458
|
-
if (batchTimerRef.current !== null) {
|
|
459
|
-
clearTimeout(batchTimerRef.current);
|
|
460
|
-
batchTimerRef.current = null;
|
|
461
|
-
}
|
|
462
|
-
if (escTimerRef.current !== null) {
|
|
463
|
-
clearTimeout(escTimerRef.current);
|
|
464
|
-
escTimerRef.current = null;
|
|
465
|
-
}
|
|
466
|
-
if (imeTimerRef.current !== null) {
|
|
467
|
-
clearTimeout(imeTimerRef.current);
|
|
468
|
-
imeTimerRef.current = null;
|
|
469
|
-
}
|
|
470
|
-
};
|
|
471
|
-
}, [stdin, isActive]);
|
|
472
|
-
// 组件卸载时清理
|
|
473
|
-
useEffect(() => {
|
|
474
|
-
return () => {
|
|
475
|
-
if (batchTimerRef.current !== null) {
|
|
476
|
-
clearTimeout(batchTimerRef.current);
|
|
477
|
-
}
|
|
478
|
-
if (escTimerRef.current !== null) {
|
|
479
|
-
clearTimeout(escTimerRef.current);
|
|
480
|
-
}
|
|
481
|
-
if (imeTimerRef.current !== null) {
|
|
482
|
-
clearTimeout(imeTimerRef.current);
|
|
483
|
-
}
|
|
484
|
-
if (cursorRelocTimerRef.current !== null) {
|
|
485
|
-
clearTimeout(cursorRelocTimerRef.current);
|
|
486
|
-
}
|
|
487
|
-
};
|
|
488
|
-
}, []);
|
|
489
|
-
useInput(() => { }, { isActive });
|
|
490
|
-
/**
|
|
491
|
-
* 将终端物理光标固定到当前行第 3 列,避免 IME composing 从行末溢出。
|
|
492
|
-
*
|
|
493
|
-
* 背景:ink 渲染完成后,终端光标停留在最后一行(StatusBar)末尾。
|
|
494
|
-
* macOS 终端模拟器的内联 IME 会在物理光标位置显示 composing 拼音,
|
|
495
|
-
* 若光标在行末会导致行宽溢出、TUI 布局被挤压偏移。
|
|
496
|
-
*
|
|
497
|
-
* 注意:只能移列,绝对不能移行(\x1B[1A 等)。
|
|
498
|
-
* ink 增量渲染以当前光标行为起点,移行后每次 re-render 都会导致
|
|
499
|
-
* TUI 整体上偏移一行(每次输入字符都触发 re-render)。
|
|
500
|
-
*
|
|
501
|
-
* 解决方案:仅将列定位到 3(❯ 占 2 列,输入框第一个字符从第 3 列开始),
|
|
502
|
-
* IME composing 文本从该列开始,不会从行末溢出。
|
|
503
|
-
*/
|
|
504
|
-
useEffect(() => {
|
|
505
|
-
// 取消之前的定时器
|
|
506
|
-
if (cursorRelocTimerRef.current !== null) {
|
|
507
|
-
clearTimeout(cursorRelocTimerRef.current);
|
|
508
|
-
}
|
|
509
|
-
if (!showCursor || !isActive) {
|
|
510
|
-
// 非输入状态:将物理光标移到列 1(行首),避免 IME 候选框在可见区域弹出
|
|
511
|
-
cursorRelocTimerRef.current = setTimeout(() => {
|
|
512
|
-
cursorRelocTimerRef.current = null;
|
|
513
|
-
process.stdout.write('\x1B[1G');
|
|
514
|
-
}, 80);
|
|
515
|
-
}
|
|
516
|
-
else {
|
|
517
|
-
// 输入激活状态:
|
|
518
|
-
// 1. 上移 rowsBelow 行,到达输入框所在行
|
|
519
|
-
// 2. 将列定位到 3(❯ 占 2 列,输入框第一个字符从第 3 列开始)
|
|
520
|
-
// 3. 下移 rowsBelow 行,回到原来的光标行(ink 渲染起点不变)
|
|
521
|
-
// 这样 IME composing 显示在输入框行而非 StatusBar 行
|
|
522
|
-
cursorRelocTimerRef.current = setTimeout(() => {
|
|
523
|
-
cursorRelocTimerRef.current = null;
|
|
524
|
-
const up = rowsBelow > 0 ? `\x1B[${rowsBelow}A` : '';
|
|
525
|
-
const down = rowsBelow > 0 ? `\x1B[${rowsBelow}B` : '';
|
|
526
|
-
process.stdout.write(`${up}\x1B[3G${down}`);
|
|
527
|
-
}, 80);
|
|
528
|
-
}
|
|
529
|
-
return () => {
|
|
530
|
-
if (cursorRelocTimerRef.current !== null) {
|
|
531
|
-
clearTimeout(cursorRelocTimerRef.current);
|
|
532
|
-
cursorRelocTimerRef.current = null;
|
|
533
|
-
}
|
|
534
|
-
};
|
|
535
|
-
});
|
|
536
|
-
// --- 渲染 ---
|
|
537
|
-
const isEmpty = value.length === 0;
|
|
538
|
-
if (isEmpty) {
|
|
539
|
-
// value 清空时重置粘贴状态
|
|
540
|
-
if (pasteCountRef.current > 0) {
|
|
541
|
-
pasteCountRef.current = 0;
|
|
542
|
-
pastedChunksRef.current.clear();
|
|
543
|
-
}
|
|
544
|
-
// 更新光标位置信息(空输入时光标在起始位置)
|
|
545
|
-
if (showCursor && placeholder.length > 0) {
|
|
546
|
-
return (_jsxs(Box, { children: [_jsx(Text, { inverse: true, color: "white", children: placeholder[0] }), _jsx(Text, { color: "gray", children: placeholder.slice(1) }), _jsx(Text, { color: "gray", dimColor: true, children: " [Tab]" })] }));
|
|
547
|
-
}
|
|
548
|
-
return (_jsxs(Box, { children: [showCursor && _jsx(Text, { inverse: true, children: " " }), _jsx(Text, { color: "gray", children: placeholder })] }));
|
|
549
|
-
}
|
|
550
|
-
const lines = value.split('\n');
|
|
551
|
-
const { row: cursorRow, col: cursorCol } = getCursorRowCol(value, cursor);
|
|
552
|
-
/** 渲染一段文本,将其中的占位符高亮 */
|
|
553
|
-
const renderWithPlaceholders = (text, keyPrefix) => {
|
|
554
|
-
const parts = [];
|
|
555
|
-
let lastIndex = 0;
|
|
556
|
-
const re = new RegExp(PASTE_PLACEHOLDER_RE.source, 'g');
|
|
557
|
-
let m;
|
|
558
|
-
while ((m = re.exec(text)) !== null) {
|
|
559
|
-
if (m.index > lastIndex) {
|
|
560
|
-
parts.push(_jsx(Text, { children: text.slice(lastIndex, m.index) }, `${keyPrefix}-t-${lastIndex}`));
|
|
561
|
-
}
|
|
562
|
-
parts.push(_jsx(Text, { color: "cyan", dimColor: true, children: m[0] }, `${keyPrefix}-p-${m.index}`));
|
|
563
|
-
lastIndex = m.index + m[0].length;
|
|
564
|
-
}
|
|
565
|
-
if (lastIndex < text.length) {
|
|
566
|
-
parts.push(_jsx(Text, { children: text.slice(lastIndex) }, `${keyPrefix}-t-${lastIndex}`));
|
|
567
|
-
}
|
|
568
|
-
return parts;
|
|
268
|
+
const v = valueRef.current;
|
|
269
|
+
const c = cursorRef.current;
|
|
270
|
+
const result = removeTextRange(v, c - insertedLen, c);
|
|
271
|
+
emitChange(result.value);
|
|
272
|
+
setCursor(result.cursor);
|
|
569
273
|
};
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
274
|
+
useMultilineInputStream({
|
|
275
|
+
stdin: stdin ?? undefined,
|
|
276
|
+
isActive,
|
|
277
|
+
pasteDetectWindowMs: PASTE_DETECT_WINDOW_MS,
|
|
278
|
+
imeComposeWindowMs: IME_COMPOSE_WINDOW_MS,
|
|
279
|
+
isAsciiLowerAlpha,
|
|
280
|
+
hasNonAscii,
|
|
281
|
+
insertPaste,
|
|
282
|
+
handleNormalInput,
|
|
283
|
+
commitImeBuffer,
|
|
284
|
+
flushImeBuffer,
|
|
285
|
+
appendImeBuffer: (text) => {
|
|
286
|
+
imeBufferRef.current += text;
|
|
287
|
+
},
|
|
288
|
+
getImeBuffer: () => imeBufferRef.current,
|
|
289
|
+
clearImeBufferWithInsertedLenRollback,
|
|
290
|
+
});
|
|
291
|
+
useInput(() => { }, { isActive });
|
|
292
|
+
useTerminalCursorSync({ showCursor, isActive, rowsBelow });
|
|
293
|
+
return (_jsx(InputTextView, { value: value, cursor: cursor, placeholder: placeholder, showCursor: showCursor, onResetPastedChunks: () => {
|
|
294
|
+
if (pasteCountRef.current > 0) {
|
|
295
|
+
pasteCountRef.current = 0;
|
|
296
|
+
pastedChunksRef.current.clear();
|
|
574
297
|
}
|
|
575
|
-
|
|
576
|
-
const before = line.slice(0, cursorCol);
|
|
577
|
-
const cursorChar = line[cursorCol] ?? ' ';
|
|
578
|
-
const after = line.slice(cursorCol + 1);
|
|
579
|
-
return (_jsxs(Box, { children: [renderWithPlaceholders(before, 'b'), _jsx(Text, { inverse: true, children: cursorChar }), renderWithPlaceholders(after, 'a')] }, i));
|
|
580
|
-
}) }));
|
|
298
|
+
} }));
|
|
581
299
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
interface StreamingDraftProps {
|
|
3
|
+
text: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* 流式阶段先走轻量纯文本渲染,避免每个 chunk 都触发完整 markdown 重排。
|
|
7
|
+
* 正式消息落盘后仍由 MessageItem -> MarkdownText 渲染。
|
|
8
|
+
*/
|
|
9
|
+
declare function StreamingDraft({ text }: StreamingDraftProps): import("react/jsx-runtime").JSX.Element | null;
|
|
10
|
+
declare const _default: React.MemoExoticComponent<typeof StreamingDraft>;
|
|
11
|
+
export default _default;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useMemo } from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
/**
|
|
5
|
+
* 流式阶段先走轻量纯文本渲染,避免每个 chunk 都触发完整 markdown 重排。
|
|
6
|
+
* 正式消息落盘后仍由 MessageItem -> MarkdownText 渲染。
|
|
7
|
+
*/
|
|
8
|
+
function StreamingDraft({ text }) {
|
|
9
|
+
const lines = useMemo(() => text.split('\n'), [text]);
|
|
10
|
+
if (!text)
|
|
11
|
+
return null;
|
|
12
|
+
return (_jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsxs(Box, { alignItems: "flex-start", children: [_jsx(Text, { color: "yellow", children: "\u25CF" }), _jsx(Box, { marginLeft: 1, flexDirection: "column", children: lines.map((line, index) => (_jsx(Text, { wrap: "wrap", children: line || ' ' }, index))) })] }) }));
|
|
13
|
+
}
|
|
14
|
+
export default React.memo(StreamingDraft);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const PASTE_PLACEHOLDER_RE: RegExp;
|
|
2
|
+
export interface CursorPosition {
|
|
3
|
+
row: number;
|
|
4
|
+
col: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function getCursorRowCol(text: string, pos: number): CursorPosition;
|
|
7
|
+
export declare function rowColToOffset(text: string, row: number, col: number): number;
|
|
8
|
+
export declare function findPlaceholderBeforeCursor(text: string, pos: number): {
|
|
9
|
+
start: number;
|
|
10
|
+
end: number;
|
|
11
|
+
} | null;
|
|
12
|
+
export declare function expandPlaceholders(text: string, lookup: (id: number) => string | undefined): string;
|
|
13
|
+
export declare function insertTextAtCursor(text: string, cursor: number, inserted: string): {
|
|
14
|
+
value: string;
|
|
15
|
+
cursor: number;
|
|
16
|
+
};
|
|
17
|
+
export declare function removeTextRange(text: string, start: number, end: number): {
|
|
18
|
+
value: string;
|
|
19
|
+
cursor: number;
|
|
20
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const PASTE_PLACEHOLDER_RE = /\[Pasted text #(\d+) \+(\d+) lines\]/g;
|
|
2
|
+
export function getCursorRowCol(text, pos) {
|
|
3
|
+
const before = text.slice(0, pos);
|
|
4
|
+
const row = (before.match(/\n/g) || []).length;
|
|
5
|
+
const lastNewline = before.lastIndexOf('\n');
|
|
6
|
+
const col = lastNewline === -1 ? pos : pos - lastNewline - 1;
|
|
7
|
+
return { row, col };
|
|
8
|
+
}
|
|
9
|
+
export function rowColToOffset(text, row, col) {
|
|
10
|
+
const lines = text.split('\n');
|
|
11
|
+
let offset = 0;
|
|
12
|
+
for (let i = 0; i < row && i < lines.length; i++) {
|
|
13
|
+
offset += lines[i].length + 1;
|
|
14
|
+
}
|
|
15
|
+
const targetLine = lines[Math.min(row, lines.length - 1)] ?? '';
|
|
16
|
+
return offset + Math.min(col, targetLine.length);
|
|
17
|
+
}
|
|
18
|
+
export function findPlaceholderBeforeCursor(text, pos) {
|
|
19
|
+
if (pos === 0 || text[pos - 1] !== ']')
|
|
20
|
+
return null;
|
|
21
|
+
const before = text.slice(0, pos);
|
|
22
|
+
const idx = before.lastIndexOf('[Pasted text #');
|
|
23
|
+
if (idx === -1)
|
|
24
|
+
return null;
|
|
25
|
+
const sub = before.slice(idx);
|
|
26
|
+
const matched = sub.match(/^\[Pasted text #\d+ \+\d+ lines\]$/);
|
|
27
|
+
if (!matched)
|
|
28
|
+
return null;
|
|
29
|
+
return { start: idx, end: pos };
|
|
30
|
+
}
|
|
31
|
+
export function expandPlaceholders(text, lookup) {
|
|
32
|
+
return text.replace(PASTE_PLACEHOLDER_RE, (match, idStr) => {
|
|
33
|
+
const id = parseInt(idStr, 10);
|
|
34
|
+
return lookup(id) ?? match;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export function insertTextAtCursor(text, cursor, inserted) {
|
|
38
|
+
return {
|
|
39
|
+
value: text.slice(0, cursor) + inserted + text.slice(cursor),
|
|
40
|
+
cursor: cursor + inserted.length,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function removeTextRange(text, start, end) {
|
|
44
|
+
return {
|
|
45
|
+
value: text.slice(0, start) + text.slice(end),
|
|
46
|
+
cursor: start,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
interface UseMultilineInputStreamOptions {
|
|
2
|
+
stdin?: NodeJS.ReadStream;
|
|
3
|
+
isActive: boolean;
|
|
4
|
+
pasteDetectWindowMs: number;
|
|
5
|
+
imeComposeWindowMs: number;
|
|
6
|
+
isAsciiLowerAlpha: (text: string) => boolean;
|
|
7
|
+
hasNonAscii: (text: string) => boolean;
|
|
8
|
+
insertPaste: (text: string) => void;
|
|
9
|
+
handleNormalInput: (raw: string) => void;
|
|
10
|
+
commitImeBuffer: () => void;
|
|
11
|
+
flushImeBuffer: () => void;
|
|
12
|
+
appendImeBuffer: (text: string) => void;
|
|
13
|
+
getImeBuffer: () => string;
|
|
14
|
+
clearImeBufferWithInsertedLenRollback: () => void;
|
|
15
|
+
}
|
|
16
|
+
export declare function useMultilineInputStream({ stdin, isActive, pasteDetectWindowMs, imeComposeWindowMs, isAsciiLowerAlpha, hasNonAscii, insertPaste, handleNormalInput, commitImeBuffer, flushImeBuffer, appendImeBuffer, getImeBuffer, clearImeBufferWithInsertedLenRollback, }: UseMultilineInputStreamOptions): void;
|
|
17
|
+
export {};
|