@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.
@@ -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,8 @@
1
+ interface UseTerminalCursorSyncOptions {
2
+ showCursor: boolean;
3
+ isActive: boolean;
4
+ rowsBelow: number;
5
+ delayMs?: number;
6
+ }
7
+ export declare function useTerminalCursorSync({ showCursor, isActive, rowsBelow, delayMs, }: UseTerminalCursorSyncOptions): void;
8
+ export {};
@@ -0,0 +1,44 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { moveCursorToColumn, relocateCursorToInputLine } from '../terminal/cursor.js';
3
+ export function useTerminalCursorSync({ showCursor, isActive, rowsBelow, 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 commandKey = `input:${rowsBelow}:3`;
31
+ if (lastCursorCommandRef.current === commandKey)
32
+ return;
33
+ lastCursorCommandRef.current = commandKey;
34
+ relocateCursorToInputLine(rowsBelow, 3);
35
+ }, delayMs);
36
+ }
37
+ return () => {
38
+ if (cursorRelocTimerRef.current !== null) {
39
+ clearTimeout(cursorRelocTimerRef.current);
40
+ cursorRelocTimerRef.current = null;
41
+ }
42
+ };
43
+ }, [showCursor, isActive, rowsBelow, delayMs]);
44
+ }
@@ -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
+ }
@@ -1,20 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useCallback, useRef, useEffect } from 'react';
3
- import { Box, Text, useInput, useApp } from 'ink';
4
- import Spinner from 'ink-spinner';
5
- import MultilineInput from '../components/MultilineInput.js';
3
+ import { Box, useInput, useApp } from 'ink';
6
4
  import WelcomeHeader from '../components/WelcomeHeader.js';
7
- import MessageItem from '../components/MessageItem.js';
8
- import StreamingText from '../components/StreamingText.js';
9
- import StatusBar from '../components/StatusBar.js';
10
- import SlashCommandMenu from '../components/SlashCommandMenu.js';
11
- import DangerConfirm from '../components/DangerConfirm.js';
5
+ import MessageViewport from '../components/MessageViewport.js';
6
+ import ComposerPane from '../components/ComposerPane.js';
7
+ import FooterPane from '../components/FooterPane.js';
12
8
  import { useWindowFocus } from '../hooks/useFocus.js';
13
9
  import { useInputHistory } from '../hooks/useInputHistory.js';
14
10
  import { useDoubleCtrlCExit } from '../hooks/useDoubleCtrlCExit.js';
15
11
  import { useTerminalWidth } from '../hooks/useTerminalWidth.js';
16
12
  import { useStreamThrottle } from '../hooks/useStreamThrottle.js';
17
- import { useTokenDisplay } from '../hooks/useTokenDisplay.js';
18
13
  import { useSlashMenu } from '../hooks/useSlashMenu.js';
19
14
  import { executeSlashCommand } from './slashCommands.js';
20
15
  import { QueryEngine } from '../core/QueryEngine.js';
@@ -24,6 +19,7 @@ import { subscribeAgentCount, getActiveAgentCount } from '../core/spawnRegistry.
24
19
  import { logError, logInfo, logWarn } from '../core/logger.js';
25
20
  import { getAgentSubCommands } from '../commands/index.js';
26
21
  import { setActiveAgent } from '../config/agentState.js';
