@code4bug/jarvis-agent 1.2.1 → 1.3.2

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.
Files changed (35) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/jarvis.md +1 -1
  3. package/dist/components/AnimatedStatusText.d.ts +10 -0
  4. package/dist/components/AnimatedStatusText.js +17 -0
  5. package/dist/components/ComposerPane.d.ts +25 -0
  6. package/dist/components/ComposerPane.js +10 -0
  7. package/dist/components/FooterPane.d.ts +9 -0
  8. package/dist/components/FooterPane.js +22 -0
  9. package/dist/components/InputTextView.d.ts +11 -0
  10. package/dist/components/InputTextView.js +44 -0
  11. package/dist/components/MessageList.d.ts +9 -0
  12. package/dist/components/MessageList.js +8 -0
  13. package/dist/components/MessageViewport.d.ts +21 -0
  14. package/dist/components/MessageViewport.js +11 -0
  15. package/dist/components/MultilineInput.js +75 -343
  16. package/dist/components/StreamingDraft.d.ts +11 -0
  17. package/dist/components/StreamingDraft.js +14 -0
  18. package/dist/components/inputEditing.d.ts +20 -0
  19. package/dist/components/inputEditing.js +48 -0
  20. package/dist/core/WorkerBridge.d.ts +3 -0
  21. package/dist/core/WorkerBridge.js +75 -16
  22. package/dist/core/query.js +68 -10
  23. package/dist/hooks/useMultilineInputStream.d.ts +17 -0
  24. package/dist/hooks/useMultilineInputStream.js +141 -0
  25. package/dist/hooks/useTerminalCursorSync.d.ts +11 -0
  26. package/dist/hooks/useTerminalCursorSync.js +46 -0
  27. package/dist/hooks/useTerminalSize.d.ts +7 -0
  28. package/dist/hooks/useTerminalSize.js +21 -0
  29. package/dist/screens/repl.js +74 -33
  30. package/dist/services/api/llm.js +3 -1
  31. package/dist/skills/index.js +10 -3
  32. package/dist/terminal/cursor.d.ts +6 -0
  33. package/dist/terminal/cursor.js +22 -0
  34. package/dist/tools/writeFile.js +63 -2
  35. package/package.json +1 -1
@@ -1,8 +1,11 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useState, useEffect, useRef } from 'react';
3
- import { Box, Text, useInput, useStdin } from 'ink';
4
- /** 粘贴占位符的正则,匹配 [Pasted text #N +X lines] */
5
- const PASTE_PLACEHOLDER_RE = /\[Pasted text #(\d+) \+(\d+) lines\]/g;
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
  /**
@@ -15,6 +18,8 @@ const IME_COMPOSE_WINDOW_MS = 80;
15
18
  const isAsciiLowerAlpha = (s) => /^[a-z]+$/.test(s);
16
19
  /** 判断字符串是否包含非 ASCII 字符(中文、日文等 CJK 字符) */
17
20
  const hasNonAscii = (s) => /[^\x00-\x7F]/.test(s);
21
+ const charDisplayWidth = (ch) => /[^\u0000-\u00ff]/.test(ch) ? 2 : 1;
22
+ const textDisplayWidth = (text) => Array.from(text).reduce((sum, ch) => sum + charDisplayWidth(ch), 0);
18
23
  /**
19
24
  * 多行文本输入组件,支持光标移动和粘贴折叠。
20
25
  *
@@ -31,20 +36,12 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
31
36
  // 粘贴内容存储
32
37
  const pasteCountRef = useRef(0);
33
38
  const pastedChunksRef = useRef(new Map());
34
- // bracketed paste 缓冲
35
- const pasteBufferRef = useRef(null);
36
- // 时间窗口粘贴检测:收集短时间内连续到达的数据块
37
- const batchBufferRef = useRef('');
38
- const batchTimerRef = useRef(null);
39
39
  // IME composing 缓冲:积累拼音字母,等 composing 结束后再一次性更新
40
40
  // imeBufferRef: 当前正在 composing 的拼音字母
41
41
  // imeTimerRef: composing 超时定时器,超时后将拼音作为普通文本提交
42
42
  // imeInsertedLen: 已经临时插入到 value 中的 composing 文本长度(用于替换)
43
43
  const imeBufferRef = useRef('');
44
- const imeTimerRef = useRef(null);
45
44
  const imeInsertedLenRef = useRef(0);
46
- // 终端光标重定位定时器(用于 IME composing 位置修正)
47
- const cursorRelocTimerRef = useRef(null);
48
45
  // 启用 bracketed paste mode,并在激活时清空 stdin 缓冲区
49
46
  useEffect(() => {
50
47
  if (!stdin || !isActive)
@@ -53,9 +50,9 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
53
50
  if (typeof stdin.read === 'function') {
54
51
  while (stdin.read() !== null) { /* drain */ }
