@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
@@ -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
  }
@@ -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 {};
@@ -0,0 +1,141 @@
1
+ import { useEffect, useRef } from 'react';
2
+ export function useMultilineInputStream({ stdin, isActive, pasteDetectWindowMs, imeComposeWindowMs, isAsciiLowerAlpha, hasNonAscii, insertPaste, handleNormalInput, commitImeBuffer, flushImeBuffer, appendImeBuffer, getImeBuffer, clearImeBufferWithInsertedLenRollback, }) {
3
+ const pasteBufferRef = useRef(null);
4
+ const batchBufferRef = useRef('');
5
+ const batchTimerRef = useRef(null);
6
+ const imeTimerRef = useRef(null);
7
+ const escTimerRef = useRef(null);
8
+ const ESC_WAIT_MS = 50;
9
+ useEffect(() => {
10
+ if (!stdin || !isActive)
11
+ return;
12
+ const flushBatchBuffer = () => {
13
+ const buf = batchBufferRef.current;
14
+ batchBufferRef.current = '';
15
+ batchTimerRef.current = null;
16
+ if (!buf)
17
+ return;
18
+ const cleaned = buf.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
19
+ if (cleaned.includes('\n') && cleaned.length > 1) {
20
+ insertPaste(cleaned);
21
+ return;
22
+ }
23
+ if (isAsciiLowerAlpha(buf)) {
24
+ appendImeBuffer(buf);
25
+ if (imeTimerRef.current !== null) {
26
+ clearTimeout(imeTimerRef.current);
27
+ }
28
+ imeTimerRef.current = setTimeout(flushImeBuffer, imeComposeWindowMs);
29
+ return;
30
+ }
31
+ if (hasNonAscii(buf) && getImeBuffer().length > 0) {
32
+ if (imeTimerRef.current !== null) {
33
+ clearTimeout(imeTimerRef.current);
34
+ imeTimerRef.current = null;
35
+ }
36
+ clearImeBufferWithInsertedLenRollback();
37
+ handleNormalInput(buf);
38
+ return;
39
+ }
40
+ if (getImeBuffer().length > 0) {
41
+ commitImeBuffer();
42
+ }
43
+ handleNormalInput(buf);
44
+ };
45
+ const onData = (data) => {
46
+ const raw = data.toString('utf-8');
47
+ if (raw.includes('\x1B[200~')) {
48
+ const startIdx = raw.indexOf('\x1B[200~') + 6;
49
+ const endIdx = raw.indexOf('\x1B[201~');
50
+ if (endIdx !== -1) {
51
+ insertPaste(raw.slice(startIdx, endIdx));
52
+ }
53
+ else {
54
+ pasteBufferRef.current = raw.slice(startIdx);
55
+ }
56
+ return;
57
+ }
58
+ if (pasteBufferRef.current !== null) {
59
+ const endIdx = raw.indexOf('\x1B[201~');
60
+ if (endIdx !== -1) {
61
+ pasteBufferRef.current += raw.slice(0, endIdx);
62
+ insertPaste(pasteBufferRef.current);
63
+ pasteBufferRef.current = null;
64
+ }
65
+ else {
66
+ pasteBufferRef.current += raw;
67
+ }
68
+ return;
69
+ }
70
+ if (escTimerRef.current !== null) {
71
+ clearTimeout(escTimerRef.current);
72
+ escTimerRef.current = null;
73
+ if (raw === '\r' || raw === '\n') {
74
+ handleNormalInput('\x1B\r');
75
+ return;
76
+ }
77
+ handleNormalInput('\x1B');
78
+ }
79
+ const isSingleControl = raw === '\r' || raw === '\n' ||
80
+ raw === '\x7F' || raw === '\x08' ||
81
+ raw === '\t' || raw === '\x1B' ||
82
+ raw === '\x1B\r' || raw === '\x1B\n' ||
83
+ raw.startsWith('\x1B[') || raw.startsWith('\x1B]') || raw.startsWith('\x1BO');
84
+ if (raw === '\x03' || raw === '\x0F' || raw === '\x0C')
85
+ return;
86
+ if (raw === '\x1B') {
87
+ escTimerRef.current = setTimeout(() => {
88
+ escTimerRef.current = null;
89
+ handleNormalInput('\x1B');
90
+ }, ESC_WAIT_MS);
91
+ return;
92
+ }
93
+ if (isSingleControl && batchBufferRef.current === '') {
94
+ if (getImeBuffer().length > 0) {
95
+ commitImeBuffer();
96
+ }
97
+ handleNormalInput(raw);
98
+ return;
99
+ }
100
+ if (isSingleControl && batchBufferRef.current !== '') {
101
+ batchBufferRef.current += raw;
102
+ return;
103
+ }
104
+ batchBufferRef.current += raw;
105
+ if (batchTimerRef.current !== null) {
106
+ clearTimeout(batchTimerRef.current);
107
+ }
108
+ batchTimerRef.current = setTimeout(flushBatchBuffer, pasteDetectWindowMs);
109
+ };
110
+ stdin.prependListener('data', onData);
111
+ return () => {
112
+ stdin.off('data', onData);
113
+ if (batchTimerRef.current !== null) {
114
+ clearTimeout(batchTimerRef.current);
115
+ batchTimerRef.current = null;
116
+ }
117
+ if (escTimerRef.current !== null) {
118
+ clearTimeout(escTimerRef.current);
119
+ escTimerRef.current = null;
120
+ }
121
+ if (imeTimerRef.current !== null) {
122
+ clearTimeout(imeTimerRef.current);
123
+ imeTimerRef.current = null;
124
+ }
125
+ };
126
+ }, [
127
+ stdin,
128
+ isActive,
129
+ pasteDetectWindowMs,
130
+ imeComposeWindowMs,
131
+ isAsciiLowerAlpha,
132
+ hasNonAscii,
133
+ insertPaste,
134
+ handleNormalInput,
135
+ commitImeBuffer,
136
+ flushImeBuffer,
137
+ appendImeBuffer,
138
+ getImeBuffer,
139
+ clearImeBufferWithInsertedLenRollback,
140
+ ]);
141
+ }
@@ -0,0 +1,11 @@
1
+ interface UseTerminalCursorSyncOptions {
2
+ showCursor: boolean;
3
+ isActive: boolean;
4
+ rowsBelow: number;
5
+ cursorRow?: number;
6
+ rowsInInput?: number;
7
+ cursorColumn?: number;
8
+ delayMs?: number;
9
+ }
10
+ export declare function useTerminalCursorSync({ showCursor, isActive, rowsBelow, cursorRow, rowsInInput, cursorColumn, delayMs, }: UseTerminalCursorSyncOptions): void;
11
+ export {};
@@ -0,0 +1,46 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { moveCursorToColumn, relocateCursorToInputLine } from '../terminal/cursor.js';
3
+ export function useTerminalCursorSync({ showCursor, isActive, rowsBelow, cursorRow = 0, rowsInInput = 1, cursorColumn = 3, delayMs = 80, }) {
4
+ const cursorRelocTimerRef = useRef(null);
5
+ const lastCursorCommandRef = useRef('');
6
+ useEffect(() => {
7
+ return () => {
8
+ if (cursorRelocTimerRef.current !== null) {
9
+ clearTimeout(cursorRelocTimerRef.current);
10
+ }
11
+ };
12
+ }, []);
13
+ useEffect(() => {
14
+ if (cursorRelocTimerRef.current !== null) {
15
+ clearTimeout(cursorRelocTimerRef.current);
16
+ }
17
+ if (!showCursor || !isActive) {
18
+ cursorRelocTimerRef.current = setTimeout(() => {
19
+ cursorRelocTimerRef.current = null;
20
+ const commandKey = 'col:1';
21
+ if (lastCursorCommandRef.current === commandKey)
22
+ return;
23
+ lastCursorCommandRef.current = commandKey;
24
+ moveCursorToColumn(1);
25
+ }, delayMs);
26
+ }
27
+ else {
28
+ cursorRelocTimerRef.current = setTimeout(() => {
29
+ cursorRelocTimerRef.current = null;
30
+ const extraRowsUp = Math.max(rowsInInput - 1 - cursorRow, 0);
31
+ const safeColumn = Math.max(cursorColumn, 1);
32
+ const commandKey = `input:${rowsBelow}:${extraRowsUp}:${safeColumn}`;
33
+ if (lastCursorCommandRef.current === commandKey)
34
+ return;
35
+ lastCursorCommandRef.current = commandKey;
36
+ relocateCursorToInputLine(rowsBelow, safeColumn, extraRowsUp);
37
+ }, delayMs);
38
+ }
39
+ return () => {
40
+ if (cursorRelocTimerRef.current !== null) {
41
+ clearTimeout(cursorRelocTimerRef.current);
42
+ cursorRelocTimerRef.current = null;
43
+ }
44
+ };
45
+ }, [showCursor, isActive, rowsBelow, cursorRow, rowsInInput, cursorColumn, delayMs]);
46
+ }
@@ -0,0 +1,7 @@
1
+ interface TerminalSize {
2
+ width: number;
3
+ height: number;
4
+ }
5
+ /** 响应式终端尺寸 */
6
+ export declare function useTerminalSize(): TerminalSize;
7
+ export {};
@@ -0,0 +1,21 @@
1
+ import { useState, useEffect } from 'react';
2
+ /** 响应式终端尺寸 */
3
+ export function useTerminalSize() {
4
+ const [size, setSize] = useState(() => ({
5
+ width: process.stdout.columns || 80,
6
+ height: process.stdout.rows || 24,
7
+ }));
8
+ useEffect(() => {
9
+ const onResize = () => {
10
+ setSize({
11
+ width: process.stdout.columns || 80,
12
+ height: process.stdout.rows || 24,
13
+ });
14
+ };
15
+ process.stdout.on('resize', onResize);
16
+ return () => {
17
+ process.stdout.off('resize', onResize);
18
+ };
19
+ }, []);
20
+ return size;
21
+ }