@code4bug/jarvis-agent 1.3.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.
@@ -18,6 +18,8 @@ const IME_COMPOSE_WINDOW_MS = 80;
18
18
  const isAsciiLowerAlpha = (s) => /^[a-z]+$/.test(s);
19
19
  /** 判断字符串是否包含非 ASCII 字符(中文、日文等 CJK 字符) */
20
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);
21
23
  /**
22
24
  * 多行文本输入组件,支持光标移动和粘贴折叠。
23
25
  *
@@ -289,7 +291,19 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
289
291
  clearImeBufferWithInsertedLenRollback,
290
292
  });
291
293
  useInput(() => { }, { isActive });
292
- useTerminalCursorSync({ showCursor, isActive, rowsBelow });
294
+ const lines = value.length > 0 ? value.split('\n') : [''];
295
+ const { row: cursorRow, col: cursorCol } = getCursorRowCol(value, cursor);
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
+ });
293
307
  return (_jsx(InputTextView, { value: value, cursor: cursor, placeholder: placeholder, showCursor: showCursor, onResetPastedChunks: () => {
294
308
  if (pasteCountRef.current > 0) {
295
309
  pasteCountRef.current = 0;
@@ -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;
@@ -36,12 +36,61 @@ await tsImport(workerData.__workerFile, pathToFileURL(workerData.__workerFile).h
36
36
  }
37
37
  export class WorkerBridge {
38
38
  worker = null;
39
+ currentRun = null;
40
+ finalizeRun(worker, action, payload) {
41
+ const run = this.currentRun;
42
+ if (!run || run.worker !== worker || run.settled)
43
+ return;
44
+ run.settled = true;
45
+ if (run.abortTimer) {
46
+ clearTimeout(run.abortTimer);
47
+ run.abortTimer = null;
48
+ }
49
+ this.currentRun = null;
50
+ this.worker = null;
51
+ worker.terminate().catch(() => { });
52
+ if (action === 'resolve') {
53
+ run.resolve(payload);
54
+ }
55
+ else {
56
+ run.reject(payload);
57
+ }
58
+ }
59
+ buildAbortedTranscript(transcript, userInput) {
60
+ const abortNotice = '[系统提示] 用户中断了上一轮回复(按下 ESC)。上一条助手消息可能不完整,请在后续回复中注意这一点。';
61
+ const nextTranscript = [...transcript];
62
+ const lastMessage = nextTranscript[nextTranscript.length - 1];
63
+ if (userInput.trim()) {
64
+ const hasSameTrailingUserInput = lastMessage?.role === 'user' && lastMessage.content === userInput;
65
+ if (!hasSameTrailingUserInput) {
66
+ nextTranscript.push({ role: 'user', content: userInput });
67
+ }
68
+ }
69
+ nextTranscript.push({ role: 'user', content: abortNotice });
70
+ return nextTranscript;
71
+ }
39
72
  /** 在独立 Worker 线程中执行查询,返回更新后的 transcript */