55
52
  }
56
- process.stdout.write('\x1B[?2004h');
53
+ enableBracketedPaste();
57
54
  return () => {
58
- process.stdout.write('\x1B[?2004l');
55
+ disableBracketedPaste();
59
56
  };
60
57
  }, [stdin, isActive]);
61
58
  // 标记内部触发的 value 变更,避免 useEffect 覆盖光标位置
@@ -98,24 +95,6 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
98
95
  onSlashMenuCloseRef.current = onSlashMenuClose;
99
96
  const onTabFillPlaceholderRef = useRef(onTabFillPlaceholder);
100
97
  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
98
  /** 将粘贴的多行内容折叠为占位符,插入到当前光标位置 */
120
99
  const insertPaste = (pastedText) => {
121
100
  const v = valueRef.current;
@@ -124,40 +103,22 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
124
103
  const lineCount = cleaned.split('\n').length;
125
104
  // 单行粘贴直接插入,不折叠
126
105
  if (lineCount <= 1) {
127
- const newVal = v.slice(0, c) + cleaned + v.slice(c);
128
- emitChange(newVal);
129
- setCursor(c + cleaned.length);
106
+ const result = insertTextAtCursor(v, c, cleaned);
107
+ emitChange(result.value);
108
+ setCursor(result.cursor);
130
109
  return;
131
110
  }
132
111
  // 多行 → 生成占位符
133
112
  const id = ++pasteCountRef.current;
134
113
  pastedChunksRef.current.set(id, { id, content: cleaned, lineCount });
135
114
  const tag = `[Pasted text #${id} +${lineCount} lines]`;
136
- const newVal = v.slice(0, c) + tag + v.slice(c);
137
- emitChange(newVal);
138
- setCursor(c + tag.length);
115
+ const result = insertTextAtCursor(v, c, tag);
116
+ emitChange(result.value);
117
+ setCursor(result.cursor);
139
118
  };
140
119
  /** 展开 value 中所有占位符为真实内容 */
