vox-code 2.0.0__py3-none-any.whl

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 (88) hide show
  1. vox_code-2.0.0.dist-info/METADATA +258 -0
  2. vox_code-2.0.0.dist-info/RECORD +88 -0
  3. vox_code-2.0.0.dist-info/WHEEL +4 -0
  4. vox_code-2.0.0.dist-info/entry_points.txt +3 -0
  5. voxcli/__init__.py +3 -0
  6. voxcli/__main__.py +5 -0
  7. voxcli/agent/__init__.py +12 -0
  8. voxcli/agent/agent.py +449 -0
  9. voxcli/agent/agent_budget.py +133 -0
  10. voxcli/agent/agent_orchestrator.py +414 -0
  11. voxcli/agent/plan_execute_agent.py +514 -0
  12. voxcli/agent/roles.py +80 -0
  13. voxcli/agent/sub_agent.py +351 -0
  14. voxcli/catalog.py +477 -0
  15. voxcli/chat.py +91 -0
  16. voxcli/cli/__init__.py +4 -0
  17. voxcli/cli/main.py +452 -0
  18. voxcli/cli/parser.py +71 -0
  19. voxcli/config.py +518 -0
  20. voxcli/gui/__main__.py +3 -0
  21. voxcli/gui/main.py +22 -0
  22. voxcli/gui/pet/__init__.py +5 -0
  23. voxcli/gui/pet/base.py +62 -0
  24. voxcli/gui/pet/coordinator.py +888 -0
  25. voxcli/gui/pet/data.py +430 -0
  26. voxcli/gui/pet/widgets.py +683 -0
  27. voxcli/gui/pet/windows.py +2298 -0
  28. voxcli/gui/pet/workers.py +54 -0
  29. voxcli/gui/pet_app.py +7 -0
  30. voxcli/hitl/__init__.py +11 -0
  31. voxcli/hitl/handler.py +11 -0
  32. voxcli/hitl/policy.py +32 -0
  33. voxcli/hitl/request.py +13 -0
  34. voxcli/hitl/result.py +11 -0
  35. voxcli/hitl/terminal_handler.py +64 -0
  36. voxcli/hitl/tool_registry.py +64 -0
  37. voxcli/llm/base.py +93 -0
  38. voxcli/llm/factory.py +178 -0
  39. voxcli/llm/ollama_client.py +137 -0
  40. voxcli/llm/openai_compatible.py +249 -0
  41. voxcli/memory/base.py +16 -0
  42. voxcli/memory/budget.py +53 -0
  43. voxcli/memory/compressor.py +198 -0
  44. voxcli/memory/entry.py +36 -0
  45. voxcli/memory/long_term.py +126 -0
  46. voxcli/memory/manager.py +101 -0
  47. voxcli/memory/retriever.py +72 -0
  48. voxcli/memory/short_term.py +84 -0
  49. voxcli/memory/tokenizer.py +21 -0
  50. voxcli/plan/__init__.py +5 -0
  51. voxcli/plan/execution_plan.py +225 -0
  52. voxcli/plan/planner.py +198 -0
  53. voxcli/plan/task.py +123 -0
  54. voxcli/policy/audit_log.py +111 -0
  55. voxcli/policy/command_guard.py +34 -0
  56. voxcli/policy/exception.py +5 -0
  57. voxcli/policy/path_guard.py +32 -0
  58. voxcli/prompting/__init__.py +7 -0
  59. voxcli/prompting/presenter.py +154 -0
  60. voxcli/rag/__init__.py +16 -0
  61. voxcli/rag/analyzer.py +89 -0
  62. voxcli/rag/chunk.py +17 -0
  63. voxcli/rag/chunker.py +137 -0
  64. voxcli/rag/embedding.py +75 -0
  65. voxcli/rag/formatter.py +40 -0
  66. voxcli/rag/index.py +96 -0
  67. voxcli/rag/relation.py +14 -0
  68. voxcli/rag/retriever.py +58 -0
  69. voxcli/rag/store.py +155 -0
  70. voxcli/rag/tokenizer.py +26 -0
  71. voxcli/runtime/__init__.py +6 -0
  72. voxcli/runtime/session_controller.py +386 -0
  73. voxcli/tool/__init__.py +3 -0
  74. voxcli/tool/tool_registry.py +433 -0
  75. voxcli/util/animation.py +219 -0
  76. voxcli/util/ansi.py +82 -0
  77. voxcli/util/markdown.py +98 -0
  78. voxcli/web/__init__.py +17 -0
  79. voxcli/web/base.py +20 -0
  80. voxcli/web/extractor.py +77 -0
  81. voxcli/web/factory.py +38 -0
  82. voxcli/web/fetch_result.py +27 -0
  83. voxcli/web/fetcher.py +42 -0
  84. voxcli/web/network_policy.py +49 -0
  85. voxcli/web/result.py +23 -0
  86. voxcli/web/searxng.py +55 -0
  87. voxcli/web/serpapi.py +53 -0
  88. voxcli/web/zhipu.py +55 -0
