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.
Files changed (44) hide show
  1. comate_cli/__init__.py +5 -0
  2. comate_cli/__main__.py +5 -0
  3. comate_cli/main.py +128 -0
  4. comate_cli/terminal_agent/__init__.py +2 -0
  5. comate_cli/terminal_agent/animations.py +283 -0
  6. comate_cli/terminal_agent/app.py +261 -0
  7. comate_cli/terminal_agent/assistant_render.py +243 -0
  8. comate_cli/terminal_agent/env_utils.py +37 -0
  9. comate_cli/terminal_agent/error_display.py +46 -0
  10. comate_cli/terminal_agent/event_renderer.py +867 -0
  11. comate_cli/terminal_agent/fragment_utils.py +25 -0
  12. comate_cli/terminal_agent/history_printer.py +150 -0
  13. comate_cli/terminal_agent/input_geometry.py +92 -0
  14. comate_cli/terminal_agent/layout_coordinator.py +188 -0
  15. comate_cli/terminal_agent/logging_adapter.py +147 -0
  16. comate_cli/terminal_agent/logo.py +58 -0
  17. comate_cli/terminal_agent/markdown_render.py +24 -0
  18. comate_cli/terminal_agent/mention_completer.py +293 -0
  19. comate_cli/terminal_agent/message_style.py +33 -0
  20. comate_cli/terminal_agent/models.py +89 -0
  21. comate_cli/terminal_agent/question_view.py +584 -0
  22. comate_cli/terminal_agent/rewind_store.py +712 -0
  23. comate_cli/terminal_agent/rpc_protocol.py +103 -0
  24. comate_cli/terminal_agent/rpc_stdio.py +280 -0
  25. comate_cli/terminal_agent/selection_menu.py +305 -0
  26. comate_cli/terminal_agent/session_view.py +99 -0
  27. comate_cli/terminal_agent/slash_commands.py +142 -0
  28. comate_cli/terminal_agent/startup.py +77 -0
  29. comate_cli/terminal_agent/status_bar.py +258 -0
  30. comate_cli/terminal_agent/text_effects.py +30 -0
  31. comate_cli/terminal_agent/tool_view.py +584 -0
  32. comate_cli/terminal_agent/tui.py +1006 -0
  33. comate_cli/terminal_agent/tui_parts/__init__.py +17 -0
  34. comate_cli/terminal_agent/tui_parts/commands.py +759 -0
  35. comate_cli/terminal_agent/tui_parts/history_sync.py +262 -0
  36. comate_cli/terminal_agent/tui_parts/input_behavior.py +324 -0
  37. comate_cli/terminal_agent/tui_parts/key_bindings.py +307 -0
  38. comate_cli/terminal_agent/tui_parts/render_panels.py +537 -0
  39. comate_cli/terminal_agent/tui_parts/slash_command_registry.py +45 -0
  40. comate_cli/terminal_agent/tui_parts/ui_mode.py +9 -0
  41. comate_cli-0.1.0.dist-info/METADATA +37 -0
  42. comate_cli-0.1.0.dist-info/RECORD +44 -0
  43. comate_cli-0.1.0.dist-info/WHEEL +4 -0
  44. 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