141
120
  const expandPlaceholders = (text) => {
142
- return text.replace(PASTE_PLACEHOLDER_RE, (match, idStr) => {
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;
121
+ return expandPastedPlaceholders(text, (id) => pastedChunksRef.current.get(id)?.content);
161
122
  };
162
123
  /** 处理单个普通输入字符/按键(非粘贴) */
163
124
  const handleNormalInput = (raw) => {
@@ -165,9 +126,9 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
165
126
  const c = cursorRef.current;
166
127
  // Alt+Enter → 插入换行
167
128
  if (raw === '\x1B\r' || raw === '\x1B\n') {
168
- const newVal = v.slice(0, c) + '\n' + v.slice(c);
169
- emitChange(newVal);
170
- setCursor(c + 1);
129
+ const result = insertTextAtCursor(v, c, '\n');
130
+ emitChange(result.value);
131
+ setCursor(result.cursor);
171
132
  return;
172
133
  }
173
134
  // Enter → 提交(展开占位符)
@@ -189,14 +150,14 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
189
150
  const match = v.slice(ph.start, ph.end).match(/\[Pasted text #(\d+)/);
190
151
  if (match)
191
152
  pastedChunksRef.current.delete(parseInt(match[1], 10));
192
- const newVal = v.slice(0, ph.start) + v.slice(ph.end);
193
- emitChange(newVal);
194
- setCursor(ph.start);
153
+ const result = removeTextRange(v, ph.start, ph.end);
154
+ emitChange(result.value);
155
+ setCursor(result.cursor);
195
156
  }
196
157
  else {
197
- const newVal = v.slice(0, c - 1) + v.slice(c);
198
- emitChange(newVal);
199
- setCursor(c - 1);
158
+ const result = removeTextRange(v, c - 1, c);
159
+ emitChange(result.value);
160
+ setCursor(result.cursor);
200
161
  }
201
162
  }
202
163
  return;
@@ -278,73 +239,12 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
278
239
  if (raw.startsWith('\x1B[') || raw.startsWith('\x1B]') || raw.startsWith('\x1BO'))
279
240
  return;
280
241
  // 可打印字符 → 在光标位置插入
281
- const newVal = v.slice(0, c) + raw + v.slice(c);
282
- emitChange(newVal);
283
- setCursor(c + raw.length);
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);
242
+ const result = insertTextAtCursor(v, c, raw);
243
+ emitChange(result.value);
244
+ setCursor(result.cursor);
341
245
  };
342
246
  /** 提交 IME 缓冲区中的拼音为普通文本(composing 超时或被打断时调用) */
343
247
  const commitImeBuffer = () => {
344
- if (imeTimerRef.current !== null) {
345
- clearTimeout(imeTimerRef.current);
346
- imeTimerRef.current = null;
347
- }
348
248
  const buf = imeBufferRef.current;
349
249
  const insertedLen = imeInsertedLenRef.current;
350
250
  imeBufferRef.current = '';
@@ -359,223 +259,55 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
359
259
  };
360
260
  /** IME composing 超时:将积累的拼音作为普通文本提交 */
361
261
  const flushImeBuffer = () => {
362
- imeTimerRef.current = null;
363
262
  commitImeBuffer();
364
263
  };
365
- // Alt+Enter 组合键检测:ESC 可能单独到达,需要等待后续字符
366
- const escTimerRef = useRef(null);
367
- const ESC_WAIT_MS = 50; // 等待后续字符的时间窗口
368
- useEffect(() => {
369
- if (!stdin || !isActive)
264
+ const clearImeBufferWithInsertedLenRollback = () => {
265
+ const insertedLen = imeInsertedLenRef.current;
266
+ imeBufferRef.current = '';
267
+ imeInsertedLenRef.current = 0;
268
+ if (insertedLen <= 0)
370
269
  return;
371
- const onData = (data) => {
372
- const raw = data.toString('utf-8');
373
- // === Bracketed paste 模式处理(优先级最高) ===
374
- if (raw.includes('\x1B[200~')) {
375
- const startIdx = raw.indexOf('\x1B[200~') + 6;
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
- };
270
+ const v = valueRef.current;
271
+ const c = cursorRef.current;
272
+ const result = removeTextRange(v, c - insertedLen, c);
273
+ emitChange(result.value);
274
+ setCursor(result.cursor);
275
+ };
276
+ useMultilineInputStream({
277
+ stdin: stdin ?? undefined,
278
+ isActive,
279
+ pasteDetectWindowMs: PASTE_DETECT_WINDOW_MS,
280
+ imeComposeWindowMs: IME_COMPOSE_WINDOW_MS,
281
+ isAsciiLowerAlpha,
282
+ hasNonAscii,
283
+ insertPaste,
284
+ handleNormalInput,
285
+ commitImeBuffer,
286
+ flushImeBuffer,
287
+ appendImeBuffer: (text) => {
288
+ imeBufferRef.current += text;
289
+ },
290
+ getImeBuffer: () => imeBufferRef.current,
291
+ clearImeBufferWithInsertedLenRollback,
535
292
  });
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');
293
+ useInput(() => { }, { isActive });
294
+ const lines = value.length > 0 ? value.split('\n') : [''];
551
295
  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;
569
- };
570
- return (_jsx(Box, { flexDirection: "column", children: lines.map((line, i) => {
571
- if (!showCursor || i !== cursorRow) {
572
- const parts = renderWithPlaceholders(line, `l${i}`);
573
- return _jsx(Box, { children: parts.length > 0 ? parts : _jsx(Text, { children: " " }) }, i);
296
+ const activeLine = lines[cursorRow] ?? '';
297
+ const beforeCursor = activeLine.slice(0, cursorCol);
298
+ const cursorColumn = 3 + textDisplayWidth(beforeCursor);
299
+ useTerminalCursorSync({
300
+ showCursor,
301
+ isActive,
302
+ rowsBelow,
303
+ cursorRow,
304
+ rowsInInput: lines.length,
305
+ cursorColumn,
306
+ });
307
+ return (_jsx(InputTextView, { value: value, cursor: cursor, placeholder: placeholder, showCursor: showCursor, onResetPastedChunks: () => {
308
+ if (pasteCountRef.current > 0) {
309
+ pasteCountRef.current = 0;
310
+ pastedChunksRef.current.clear();
574
311
  }
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
- }) }));
312
+ } }));
581
313
  }
@@ -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
+ }
@@ -2,6 +2,9 @@ import { TranscriptMessage } from '../types/index.js';
2
2
  import { EngineCallbacks } from './QueryEngine.js';
3
3
  export declare class WorkerBridge {
4
4
  private worker;
5
+ private currentRun;
6
+ private finalizeRun;
7
+ private buildAbortedTranscript;
5
8
  /** 在独立 Worker 线程中执行查询,返回更新后的 transcript */
6
9
  run(userInput: string, transcript: TranscriptMessage[], callbacks: EngineCallbacks, options?: {
7
10
  includeUserProfile?: boolean;