comate-cli 0.1.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.
- comate_cli/__init__.py +5 -0
- comate_cli/__main__.py +5 -0
- comate_cli/main.py +128 -0
- comate_cli/terminal_agent/__init__.py +2 -0
- comate_cli/terminal_agent/animations.py +283 -0
- comate_cli/terminal_agent/app.py +261 -0
- comate_cli/terminal_agent/assistant_render.py +243 -0
- comate_cli/terminal_agent/env_utils.py +37 -0
- comate_cli/terminal_agent/error_display.py +46 -0
- comate_cli/terminal_agent/event_renderer.py +867 -0
- comate_cli/terminal_agent/fragment_utils.py +25 -0
- comate_cli/terminal_agent/history_printer.py +150 -0
- comate_cli/terminal_agent/input_geometry.py +92 -0
- comate_cli/terminal_agent/layout_coordinator.py +188 -0
- comate_cli/terminal_agent/logging_adapter.py +147 -0
- comate_cli/terminal_agent/logo.py +58 -0
- comate_cli/terminal_agent/markdown_render.py +24 -0
- comate_cli/terminal_agent/mention_completer.py +293 -0
- comate_cli/terminal_agent/message_style.py +33 -0
- comate_cli/terminal_agent/models.py +89 -0
- comate_cli/terminal_agent/question_view.py +584 -0
- comate_cli/terminal_agent/rewind_store.py +712 -0
- comate_cli/terminal_agent/rpc_protocol.py +103 -0
- comate_cli/terminal_agent/rpc_stdio.py +280 -0
- comate_cli/terminal_agent/selection_menu.py +305 -0
- comate_cli/terminal_agent/session_view.py +99 -0
- comate_cli/terminal_agent/slash_commands.py +142 -0
- comate_cli/terminal_agent/startup.py +77 -0
- comate_cli/terminal_agent/status_bar.py +258 -0
- comate_cli/terminal_agent/text_effects.py +30 -0
- comate_cli/terminal_agent/tool_view.py +584 -0
- comate_cli/terminal_agent/tui.py +1006 -0
- comate_cli/terminal_agent/tui_parts/__init__.py +17 -0
- comate_cli/terminal_agent/tui_parts/commands.py +759 -0
- comate_cli/terminal_agent/tui_parts/history_sync.py +262 -0
- comate_cli/terminal_agent/tui_parts/input_behavior.py +324 -0
- comate_cli/terminal_agent/tui_parts/key_bindings.py +307 -0
- comate_cli/terminal_agent/tui_parts/render_panels.py +537 -0
- comate_cli/terminal_agent/tui_parts/slash_command_registry.py +45 -0
- comate_cli/terminal_agent/tui_parts/ui_mode.py +9 -0
- comate_cli-0.1.0.dist-info/METADATA +37 -0
- comate_cli-0.1.0.dist-info/RECORD +44 -0
- comate_cli-0.1.0.dist-info/WHEEL +4 -0
- comate_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
|
12
|
+
from prompt_toolkit.document import Document
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class MentionContext:
|
|
19
|
+
marker_index: int
|
|
20
|
+
fragment: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LocalFileMentionCompleter(Completer):
|
|
24
|
+
"""Offer fuzzy `@` path suggestions backed by workspace files."""
|
|
25
|
+
|
|
26
|
+
_TRIGGER_GUARDS = frozenset((".", "-", "_", "`", "'", '"', ":", "@", "#", "~"))
|
|
27
|
+
_IGNORED_NAME_GROUPS: dict[str, tuple[str, ...]] = {
|
|
28
|
+
"vcs_metadata": (".DS_Store", ".bzr", ".git", ".hg", ".svn"),
|
|
29
|
+
"tooling_caches": (
|
|
30
|
+
".build",
|
|
31
|
+
".cache",
|
|
32
|
+
".coverage",
|
|
33
|
+
".fleet",
|
|
34
|
+
".gradle",
|
|
35
|
+
".idea",
|
|
36
|
+
".ipynb_checkpoints",
|
|
37
|
+
".pnpm-store",
|
|
38
|
+
".pytest_cache",
|
|
39
|
+
".pub-cache",
|
|
40
|
+
".ruff_cache",
|
|
41
|
+
".swiftpm",
|
|
42
|
+
".tox",
|
|
43
|
+
".venv",
|
|
44
|
+
".vs",
|
|
45
|
+
".vscode",
|
|
46
|
+
".yarn",
|
|
47
|
+
".yarn-cache",
|
|
48
|
+
),
|
|
49
|
+
"js_frontend": (
|
|
50
|
+
".next",
|
|
51
|
+
".nuxt",
|
|
52
|
+
".parcel-cache",
|
|
53
|
+
".svelte-kit",
|
|
54
|
+
".turbo",
|
|
55
|
+
".vercel",
|
|
56
|
+
"node_modules",
|
|
57
|
+
),
|
|
58
|
+
"python_packaging": (
|
|
59
|
+
"__pycache__",
|
|
60
|
+
"build",
|
|
61
|
+
"coverage",
|
|
62
|
+
"dist",
|
|
63
|
+
"htmlcov",
|
|
64
|
+
"pip-wheel-metadata",
|
|
65
|
+
"venv",
|
|
66
|
+
),
|
|
67
|
+
"java_jvm": (".mvn", "out", "target"),
|
|
68
|
+
"dotnet_native": ("bin", "cmake-build-debug", "cmake-build-release", "obj"),
|
|
69
|
+
"bazel_buck": ("bazel-bin", "bazel-out", "bazel-testlogs", "buck-out"),
|
|
70
|
+
"misc_artifacts": (
|
|
71
|
+
".dart_tool",
|
|
72
|
+
".serverless",
|
|
73
|
+
".stack-work",
|
|
74
|
+
".terraform",
|
|
75
|
+
".terragrunt-cache",
|
|
76
|
+
"DerivedData",
|
|
77
|
+
"Pods",
|
|
78
|
+
"deps",
|
|
79
|
+
"tmp",
|
|
80
|
+
"vendor",
|
|
81
|
+
),
|
|
82
|
+
}
|
|
83
|
+
_IGNORED_NAMES = frozenset(name for group in _IGNORED_NAME_GROUPS.values() for name in group)
|
|
84
|
+
_IGNORED_PATTERN_PARTS: tuple[str, ...] = (
|
|
85
|
+
r".*_cache$",
|
|
86
|
+
r".*-cache$",
|
|
87
|
+
r".*\.egg-info$",
|
|
88
|
+
r".*\.dist-info$",
|
|
89
|
+
r".*\.py[co]$",
|
|
90
|
+
r".*\.class$",
|
|
91
|
+
r".*\.sw[po]$",
|
|
92
|
+
r".*~$",
|
|
93
|
+
r".*\.(?:tmp|bak)$",
|
|
94
|
+
)
|
|
95
|
+
_IGNORED_PATTERNS = re.compile(
|
|
96
|
+
"|".join(f"(?:{part})" for part in _IGNORED_PATTERN_PARTS),
|
|
97
|
+
re.IGNORECASE,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
root: Path,
|
|
103
|
+
*,
|
|
104
|
+
refresh_interval: float = 2.0,
|
|
105
|
+
limit: int = 1000,
|
|
106
|
+
) -> None:
|
|
107
|
+
self._root = root
|
|
108
|
+
self._refresh_interval = refresh_interval
|
|
109
|
+
self._limit = limit
|
|
110
|
+
|
|
111
|
+
self._top_cache_time: float = 0.0
|
|
112
|
+
self._top_cached_paths: list[str] = []
|
|
113
|
+
|
|
114
|
+
self._deep_cache_time: float = 0.0
|
|
115
|
+
self._deep_cached_paths: list[str] = []
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def _is_ignored(cls, name: str) -> bool:
|
|
119
|
+
if not name:
|
|
120
|
+
return True
|
|
121
|
+
if name in cls._IGNORED_NAMES:
|
|
122
|
+
return True
|
|
123
|
+
return bool(cls._IGNORED_PATTERNS.fullmatch(name))
|
|
124
|
+
|
|
125
|
+
def extract_context(self, text_before_cursor: str) -> MentionContext | None:
|
|
126
|
+
marker_index = text_before_cursor.rfind("@")
|
|
127
|
+
if marker_index == -1:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
if marker_index > 0:
|
|
131
|
+
previous = text_before_cursor[marker_index - 1]
|
|
132
|
+
if previous.isalnum() or previous in self._TRIGGER_GUARDS:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
fragment = text_before_cursor[marker_index + 1 :]
|
|
136
|
+
if any(char.isspace() for char in fragment):
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
return MentionContext(marker_index=marker_index, fragment=fragment)
|
|
140
|
+
|
|
141
|
+
def suggest(self, text_before_cursor: str, *, max_items: int = 12) -> tuple[MentionContext, list[str]] | None:
|
|
142
|
+
context = self.extract_context(text_before_cursor)
|
|
143
|
+
if context is None:
|
|
144
|
+
return None
|
|
145
|
+
if self._is_completed_file(context.fragment):
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
fragment_lower = context.fragment.lower()
|
|
149
|
+
source = self._candidate_paths(context.fragment)
|
|
150
|
+
if not source:
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
matched: list[str] = []
|
|
154
|
+
for path in source:
|
|
155
|
+
if not fragment_lower:
|
|
156
|
+
matched.append(path)
|
|
157
|
+
continue
|
|
158
|
+
if fragment_lower in path.lower():
|
|
159
|
+
matched.append(path)
|
|
160
|
+
|
|
161
|
+
if not matched:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
def _rank(path: str) -> tuple[int, str]:
|
|
165
|
+
basename = path.rstrip("/").split("/")[-1].lower()
|
|
166
|
+
if basename.startswith(fragment_lower):
|
|
167
|
+
category = 0
|
|
168
|
+
elif fragment_lower in basename:
|
|
169
|
+
category = 1
|
|
170
|
+
else:
|
|
171
|
+
category = 2
|
|
172
|
+
return (category, path.lower())
|
|
173
|
+
|
|
174
|
+
matched.sort(key=_rank)
|
|
175
|
+
return context, matched[: max(max_items, 1)]
|
|
176
|
+
|
|
177
|
+
def apply_completion(
|
|
178
|
+
self,
|
|
179
|
+
*,
|
|
180
|
+
full_text: str,
|
|
181
|
+
cursor_position: int,
|
|
182
|
+
context: MentionContext,
|
|
183
|
+
completion: str,
|
|
184
|
+
append_space: bool,
|
|
185
|
+
) -> tuple[str, int]:
|
|
186
|
+
before_cursor = full_text[:cursor_position]
|
|
187
|
+
after_cursor = full_text[cursor_position:]
|
|
188
|
+
fragment_start = context.marker_index + 1
|
|
189
|
+
replacement_before = f"{before_cursor[:fragment_start]}{completion}"
|
|
190
|
+
new_cursor = len(replacement_before)
|
|
191
|
+
replacement_after = after_cursor
|
|
192
|
+
|
|
193
|
+
if append_space:
|
|
194
|
+
should_insert_space = not replacement_after.startswith(" ")
|
|
195
|
+
if should_insert_space:
|
|
196
|
+
replacement_before = f"{replacement_before} "
|
|
197
|
+
new_cursor += 1
|
|
198
|
+
|
|
199
|
+
return f"{replacement_before}{replacement_after}", new_cursor
|
|
200
|
+
|
|
201
|
+
def _candidate_paths(self, fragment: str) -> list[str]:
|
|
202
|
+
if "/" not in fragment and len(fragment) < 3:
|
|
203
|
+
return self._get_top_level_paths()
|
|
204
|
+
return self._get_deep_paths()
|
|
205
|
+
|
|
206
|
+
def _get_top_level_paths(self) -> list[str]:
|
|
207
|
+
now = time.monotonic()
|
|
208
|
+
if now - self._top_cache_time <= self._refresh_interval:
|
|
209
|
+
return self._top_cached_paths
|
|
210
|
+
|
|
211
|
+
entries: list[str] = []
|
|
212
|
+
try:
|
|
213
|
+
for entry in sorted(self._root.iterdir(), key=lambda path: path.name):
|
|
214
|
+
name = entry.name
|
|
215
|
+
if self._is_ignored(name):
|
|
216
|
+
continue
|
|
217
|
+
entries.append(f"{name}/" if entry.is_dir() else name)
|
|
218
|
+
if len(entries) >= self._limit:
|
|
219
|
+
break
|
|
220
|
+
except OSError as exc:
|
|
221
|
+
logger.debug("top-level mention scan failed: %s", exc)
|
|
222
|
+
return self._top_cached_paths
|
|
223
|
+
|
|
224
|
+
self._top_cached_paths = entries
|
|
225
|
+
self._top_cache_time = now
|
|
226
|
+
return self._top_cached_paths
|
|
227
|
+
|
|
228
|
+
def _get_deep_paths(self) -> list[str]:
|
|
229
|
+
now = time.monotonic()
|
|
230
|
+
if now - self._deep_cache_time <= self._refresh_interval:
|
|
231
|
+
return self._deep_cached_paths
|
|
232
|
+
|
|
233
|
+
paths: list[str] = []
|
|
234
|
+
try:
|
|
235
|
+
for current_root, dirs, files in os.walk(self._root):
|
|
236
|
+
relative_root = Path(current_root).relative_to(self._root)
|
|
237
|
+
dirs[:] = sorted(directory for directory in dirs if not self._is_ignored(directory))
|
|
238
|
+
|
|
239
|
+
if relative_root.parts and any(self._is_ignored(part) for part in relative_root.parts):
|
|
240
|
+
dirs[:] = []
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
if relative_root.parts:
|
|
244
|
+
paths.append(relative_root.as_posix() + "/")
|
|
245
|
+
if len(paths) >= self._limit:
|
|
246
|
+
break
|
|
247
|
+
|
|
248
|
+
for file_name in sorted(files):
|
|
249
|
+
if self._is_ignored(file_name):
|
|
250
|
+
continue
|
|
251
|
+
relative = (relative_root / file_name).as_posix()
|
|
252
|
+
if not relative:
|
|
253
|
+
continue
|
|
254
|
+
paths.append(relative)
|
|
255
|
+
if len(paths) >= self._limit:
|
|
256
|
+
break
|
|
257
|
+
|
|
258
|
+
if len(paths) >= self._limit:
|
|
259
|
+
break
|
|
260
|
+
except OSError as exc:
|
|
261
|
+
logger.debug("deep mention scan failed: %s", exc)
|
|
262
|
+
return self._deep_cached_paths
|
|
263
|
+
|
|
264
|
+
self._deep_cached_paths = paths
|
|
265
|
+
self._deep_cache_time = now
|
|
266
|
+
return self._deep_cached_paths
|
|
267
|
+
|
|
268
|
+
def _is_completed_file(self, fragment: str) -> bool:
|
|
269
|
+
candidate = fragment.rstrip("/")
|
|
270
|
+
if not candidate:
|
|
271
|
+
return False
|
|
272
|
+
try:
|
|
273
|
+
return (self._root / candidate).is_file()
|
|
274
|
+
except OSError:
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
def get_completions(
|
|
278
|
+
self, document: Document, complete_event: CompleteEvent
|
|
279
|
+
) -> Iterable[Completion]:
|
|
280
|
+
del complete_event
|
|
281
|
+
result = self.suggest(document.text_before_cursor, max_items=12)
|
|
282
|
+
if result is None:
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
context, candidates = result
|
|
286
|
+
for candidate in candidates:
|
|
287
|
+
append_space = not candidate.endswith("/") and not document.text_after_cursor.startswith(" ")
|
|
288
|
+
insert_text = f"{candidate} " if append_space else candidate
|
|
289
|
+
yield Completion(
|
|
290
|
+
text=insert_text,
|
|
291
|
+
start_position=-len(context.fragment),
|
|
292
|
+
display=candidate,
|
|
293
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
|
|
5
|
+
ASSISTANT_PREFIX = "●"
|
|
6
|
+
ASSISTANT_PREFIX_STYLE = "bold cyan"
|
|
7
|
+
ASSISTANT_MESSAGE_GAP_LINES = 1
|
|
8
|
+
USER_PREFIX = ">"
|
|
9
|
+
USER_PREFIX_STYLE = "bold ansicyan"
|
|
10
|
+
|
|
11
|
+
TOOL_RUNNING_STYLE = "bold blue"
|
|
12
|
+
TOOL_SUCCESS_STYLE = "bold green"
|
|
13
|
+
TOOL_ERROR_STYLE = "bold red"
|
|
14
|
+
TOOL_RUNNING_PREFIX = "→"
|
|
15
|
+
TOOL_SUCCESS_PREFIX = "✓"
|
|
16
|
+
TOOL_ERROR_PREFIX = "✖"
|
|
17
|
+
|
|
18
|
+
THINKING_PREFIX = "💭"
|
|
19
|
+
THINKING_STYLE = "dim" # 灰色
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def print_assistant_prefix_line(console: Console, content: str) -> None:
|
|
23
|
+
"""Print assistant prefix and content on the same visual line."""
|
|
24
|
+
console.print(
|
|
25
|
+
f"[{ASSISTANT_PREFIX_STYLE}]{ASSISTANT_PREFIX}[/] {content}",
|
|
26
|
+
new_line_start=True,
|
|
27
|
+
soft_wrap=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def print_assistant_gap(console: Console) -> None:
|
|
32
|
+
for _ in range(ASSISTANT_MESSAGE_GAP_LINES):
|
|
33
|
+
console.print()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
ToolStatus = Literal["running", "success", "error"]
|
|
11
|
+
HistoryEntryType = Literal["user", "assistant", "tool_call", "tool_result", "system", "thinking", "elapsed"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LoadingStateType(Enum):
|
|
15
|
+
"""Loading 状态类型,用于决定渲染策略。"""
|
|
16
|
+
|
|
17
|
+
TOOL_CALL = "tool_call" # 工具调用 - 静态显示
|
|
18
|
+
THINKING = "thinking" # 思考中 - 流光效果
|
|
19
|
+
ANIMATION = "animation" # 动画状态(如 vibeing)- 流光效果
|
|
20
|
+
IDLE = "idle" # 空闲状态 - 无显示
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class LoadingState:
|
|
25
|
+
"""语义化的 loading 状态,携带类型信息和显示数据。
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
type: Loading 状态类型,决定渲染策略
|
|
29
|
+
text: 显示文本内容
|
|
30
|
+
metadata: 额外元数据(如工具名、耗时、token 数等)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
type: LoadingStateType
|
|
34
|
+
text: str = ""
|
|
35
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def tool_call(cls, text: str, **metadata) -> LoadingState:
|
|
39
|
+
"""创建工具调用状态的工厂方法。"""
|
|
40
|
+
return cls(type=LoadingStateType.TOOL_CALL, text=text, metadata=metadata)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def thinking(cls, text: str, **metadata) -> LoadingState:
|
|
44
|
+
"""创建思考状态的工厂方法。"""
|
|
45
|
+
return cls(type=LoadingStateType.THINKING, text=text, metadata=metadata)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def animation(cls, text: str, **metadata) -> LoadingState:
|
|
49
|
+
"""创建动画状态的工厂方法。"""
|
|
50
|
+
return cls(type=LoadingStateType.ANIMATION, text=text, metadata=metadata)
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def idle(cls) -> LoadingState:
|
|
54
|
+
"""创建空闲状态的工厂方法。"""
|
|
55
|
+
return cls(type=LoadingStateType.IDLE, text="")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ToolRunState:
|
|
60
|
+
tool_call_id: str
|
|
61
|
+
tool_name: str
|
|
62
|
+
args: dict[str, Any]
|
|
63
|
+
args_summary: str
|
|
64
|
+
status: ToolStatus = "running"
|
|
65
|
+
pulse_frame: int = 0
|
|
66
|
+
result_preview: str = ""
|
|
67
|
+
started_at_monotonic: float = 0.0
|
|
68
|
+
is_task: bool = False
|
|
69
|
+
subagent_name: str = ""
|
|
70
|
+
task_desc: str = ""
|
|
71
|
+
subagent_source_prefix: str = ""
|
|
72
|
+
baseline_source_tokens: int = 0
|
|
73
|
+
task_tokens: int = 0
|
|
74
|
+
last_progress_render_ts: float = 0.0
|
|
75
|
+
last_progress_tokens: int = 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class HistoryEntry:
|
|
80
|
+
entry_type: HistoryEntryType
|
|
81
|
+
text: str | Text # 支持普通字符串或 Rich Text 对象
|
|
82
|
+
severity: Literal["info", "warning", "error"] = "info"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class TodoItemState:
|
|
87
|
+
content: str
|
|
88
|
+
status: str
|
|
89
|
+
priority: str
|