22
+ import { hideTerminalCursor, showTerminalCursor } from '../terminal/cursor.js';
27
23
  export default function REPL() {
28
24
  const { exit } = useApp();
29
25
  const width = useTerminalWidth();
@@ -43,13 +39,34 @@ export default function REPL() {
43
39
  const [placeholder, setPlaceholder] = useState('');
44
40
  const [activeAgents, setActiveAgents] = useState(getActiveAgentCount());
45
41
  const lastEscRef = useRef(0);
42
+ const lastAbortNoticeRef = useRef(0);
46
43
  const sessionRef = useRef({
47
44
  id: '', messages: [], createdAt: 0, updatedAt: 0, totalTokens: 0, totalCost: 0,
48
45
  });
49
46
  const engineRef = useRef(null);
47
+ const tokenCountRef = useRef(0);
50
48
  // 节流 hooks
51
49
  const { streamText, streamBufferRef, startStreamTimer, stopStreamTimer, appendStreamChunk, clearStream, handleThinkingUpdate, finishThinking, thinkingIdRef, stopAll, } = useStreamThrottle(setMessages);
52
- const { displayTokens, tokenCountRef, startTokenTimer, stopTokenTimer, updateTokenCount, syncTokenDisplay, resetTokens, } = useTokenDisplay();
50
+ const syncTokenDisplay = useCallback((count) => {
51
+ tokenCountRef.current = count;
52
+ }, []);
53
+ const resetTokens = useCallback(() => {
54
+ tokenCountRef.current = 0;
55
+ }, []);
56
+ const appendAbortNotice = useCallback(() => {
57
+ const now = Date.now();
58
+ if (now - lastAbortNoticeRef.current < 800)
59
+ return;
60
+ lastAbortNoticeRef.current = now;
61
+ setMessages((prev) => [...prev, {
62
+ id: `abort-notice-${now}`,
63
+ type: 'system',
64
+ status: 'aborted',
65
+ content: '已中断当前推理',
66
+ timestamp: now,
67
+ abortHint: '推理已中断(ESC)',
68
+ }]);
69
+ }, []);
53
70
  // ===== 新会话逻辑 =====
54
71
  const handleNewSession = useCallback(() => {
55
72
  logInfo('ui.new_session');
@@ -125,17 +142,15 @@ export default function REPL() {
125
142
  setLoopState(state);
126
143
  setIsProcessing(state.isRunning);
127
144
  if (state.isRunning) {
128
- startTokenTimer();
129
145
  startStreamTimer();
130
146
  }
131
147
  else {
132
- stopTokenTimer();
133
148
  stopAll();
134
149
  }
135
150
  },
136
151
  onSessionUpdate: (s) => {
137
152
  sessionRef.current = s;
138
- updateTokenCount(s.totalTokens);
153
+ tokenCountRef.current = s.totalTokens;
139
154
  },
140
155
  onConfirmDangerousCommand: (command, reason, ruleName) => {
141
156
  return new Promise((resolve) => {
@@ -321,19 +336,11 @@ export default function REPL() {
321
336
  }, [placeholder]);
322
337
  // ===== 隐藏系统默认终端光标,使用 ink 渲染的反色光标代替 =====
323
338
  useEffect(() => {
324
- process.stdout.write('\x1B[?25l');
339
+ hideTerminalCursor();
325
340
  return () => {
326
- process.stdout.write('\x1B[?25h');
341
+ showTerminalCursor();
327
342
  };
328
343
  }, []);
329
- // ===== processing/streaming 期间,每次 re-render 后将物理光标移到行首 =====
330
- // ink 每次渲染后光标停在 StatusBar 行末,macOS IME 会在该位置显示 composing 候选框,
331
- // 导致候选框与进度条重叠。将光标移到列 1(行首)让候选框出现在屏幕左侧空白区域。
332
- useEffect(() => {
333
- if (!isProcessing)
334
- return;
335
- process.stdout.write('\x1B[1G');
336
- });
337
344
  // ===== processing 期间隐藏终端光标,阻止 IME composing 显示 =====
338
345
  const isProcessingRef = useRef(isProcessing);
339
346
  isProcessingRef.current = isProcessing;
@@ -341,7 +348,7 @@ export default function REPL() {
341
348
  if (!isProcessing)
342
349
  return;
343
350
  // 隐藏终端光标 — 终端 IME 的 inline composing 仅在光标可见时渲染
344
- process.stdout.write('\x1B[?25l');
351
+ hideTerminalCursor();
345
352
  // 高优先级 stdin 拦截:吞掉 processing 期间的所有输入(Ctrl+C / ESC 除外)
346
353
  // 防止字符积累在缓冲区,processing 结束后污染输入框
347
354
  const drain = (data) => {
@@ -374,6 +381,7 @@ export default function REPL() {
374
381
  return;
375
382
  }
376
383
  if (key.escape && engineRef.current) {
384
+ appendAbortNotice();
377
385
  engineRef.current.abort();
378
386
  return;
379
387
  }
@@ -412,6 +420,7 @@ export default function REPL() {
412
420
  if (key.escape) {
413
421
  if (isProcessing && engineRef.current) {
414
422
  logWarn('ui.abort_by_escape');
423
+ appendAbortNotice();
415
424
  engineRef.current.abort();
416
425
  }
417
426
  else if (input.length > 0) {
@@ -428,8 +437,10 @@ export default function REPL() {
428
437
  }
429
438
  });
430
439
  // ===== 渲染 =====
431
- return (_jsxs(Box, { flexDirection: "column", width: width, children: [showWelcome && _jsx(WelcomeHeader, { width: width }), _jsxs(Box, { flexDirection: "column", paddingX: 1, marginTop: showWelcome ? 0 : 1, children: [messages.map((msg) => (_jsx(MessageItem, { msg: msg, showDetails: showDetails }, msg.id))), streamText && _jsx(StreamingText, { text: streamText }), dangerConfirm && (_jsx(DangerConfirm, { command: dangerConfirm.command, reason: dangerConfirm.reason, ruleName: dangerConfirm.ruleName, onSelect: (choice) => {
432
- dangerConfirm.resolve(choice);
433
- setDangerConfirm(null);
434
- } })), loopState?.isRunning && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { color: "gray", children: [" iteration ", loopState.iteration, "/", loopState.maxIterations] })] }))] }), _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: "gray", children: '─'.repeat(Math.max(width - 2, 1)) }), slashMenu.slashMenuVisible && !isProcessing && (_jsx(SlashCommandMenu, { commands: slashMenu.slashMenuItems, selectedIndex: slashMenu.slashMenuIndex })), _jsx(Box, { children: countdown !== null ? (_jsxs(Box, { children: [_jsx(Text, { color: "gray", dimColor: true, children: "\u276F " }), _jsx(Text, { color: "yellow", children: "Press " }), _jsx(Text, { color: "yellow", bold: true, children: "Ctrl+C" }), _jsx(Text, { color: "yellow", children: " again to exit " }), _jsxs(Text, { color: "gray", dimColor: true, children: ["(", countdown, "s)"] })] })) : isProcessing ? (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: "\u276F " }), _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: "gray", italic: true, children: " processing..." })] })) : (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: "\u276F " }), _jsx(MultilineInput, { value: input, onChange: handleInputChange, onSubmit: handleEditorSubmit, onUpArrow: handleUpArrow, onDownArrow: handleDownArrow, placeholder: placeholder, isActive: !isProcessing, showCursor: windowFocused && !isProcessing, slashMenuActive: slashMenu.slashMenuVisible, onSlashMenuUp: slashMenu.handleSlashMenuUp, onSlashMenuDown: slashMenu.handleSlashMenuDown, onSlashMenuSelect: handleSlashMenuAutocomplete, onSlashMenuClose: slashMenu.handleSlashMenuClose, onTabFillPlaceholder: handleTabFillPlaceholder })] })) }), _jsx(Text, { color: "gray", children: '─'.repeat(Math.max(width - 2, 1)) }), _jsx(StatusBar, { width: width - 2, totalTokens: displayTokens, activeAgents: activeAgents })] })] }));
440
+ return (_jsxs(Box, { flexDirection: "column", width: width, children: [showWelcome && _jsx(WelcomeHeader, { width: width }), _jsx(Box, { marginTop: showWelcome ? 0 : 1, children: _jsx(MessageViewport, { messages: messages, streamText: streamText, showDetails: showDetails, dangerConfirm: dangerConfirm, loopState: loopState, onResolveDangerConfirm: (choice) => {
441
+ if (!dangerConfirm)
442
+ return;
443
+ dangerConfirm.resolve(choice);
444
+ setDangerConfirm(null);
445
+ } }) }), _jsx(ComposerPane, { width: width, countdown: countdown, isProcessing: isProcessing, input: input, placeholder: placeholder, windowFocused: windowFocused, slashMenuVisible: slashMenu.slashMenuVisible, slashMenuItems: slashMenu.slashMenuItems, slashMenuIndex: slashMenu.slashMenuIndex, onInputChange: handleInputChange, onSubmit: handleEditorSubmit, onUpArrow: handleUpArrow, onDownArrow: handleDownArrow, onSlashMenuUp: slashMenu.handleSlashMenuUp, onSlashMenuDown: slashMenu.handleSlashMenuDown, onSlashMenuSelect: handleSlashMenuAutocomplete, onSlashMenuClose: slashMenu.handleSlashMenuClose, onTabFillPlaceholder: handleTabFillPlaceholder }), _jsx(FooterPane, { width: width, tokenCountRef: tokenCountRef, activeAgents: activeAgents })] }));
435
446
  }