@@ -0,0 +1,433 @@
1
+ """工具注册表 - 管理所有可用工具"""
2
+
3
+ import json
4
+ import subprocess
5
+ import time
6
+ import logging
7
+ from concurrent.futures import ThreadPoolExecutor, Future, TimeoutError
8
+ from pathlib import Path
9
+ from typing import Dict, List, Optional, Callable, Any
10
+
11
+ from ..policy.path_guard import PathGuard
12
+ from ..policy.command_guard import CommandGuard
13
+ from ..policy.exception import PolicyException
14
+ from ..policy.audit_log import AuditLog
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _DEFAULT_COMMAND_TIMEOUT = 60
19
+ _DEFAULT_BATCH_TIMEOUT = 90
20
+ _MAX_PARALLEL_TOOLS = 4
21
+ _MAX_COMMAND_OUTPUT_CHARS = 8_000
22
+ _MAX_WRITE_FILE_BYTES = 5 * 1024 * 1024
23
+ _DEFAULT_FETCH_MAX_CHARS = 8_000
24
+ _AUDIT_TOOLS = {"write_file", "execute_command", "create_project"}
25
+
26
+ ToolExecutor = Callable[[Dict[str, str]], str]
27
+
28
+
29
+ class ToolDef:
30
+ def __init__(self, name: str, description: str, parameters: dict, executor: ToolExecutor):
31
+ self.name = name
32
+ self.description = description
33
+ self.parameters = parameters
34
+ self.executor = executor
35
+
36
+
37
+ class ToolInvocation:
38
+ def __init__(self, id: str, name: str, arguments_json: str):
39
+ self.id = id
40
+ self.name = name
41
+ self.arguments_json = arguments_json
42
+
43
+
44
+ class ToolExecutionResult:
45
+ def __init__(self, id: str, name: str, arguments_json: str,
46
+ result: str, elapsed_ms: float, timed_out: bool = False):
47
+ self.id = id
48
+ self.name = name
49
+ self.arguments_json = arguments_json
50
+ self.result = result
51
+ self.elapsed_ms = elapsed_ms
52
+ self.timed_out = timed_out
53
+
54
+ @staticmethod
55
+ def completed(invocation: ToolInvocation, result: str, elapsed_ms: float) -> "ToolExecutionResult":
56
+ return ToolExecutionResult(invocation.id, invocation.name, invocation.arguments_json,
57
+ result, elapsed_ms, False)
58
+
59
+ @staticmethod
60
+ def failed(invocation: ToolInvocation, message: str) -> "ToolExecutionResult":
61
+ return ToolExecutionResult.completed(invocation, f"工具执行失败: {message}", 0)
62
+
63
+ @staticmethod
64
+ def timed_out(invocation: ToolInvocation, timeout_seconds: int) -> "ToolExecutionResult":
65
+ return ToolExecutionResult(
66
+ invocation.id, invocation.name, invocation.arguments_json,
67
+ f"工具执行超时({timeout_seconds}秒),已取消",
68
+ timeout_seconds * 1000, True
69
+ )
70
+
71
+
72
+ class ToolRegistry:
73
+ def __init__(self, command_timeout: int = _DEFAULT_COMMAND_TIMEOUT,
74
+ batch_timeout: int = _DEFAULT_BATCH_TIMEOUT):
75
+ self._tools: Dict[str, ToolDef] = {}
76
+ self._command_timeout = command_timeout
77
+ self._batch_timeout = batch_timeout
78
+ self._project_path: str = Path.cwd().as_posix()
79
+ self._path_guard = PathGuard(self._project_path)
80
+ self._audit_log = AuditLog()
81
+ self._search_provider = None
82
+ self._web_fetcher = None
83
+ self._html_extractor = None
84
+ self._network_policy = None
85
+ self._register_all()
86
+
87
+ def _register_all(self):
88
+ self._register_file_tools()
89
+ self._register_shell_tools()
90
+ self._register_code_tools()
91
+ self._register_rag_tools()
92
+ self._register_web_tools()
93
+
94
+ def set_project_path(self, project_path: str):
95
+ self._project_path = project_path
96
+ self._path_guard = PathGuard(project_path)
97
+
98
+ @property
99
+ def project_path(self) -> str:
100
+ return self._project_path
101
+
102
+ @property
103
+ def audit_log(self) -> AuditLog:
104
+ return self._audit_log
105
+
106
+ def get_tool_definitions(self) -> List[ToolDef]:
107
+ return list(self._tools.values())
108
+
109
+ def has_tool(self, name: str) -> bool:
110
+ return name in self._tools
111
+
112
+ def execute_tool(self, name: str, arguments_json: str) -> str:
113
+ tool = self._tools.get(name)
114
+ if tool is None:
115
+ return f"未知工具: {name}"
116
+
117
+ should_audit = name in _AUDIT_TOOLS
118
+ start = time.time()
119
+
120
+ try:
121
+ args = json.loads(arguments_json)
122
+ arg_map = {k: str(v) for k, v in args.items()}
123
+ result = tool.executor(arg_map)
124
+ if should_audit:
125
+ self._audit_log.record(AuditLog.allow(name, arguments_json,
126
+ _elapsed_ms(start)))
127
+ return result
128
+ except PolicyException as e:
129
+ if should_audit:
130
+ self._audit_log.record(AuditLog.deny_by_policy(
131
+ name, arguments_json, str(e), _elapsed_ms(start)))
132
+ return f"🛡️ 策略拒绝: {e}"
133
+ except Exception as e:
134
+ if should_audit:
135
+ self._audit_log.record(AuditLog.error(
136
+ name, arguments_json, str(e), _elapsed_ms(start)))
137
+ return f"工具执行失败: {e}"
138
+
139
+ def execute_tools(self, invocations: List[ToolInvocation]) -> List[ToolExecutionResult]:
140
+ if not invocations:
141
+ return []
142
+ if len(invocations) == 1:
143
+ inv = invocations[0]
144
+ start = time.time()
145
+ result = self.execute_tool(inv.name, inv.arguments_json)
146
+ return [ToolExecutionResult.completed(inv, result, _elapsed_ms(start))]
147
+
148
+ parallelism = min(len(invocations), _MAX_PARALLEL_TOOLS)
149
+ results: List[Optional[ToolExecutionResult]] = [None] * len(invocations)
150
+
151
+ with ThreadPoolExecutor(max_workers=parallelism) as executor:
152
+ future_map: Dict[Future, int] = {}
153
+ for i, inv in enumerate(invocations):
154
+ future = executor.submit(self._execute_single, inv)
155
+ future_map[future] = i
156
+
157
+ for future in future_map:
158
+ idx = future_map[future]
159
+ try:
160
+ results[idx] = future.result(timeout=self._batch_timeout)
161
+ except TimeoutError:
162
+ results[idx] = ToolExecutionResult.timed_out(
163
+ invocations[idx], self._batch_timeout)
164
+ except Exception as e:
165
+ results[idx] = ToolExecutionResult.failed(invocations[idx], str(e))
166
+
167
+ return [r for r in results if r is not None]
168
+
169
+ def _execute_single(self, inv: ToolInvocation) -> ToolExecutionResult:
170
+ start = time.time()
171
+ result = self.execute_tool(inv.name, inv.arguments_json)
172
+ return ToolExecutionResult.completed(inv, result, _elapsed_ms(start))
173
+
174
+ # ---- Tool registration ----
175
+
176
+ def _register_file_tools(self):
177
+ self._tools["read_file"] = ToolDef(
178
+ name="read_file",
179
+ description="读取文件内容(仅限项目根目录之内)",
180
+ parameters=_make_params({"path": {"type": "string", "description": "文件路径"}}, ["path"]),
181
+ executor=lambda args: self._read_file(args.get("path", "")),
182
+ )
183
+ self._tools["write_file"] = ToolDef(
184
+ name="write_file",
185
+ description="写入文件内容(仅限项目根目录之内,单文件 5MB 上限)",
186
+ parameters=_make_params({
187
+ "path": {"type": "string", "description": "文件路径"},
188
+ "content": {"type": "string", "description": "文件内容"},
189
+ }, ["path", "content"]),
190
+ executor=lambda args: self._write_file(args.get("path", ""), args.get("content", "")),
191
+ )
192
+ self._tools["list_dir"] = ToolDef(
193
+ name="list_dir",
194
+ description="列出目录内容(仅限项目根目录之内)",
195
+ parameters=_make_params({"path": {"type": "string", "description": "目录路径"}}, ["path"]),
196
+ executor=lambda args: self._list_dir(args.get("path", "")),
197
+ )
198
+
199
+ def _register_shell_tools(self):
200
+ self._tools["execute_command"] = ToolDef(
201
+ name="execute_command",
202
+ description="在当前项目目录中执行短时 Shell 命令(默认 60 秒超时,不允许全盘扫描)",
203
+ parameters=_make_params({"command": {"type": "string", "description": "要执行的命令"}}, ["command"]),
204
+ executor=lambda args: self._execute_command(args.get("command", "")),
205
+ )
206
+
207
+ def _register_code_tools(self):
208
+ self._tools["create_project"] = ToolDef(
209
+ name="create_project",
210
+ description="创建新项目结构",
211
+ parameters=_make_params({
212
+ "name": {"type": "string", "description": "项目名称"},
213
+ "type": {"type": "string", "description": "项目类型 (java/python/node)"},
214
+ }, ["name", "type"]),
215
+ executor=lambda args: self._create_project(args.get("name", ""), args.get("type", "")),
216
+ )
217
+
218
+ def _register_rag_tools(self):
219
+ self._tools["search_code"] = ToolDef(
220
+ name="search_code",
221
+ description="语义检索代码库,根据自然语言描述查找相关代码块",
222
+ parameters=_make_params({
223
+ "query": {"type": "string", "description": "自然语言查询描述"},
224
+ "top_k": {"type": "integer", "description": "返回结果数量(默认5)"},
225
+ }, ["query"]),
226
+ executor=lambda args: self._search_code(args.get("query", ""),
227
+ int(args.get("top_k", "5"))),
228
+ )
229
+
230
+ def _register_web_tools(self):
231
+ self._tools["web_search"] = ToolDef(
232
+ name="web_search",
233
+ description="搜索互联网,获取实时信息(最新版本、官方文档、技术资讯等)",
234
+ parameters=_make_params({
235
+ "query": {"type": "string", "description": "搜索关键词"},
236
+ "top_k": {"type": "integer", "description": "返回结果数量(默认5)"},
237
+ }, ["query"]),
238
+ executor=lambda args: self._web_search(args.get("query", ""),
239
+ int(args.get("top_k", "5"))),
240
+ )
241
+ self._tools["web_fetch"] = ToolDef(
242
+ name="web_fetch",
243
+ description="抓取指定 URL,提取正文转 Markdown",
244
+ parameters=_make_params({
245
+ "url": {"type": "string", "description": "完整 URL,需 http 或 https 协议"},
246
+ "max_chars": {"type": "integer", "description": "返回 Markdown 最大字符数(默认 8000)"},
247
+ }, ["url"]),
248
+ executor=lambda args: self._web_fetch(args.get("url", ""),
249
+ int(args.get("max_chars", str(_DEFAULT_FETCH_MAX_CHARS)))),
250
+ )
251
+
252
+ # ---- Tool implementations ----
253
+
254
+ def _read_file(self, path: str) -> str:
255
+ safe = self._path_guard.resolve_safe(path)
256
+ try:
257
+ content = Path(safe).read_text(encoding="utf-8")
258
+ return f"文件内容:\n{content}"
259
+ except Exception as e:
260
+ return f"读取文件失败: {e}"
261
+
262
+ def _write_file(self, path: str, content: str) -> str:
263
+ content_bytes = content.encode("utf-8")
264
+ if len(content_bytes) > _MAX_WRITE_FILE_BYTES:
265
+ raise PolicyException(
266
+ f"写入内容 {len(content_bytes)} 字节超过 {_MAX_WRITE_FILE_BYTES // 1024 // 1024}MB 上限")
267
+ safe = self._path_guard.resolve_safe(path)
268
+ try:
269
+ Path(safe).parent.mkdir(parents=True, exist_ok=True)
270
+ Path(safe).write_text(content, encoding="utf-8")
271
+ return f"文件已写入: {path}"
272
+ except Exception as e:
273
+ return f"写入文件失败: {e}"
274
+
275
+ def _list_dir(self, path: str) -> str:
276
+ safe = self._path_guard.resolve_safe(path)
277
+ try:
278
+ entries = list(Path(safe).iterdir())
279
+ if not entries:
280
+ return "目录为空或不存在"
281
+ lines = ["目录内容:"]
282
+ for entry in sorted(entries, key=lambda x: (not x.is_dir(), x.name)):
283
+ prefix = "[D]" if entry.is_dir() else "[F]"
284
+ lines.append(f"{prefix} {entry.name}")
285
+ return "\n".join(lines)
286
+ except Exception as e:
287
+ return f"列出目录失败: {e}"
288
+
289
+ def _execute_command(self, command: str) -> str:
290
+ normalized = (command or "").strip()
291
+ if not normalized:
292
+ return "执行命令失败: 命令不能为空"
293
+ deny_reason = CommandGuard.check(normalized)
294
+ if deny_reason:
295
+ raise PolicyException(deny_reason)
296
+ try:
297
+ proc = subprocess.run(
298
+ ["bash", "-c", normalized],
299
+ cwd=self._project_path,
300
+ capture_output=True, text=True,
301
+ timeout=self._command_timeout,
302
+ )
303
+ output = proc.stdout + proc.stderr
304
+ if len(output) > _MAX_COMMAND_OUTPUT_CHARS:
305
+ output = output[:_MAX_COMMAND_OUTPUT_CHARS] + "\n...(输出已截断)"
306
+ return f"命令执行完成 (exit code: {proc.returncode})\n{output}"
307
+ except subprocess.TimeoutExpired:
308
+ return f"命令执行超时({self._command_timeout}秒),已强制终止"
309
+ except Exception as e:
310
+ return f"执行命令失败: {e}"
311
+
312
+ def _create_project(self, name: str, type_: str) -> str:
313
+ safe = self._path_guard.resolve_safe(name)
314
+ try:
315
+ root = Path(safe)
316
+ root.mkdir(parents=True, exist_ok=True)
317
+ if type_ == "java":
318
+ (root / "src" / "main" / "java").mkdir(parents=True)
319
+ (root / "src" / "main" / "resources").mkdir(parents=True)
320
+ (root / "pom.xml").write_text(
321
+ f'<?xml version="1.0" encoding="UTF-8"?>\n<project>\n'
322
+ f' <modelVersion>4.0.0</modelVersion>\n'
323
+ f' <groupId>com.example</groupId>\n'
324
+ f' <artifactId>{name}</artifactId>\n'
325
+ f' <version>1.0</version>\n</project>')
326
+ elif type_ == "python":
327
+ (root / name).mkdir(exist_ok=True)
328
+ (root / "main.py").write_text("# 主程序入口\n")
329
+ (root / "requirements.txt").write_text("# 依赖列表\n")
330
+ elif type_ == "node":
331
+ (root / "package.json").write_text(
332
+ f'{{"name": "{name}", "version": "1.0.0"}}')
333
+ return f"项目已创建: {name} (类型: {type_})"
334
+ except Exception as e:
335
+ return f"创建项目失败: {e}"
336
+
337
+ def _search_code(self, query: str, top_k: int) -> str:
338
+ try:
339
+ from ..rag.retriever import CodeRetriever
340
+ from ..rag.formatter import SearchResultFormatter
341
+ with CodeRetriever(self._project_path) as retriever:
342
+ stats = retriever.get_stats()
343
+ if stats.chunk_count == 0:
344
+ return "代码库尚未索引,请先使用 /index 命令索引当前项目。"
345
+ results = retriever.hybrid_search(query, top_k)
346
+ if not results:
347
+ return "未找到与查询相关的代码。"
348
+ return SearchResultFormatter.format_for_tool(query, results)
349
+ except ImportError:
350
+ return "代码检索功能不可用(缺少依赖模块)"
351
+ except Exception as e:
352
+ return f"代码检索失败: {e}"
353
+
354
+ def _web_search(self, query: str, top_k: int) -> str:
355
+ if not query:
356
+ return "搜索关键词不能为空"
357
+ try:
358
+ from ..web.factory import SearchProviderFactory
359
+ provider = SearchProviderFactory.create()
360
+ if not provider.is_ready():
361
+ return f"⚠️ {provider.unavailable_hint()}"
362
+ results = provider.search(query, top_k)
363
+ return self._format_search_results(provider.name, query, results)
364
+ except ImportError:
365
+ return "联网搜索功能不可用(缺少 web 模块依赖)"
366
+ except Exception as e:
367
+ return f"搜索失败: {e}"
368
+
369
+ @staticmethod
370
+ def _format_search_results(provider_name: str, query: str, results) -> str:
371
+ lines = [f"🔍 [{provider_name}] {query}\n"]
372
+ if not results:
373
+ lines.append("未找到相关结果。")
374
+ else:
375
+ for r in results:
376
+ snippet = (r.snippet[:200] + "...") if len(r.snippet) > 200 else r.snippet
377
+ lines.append(f"{r.position}. {r.title}")
378
+ if snippet:
379
+ lines.append(f" {snippet}")
380
+ url_part = f" 🔗 {r.url}"
381
+ if r.source:
382
+ url_part += f" ({r.source})"
383
+ lines.append(url_part)
384
+ lines.append("")
385
+ return "\n".join(lines).strip()
386
+
387
+ def _web_fetch(self, url: str, max_chars: int) -> str:
388
+ if not url:
389
+ return "URL 不能为空"
390
+ try:
391
+ from ..web.network_policy import NetworkPolicy
392
+ from ..web.fetcher import WebFetcher
393
+ from ..web.extractor import HtmlExtractor
394
+
395
+ policy = NetworkPolicy()
396
+ deny = policy.check_url(url)
397
+ if deny:
398
+ return f"❌ 网络访问被拒绝: {deny}"
399
+ rate_reason = policy.acquire()
400
+ if rate_reason:
401
+ return f"❌ {rate_reason}"
402
+
403
+ raw = WebFetcher().fetch(url)
404
+ extracted = HtmlExtractor().extract(raw["body"], raw["url"])
405
+ md = extracted["markdown"]
406
+ original_length = len(md)
407
+ truncated = False
408
+ if max_chars > 0 and len(md) > max_chars:
409
+ md = md[:max_chars]
410
+ truncated = True
411
+
412
+ lines = [f"🌐 抓取: {raw['url']}"]
413
+ if extracted.get("title"):
414
+ lines.append(f"📄 标题: {extracted['title']}")
415
+ if not md:
416
+ lines.append("\n⚠️ 正文为空,可能是 SPA 或防爬墙(已知边界,不重试)")
417
+ else:
418
+ lines.append(f"📏 正文 {original_length} 字符{'(已截断)' if truncated else ''}")
419
+ lines.append("\n---\n")
420
+ lines.append(md)
421
+ return "\n".join(lines)
422
+ except ImportError:
423
+ return "网页抓取功能不可用(缺少 web 模块依赖)"
424
+ except Exception as e:
425
+ return f"抓取失败: {e}"
426
+
427
+
428
+ def _make_params(properties: dict, required: list) -> dict:
429
+ return {"type": "object", "properties": properties, "required": required}
430
+
431
+
432
+ def _elapsed_ms(start: float) -> float:
433
+ return (time.time() - start) * 1000
@@ -0,0 +1,219 @@
1
+ """终端动画效果 - Claude Code 风格的 thinking 动画、打字机效果、工具调用动画"""
2
+
3
+ import sys
4
+ import time
5
+ import threading
6
+ from typing import Optional, TextIO
7
+
8
+ from .ansi import dim, subtle, success, is_enabled
9
+
10
+ # ============================================================
11
+ # Thinking 动画
12
+ # ============================================================
13
+
14
+ class ThinkingDots:
15
+ """Claude Code 风格的 thinking 动画 (● ● ●)
16
+
17
+ 在 LLM 响应等待期间显示脉冲动画,收到首个 delta 后自动停止。
18
+ """
19
+
20
+ _FRAMES = ["● ○ ○", "○ ● ○", "○ ○ ●", "○ ● ○"]
21
+
22
+ def __init__(self, stream: Optional[TextIO] = None):
23
+ self._stream = stream or sys.stdout
24
+ self._running = False
25
+ self._thread: Optional[threading.Thread] = None
26
+
27
+ def start(self):
28
+ if not is_enabled() or not self._stream.isatty():
29
+ self._running = False
30
+ return
31
+ self._running = True
32
+ self._thread = threading.Thread(target=self._animate, daemon=True)
33
+ self._thread.start()
34
+
35
+ def _animate(self):
36
+ idx = 0
37
+ while self._running:
38
+ frame = self._FRAMES[idx % len(self._FRAMES)]
39
+ self._stream.write(f"\r{dim(frame)}")
40
+ self._stream.flush()
41
+ time.sleep(0.25)
42
+ idx += 1
43
+
44
+ def stop(self):
45
+ if not self._running:
46
+ return
47
+ self._running = False
48
+ if self._thread:
49
+ self._thread.join(0.3)
50
+ self._stream.write("\r\033[K")
51
+ self._stream.flush()
52
+
53
+
54
+ # ============================================================
55
+ # 打字机效果 - 流式输出
56
+ # ============================================================
57
+
58
+ class Typewriter:
59
+ """打字机效果 - 流式输出文本。
60
+
61
+ 模拟 Claude Code 的输出节奏:字符以微节奏流出(非逐字卡顿),
62
+ ANSI 转义序列整体快速写入,仅在可见字符间引入极短间隔。
63
+ 非 TTY 环境退化为直接输出。
64
+ """
65
+
66
+ def __init__(self, stream: Optional[TextIO] = None, char_delay: float = 0.004):
67
+ self._stream = stream or sys.stdout
68
+ # 仅在 TTY 启用延迟,管道/重定向直接输出
69
+ self._char_delay = char_delay if is_enabled() and self._stream.isatty() else 0.0
70
+ self._in_escape = False
71
+
72
+ def write(self, text: str):
73
+ """写出文本,可见字符间插入微延迟,ANSI 序列整体跳过。"""
74
+ if self._char_delay <= 0 or not text:
75
+ self._stream.write(text)
76
+ self._stream.flush()
77
+ return
78
+ buf = []
79
+ for ch in text:
80
+ if ch == "\033":
81
+ self._in_escape = True
82
+ buf.append(ch)
83
+ continue
84
+ if self._in_escape:
85
+ buf.append(ch)
86
+ # ANSI 序列以字母结尾(a-z / A-Z)
87
+ if ch.isalpha():
88
+ self._in_escape = False
89
+ continue
90
+ buf.append(ch)
91
+ # 累积一段后写出并延迟
92
+ if len(buf) >= 32 or ch == "\n":
93
+ self._stream.write("".join(buf))
94
+ self._stream.flush()
95
+ buf.clear()
96
+ if ch != "\n":
97
+ time.sleep(self._char_delay)
98
+ if buf:
99
+ self._stream.write("".join(buf))
100
+ self._stream.flush()
101
+
102
+ def write_fast(self, text: str):
103
+ """快速写出,无需动画(代码块、工具结果等)"""
104
+ self._stream.write(text)
105
+ self._stream.flush()
106
+
107
+ def newline(self):
108
+ self._stream.write("\n")
109
+ self._stream.flush()
110
+
111
+ def clear_line(self):
112
+ self._stream.write("\r\033[K")
113
+ self._stream.flush()
114
+
115
+
116
+ # ============================================================
117
+ # 工具调用动画(流式)
118
+ # ============================================================
119
+
120
+ class ToolCallAnimator:
121
+ """工具调用动画 - 显示工具执行状态
122
+
123
+ 工具调用使用 ANSI 转义序列实现原地更新:
124
+ 1. running() 逐行打印所有工具调用
125
+ 2. finish_all() 回退到起始位置,逐行覆写为 ✓ 格式
126
+
127
+ 示例输出:
128
+ ✓ read_file: /path/to/file
129
+ ✓ execute_command: npm test
130
+ """
131
+
132
+ def __init__(self, stream: Optional[TextIO] = None):
133
+ self._stream = stream or sys.stdout
134
+ self._can_animate = is_enabled() and self._stream.isatty()
135
+ self._line_count = 0
136
+ self._entries: list[tuple[str, str]] = []
137
+
138
+ def running(self, tool_name: str, detail: str = ""):
139
+ """记录一个正在运行的工具调用(追加一行)"""
140
+ self._entries.append((tool_name, detail))
141
+ msg = f" > {tool_name}"
142
+ if detail:
143
+ msg += f" {detail}"
144
+ self._stream.write(f"{subtle(msg)}\n")
145
+ self._stream.flush()
146
+ self._line_count += 1
147
+
148
+ def finish_all(self):
149
+ """将所有工具调用标记为已完成(原地更新)"""
150
+ if not self._entries:
151
+ return
152
+ if self._can_animate:
153
+ # 回退到工具调用列表的起始行
154
+ self._stream.write(f"\033[{self._line_count}A")
155
+ self._stream.flush()
156
+ for name, detail in self._entries:
157
+ msg = f" {name}"
158
+ if detail:
159
+ msg += f": {detail}"
160
+ self._stream.write(f"{success(' ✓')} {subtle(msg)}\n")
161
+ self._stream.flush()
162
+ else:
163
+ for name, detail in self._entries:
164
+ msg = f" {name}"
165
+ if detail:
166
+ msg += f": {detail}"
167
+ self._stream.write(f"{success(' ✓')} {subtle(msg)}\n")
168
+ self._stream.flush()
169
+ self._line_count = 0
170
+ self._entries = []
171
+
172
+ def done(self, tool_name: str, detail: str = ""):
173
+ """单工具兼容接口(仍使用 \r,仅用于单个工具场景)"""
174
+ msg = f" {tool_name}"
175
+ if detail:
176
+ msg += f": {detail}"
177
+ self._stream.write(f"\r{success(' ✓')} {subtle(msg)}\n")
178
+ self._stream.flush()
179
+
180
+
181
+ # ============================================================
182
+ # 进度指示器(轻量)
183
+ # ============================================================
184
+
185
+ class ProgressDots:
186
+ """简单的进度点动画 — 用于等待、搜索等短暂操作"""
187
+
188
+ def __init__(self, text: str = "", stream: Optional[TextIO] = None):
189
+ self._text = text
190
+ self._stream = stream or sys.stdout
191
+ self._running = False
192
+ self._thread: Optional[threading.Thread] = None
193
+
194
+ def start(self):
195
+ if not is_enabled() or not self._stream.isatty():
196
+ self._stream.write(f"{self._text}...\n")
197
+ return
198
+ self._running = True
199
+ self._thread = threading.Thread(target=self._animate, daemon=True)
200
+ self._thread.start()
201
+
202
+ def _animate(self):
203
+ idx = 0
204
+ prefix = f"\r{self._text}" if self._text else "\r"
205
+ while self._running:
206
+ dots = "." * ((idx % 3) + 1)
207
+ self._stream.write(f"{prefix}{dots} ")
208
+ self._stream.flush()
209
+ time.sleep(0.4)
210
+ idx += 1
211
+
212
+ def stop(self, final_msg: str = ""):
213
+ self._running = False
214
+ if self._thread:
215
+ self._thread.join(0.3)
216
+ self._stream.write("\r\033[K")
217
+ if final_msg:
218
+ self._stream.write(f"{final_msg}\n")
219
+ self._stream.flush()