40
73
  run(userInput, transcript, callbacks, options) {
41
74
  return new Promise((resolve, reject) => {
42
75
  const workerTsPath = path.join(__dirname, 'queryWorker.ts');
43
76
  const worker = createWorker(workerTsPath);
44
77
  this.worker = worker;
78
+ this.currentRun = {
79
+ worker,
80
+ resolve,
81
+ reject,
82
+ transcript,
83
+ userInput,
84
+ callbacks,
85
+ settled: false,
86
+ abortTimer: null,
87
+ lastLoopState: {
88
+ iteration: 0,
89
+ maxIterations: 0,
90
+ isRunning: true,
91
+ aborted: false,
92
+ },
93
+ };
45
94
  logInfo('worker_bridge.run.start', {
46
95
  inputLength: userInput.length,
47
96
  transcriptLength: transcript.length,
@@ -61,6 +110,9 @@ export class WorkerBridge {
61
110
  callbacks.onClearStreamText?.();
62
111
  break;
63
112
  case 'loop_state':
113
+ if (this.currentRun?.worker === worker) {
114
+ this.currentRun.lastLoopState = msg.state;
115
+ }
64
116
  callbacks.onLoopStateChange(msg.state);
65
117
  break;
66
118
  case 'session_update':
@@ -146,31 +198,25 @@ export class WorkerBridge {
146
198
  break;
147
199
  }
148
200
  case 'done':
149
- this.worker = null;
150
- worker.terminate();
151
201
  logInfo('worker_bridge.run.done', {
152
202
  transcriptLength: msg.transcript.length,
153
203
  });
154
- resolve(msg.transcript);
204
+ this.finalizeRun(worker, 'resolve', msg.transcript);
155
205
  break;
156
206
  case 'error':
157
- this.worker = null;
158
- worker.terminate();
159
207
  logError('worker_bridge.run.error', msg.message);
160
- reject(new Error(msg.message));
208
+ this.finalizeRun(worker, 'reject', new Error(msg.message));
161
209
  break;
162
210
  }
163
211
  });
164
212
  worker.on('error', (err) => {
165
- this.worker = null;
166
213
  logError('worker_bridge.worker_error', err);
167
- reject(err);
214
+ this.finalizeRun(worker, 'reject', err);
168
215
  });
169
216
  worker.on('exit', (code) => {
170
- if (code !== 0 && this.worker) {
171
- this.worker = null;
217
+ if (code !== 0 && this.currentRun?.worker === worker && !this.currentRun.settled) {
172
218
  logError('worker_bridge.worker_exit_abnormal', undefined, { code });
173
- reject(new Error(`Worker 异常退出,code=${code}`));
219
+ this.finalizeRun(worker, 'reject', new Error(`Worker 异常退出,code=${code}`));
174
220
  }
175
221
  });
176
222
  // 启动执行
@@ -180,10 +226,23 @@ export class WorkerBridge {
180
226
  }
181
227
  /** 向 Worker 发送中断信号 */
182
228
  abort() {
183
- if (this.worker) {
184
- logWarn('worker_bridge.abort_forwarded');
185
- const msg = { type: 'abort' };
186
- this.worker.postMessage(msg);
187
- }
229
+ const run = this.currentRun;
230
+ if (!run || run.settled)
231
+ return;
232
+ logWarn('worker_bridge.abort_forwarded');
233
+ const msg = { type: 'abort' };
234
+ run.worker.postMessage(msg);
235
+ run.callbacks.onClearStreamText?.();
236
+ run.callbacks.onLoopStateChange({
237
+ ...run.lastLoopState,
238
+ isRunning: false,
239
+ aborted: true,
240
+ });
241
+ run.abortTimer = setTimeout(() => {
242
+ if (!this.currentRun || this.currentRun.worker !== run.worker || this.currentRun.settled)
243
+ return;
244
+ logWarn('worker_bridge.abort_force_resolve');
245
+ this.finalizeRun(run.worker, 'resolve', this.buildAbortedTranscript(run.transcript, run.userInput));
246
+ }, 120);
188
247
  }
189
248
  }
@@ -352,21 +352,59 @@ function canRunInParallelDirect(calls) {
352
352
  /** 在独立 Worker 线程中执行单个工具,返回结果字符串 */
353
353
  function runToolInWorker(tc, abortSignal) {
354
354
  return new Promise((resolve, reject) => {
355
+ let settled = false;
356
+ let abortForwarded = false;
357
+ let abortPollTimer = null;
358
+ let forceTerminateTimer = null;
359
+ const cleanup = () => {
360
+ if (abortPollTimer !== null) {
361
+ clearInterval(abortPollTimer);
362
+ abortPollTimer = null;
363
+ }
364
+ if (forceTerminateTimer !== null) {
365
+ clearTimeout(forceTerminateTimer);
366
+ forceTerminateTimer = null;
367
+ }
368
+ };
369
+ const safeResolve = (result) => {
370
+ if (settled)
371
+ return;
372
+ settled = true;
373
+ cleanup();
374
+ worker.terminate().catch(() => { });
375
+ resolve(result);
376
+ };
377
+ const safeReject = (err) => {
378
+ if (settled)
379
+ return;
380
+ settled = true;
381
+ cleanup();
382
+ worker.terminate().catch(() => { });
383
+ reject(err);
384
+ };
355
385
  const isTsx = __filename.endsWith('.ts');
356
386
  const workerScript = isTsx
357
387
  ? `
358
388
  import { tsImport } from 'tsx/esm/api';
359
389
  import { workerData, parentPort } from 'worker_threads';
360
390
  import { pathToFileURL } from 'url';
391
+ const abortSignal = { aborted: Boolean(workerData.abortSignal?.aborted) };
392
+ parentPort.on('message', (msg) => {
393
+ if (msg?.type === 'abort') abortSignal.aborted = true;
394
+ });
361
395
  const mod = await tsImport(workerData.__file, pathToFileURL(workerData.__file).href);
362
- const result = await mod.runToolDirect(workerData.tc, workerData.abortSignal);
363
- parentPort.postMessage({ result });
396
+ const result = await mod.runToolDirect(workerData.tc, abortSignal);
397
+ parentPort.postMessage({ type: 'result', result });
364
398
  `
365
399
  : `
366
400
  import { runToolDirect } from '${__filename.replace(/\.ts$/, '.js')}';
367
401
  import { workerData, parentPort } from 'worker_threads';
368
- const result = await runToolDirect(workerData.tc, workerData.abortSignal);
369
- parentPort.postMessage({ result });
402
+ const abortSignal = { aborted: Boolean(workerData.abortSignal?.aborted) };
403
+ parentPort.on('message', (msg) => {
404
+ if (msg?.type === 'abort') abortSignal.aborted = true;
405
+ });
406
+ const result = await runToolDirect(workerData.tc, abortSignal);
407
+ parentPort.postMessage({ type: 'result', result });
370
408
  `;
371
409
  const worker = new Worker(workerScript, {
372
410
  eval: true,
@@ -376,17 +414,37 @@ parentPort.postMessage({ result });
376
414
  abortSignal: { aborted: abortSignal.aborted },
377
415
  },
378
416
  });
417
+ abortPollTimer = setInterval(() => {
418
+ if (!abortSignal.aborted || abortForwarded || settled)
419
+ return;
420
+ abortForwarded = true;
421
+ logWarn('tool.worker.abort_forwarded', { toolName: tc.name });
422
+ worker.postMessage({ type: 'abort' });
423
+ forceTerminateTimer = setTimeout(() => {
424
+ if (settled)
425
+ return;
426
+ logWarn('tool.worker.force_terminated', { toolName: tc.name });
427
+ safeResolve('(工具执行被中断)');
428
+ }, 1500);
429
+ }, 50);
379
430
  worker.on('message', (msg) => {
380
- worker.terminate();
381
- resolve(msg.result);
431
+ if (msg.type === 'result') {
432
+ safeResolve(msg.result ?? '');
433
+ }
382
434
  });
383
435
  worker.on('error', (err) => {
384
- worker.terminate();
385
- reject(err);
436
+ safeReject(err);
386
437
  });
387
438
  worker.on('exit', (code) => {
388
- if (code !== 0)
389
- reject(new Error(`工具 Worker 异常退出 code=${code}`));
439
+ if (settled)
440
+ return;
441
+ if (abortSignal.aborted) {
442
+ safeResolve('(工具执行被中断)');
443
+ return;
444
+ }
445
+ if (code !== 0) {
446
+ safeReject(new Error(`工具 Worker 异常退出 code=${code}`));
447
+ }
390
448
  });
391
449
  });
392
450
  }
@@ -2,7 +2,10 @@ interface UseTerminalCursorSyncOptions {
2
2
  showCursor: boolean;
3
3
  isActive: boolean;
4
4
  rowsBelow: number;
5
+ cursorRow?: number;
6
+ rowsInInput?: number;
7
+ cursorColumn?: number;
5
8
  delayMs?: number;
6
9
  }
7
- export declare function useTerminalCursorSync({ showCursor, isActive, rowsBelow, delayMs, }: UseTerminalCursorSyncOptions): void;
10
+ export declare function useTerminalCursorSync({ showCursor, isActive, rowsBelow, cursorRow, rowsInInput, cursorColumn, delayMs, }: UseTerminalCursorSyncOptions): void;
8
11
  export {};
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useRef } from 'react';
2
2
  import { moveCursorToColumn, relocateCursorToInputLine } from '../terminal/cursor.js';
3
- export function useTerminalCursorSync({ showCursor, isActive, rowsBelow, delayMs = 80, }) {
3
+ export function useTerminalCursorSync({ showCursor, isActive, rowsBelow, cursorRow = 0, rowsInInput = 1, cursorColumn = 3, delayMs = 80, }) {
4
4
  const cursorRelocTimerRef = useRef(null);
5
5
  const lastCursorCommandRef = useRef('');
6
6
  useEffect(() => {
@@ -27,11 +27,13 @@ export function useTerminalCursorSync({ showCursor, isActive, rowsBelow, delayMs
27
27
  else {
28
28
  cursorRelocTimerRef.current = setTimeout(() => {
29
29
  cursorRelocTimerRef.current = null;
30
- const commandKey = `input:${rowsBelow}:3`;
30
+ const extraRowsUp = Math.max(rowsInInput - 1 - cursorRow, 0);
31
+ const safeColumn = Math.max(cursorColumn, 1);
32
+ const commandKey = `input:${rowsBelow}:${extraRowsUp}:${safeColumn}`;
31
33
  if (lastCursorCommandRef.current === commandKey)
32
34
  return;
33
35
  lastCursorCommandRef.current = commandKey;
34
- relocateCursorToInputLine(rowsBelow, 3);
36
+ relocateCursorToInputLine(rowsBelow, safeColumn, extraRowsUp);
35
37
  }, delayMs);
36
38
  }
37
39
  return () => {
@@ -40,5 +42,5 @@ export function useTerminalCursorSync({ showCursor, isActive, rowsBelow, delayMs
40
42
  cursorRelocTimerRef.current = null;
41
43
  }
42
44
  };
43
- }, [showCursor, isActive, rowsBelow, delayMs]);
45
+ }, [showCursor, isActive, rowsBelow, cursorRow, rowsInInput, cursorColumn, delayMs]);
44
46
  }
@@ -39,6 +39,7 @@ export default function REPL() {
39
39
  const [placeholder, setPlaceholder] = useState('');
40
40
  const [activeAgents, setActiveAgents] = useState(getActiveAgentCount());
41
41
  const lastEscRef = useRef(0);
42
+ const abortRequestedRef = useRef(false);
42
43
  const lastAbortNoticeRef = useRef(0);
43
44
  const sessionRef = useRef({
44
45
  id: '', messages: [], createdAt: 0, updatedAt: 0, totalTokens: 0, totalCost: 0,
@@ -67,6 +68,34 @@ export default function REPL() {
67
68
  abortHint: '推理已中断(ESC)',
68
69
  }]);
69
70
  }, []);
71
+ const markPendingMessagesAborted = useCallback(() => {
72
+ setMessages((prev) => prev
73
+ .filter((msg) => !(msg.type === 'thinking' && msg.status === 'pending'))
74
+ .map((msg) => {
75
+ if (msg.status !== 'pending')
76
+ return msg;
77
+ if (msg.type === 'tool_exec') {
78
+ return {
79
+ ...msg,
80
+ status: 'aborted',
81
+ content: `${msg.toolName || '工具'} 已中断`,
82
+ abortHint: msg.abortHint ?? '命令已中断(ESC)',
83
+ };
84
+ }
85
+ return msg;
86
+ }));
87
+ finishThinking();
88
+ clearStream();
89
+ }, [clearStream, finishThinking]);
90
+ const requestAbort = useCallback(() => {
91
+ if (!isProcessing || !engineRef.current || abortRequestedRef.current)
92
+ return;
93
+ abortRequestedRef.current = true;
94
+ logWarn('ui.abort_by_escape');
95
+ markPendingMessagesAborted();
96
+ appendAbortNotice();
97
+ engineRef.current.abort();
98
+ }, [appendAbortNotice, isProcessing, markPendingMessagesAborted]);
70
99
  // ===== 新会话逻辑 =====
71
100
  const handleNewSession = useCallback(() => {
72
101
  logInfo('ui.new_session');
@@ -78,6 +107,7 @@ export default function REPL() {
78
107
  clearStream();
79
108
  setLoopState(null);
80
109
  setIsProcessing(false);
110
+ abortRequestedRef.current = false;
81
111
  setShowWelcome(true);
82
112
  resetTokens();
83
113
  generateAgentHint().then((hint) => setPlaceholder(hint)).catch(() => { });
@@ -145,6 +175,7 @@ export default function REPL() {
145
175
  startStreamTimer();
146
176
  }
147
177
  else {
178
+ abortRequestedRef.current = false;
148
179
  stopAll();
149
180
  }
150
181
  },
@@ -227,6 +258,7 @@ export default function REPL() {
227
258
  setShowWelcome(false);
228
259
  pushHistory(trimmed);
229
260
  clearStream();
261
+ abortRequestedRef.current = false;
230
262
  await engineRef.current.handleQuery(prompt, callbacks);
231
263
  return;
232
264
  }
@@ -285,6 +317,7 @@ export default function REPL() {
285
317
  pushHistory(trimmed);
286
318
  setInput('');
287
319
  clearStream();
320
+ abortRequestedRef.current = false;
288
321
  await engineRef.current.handleQuery(trimmed, callbacks);
289
322
  }, [isProcessing, pushHistory, clearStream, slashMenu, handleNewSession]);
290
323
  // ===== 输入处理 =====
@@ -380,9 +413,8 @@ export default function REPL() {
380
413
  handleCtrlC();
381
414
  return;
382
415
  }
383
- if (key.escape && engineRef.current) {
384
- appendAbortNotice();
385
- engineRef.current.abort();
416
+ if (key.escape) {
417
+ requestAbort();
386
418
  return;
387
419
  }
388
420
  if (key.ctrl && ch === 'o') {
@@ -418,10 +450,8 @@ export default function REPL() {
418
450
  return;
419
451
  }
420
452
  if (key.escape) {
421
- if (isProcessing && engineRef.current) {
422
- logWarn('ui.abort_by_escape');
423
- appendAbortNotice();
424
- engineRef.current.abort();
453
+ if (isProcessing) {
454
+ requestAbort();
425
455
  }
426
456
  else if (input.length > 0) {
427
457
  const now = Date.now();
@@ -3,4 +3,4 @@ export declare function showTerminalCursor(): void;
3
3
  export declare function enableBracketedPaste(): void;
4
4
  export declare function disableBracketedPaste(): void;
5
5
  export declare function moveCursorToColumn(column: number): void;
6
- export declare function relocateCursorToInputLine(rowsBelow: number, column: number): void;
6
+ export declare function relocateCursorToInputLine(rowsBelow: number, column: number, extraRowsUp?: number): void;
@@ -14,8 +14,9 @@ export function disableBracketedPaste() {
14
14
  export function moveCursorToColumn(column) {
15
15
  process.stdout.write(`${ESC}${column}G`);
16
16
  }
17
- export function relocateCursorToInputLine(rowsBelow, column) {
18
- const up = rowsBelow > 0 ? `${ESC}${rowsBelow}A` : '';
19
- const down = rowsBelow > 0 ? `${ESC}${rowsBelow}B` : '';
17
+ export function relocateCursorToInputLine(rowsBelow, column, extraRowsUp = 0) {
18
+ const totalRowsUp = Math.max(rowsBelow + extraRowsUp, 0);
19
+ const up = totalRowsUp > 0 ? `${ESC}${totalRowsUp}A` : '';
20
+ const down = totalRowsUp > 0 ? `${ESC}${totalRowsUp}B` : '';
20
21
  process.stdout.write(`${up}${ESC}${column}G${down}`);
21
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code4bug/jarvis-agent",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "基于 React + TypeScript + Ink 构建的命令行智能体交互界面",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",