@@ -185,7 +185,9 @@ export class LLMServiceImpl {
185
185
  '不要为了讨好用户而突破角色边界。' +
186
186
  '\n\n[安全围栏] 系统已内置安全围栏(Safeguard),会自动拦截危险命令并弹出交互式确认菜单。' +
187
187
  '当你需要执行任何命令时,直接调用 Bash 工具即可,不要自行判断命令是否危险,不要用文字询问用户"是否确认执行"。' +
188
- '安全围栏会自动处理拦截和用户确认流程。';
188
+ '安全围栏会自动处理拦截和用户确认流程。' +
189
+ '\n\n[文件修改约束] 修改已有文件时,先读取目标文件,再优先使用局部修改方式。' +
190
+ '如果只改少量内容,优先使用 write_file 的 replace 模式;需要结构化补丁时使用 diff 模式;只有新建文件或确实需要大范围重写时才使用 overwrite。';
189
191
  // 追加系统环境信息,帮助 LLM 感知用户运行环境
190
192
  const systemInfo = getSystemInfoPrompt();
191
193
  // 追加当前激活模型信息
@@ -85,7 +85,6 @@ function buildParamsFromScript(scriptPath, skill) {
85
85
  * 通过写入临时 .py 文件再执行,避免 shell 转义问题。
86
86
  */
87
87
  async function executeSkillScript(scriptPath, skill, args) {
88
- const funcName = skill.meta.name.replace(/-/g, '_');
89
88
  const kwargs = [];
90
89
  for (const [key, value] of Object.entries(args)) {
91
90
  if (key === 'arguments') {
@@ -112,8 +111,16 @@ async function executeSkillScript(scriptPath, skill, args) {
112
111
  const pyCode = [
113
112
  'import sys,os,json',
114
113
  `sys.path.insert(0, ${JSON.stringify(path.dirname(scriptPath))})`,
115
- `from skill import ${funcName}`,
116
- `result = ${funcName}(${kwargs.join(', ')})`,
114
+ 'import skill as skill_module',
115
+ `tool_name = getattr(skill_module, "TOOL_METADATA", {}).get("name") or ${JSON.stringify(skill.meta.name.replace(/-/g, '_'))}`,
116
+ 'func = getattr(skill_module, tool_name, None)',
117
+ 'if func is None:',
118
+ ' candidates = [name for name, value in vars(skill_module).items() if callable(value) and not name.startswith("_")]',
119
+ ' if len(candidates) == 1:',
120
+ ' func = getattr(skill_module, candidates[0])',
121
+ ' else:',
122
+ ' raise ImportError(f"未找到可调用函数: {tool_name},候选: {candidates}")',
123
+ `result = func(${kwargs.join(', ')})`,
117
124
  'print(json.dumps(result, ensure_ascii=False, indent=2))',
118
125
  ].join('\n');
119
126
  try {
@@ -0,0 +1,6 @@
1
+ export declare function hideTerminalCursor(): void;
2
+ export declare function showTerminalCursor(): void;
3
+ export declare function enableBracketedPaste(): void;
4
+ export declare function disableBracketedPaste(): void;
5
+ export declare function moveCursorToColumn(column: number): void;
6
+ export declare function relocateCursorToInputLine(rowsBelow: number, column: number): void;
@@ -0,0 +1,21 @@
1
+ const ESC = '\x1B[';
2
+ export function hideTerminalCursor() {
3
+ process.stdout.write(`${ESC}?25l`);
4
+ }
5
+ export function showTerminalCursor() {
6
+ process.stdout.write(`${ESC}?25h`);
7
+ }
8
+ export function enableBracketedPaste() {
9
+ process.stdout.write(`${ESC}?2004h`);
10
+ }
11
+ export function disableBracketedPaste() {
12
+ process.stdout.write(`${ESC}?2004l`);
13
+ }
14
+ export function moveCursorToColumn(column) {
15
+ process.stdout.write(`${ESC}${column}G`);
16
+ }
17
+ export function relocateCursorToInputLine(rowsBelow, column) {
18
+ const up = rowsBelow > 0 ? `${ESC}${rowsBelow}A` : '';
19
+ const down = rowsBelow > 0 ? `${ESC}${rowsBelow}B` : '';
20
+ process.stdout.write(`${up}${ESC}${column}G${down}`);
21
+ }
@@ -73,6 +73,35 @@ function applyUnifiedDiff(original, diff) {
73
73
  }
74
74
  return result.join('\n');
75
75
  }
76
+ /**
77
+ * 基于文本片段做精确替换,适合只改少量代码的场景。
78
+ */
79
+ function applyStringReplace(original, oldString, newString, replaceAll, expectedReplacements) {
80
+ if (!oldString) {
81
+ throw new Error('replace 模式下 old_string 不能为空');
82
+ }
83
+ let matchCount = 0;
84
+ let searchStart = 0;
85
+ while (true) {
86
+ const idx = original.indexOf(oldString, searchStart);
87
+ if (idx === -1)
88
+ break;
89
+ matchCount++;
90
+ searchStart = idx + oldString.length;
91
+ }
92
+ if (matchCount === 0) {
93
+ throw new Error('replace 模式未找到要替换的内容');
94
+ }
95
+ if (expectedReplacements !== undefined && matchCount !== expectedReplacements) {
96
+ throw new Error(`replace 模式匹配数量不符合预期,期望 ${expectedReplacements},实际 ${matchCount}`);
97
+ }
98
+ if (!replaceAll && matchCount !== 1) {
99
+ throw new Error(`replace 模式要求目标内容唯一,当前匹配到 ${matchCount} 处。请提供更精确的 old_string,或开启 replace_all`);
100
+ }
101
+ return replaceAll
102
+ ? original.split(oldString).join(newString)
103
+ : original.replace(oldString, newString);
104
+ }
76
105
  /**
77
106
  * 写入文件内容(支持安全检测)。
78
107
  * 返回写入结果消息。
@@ -95,13 +124,33 @@ function doWrite(filePath, content) {
95
124
  }
96
125
  export const writeFile = {
97
126
  name: 'write_file',
98
- description: '写入内容到指定文件。支持两种模式:overwrite(默认)完整替换文件内容;diff 模式接收 unified diff 格式补丁,对文件进行增量更新。',
127
+ description: '写入内容到指定文件。修改已有文件时优先使用 replace 或 diff 做局部更新,只有新建文件或大范围重写时才使用 overwrite。支持三种模式:overwrite(完整替换)、replace(按文本片段精确替换)、diff(基于 unified diff 增量更新)。',
99
128
  parameters: {
100
129
  path: { type: 'string', description: '文件路径', required: true },
101
130
  content: { type: 'string', description: '文件内容(overwrite 模式必填)', required: false },
102
131
  mode: {
103
132
  type: 'string',
104
- description: '写入模式:overwrite(完整替换,默认)| diff(增量更新)',
133
+ description: '写入模式:overwrite(完整替换,默认)| replace(局部替换)| diff(增量更新)',
134
+ required: false,
135
+ },
136
+ old_string: {
137
+ type: 'string',
138
+ description: 'replace 模式必填:要查找并替换的原始文本片段',
139
+ required: false,
140
+ },
141
+ new_string: {
142
+ type: 'string',
143
+ description: 'replace 模式必填:替换后的文本片段',
144
+ required: false,
145
+ },
146
+ replace_all: {
147
+ type: 'boolean',
148
+ description: 'replace 模式可选:是否替换所有匹配项,默认 false',
149
+ required: false,
150
+ },
151
+ expected_replacements: {
152
+ type: 'number',
153
+ description: 'replace 模式可选:期望匹配数量,不符合时直接报错,防止误替换',
105
154
  required: false,
106
155
  },
107
156
  diff: {
@@ -113,6 +162,18 @@ export const writeFile = {
113
162
  execute: async (args) => {
114
163
  const filePath = args.path;
115
164
  const mode = args.mode || 'overwrite';
165
+ if (mode === 'replace') {
166
+ const oldString = args.old_string;
167
+ const newString = args.new_string ?? '';
168
+ const replaceAll = Boolean(args.replace_all);
169
+ const expectedReplacements = args.expected_replacements === undefined ? undefined : Number(args.expected_replacements);
170
+ if (!fs.existsSync(filePath)) {
171
+ throw new Error(`replace 模式要求目标文件已存在: ${filePath}`);
172
+ }
173
+ const original = fs.readFileSync(filePath, 'utf-8');
174
+ const replaced = applyStringReplace(original, oldString, newString, replaceAll, expectedReplacements);
175
+ return doWrite(filePath, replaced);
176
+ }
116
177
  if (mode === 'diff') {
117
178
  const diffContent = args.diff;
118
179
  if (!diffContent) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code4bug/jarvis-agent",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "description": "基于 React + TypeScript + Ink 构建的命令行智能体交互界面",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",