my-pi-agent 0.1.0

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 (141) hide show
  1. package/README.md +318 -0
  2. package/package.json +45 -0
  3. package/pyproject.toml +50 -0
  4. package/src/my_agent_core/__init__.py +123 -0
  5. package/src/my_agent_core/agent.py +441 -0
  6. package/src/my_agent_core/background.py +121 -0
  7. package/src/my_agent_core/context.py +505 -0
  8. package/src/my_agent_core/events.py +153 -0
  9. package/src/my_agent_core/extensions/__init__.py +9 -0
  10. package/src/my_agent_core/extensions/core.py +197 -0
  11. package/src/my_agent_core/hooks.py +130 -0
  12. package/src/my_agent_core/loop.py +709 -0
  13. package/src/my_agent_core/main.py +134 -0
  14. package/src/my_agent_core/memory.py +241 -0
  15. package/src/my_agent_core/message_queue.py +110 -0
  16. package/src/my_agent_core/plugins.py +212 -0
  17. package/src/my_agent_core/registry.py +186 -0
  18. package/src/my_agent_core/session/__init__.py +79 -0
  19. package/src/my_agent_core/session/entries.py +197 -0
  20. package/src/my_agent_core/session/jsonl.py +60 -0
  21. package/src/my_agent_core/session/memory.py +137 -0
  22. package/src/my_agent_core/session/session.py +400 -0
  23. package/src/my_agent_core/session/storage.py +245 -0
  24. package/src/my_agent_core/session/store.py +131 -0
  25. package/src/my_agent_core/session/tree.py +86 -0
  26. package/src/my_agent_core/skills.py +149 -0
  27. package/src/my_agent_core/subagent_tasks.py +170 -0
  28. package/src/my_agent_core/subagents.py +148 -0
  29. package/src/my_agent_core/task_store.py +248 -0
  30. package/src/my_agent_core/tool_history.py +189 -0
  31. package/src/my_agent_core/tools/__init__.py +5 -0
  32. package/src/my_agent_core/tools/builtin/__init__.py +5 -0
  33. package/src/my_agent_core/tools/builtin/task.py +30 -0
  34. package/src/my_agent_core/tools/builtin/task_tools.py +215 -0
  35. package/src/my_agent_core/tools/core.py +239 -0
  36. package/src/my_agent_llm/__init__.py +45 -0
  37. package/src/my_agent_llm/auth/__init__.py +46 -0
  38. package/src/my_agent_llm/auth/antigravity.py +209 -0
  39. package/src/my_agent_llm/auth/manager.py +259 -0
  40. package/src/my_agent_llm/auth/quota.py +56 -0
  41. package/src/my_agent_llm/auth/schema.py +94 -0
  42. package/src/my_agent_llm/client.py +116 -0
  43. package/src/my_agent_llm/config.py +17 -0
  44. package/src/my_agent_llm/events.py +84 -0
  45. package/src/my_agent_llm/models.py +195 -0
  46. package/src/my_agent_llm/providers/__init__.py +4 -0
  47. package/src/my_agent_llm/providers/_base.py +94 -0
  48. package/src/my_agent_llm/providers/anthropic.py +298 -0
  49. package/src/my_agent_llm/providers/antigravity.py +480 -0
  50. package/src/my_agent_llm/providers/deepseek.py +196 -0
  51. package/src/my_agent_llm/providers/openai.py +364 -0
  52. package/src/my_agent_llm/providers/registry.py +16 -0
  53. package/src/my_agent_llm/stream.py +218 -0
  54. package/src/my_coding_agent/__init__.py +66 -0
  55. package/src/my_coding_agent/agent.py +208 -0
  56. package/src/my_coding_agent/cli.py +78 -0
  57. package/src/my_coding_agent/file_reference.py +80 -0
  58. package/src/my_coding_agent/macro.py +408 -0
  59. package/src/my_coding_agent/mcp.py +243 -0
  60. package/src/my_coding_agent/mutation_queue.py +37 -0
  61. package/src/my_coding_agent/paths.py +119 -0
  62. package/src/my_coding_agent/permissions.py +84 -0
  63. package/src/my_coding_agent/prompt.py +54 -0
  64. package/src/my_coding_agent/rpc_server.py +2817 -0
  65. package/src/my_coding_agent/settings.py +126 -0
  66. package/src/my_coding_agent/tools/__init__.py +55 -0
  67. package/src/my_coding_agent/tools/base.py +58 -0
  68. package/src/my_coding_agent/tools/bash.py +206 -0
  69. package/src/my_coding_agent/tools/edit.py +226 -0
  70. package/src/my_coding_agent/tools/find.py +118 -0
  71. package/src/my_coding_agent/tools/grep.py +177 -0
  72. package/src/my_coding_agent/tools/ls.py +112 -0
  73. package/src/my_coding_agent/tools/read.py +113 -0
  74. package/src/my_coding_agent/tools/write.py +72 -0
  75. package/tui/README.md +27 -0
  76. package/tui/bin/my-agent.js +98 -0
  77. package/tui/dist/app.d.ts +41 -0
  78. package/tui/dist/app.js +110 -0
  79. package/tui/dist/bridge/event-translator.d.ts +92 -0
  80. package/tui/dist/bridge/event-translator.js +216 -0
  81. package/tui/dist/bridge/kernel-bridge.d.ts +48 -0
  82. package/tui/dist/bridge/kernel-bridge.js +132 -0
  83. package/tui/dist/client.d.ts +63 -0
  84. package/tui/dist/client.js +239 -0
  85. package/tui/dist/components/assistant-message.d.ts +19 -0
  86. package/tui/dist/components/assistant-message.js +90 -0
  87. package/tui/dist/components/compaction-summary-message.d.ts +19 -0
  88. package/tui/dist/components/compaction-summary-message.js +46 -0
  89. package/tui/dist/components/custom-editor.d.ts +18 -0
  90. package/tui/dist/components/custom-editor.js +56 -0
  91. package/tui/dist/components/dynamic-border.d.ts +9 -0
  92. package/tui/dist/components/dynamic-border.js +14 -0
  93. package/tui/dist/components/footer.d.ts +39 -0
  94. package/tui/dist/components/footer.js +199 -0
  95. package/tui/dist/components/header.d.ts +4 -0
  96. package/tui/dist/components/header.js +21 -0
  97. package/tui/dist/components/keys.d.ts +5 -0
  98. package/tui/dist/components/keys.js +12 -0
  99. package/tui/dist/components/login-selector.d.ts +26 -0
  100. package/tui/dist/components/login-selector.js +181 -0
  101. package/tui/dist/components/logout-selector.d.ts +19 -0
  102. package/tui/dist/components/logout-selector.js +88 -0
  103. package/tui/dist/components/model-selector.d.ts +40 -0
  104. package/tui/dist/components/model-selector.js +268 -0
  105. package/tui/dist/components/session-selector.d.ts +54 -0
  106. package/tui/dist/components/session-selector.js +393 -0
  107. package/tui/dist/components/settings-selector.d.ts +24 -0
  108. package/tui/dist/components/settings-selector.js +146 -0
  109. package/tui/dist/components/status-indicator.d.ts +25 -0
  110. package/tui/dist/components/status-indicator.js +60 -0
  111. package/tui/dist/components/theme-selector.d.ts +14 -0
  112. package/tui/dist/components/theme-selector.js +77 -0
  113. package/tui/dist/components/thinking-selector.d.ts +21 -0
  114. package/tui/dist/components/thinking-selector.js +128 -0
  115. package/tui/dist/components/tool-execution.d.ts +31 -0
  116. package/tui/dist/components/tool-execution.js +206 -0
  117. package/tui/dist/components/tree-selector.d.ts +40 -0
  118. package/tui/dist/components/tree-selector.js +173 -0
  119. package/tui/dist/components/user-message-selector.d.ts +21 -0
  120. package/tui/dist/components/user-message-selector.js +103 -0
  121. package/tui/dist/components/user-message.d.ts +5 -0
  122. package/tui/dist/components/user-message.js +15 -0
  123. package/tui/dist/index.d.ts +11 -0
  124. package/tui/dist/index.js +11 -0
  125. package/tui/dist/interactive/chat-viewport.d.ts +19 -0
  126. package/tui/dist/interactive/chat-viewport.js +41 -0
  127. package/tui/dist/interactive/components.d.ts +1 -0
  128. package/tui/dist/interactive/components.js +1 -0
  129. package/tui/dist/interactive/interactive-mode.d.ts +89 -0
  130. package/tui/dist/interactive/interactive-mode.js +1625 -0
  131. package/tui/dist/interactive/theme.d.ts +1 -0
  132. package/tui/dist/interactive/theme.js +1 -0
  133. package/tui/dist/interactive/tui-renderer.d.ts +8 -0
  134. package/tui/dist/interactive/tui-renderer.js +10 -0
  135. package/tui/dist/protocol.d.ts +78 -0
  136. package/tui/dist/protocol.js +1 -0
  137. package/tui/dist/theme/dark.json +54 -0
  138. package/tui/dist/theme/light.json +71 -0
  139. package/tui/dist/theme/theme.d.ts +20 -0
  140. package/tui/dist/theme/theme.js +86 -0
  141. package/tui/package.json +25 -0
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import os
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from my_agent_core.tools import Tool, tool
10
+
11
+ from my_coding_agent.tools.base import (
12
+ DEFAULT_IGNORE_DIRS,
13
+ DEFAULT_MAX_BYTES,
14
+ StringCompatibleToolResult,
15
+ resolve_path,
16
+ )
17
+
18
+ DEFAULT_FIND_LIMIT = 1000
19
+
20
+
21
+ class FindResult(StringCompatibleToolResult):
22
+ """Find 工具执行结果:继承 StringCompatibleToolResult。"""
23
+
24
+
25
+ def make_find_tool(workspace: Path | str) -> Tool:
26
+ """创建 find 工具工厂,在 workspace 内按 glob pattern 检索文件。"""
27
+ workspace = Path(workspace).resolve()
28
+
29
+ @tool(
30
+ name="find",
31
+ description="Search for files by glob pattern. Returns matching file paths relative to the search directory. Output is truncated to 1000 results or 50KB (whichever is hit first).",
32
+ is_parallel_safe=True,
33
+ )
34
+ async def find(pattern: str = "*", path: str = ".", limit: int = DEFAULT_FIND_LIMIT) -> str:
35
+ try:
36
+ target_root = resolve_path(workspace, path)
37
+ if not target_root.exists():
38
+ return f"Error: Path not found: {path}"
39
+
40
+ effective_limit = limit if (limit is not None and limit > 0) else DEFAULT_FIND_LIMIT
41
+ matched_paths: list[str] = []
42
+ norm_pattern = pattern.replace("\\", "/")
43
+
44
+ if target_root.is_file():
45
+ try:
46
+ rel = str(target_root.relative_to(workspace)).replace("\\", "/")
47
+ except ValueError:
48
+ rel = str(target_root).replace("\\", "/")
49
+ if (
50
+ fnmatch.fnmatch(rel, norm_pattern)
51
+ or fnmatch.fnmatch(rel, pattern)
52
+ or fnmatch.fnmatch(target_root.name, pattern)
53
+ ):
54
+ matched_paths.append(rel)
55
+ else:
56
+ for root, dirs, files in os.walk(target_root):
57
+ dirs[:] = [d for d in dirs if d not in DEFAULT_IGNORE_DIRS]
58
+ dirs.sort()
59
+ for f in sorted(files):
60
+ full_p = Path(root) / f
61
+ try:
62
+ rel = str(full_p.relative_to(workspace)).replace("\\", "/")
63
+ except ValueError:
64
+ rel = str(full_p).replace("\\", "/")
65
+
66
+ if (
67
+ fnmatch.fnmatch(rel, norm_pattern)
68
+ or fnmatch.fnmatch(rel, pattern)
69
+ or fnmatch.fnmatch(f, pattern)
70
+ ):
71
+ matched_paths.append(rel)
72
+ if len(matched_paths) >= effective_limit:
73
+ matched_paths.append(f"[Truncated at limit of {effective_limit} results]")
74
+ break
75
+ if len(matched_paths) >= effective_limit:
76
+ break
77
+
78
+ output = "\n".join(matched_paths) if matched_paths else f"No files matching '{pattern}' found."
79
+ encoded = output.encode("utf-8")
80
+ if len(encoded) > DEFAULT_MAX_BYTES:
81
+ encoded = encoded[:DEFAULT_MAX_BYTES]
82
+ last_nl = encoded.rfind(b"\n")
83
+ if last_nl != -1:
84
+ encoded = encoded[:last_nl]
85
+ output = encoded.decode("utf-8", errors="ignore")
86
+ output += f"\n\n[Output truncated: exceeded {DEFAULT_MAX_BYTES // 1024}KB limit]"
87
+
88
+ return output
89
+ except Exception as e:
90
+ return f"Error: {e}"
91
+
92
+ orig_execute = find.execute
93
+
94
+ async def execute(
95
+ args: dict[str, Any] | None = None,
96
+ signal: Any | None = None,
97
+ on_update: Callable[[Any], None] | None = None,
98
+ tool_call_id: str | None = None,
99
+ **kwargs: Any,
100
+ ) -> FindResult:
101
+ call_args = dict(args) if isinstance(args, dict) else {}
102
+ call_args.update(kwargs)
103
+ res = await orig_execute(
104
+ call_args,
105
+ signal=signal,
106
+ on_update=on_update,
107
+ tool_call_id=tool_call_id,
108
+ )
109
+ return FindResult(
110
+ ok=res.ok,
111
+ data=res.data,
112
+ error=res.error,
113
+ meta=res.meta,
114
+ terminate=res.terminate,
115
+ )
116
+
117
+ find.execute = execute
118
+ return find
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import os
5
+ import re
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from my_agent_core.tools import Tool, tool
11
+
12
+ from my_coding_agent.tools.base import (
13
+ DEFAULT_IGNORE_DIRS,
14
+ DEFAULT_MAX_BYTES,
15
+ StringCompatibleToolResult,
16
+ is_binary_file,
17
+ resolve_path,
18
+ )
19
+
20
+
21
+ class GrepResult(StringCompatibleToolResult):
22
+ """Grep 工具执行结果:继承 StringCompatibleToolResult。"""
23
+
24
+
25
+ def make_grep_tool(workspace: Path | str) -> Tool:
26
+ """创建 grep 工具工厂,在 workspace 内检索文件(对标 Pi 官方 grep 协议与参数)。"""
27
+ workspace = Path(workspace).resolve()
28
+
29
+ @tool(
30
+ name="grep",
31
+ description="Search file contents for patterns (respects .gitignore). Supports regex, case sensitivity, context lines, glob filter, and line limits.",
32
+ is_parallel_safe=True,
33
+ )
34
+ async def grep(
35
+ pattern: str,
36
+ path: str = ".",
37
+ glob: str | None = None,
38
+ ignore_case: bool | None = None,
39
+ literal: bool | None = None,
40
+ context: int = 0,
41
+ limit: int | None = None,
42
+ regex: bool = False,
43
+ case_sensitive: bool = False,
44
+ ) -> str:
45
+ try:
46
+ target_root = resolve_path(workspace, path)
47
+ if not target_root.exists():
48
+ return f"Error: Path not found: {path}"
49
+
50
+ # 解析大小写敏感度(Pi 规范优先: ignoreCase / ignore_case)
51
+ actual_ignore_case = True
52
+ if ignore_case is not None:
53
+ actual_ignore_case = bool(ignore_case)
54
+ elif case_sensitive:
55
+ actual_ignore_case = False
56
+
57
+ # 解析字面量检索(Pi 规范: literal=True 优先,否则 regex=True)
58
+ is_literal = False
59
+ if literal is not None:
60
+ is_literal = bool(literal)
61
+ else:
62
+ is_literal = not regex
63
+
64
+ flags = re.IGNORECASE if actual_ignore_case else 0
65
+ try:
66
+ regex_pat = re.escape(pattern) if is_literal else pattern
67
+ compiled_regex = re.compile(regex_pat, flags)
68
+ except re.error as e:
69
+ return f"Error: Invalid regular expression: {e}"
70
+
71
+ effective_glob = glob
72
+ effective_limit = limit if limit is not None else 100
73
+ effective_context = max(0, context)
74
+
75
+ matches: list[str] = []
76
+ files_to_search: list[Path] = []
77
+
78
+ if target_root.is_file():
79
+ if not effective_glob or fnmatch.fnmatch(target_root.name, effective_glob):
80
+ files_to_search = [target_root]
81
+ else:
82
+ for root, dirs, files in os.walk(target_root):
83
+ dirs[:] = [d for d in dirs if d not in DEFAULT_IGNORE_DIRS]
84
+ dirs.sort()
85
+ for f in sorted(files):
86
+ if effective_glob and not fnmatch.fnmatch(f, effective_glob):
87
+ continue
88
+ files_to_search.append(Path(root) / f)
89
+
90
+ for file_path in files_to_search:
91
+ if is_binary_file(file_path):
92
+ continue
93
+ try:
94
+ rel_str = str(file_path.relative_to(workspace)).replace("\\", "/")
95
+ except ValueError:
96
+ rel_str = str(file_path).replace("\\", "/")
97
+
98
+ try:
99
+ with open(file_path, encoding="utf-8", errors="replace") as f:
100
+ file_lines = f.read().splitlines()
101
+
102
+ matched_line_indices = [idx for idx, line in enumerate(file_lines) if compiled_regex.search(line)]
103
+
104
+ if not matched_line_indices:
105
+ continue
106
+
107
+ # 处理上下文行合并
108
+ if effective_context > 0:
109
+ emitted_indices: set[int] = set()
110
+ for idx in matched_line_indices:
111
+ start_ctx = max(0, idx - effective_context)
112
+ end_ctx = min(len(file_lines), idx + effective_context + 1)
113
+ for c_idx in range(start_ctx, end_ctx):
114
+ if c_idx not in emitted_indices:
115
+ emitted_indices.add(c_idx)
116
+ sep = ":" if c_idx == idx else "-"
117
+ matches.append(f"{rel_str}{sep}{c_idx + 1}: {file_lines[c_idx]}")
118
+ if len(matches) >= effective_limit:
119
+ matches.append(f"[Reached maximum limit of {effective_limit} matches]")
120
+ return "\n".join(matches)
121
+ else:
122
+ for idx in matched_line_indices:
123
+ matches.append(f"{rel_str}:{idx + 1}: {file_lines[idx]}")
124
+ if len(matches) >= effective_limit:
125
+ matches.append(f"[Reached maximum limit of {effective_limit} matches]")
126
+ return "\n".join(matches)
127
+
128
+ except (OSError, UnicodeDecodeError):
129
+ continue
130
+
131
+ output = "\n".join(matches) if matches else f"No matches found for pattern '{pattern}'."
132
+ encoded = output.encode("utf-8")
133
+ if len(encoded) > DEFAULT_MAX_BYTES:
134
+ encoded = encoded[:DEFAULT_MAX_BYTES]
135
+ last_nl = encoded.rfind(b"\n")
136
+ if last_nl != -1:
137
+ encoded = encoded[:last_nl]
138
+ output = encoded.decode("utf-8", errors="ignore")
139
+ output += f"\n\n[Output truncated: exceeded {DEFAULT_MAX_BYTES // 1024}KB limit]"
140
+
141
+ return output
142
+ except Exception as e:
143
+ return f"Error: {e}"
144
+
145
+ orig_execute = grep.execute
146
+
147
+ async def execute(
148
+ args: dict[str, Any] | None = None,
149
+ signal: Any | None = None,
150
+ on_update: Callable[[Any], None] | None = None,
151
+ tool_call_id: str | None = None,
152
+ **kwargs: Any,
153
+ ) -> GrepResult:
154
+ call_args = dict(args) if isinstance(args, dict) else {}
155
+ call_args.update(kwargs)
156
+ if "ignoreCase" in call_args and "ignore_case" not in call_args:
157
+ call_args["ignore_case"] = call_args.pop("ignoreCase")
158
+ if "glob_filter" in call_args and "glob" not in call_args:
159
+ call_args["glob"] = call_args.pop("glob_filter")
160
+ if "max_matches" in call_args and "limit" not in call_args:
161
+ call_args["limit"] = call_args.pop("max_matches")
162
+ res = await orig_execute(
163
+ call_args,
164
+ signal=signal,
165
+ on_update=on_update,
166
+ tool_call_id=tool_call_id,
167
+ )
168
+ return GrepResult(
169
+ ok=res.ok,
170
+ data=res.data,
171
+ error=res.error,
172
+ meta=res.meta,
173
+ terminate=res.terminate,
174
+ )
175
+
176
+ grep.execute = execute
177
+ return grep
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import os
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from my_agent_core.tools import Tool, tool
10
+
11
+ from my_coding_agent.tools.base import (
12
+ DEFAULT_MAX_BYTES,
13
+ StringCompatibleToolResult,
14
+ resolve_path,
15
+ )
16
+
17
+ DEFAULT_LS_LIMIT = 500
18
+
19
+
20
+ class LsResult(StringCompatibleToolResult):
21
+ """Ls 工具执行结果:继承 StringCompatibleToolResult。"""
22
+
23
+
24
+ def make_ls_tool(workspace: Path | str) -> Tool:
25
+ """创建工作区绑定的 ls 工具(对标 Pi 官方 ls 实现)。
26
+
27
+ - path: 目录路径(默认当前工作目录 ".")
28
+ - limit: 最多返回条目数(默认 500)
29
+ - 排序:按字母不区分大小写排序,目录带 '/' 后缀
30
+ - 包含点文件(dotfiles),输出截断至 500 条或 50KB
31
+ """
32
+ workspace = Path(workspace).resolve()
33
+
34
+ @tool(
35
+ name="ls",
36
+ description="List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to 500 entries or 50KB (whichever is hit first).",
37
+ is_parallel_safe=True,
38
+ )
39
+ async def ls(path: str = ".", limit: int = DEFAULT_LS_LIMIT) -> str:
40
+ try:
41
+ target = resolve_path(workspace, path)
42
+ if not target.exists():
43
+ return f"Error: Path not found: {path}"
44
+ if not target.is_dir():
45
+ return f"Error: Not a directory: {path}"
46
+
47
+ try:
48
+ raw_entries = os.listdir(target)
49
+ except OSError as e:
50
+ return f"Error: Cannot read directory: {e}"
51
+
52
+ # 字母不区分大小写排序
53
+ raw_entries.sort(key=lambda s: s.lower())
54
+
55
+ effective_limit = limit if (limit is not None and limit > 0) else DEFAULT_LS_LIMIT
56
+ results: list[str] = []
57
+ truncated_by_limit = False
58
+
59
+ for entry in raw_entries:
60
+ if len(results) >= effective_limit:
61
+ truncated_by_limit = True
62
+ break
63
+ full_path = target / entry
64
+ suffix = ""
65
+ with contextlib.suppress(OSError):
66
+ if full_path.is_dir():
67
+ suffix = "/"
68
+ results.append(f"{entry}{suffix}")
69
+
70
+ output = "\n".join(results)
71
+ encoded = output.encode("utf-8")
72
+ if len(encoded) > DEFAULT_MAX_BYTES:
73
+ encoded = encoded[:DEFAULT_MAX_BYTES]
74
+ last_nl = encoded.rfind(b"\n")
75
+ if last_nl != -1:
76
+ encoded = encoded[:last_nl]
77
+ output = encoded.decode("utf-8", errors="ignore")
78
+ output += f"\n\n[Output truncated: output exceeded {DEFAULT_MAX_BYTES // 1024}KB limit]"
79
+ elif truncated_by_limit:
80
+ output += f"\n\n[Output truncated: reached entry limit of {effective_limit}]"
81
+
82
+ return output if output else "(Empty directory)"
83
+ except Exception as e:
84
+ return f"Error: {e}"
85
+
86
+ orig_execute = ls.execute
87
+
88
+ async def execute(
89
+ args: dict[str, Any] | None = None,
90
+ signal: Any | None = None,
91
+ on_update: Callable[[Any], None] | None = None,
92
+ tool_call_id: str | None = None,
93
+ **kwargs: Any,
94
+ ) -> LsResult:
95
+ call_args = dict(args) if isinstance(args, dict) else {}
96
+ call_args.update(kwargs)
97
+ res = await orig_execute(
98
+ call_args,
99
+ signal=signal,
100
+ on_update=on_update,
101
+ tool_call_id=tool_call_id,
102
+ )
103
+ return LsResult(
104
+ ok=res.ok,
105
+ data=res.data,
106
+ error=res.error,
107
+ meta=res.meta,
108
+ terminate=res.terminate,
109
+ )
110
+
111
+ ls.execute = execute
112
+ return ls
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from my_agent_core.tools import Tool, tool
8
+
9
+ from my_coding_agent.tools.base import (
10
+ DEFAULT_MAX_BYTES,
11
+ DEFAULT_MAX_LINES,
12
+ StringCompatibleToolResult,
13
+ is_binary_file,
14
+ resolve_path,
15
+ )
16
+
17
+
18
+ class ReadResult(StringCompatibleToolResult):
19
+ """Read 工具执行结果:继承 StringCompatibleToolResult。"""
20
+
21
+
22
+ def make_read_tool(workspace: Path) -> Tool:
23
+ workspace = workspace.resolve()
24
+
25
+ @tool(
26
+ name="read",
27
+ description="Read file contents with line offset/limit pagination and automatic truncation.",
28
+ is_parallel_safe=True,
29
+ )
30
+ async def read(path: str, offset: int = 1, limit: int | None = None) -> str:
31
+ try:
32
+ target = resolve_path(workspace, path)
33
+ if not target.exists():
34
+ return f"Error: File not found: {path}"
35
+ if target.is_dir():
36
+ return f"Error: Path is a directory: {path}"
37
+ if is_binary_file(target):
38
+ size = target.stat().st_size
39
+ return f"Error: Cannot read binary file ({size} bytes): {path}"
40
+
41
+ text = target.read_text(encoding="utf-8", errors="replace")
42
+ lines = text.splitlines()
43
+ total_lines = len(lines)
44
+
45
+ if offset < 1:
46
+ offset = 1
47
+ if offset > total_lines and total_lines > 0:
48
+ return f"Error: Offset {offset} is beyond end of file ('{path}' has only {total_lines} lines total)."
49
+
50
+ start_idx = offset - 1
51
+ effective_limit = limit if limit is not None else DEFAULT_MAX_LINES
52
+ end_idx = min(start_idx + effective_limit, total_lines)
53
+
54
+ # 检查 2000 行限制
55
+ is_line_truncated = False
56
+ if end_idx - start_idx > DEFAULT_MAX_LINES:
57
+ end_idx = start_idx + DEFAULT_MAX_LINES
58
+ is_line_truncated = True
59
+
60
+ selected_lines = lines[start_idx:end_idx]
61
+ result_text = "\n".join(selected_lines)
62
+
63
+ # 检查 50KB 字节限制
64
+ is_byte_truncated = False
65
+ encoded = result_text.encode("utf-8")
66
+ if len(encoded) > DEFAULT_MAX_BYTES:
67
+ encoded = encoded[:DEFAULT_MAX_BYTES]
68
+ last_nl = encoded.rfind(b"\n")
69
+ if last_nl != -1:
70
+ encoded = encoded[:last_nl]
71
+ result_text = encoded.decode("utf-8", errors="ignore")
72
+ end_idx = start_idx + len(result_text.splitlines())
73
+ is_byte_truncated = True
74
+
75
+ truncated = (
76
+ is_line_truncated
77
+ or is_byte_truncated
78
+ or (end_idx < total_lines and limit is None)
79
+ )
80
+ if truncated:
81
+ result_text += f"\n\n[Showing lines {offset}-{end_idx} of {total_lines}. Use offset={end_idx + 1} to continue.]"
82
+
83
+ return result_text
84
+ except Exception as e:
85
+ return f"Error: {e}"
86
+
87
+ orig_execute = read.execute
88
+
89
+ async def execute(
90
+ args: dict[str, Any] | None = None,
91
+ signal: Any | None = None,
92
+ on_update: Callable[[Any], None] | None = None,
93
+ tool_call_id: str | None = None,
94
+ **kwargs: Any,
95
+ ) -> ReadResult:
96
+ call_args = dict(args) if isinstance(args, dict) else {}
97
+ call_args.update(kwargs)
98
+ res = await orig_execute(
99
+ call_args,
100
+ signal=signal,
101
+ on_update=on_update,
102
+ tool_call_id=tool_call_id,
103
+ )
104
+ return ReadResult(
105
+ ok=res.ok,
106
+ data=res.data,
107
+ error=res.error,
108
+ meta=res.meta,
109
+ terminate=res.terminate,
110
+ )
111
+
112
+ read.execute = execute
113
+ return read
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from my_agent_core.tools import Tool, tool
8
+
9
+ from my_coding_agent.mutation_queue import FileMutationQueue
10
+ from my_coding_agent.tools.base import StringCompatibleToolResult, resolve_path
11
+
12
+
13
+ class WriteResult(StringCompatibleToolResult):
14
+ """Write 工具执行结果:继承 StringCompatibleToolResult。"""
15
+
16
+
17
+ def make_write_tool(workspace: Path, mutation_queue: FileMutationQueue | None = None) -> Tool:
18
+ """创建工作区绑定的 write 工具。
19
+
20
+ - workspace: 工作区根目录路径
21
+ - mutation_queue: 单文件并发互斥锁队列,缺省时自动新建
22
+ """
23
+ workspace = Path(workspace).resolve()
24
+ queue = mutation_queue or FileMutationQueue()
25
+
26
+ @tool(
27
+ name="write",
28
+ description="Write complete content to a file, automatically creating parent directories.",
29
+ is_parallel_safe=True,
30
+ )
31
+ async def write(path: str, content: str) -> str:
32
+ try:
33
+ target = resolve_path(workspace, path)
34
+ if target.is_dir():
35
+ return f"Error: Path is a directory: {path}"
36
+
37
+ async with queue.acquire(target):
38
+ target.parent.mkdir(parents=True, exist_ok=True)
39
+ target.write_text(content, encoding="utf-8", newline="")
40
+ bytes_count = len(content.encode("utf-8"))
41
+ lines_count = len(content.splitlines())
42
+ return f"Successfully wrote {bytes_count} bytes ({lines_count} lines) to {path}"
43
+ except Exception as e:
44
+ return f"Error: {e}"
45
+
46
+ orig_execute = write.execute
47
+
48
+ async def execute(
49
+ args: dict[str, Any] | None = None,
50
+ signal: Any | None = None,
51
+ on_update: Callable[[Any], None] | None = None,
52
+ tool_call_id: str | None = None,
53
+ **kwargs: Any,
54
+ ) -> WriteResult:
55
+ call_args = dict(args) if isinstance(args, dict) else {}
56
+ call_args.update(kwargs)
57
+ res = await orig_execute(
58
+ call_args,
59
+ signal=signal,
60
+ on_update=on_update,
61
+ tool_call_id=tool_call_id,
62
+ )
63
+ return WriteResult(
64
+ ok=res.ok,
65
+ data=res.data,
66
+ error=res.error,
67
+ meta=res.meta,
68
+ terminate=res.terminate,
69
+ )
70
+
71
+ write.execute = execute
72
+ return write
package/tui/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # my-agent-tui
2
+
3
+ `my-pi-agent` 的高质感交互终端表现层(基于 `@earendil-works/pi-tui` 的 Node.js / TypeScript 前端 Shell)。
4
+
5
+ ## 核心特性
6
+
7
+ - **Pi 原厂渲染引擎**:基于 `@earendil-works/pi-tui` 的 `TuiMainScreen` 差量重绘器,保留原生终端历史与鼠标滚轮翻看;
8
+ - **CSI 2026 同步屏障**:垂直原子刷新,彻底消灭字符撕裂与屏幕闪烁;
9
+ - **24-bit TrueColor 卡片系统**:移植 Pi 官方 `dark.json` 柔和调色盘,消息气泡与圆角卡片;
10
+ - **动态思考折叠块 (Thinking Block)**:流式大模型思考过程展示,`Ctrl+O` 随时展开/折叠;
11
+ - **就地更新工具卡片**:圆角细线卡片(`╭─ ⚙️ read ... ─╮`)、点阵 Spinner 微动效、执行完成变绿与耗时提示;
12
+ - **富文本多行编辑器与 IME 光标锚定**:支持中文输入法硬件光标精准对齐,候选框永不乱跳;
13
+ - **模糊补全气泡**:`@` 文件模糊补全与 `/` 斜杠命令提示浮窗;
14
+ - **stdio JSON-RPC 双向流**:与 Python 无头业务内核 `my-coding-agent` 深度集成,支持 `Esc` 瞬时打断与动态即时转向。
15
+
16
+ ## 启动与运行
17
+
18
+ ```bash
19
+ # 从仓库根目录一键启动 (推荐)
20
+ npm start
21
+
22
+ # 或在 tui/ 目录下独立执行
23
+ cd tui
24
+ npm install
25
+ npm run build
26
+ npm start
27
+ ```