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,537 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from prompt_toolkit.utils import get_cwidth
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from comate_cli.terminal_agent.animations import _cyan_sweep_text, breathing_dot_color, breathing_dot_glyph
|
|
10
|
+
from comate_cli.terminal_agent.text_effects import fit_single_line
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RenderPanelsMixin:
|
|
16
|
+
_COMPLETION_PANEL_MAX_LINES = 8
|
|
17
|
+
|
|
18
|
+
def _terminal_width(self) -> int:
|
|
19
|
+
if self._app is None:
|
|
20
|
+
return 100
|
|
21
|
+
try:
|
|
22
|
+
return max(int(self._app.output.get_size().columns), 40)
|
|
23
|
+
except Exception:
|
|
24
|
+
return 100
|
|
25
|
+
|
|
26
|
+
def _queue_text(self) -> list[tuple[str, str]]:
|
|
27
|
+
queued_messages = list(self._queued_messages)
|
|
28
|
+
if not queued_messages:
|
|
29
|
+
return [("", " ")]
|
|
30
|
+
|
|
31
|
+
width = self._terminal_width()
|
|
32
|
+
fragments: list[tuple[str, str]] = []
|
|
33
|
+
for idx, message in enumerate(queued_messages):
|
|
34
|
+
preview = " ".join(message.split())
|
|
35
|
+
line = fit_single_line(f" > {preview}", width)
|
|
36
|
+
if idx > 0:
|
|
37
|
+
fragments.append(("", "\n"))
|
|
38
|
+
fragments.append(("class:queue.item", line))
|
|
39
|
+
return fragments
|
|
40
|
+
|
|
41
|
+
def _queue_height(self) -> int:
|
|
42
|
+
return max(1, len(self._queued_messages))
|
|
43
|
+
|
|
44
|
+
def _status_text(self) -> list[tuple[str, str]]:
|
|
45
|
+
width = self._terminal_width()
|
|
46
|
+
|
|
47
|
+
# 左栏:mode 图标 + 提示文字
|
|
48
|
+
mode = self._status_bar.get_mode()
|
|
49
|
+
hint_text = "(shift+tab to cycle)"
|
|
50
|
+
if mode == "plan":
|
|
51
|
+
left_main = "⏸ Plan mode on "
|
|
52
|
+
left_style = "class:status.mode.plan"
|
|
53
|
+
else:
|
|
54
|
+
left_main = "⏵⏵ Act mode on "
|
|
55
|
+
left_style = "class:status.mode.act"
|
|
56
|
+
|
|
57
|
+
# 右栏:model | ~branch / X% left
|
|
58
|
+
right_text = self._status_bar.info_status_text()
|
|
59
|
+
git_frags = self._status_bar.git_diff_fragments()
|
|
60
|
+
|
|
61
|
+
left_w = sum(get_cwidth(c) for c in left_main + hint_text)
|
|
62
|
+
right_w = sum(get_cwidth(c) for c in right_text)
|
|
63
|
+
if git_frags:
|
|
64
|
+
right_w += 2 + sum(get_cwidth(c) for _, t in git_frags for c in t)
|
|
65
|
+
|
|
66
|
+
padding = max(1, width - left_w - right_w - 2)
|
|
67
|
+
|
|
68
|
+
frags: list[tuple[str, str]] = [
|
|
69
|
+
(left_style, left_main),
|
|
70
|
+
("class:status.hint", hint_text),
|
|
71
|
+
("class:status", " " * padding),
|
|
72
|
+
("class:status", right_text),
|
|
73
|
+
]
|
|
74
|
+
if git_frags:
|
|
75
|
+
frags.append(("class:status", " "))
|
|
76
|
+
frags.extend(git_frags)
|
|
77
|
+
return frags
|
|
78
|
+
|
|
79
|
+
def _completion_panel_state(self) -> tuple[list[Any], int] | None:
|
|
80
|
+
input_area = getattr(self, "_input_area", None)
|
|
81
|
+
if input_area is None:
|
|
82
|
+
return None
|
|
83
|
+
buffer = getattr(input_area, "buffer", None)
|
|
84
|
+
if buffer is None:
|
|
85
|
+
return None
|
|
86
|
+
complete_state = getattr(buffer, "complete_state", None)
|
|
87
|
+
if complete_state is None:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
completions = list(getattr(complete_state, "completions", []) or [])
|
|
91
|
+
if not completions:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
selected_index: int | None = None
|
|
95
|
+
current_completion = getattr(complete_state, "current_completion", None)
|
|
96
|
+
if current_completion is not None:
|
|
97
|
+
for idx, completion in enumerate(completions):
|
|
98
|
+
if completion is current_completion:
|
|
99
|
+
selected_index = idx
|
|
100
|
+
break
|
|
101
|
+
if selected_index is None:
|
|
102
|
+
raw_index = getattr(complete_state, "complete_index", None)
|
|
103
|
+
if isinstance(raw_index, int) and 0 <= raw_index < len(completions):
|
|
104
|
+
selected_index = raw_index
|
|
105
|
+
if selected_index is None:
|
|
106
|
+
selected_index = 0
|
|
107
|
+
return completions, selected_index
|
|
108
|
+
|
|
109
|
+
def _completion_panel_text(self) -> list[tuple[str, str]]:
|
|
110
|
+
state = self._completion_panel_state()
|
|
111
|
+
if state is None:
|
|
112
|
+
return self._status_text()
|
|
113
|
+
|
|
114
|
+
completions, selected_index = state
|
|
115
|
+
width = self._terminal_width()
|
|
116
|
+
max_lines = max(1, int(self._COMPLETION_PANEL_MAX_LINES))
|
|
117
|
+
visible_slots = max_lines
|
|
118
|
+
hidden_below_count = 0
|
|
119
|
+
start = 0
|
|
120
|
+
end = len(completions)
|
|
121
|
+
if len(completions) > max_lines:
|
|
122
|
+
visible_slots = max(1, max_lines - 1)
|
|
123
|
+
start = max(0, selected_index - (visible_slots // 2))
|
|
124
|
+
end = min(len(completions), start + visible_slots)
|
|
125
|
+
start = max(0, end - visible_slots)
|
|
126
|
+
hidden_below_count = len(completions) - end
|
|
127
|
+
|
|
128
|
+
fragments: list[tuple[str, str]] = []
|
|
129
|
+
visible = completions[start:end]
|
|
130
|
+
for idx, completion in enumerate(visible):
|
|
131
|
+
absolute_idx = start + idx
|
|
132
|
+
is_selected = absolute_idx == selected_index
|
|
133
|
+
line_style = (
|
|
134
|
+
"class:completion.status.current"
|
|
135
|
+
if is_selected
|
|
136
|
+
else "class:completion.status.item"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
display_text = str(getattr(completion, "display_text", completion.text))
|
|
140
|
+
meta_text = str(getattr(completion, "display_meta_text", "")).strip()
|
|
141
|
+
line_prefix = "› " if is_selected else " "
|
|
142
|
+
row_text = f"{line_prefix}{display_text}"
|
|
143
|
+
if meta_text:
|
|
144
|
+
row_text = f"{row_text} — {meta_text}"
|
|
145
|
+
|
|
146
|
+
clipped = fit_single_line(row_text, width)
|
|
147
|
+
padding = " " * max(0, width - get_cwidth(clipped))
|
|
148
|
+
fragments.append((line_style, f"{clipped}{padding}"))
|
|
149
|
+
if idx != len(visible) - 1 or hidden_below_count > 0:
|
|
150
|
+
fragments.append(("", "\n"))
|
|
151
|
+
|
|
152
|
+
if hidden_below_count > 0:
|
|
153
|
+
more_text = fit_single_line(f"… and {hidden_below_count} more", width)
|
|
154
|
+
padding = " " * max(0, width - get_cwidth(more_text))
|
|
155
|
+
fragments.append(("class:completion.status.more", f"{more_text}{padding}"))
|
|
156
|
+
|
|
157
|
+
return fragments if fragments else [("", " ")]
|
|
158
|
+
|
|
159
|
+
def _completion_panel_height(self) -> int:
|
|
160
|
+
state = self._completion_panel_state()
|
|
161
|
+
if state is None:
|
|
162
|
+
return 1
|
|
163
|
+
completions, _ = state
|
|
164
|
+
max_lines = max(1, int(self._COMPLETION_PANEL_MAX_LINES))
|
|
165
|
+
if len(completions) <= max_lines:
|
|
166
|
+
return max(1, len(completions))
|
|
167
|
+
return max_lines
|
|
168
|
+
|
|
169
|
+
def _should_hide_loading_panel(self) -> bool:
|
|
170
|
+
"""统一 loading 显隐逻辑:仅当 Todo 面板出现时隐藏 loading 区域。"""
|
|
171
|
+
return self._renderer.has_active_todos()
|
|
172
|
+
|
|
173
|
+
def _loading_text(self) -> list[tuple[str, str]]:
|
|
174
|
+
if self._should_hide_loading_panel():
|
|
175
|
+
return [("", " ")]
|
|
176
|
+
|
|
177
|
+
# MCP 初始化阶段:显示连接动画
|
|
178
|
+
initializing = getattr(self, "_initializing", False)
|
|
179
|
+
if initializing and not self._renderer.has_running_tools() and not self._busy:
|
|
180
|
+
return self._animated_loading_fragments("Connecting to MCP tools…")
|
|
181
|
+
|
|
182
|
+
# Running tools: multi-line tool panel with breathing dot.
|
|
183
|
+
if self._renderer.has_running_tools():
|
|
184
|
+
width = self._terminal_width()
|
|
185
|
+
show_flash_line = (
|
|
186
|
+
self._tool_result_flash_active and self._tool_panel_max_lines >= 2
|
|
187
|
+
)
|
|
188
|
+
# 动态计算所需行数,但至少保留配置值作为最小值
|
|
189
|
+
required_lines = self._renderer.compute_required_tool_panel_lines()
|
|
190
|
+
base_max = self._tool_panel_max_lines
|
|
191
|
+
tool_max = max(required_lines, base_max)
|
|
192
|
+
if show_flash_line:
|
|
193
|
+
tool_max = max(tool_max + 1, self._tool_panel_max_lines)
|
|
194
|
+
entries = self._renderer.tool_panel_entries(max_lines=max(1, tool_max))
|
|
195
|
+
if not entries:
|
|
196
|
+
return [("", " ")]
|
|
197
|
+
|
|
198
|
+
dot_color = breathing_dot_color(self._loading_frame)
|
|
199
|
+
now_monotonic = time.monotonic()
|
|
200
|
+
dot_glyph = breathing_dot_glyph(now_monotonic)
|
|
201
|
+
dot_style = f"fg:{dot_color} bold"
|
|
202
|
+
primary_style = "fg:#D1D5DB"
|
|
203
|
+
nested_style = "fg:#9CA3AF"
|
|
204
|
+
|
|
205
|
+
fragments: list[tuple[str, str]] = []
|
|
206
|
+
if show_flash_line and self._tool_result_animator.is_active:
|
|
207
|
+
renderable = self._tool_result_animator.renderable()
|
|
208
|
+
fragments.extend(self._rich_text_to_pt_fragments(renderable))
|
|
209
|
+
fragments.append(("", "\n"))
|
|
210
|
+
|
|
211
|
+
last_index = len(entries) - 1
|
|
212
|
+
for idx, (indent, line) in enumerate(entries):
|
|
213
|
+
if indent < 0:
|
|
214
|
+
clipped = fit_single_line(line, width - 1)
|
|
215
|
+
fragments.append((nested_style, clipped))
|
|
216
|
+
elif indent == 0:
|
|
217
|
+
clipped = fit_single_line(line, max(width - 2, 8))
|
|
218
|
+
fragments.append((dot_style, f"{dot_glyph} "))
|
|
219
|
+
fragments.append((primary_style, clipped))
|
|
220
|
+
else:
|
|
221
|
+
padding = " " * indent
|
|
222
|
+
clipped = fit_single_line(line, max(width - get_cwidth(padding), 8))
|
|
223
|
+
fragments.append((nested_style, padding))
|
|
224
|
+
fragments.append((nested_style, clipped))
|
|
225
|
+
|
|
226
|
+
if idx != last_index:
|
|
227
|
+
fragments.append(("", "\n"))
|
|
228
|
+
|
|
229
|
+
return fragments
|
|
230
|
+
|
|
231
|
+
# 优先使用动画器的渲染(流光走字 + 随机 geek 术语)
|
|
232
|
+
if self._tool_result_animator.is_active:
|
|
233
|
+
renderable = self._tool_result_animator.renderable()
|
|
234
|
+
frags = self._rich_text_to_pt_fragments(renderable)
|
|
235
|
+
return self._append_run_elapsed_fragment(frags)
|
|
236
|
+
|
|
237
|
+
if self._animator.is_active:
|
|
238
|
+
renderable = self._animator.renderable()
|
|
239
|
+
frags = self._rich_text_to_pt_fragments(renderable)
|
|
240
|
+
return self._append_run_elapsed_fragment(frags)
|
|
241
|
+
|
|
242
|
+
# 获取语义化的 loading 状态
|
|
243
|
+
loading_state = self._renderer.loading_state()
|
|
244
|
+
text = loading_state.text.strip()
|
|
245
|
+
|
|
246
|
+
# --- DEBUG: 写入临时日志,排查 _run_start_time 和 _busy 的值 ---
|
|
247
|
+
import logging as _logging
|
|
248
|
+
|
|
249
|
+
_dbg = _logging.getLogger("_loading_text_debug")
|
|
250
|
+
_dbg.debug(
|
|
251
|
+
f"branch=fallback busy={getattr(self,'_busy',None)} "
|
|
252
|
+
f"run_start={getattr(self,'_run_start_time',None)} "
|
|
253
|
+
f"text={text!r}"
|
|
254
|
+
)
|
|
255
|
+
# --- END DEBUG ---
|
|
256
|
+
|
|
257
|
+
if not text:
|
|
258
|
+
if self._busy:
|
|
259
|
+
return self._animated_loading_fragments(self._fallback_loading_phrase)
|
|
260
|
+
return [("", " ")]
|
|
261
|
+
|
|
262
|
+
return self._animated_loading_fragments(text)
|
|
263
|
+
|
|
264
|
+
def _animated_loading_fragments(self, phrase: str) -> list[tuple[str, str]]:
|
|
265
|
+
"""用与工具调用一致的呼吸圆点 + 流光走字渲染 loading 文案."""
|
|
266
|
+
width = self._terminal_width()
|
|
267
|
+
|
|
268
|
+
# 若正在计时,预先计算时间后缀宽度,给 phrase 预留空间
|
|
269
|
+
run_start: float | None = getattr(self, "_run_start_time", None)
|
|
270
|
+
elapsed_suffix = ""
|
|
271
|
+
if run_start is not None:
|
|
272
|
+
elapsed = time.monotonic() - run_start
|
|
273
|
+
elapsed_suffix = (
|
|
274
|
+
f" ({self._format_run_elapsed(elapsed)} • ctrl+c to interrupt)"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# glyph(1) + space(1) = 2,再减去时间后缀宽度
|
|
278
|
+
phrase_budget = width - 4 - get_cwidth(elapsed_suffix)
|
|
279
|
+
clipped = fit_single_line(phrase, max(phrase_budget, 8))
|
|
280
|
+
|
|
281
|
+
frame = self._loading_frame
|
|
282
|
+
dot_color = breathing_dot_color(frame)
|
|
283
|
+
now_monotonic = time.monotonic()
|
|
284
|
+
|
|
285
|
+
# 构建 Rich Text(与 SubmissionAnimator.renderable 相同结构)
|
|
286
|
+
from rich.text import Text as RichText
|
|
287
|
+
|
|
288
|
+
dot = RichText(f"{breathing_dot_glyph(now_monotonic)} ", style=f"bold {dot_color}")
|
|
289
|
+
sweep = _cyan_sweep_text(clipped, frame=frame)
|
|
290
|
+
combined = RichText.assemble(dot, sweep)
|
|
291
|
+
frags = self._rich_text_to_pt_fragments(combined)
|
|
292
|
+
if elapsed_suffix:
|
|
293
|
+
frags = frags + [("fg:#6B7280", elapsed_suffix)]
|
|
294
|
+
return frags
|
|
295
|
+
|
|
296
|
+
def _append_run_elapsed_fragment(
|
|
297
|
+
self,
|
|
298
|
+
frags: list[tuple[str, str]],
|
|
299
|
+
) -> list[tuple[str, str]]:
|
|
300
|
+
"""若当前正在计时,在 fragments 末尾追加 ' · Xs' 时间后缀."""
|
|
301
|
+
run_start: float | None = getattr(self, "_run_start_time", None)
|
|
302
|
+
if run_start is None:
|
|
303
|
+
return frags
|
|
304
|
+
elapsed = time.monotonic() - run_start
|
|
305
|
+
duration_str = self._format_run_elapsed(elapsed)
|
|
306
|
+
# Rich __rich_console__ 末尾会输出一个 "\n" segment,需要先剥掉再追加,
|
|
307
|
+
# 否则时间后缀会落在换行符之后,在单行 Window 里不可见。
|
|
308
|
+
tail: list[tuple[str, str]] = []
|
|
309
|
+
trimmed = list(frags)
|
|
310
|
+
while trimmed and trimmed[-1][1] == "\n":
|
|
311
|
+
tail.insert(0, trimmed.pop())
|
|
312
|
+
return (
|
|
313
|
+
trimmed
|
|
314
|
+
+ [("fg:#6B7280", f" ({duration_str} • ctrl+c to interrupt)")]
|
|
315
|
+
+ tail
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def _loading_height(self) -> int:
|
|
319
|
+
if self._should_hide_loading_panel():
|
|
320
|
+
return 1
|
|
321
|
+
|
|
322
|
+
# 工具面板优先:即使 animator 仍活跃,也需要为多行工具面板分配正确高度,
|
|
323
|
+
# 否则在 Task subagent 执行期间嵌套工具行会被 Window 裁剪为 1 行。
|
|
324
|
+
if self._renderer.has_running_tools():
|
|
325
|
+
show_flash_line = (
|
|
326
|
+
self._tool_result_flash_active and self._tool_panel_max_lines >= 2
|
|
327
|
+
)
|
|
328
|
+
# 动态计算所需行数,但至少保留配置值作为最小值
|
|
329
|
+
required_lines = self._renderer.compute_required_tool_panel_lines()
|
|
330
|
+
base_max = self._tool_panel_max_lines
|
|
331
|
+
tool_max = max(required_lines, base_max)
|
|
332
|
+
if show_flash_line:
|
|
333
|
+
tool_max = max(tool_max + 1, self._tool_panel_max_lines)
|
|
334
|
+
tool_lines = self._renderer.tool_panel_entries(max_lines=max(1, tool_max))
|
|
335
|
+
total = len(tool_lines) + (1 if show_flash_line else 0)
|
|
336
|
+
return max(1, total)
|
|
337
|
+
if self._animator.is_active:
|
|
338
|
+
return 1
|
|
339
|
+
if self._tool_result_animator.is_active:
|
|
340
|
+
return 1
|
|
341
|
+
if self._renderer.loading_state().text.strip():
|
|
342
|
+
return 1
|
|
343
|
+
return 1
|
|
344
|
+
|
|
345
|
+
_DIFF_PANEL_MAX_VISIBLE = 20
|
|
346
|
+
|
|
347
|
+
def _todo_title_shimmer_fragments(self, title: str) -> list[tuple[str, str]]:
|
|
348
|
+
"""Todo 标题流光走字:文字不位移,仅高亮带扫过。"""
|
|
349
|
+
if not title:
|
|
350
|
+
return [("fg:#7DD3FC bold", title)]
|
|
351
|
+
|
|
352
|
+
sweep_center = (self._loading_frame % (len(title) + 8)) - 4
|
|
353
|
+
fragments: list[tuple[str, str]] = []
|
|
354
|
+
for idx, char in enumerate(title):
|
|
355
|
+
distance = abs(idx - sweep_center)
|
|
356
|
+
if distance <= 1:
|
|
357
|
+
style = "fg:#F8FAFC bold"
|
|
358
|
+
elif distance <= 3:
|
|
359
|
+
style = "fg:#CFFAFE bold"
|
|
360
|
+
elif distance <= 5:
|
|
361
|
+
style = "fg:#A7F3D0 bold"
|
|
362
|
+
else:
|
|
363
|
+
style = "fg:#7DD3FC bold"
|
|
364
|
+
fragments.append((style, char))
|
|
365
|
+
return fragments
|
|
366
|
+
|
|
367
|
+
def _diff_panel_text(self) -> list[tuple[str, str]]:
|
|
368
|
+
import re
|
|
369
|
+
|
|
370
|
+
diff_lines = self._renderer.latest_diff_lines
|
|
371
|
+
if not diff_lines:
|
|
372
|
+
return [("", " ")]
|
|
373
|
+
|
|
374
|
+
width = self._terminal_width()
|
|
375
|
+
total = len(diff_lines)
|
|
376
|
+
viewport = self._DIFF_PANEL_MAX_VISIBLE - 1 # reserve 1 for header
|
|
377
|
+
offset = self._diff_panel_scroll
|
|
378
|
+
max_scroll = max(0, total - viewport)
|
|
379
|
+
scroll_info = ""
|
|
380
|
+
if total > viewport:
|
|
381
|
+
scroll_info = f" [{offset + 1}-{min(offset + viewport, total)}/{total}] ↑↓"
|
|
382
|
+
|
|
383
|
+
title = (
|
|
384
|
+
" Diff Preview (Ctrl+O to close, use arrow keys (↑↓) to scroll) "
|
|
385
|
+
f"{scroll_info} "
|
|
386
|
+
)
|
|
387
|
+
pad_len = max(width - len(title) - 2, 0)
|
|
388
|
+
left_pad = pad_len // 2
|
|
389
|
+
right_pad = pad_len - left_pad
|
|
390
|
+
header = f"{'─' * left_pad}{title}{'─' * right_pad}"
|
|
391
|
+
|
|
392
|
+
fragments: list[tuple[str, str]] = [("fg:#6B7280", header)]
|
|
393
|
+
|
|
394
|
+
hunk_pattern = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
|
395
|
+
# To track line numbers correctly, we must parse from the beginning
|
|
396
|
+
old_line = 0
|
|
397
|
+
new_line = 0
|
|
398
|
+
visible_start = offset
|
|
399
|
+
visible_end = offset + viewport
|
|
400
|
+
|
|
401
|
+
for idx, line in enumerate(diff_lines):
|
|
402
|
+
# Always parse hunk headers and file headers to track line numbers
|
|
403
|
+
if line.startswith("---") or line.startswith("+++"):
|
|
404
|
+
if visible_start <= idx < visible_end:
|
|
405
|
+
fragments.append(("", "\n"))
|
|
406
|
+
fragments.append(("fg:#6B7280", line))
|
|
407
|
+
continue
|
|
408
|
+
|
|
409
|
+
m = hunk_pattern.match(line)
|
|
410
|
+
if m:
|
|
411
|
+
old_line = int(m.group(1))
|
|
412
|
+
new_line = int(m.group(2))
|
|
413
|
+
if visible_start <= idx < visible_end:
|
|
414
|
+
fragments.append(("", "\n"))
|
|
415
|
+
fragments.append(("fg:#00BCD4", line))
|
|
416
|
+
continue
|
|
417
|
+
|
|
418
|
+
if visible_start <= idx < visible_end:
|
|
419
|
+
fragments.append(("", "\n"))
|
|
420
|
+
if line.startswith("-"):
|
|
421
|
+
prefix = f"{old_line:>4} "
|
|
422
|
+
prefix_width = get_cwidth(prefix)
|
|
423
|
+
content_width = max(0, width - prefix_width)
|
|
424
|
+
fitted = fit_single_line(line, content_width)
|
|
425
|
+
padding = " " * (content_width - get_cwidth(fitted))
|
|
426
|
+
|
|
427
|
+
fragments.append(("fg:#ffffff bg:#4a0202", prefix))
|
|
428
|
+
fragments.append(("fg:#ffffff bg:#4a0202", fitted + padding))
|
|
429
|
+
elif line.startswith("+"):
|
|
430
|
+
prefix = f"{new_line:>4} "
|
|
431
|
+
prefix_width = get_cwidth(prefix)
|
|
432
|
+
content_width = max(0, width - prefix_width)
|
|
433
|
+
fitted = fit_single_line(line, content_width)
|
|
434
|
+
padding = " " * (content_width - get_cwidth(fitted))
|
|
435
|
+
|
|
436
|
+
fragments.append(("fg:#c6c6c6 bg:#2e502e", prefix))
|
|
437
|
+
fragments.append(("fg:#ffffff bg:#2e502e", fitted + padding))
|
|
438
|
+
else:
|
|
439
|
+
fragments.append(("fg:#6B7280", f"{new_line:>4} "))
|
|
440
|
+
fragments.append(("fg:#6B7280", line))
|
|
441
|
+
|
|
442
|
+
# Always update line counters
|
|
443
|
+
if line.startswith("-"):
|
|
444
|
+
old_line += 1
|
|
445
|
+
elif line.startswith("+"):
|
|
446
|
+
new_line += 1
|
|
447
|
+
else:
|
|
448
|
+
old_line += 1
|
|
449
|
+
new_line += 1
|
|
450
|
+
|
|
451
|
+
return fragments
|
|
452
|
+
|
|
453
|
+
def _diff_panel_height(self) -> int:
|
|
454
|
+
diff_lines = self._renderer.latest_diff_lines
|
|
455
|
+
if not diff_lines:
|
|
456
|
+
return 0
|
|
457
|
+
viewport = self._DIFF_PANEL_MAX_VISIBLE - 1
|
|
458
|
+
content_lines = min(len(diff_lines), viewport)
|
|
459
|
+
return content_lines + 1 # +1 for header
|
|
460
|
+
|
|
461
|
+
def _todo_text(self) -> list[tuple[str, str]]:
|
|
462
|
+
lines = self._renderer.todo_panel_lines(max_lines=self._todo_panel_max_lines)
|
|
463
|
+
if not lines:
|
|
464
|
+
return [("", " ")]
|
|
465
|
+
|
|
466
|
+
width = self._terminal_width()
|
|
467
|
+
fragments: list[tuple[str, str]] = []
|
|
468
|
+
last_index = len(lines) - 1
|
|
469
|
+
for idx, line in enumerate(lines):
|
|
470
|
+
clipped = fit_single_line(line, width - 1)
|
|
471
|
+
if idx == 0:
|
|
472
|
+
fragments.extend(self._todo_title_shimmer_fragments(clipped))
|
|
473
|
+
else:
|
|
474
|
+
style = "fg:#CBD5E1"
|
|
475
|
+
if "✓" in line:
|
|
476
|
+
style = "fg:#86EFAC"
|
|
477
|
+
elif "◉" in line:
|
|
478
|
+
style = "fg:#FDE68A"
|
|
479
|
+
fragments.append((style, clipped))
|
|
480
|
+
if idx != last_index:
|
|
481
|
+
fragments.append(("", "\n"))
|
|
482
|
+
return fragments
|
|
483
|
+
|
|
484
|
+
def _todo_height(self) -> int:
|
|
485
|
+
lines = self._renderer.todo_panel_lines(max_lines=self._todo_panel_max_lines)
|
|
486
|
+
return max(1, len(lines))
|
|
487
|
+
|
|
488
|
+
def _rich_text_to_pt_fragments(self, renderable: Any) -> list[tuple[str, str]]:
|
|
489
|
+
"""将 Rich Text 转换为 prompt_toolkit 的 fragments 格式."""
|
|
490
|
+
from rich.segment import Segment
|
|
491
|
+
|
|
492
|
+
fragments: list[tuple[str, str]] = []
|
|
493
|
+
for segment in renderable.__rich_console__(console, console.options):
|
|
494
|
+
if isinstance(segment, Segment):
|
|
495
|
+
text = segment.text
|
|
496
|
+
style = segment.style
|
|
497
|
+
if style:
|
|
498
|
+
# 将 Rich style 转换为 prompt_toolkit style
|
|
499
|
+
pt_style = self._rich_style_to_pt(style)
|
|
500
|
+
fragments.append((pt_style, text))
|
|
501
|
+
else:
|
|
502
|
+
fragments.append(("", text))
|
|
503
|
+
while fragments and fragments[-1][1] == "\n":
|
|
504
|
+
fragments.pop()
|
|
505
|
+
return fragments if fragments else [("", " ")]
|
|
506
|
+
|
|
507
|
+
def _rich_style_to_pt(self, rich_style: Any) -> str:
|
|
508
|
+
"""将 Rich style 转换为 prompt_toolkit style 字符串."""
|
|
509
|
+
parts: list[str] = []
|
|
510
|
+
if rich_style.bold:
|
|
511
|
+
parts.append("bold")
|
|
512
|
+
if rich_style.italic:
|
|
513
|
+
parts.append("italic")
|
|
514
|
+
if rich_style.underline:
|
|
515
|
+
parts.append("underline")
|
|
516
|
+
if rich_style.strike:
|
|
517
|
+
parts.append("strike")
|
|
518
|
+
if rich_style.dim:
|
|
519
|
+
parts.append("dim")
|
|
520
|
+
|
|
521
|
+
# 前景色
|
|
522
|
+
if rich_style.color and rich_style.color.triplet:
|
|
523
|
+
parts.append(f"fg:{rich_style.color.triplet.hex}")
|
|
524
|
+
elif rich_style.color and rich_style.color.type.name == "STANDARD":
|
|
525
|
+
parts.append(f"fg:ansi{rich_style.color.number}")
|
|
526
|
+
elif rich_style.color and rich_style.color.type.name == "EIGHT_BIT":
|
|
527
|
+
parts.append(f"fg:ansi{rich_style.color.number}")
|
|
528
|
+
|
|
529
|
+
# 背景色
|
|
530
|
+
if rich_style.bgcolor and rich_style.bgcolor.triplet:
|
|
531
|
+
parts.append(f"bg:{rich_style.bgcolor.triplet.hex}")
|
|
532
|
+
elif rich_style.bgcolor and rich_style.bgcolor.type.name == "STANDARD":
|
|
533
|
+
parts.append(f"bg:ansi{rich_style.bgcolor.number}")
|
|
534
|
+
elif rich_style.bgcolor and rich_style.bgcolor.type.name == "EIGHT_BIT":
|
|
535
|
+
parts.append(f"bg:ansi{rich_style.bgcolor.number}")
|
|
536
|
+
|
|
537
|
+
return " ".join(parts) if parts else ""
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from comate_cli.terminal_agent.slash_commands import SlashCommandSpec
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class RegisteredSlashCommand:
|
|
11
|
+
spec: SlashCommandSpec
|
|
12
|
+
handler: Callable[[str], Any]
|
|
13
|
+
allow_when_busy: bool = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SlashCommandRegistry:
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._entries_by_name: dict[str, RegisteredSlashCommand] = {}
|
|
19
|
+
self._entries_by_spec_name: dict[str, RegisteredSlashCommand] = {}
|
|
20
|
+
|
|
21
|
+
def register(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
spec: SlashCommandSpec,
|
|
25
|
+
handler: Callable[[str], Any],
|
|
26
|
+
allow_when_busy: bool = False,
|
|
27
|
+
) -> None:
|
|
28
|
+
entry = RegisteredSlashCommand(
|
|
29
|
+
spec=spec,
|
|
30
|
+
handler=handler,
|
|
31
|
+
allow_when_busy=allow_when_busy,
|
|
32
|
+
)
|
|
33
|
+
names = (spec.name, *spec.aliases)
|
|
34
|
+
for name in names:
|
|
35
|
+
if name in self._entries_by_name:
|
|
36
|
+
raise ValueError(f"Duplicate slash command registration: {name}")
|
|
37
|
+
for name in names:
|
|
38
|
+
self._entries_by_name[name] = entry
|
|
39
|
+
self._entries_by_spec_name[spec.name] = entry
|
|
40
|
+
|
|
41
|
+
def resolve(self, name: str) -> RegisteredSlashCommand | None:
|
|
42
|
+
return self._entries_by_name.get(name)
|
|
43
|
+
|
|
44
|
+
def command_specs(self) -> tuple[SlashCommandSpec, ...]:
|
|
45
|
+
return tuple(entry.spec for entry in self._entries_by_spec_name.values())
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: comate-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Comate terminal CLI built on comate-agent-sdk
|
|
5
|
+
Project-URL: Homepage, https://github.com/AndyLee1024/agent-sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/AndyLee1024/agent-sdk
|
|
7
|
+
Author-email: Andy <andy.dev@aliyun.com>
|
|
8
|
+
Keywords: agent,cli,comate,tui
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Requires-Dist: comate-agent-sdk<1.0.0,>=0.0.1
|
|
18
|
+
Requires-Dist: prompt-toolkit>=3.0
|
|
19
|
+
Requires-Dist: rich>=14.0
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# comate-cli
|
|
23
|
+
|
|
24
|
+
Comate terminal CLI package.
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uvx comate-cli
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or install globally:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv tool install comate-cli
|
|
36
|
+
comate
|
|
37
|
+
```
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
comate_cli/__init__.py,sha256=g5VQTNVmtnM8U14p48MU_MgXfxtzNbi0xyDLtIWI_8c,80
|
|
2
|
+
comate_cli/__main__.py,sha256=LplHEjaIDUbAAG727_IZuywS-N_1XHwQeH8Cdr2vMV8,73
|
|
3
|
+
comate_cli/main.py,sha256=lfSJW978je8Goq8CffxQylEb9g2P2NMO1WxZ7HkwZkM,3872
|
|
4
|
+
comate_cli/terminal_agent/__init__.py,sha256=Q_NrCEsDymkK6sIjHyv7EqeJupjTCsnPKURkfzWieGM,39
|
|
5
|
+
comate_cli/terminal_agent/animations.py,sha256=nG6WG-2PomsobjFByrGhQTottdl8WGXBdy_gIqSRVAU,9207
|
|
6
|
+
comate_cli/terminal_agent/app.py,sha256=SUJjteV93sqImBUVq5dH6sUm3OPPdEtQnlITfaMVTrE,8416
|
|
7
|
+
comate_cli/terminal_agent/assistant_render.py,sha256=Bwkkf7GJX1SQn-mjYoolMXvKa9cIhDDNx8a4DOdDKcI,8168
|
|
8
|
+
comate_cli/terminal_agent/env_utils.py,sha256=YU9HUSYl9A3NkA-LX7aopze-GB3_3Jx_Zua5z7F5gEg,1018
|
|
9
|
+
comate_cli/terminal_agent/error_display.py,sha256=m7xbxpDRMct_svOt3vrdh-Zo_SKppi1LW-gwDENAS-A,1782
|
|
10
|
+
comate_cli/terminal_agent/event_renderer.py,sha256=Fbnp9pSF1-noEGnpAfPWs2RxjJeH0YJCw_7RJkwxQJg,34734
|
|
11
|
+
comate_cli/terminal_agent/fragment_utils.py,sha256=ZAtPXBzRKRdODsLtHWc31YLmHyWW-s9r8OctYKL1hHw,570
|
|
12
|
+
comate_cli/terminal_agent/history_printer.py,sha256=q7JN7warwru6aA26o_BLVttgJBVjhU_mfJ84HdngSSs,4981
|
|
13
|
+
comate_cli/terminal_agent/input_geometry.py,sha256=EQDCMBL0ch9-8jSpXC-eAfZw9FIT9glu8FqqhwqOwdU,2060
|
|
14
|
+
comate_cli/terminal_agent/layout_coordinator.py,sha256=9eesZ0-bccXAngIlV1Ur9Yv8lCL0H1AY1juA0QkfPwM,6180
|
|
15
|
+
comate_cli/terminal_agent/logging_adapter.py,sha256=pbZqr9EPcrv-oJqmRvugpy6vrVG3KAhEhpMRuEwCQZQ,5524
|
|
16
|
+
comate_cli/terminal_agent/logo.py,sha256=cRR_Ozuz6BTUr3WaEJP-FE_OBBntMAT2Vi6P_VCboRA,2319
|
|
17
|
+
comate_cli/terminal_agent/markdown_render.py,sha256=08orW1kmvMhrSDQhHUr4kGWJfeQhe2IRu6YO9svXhsM,662
|
|
18
|
+
comate_cli/terminal_agent/mention_completer.py,sha256=oj9qYYfz0Wmmqb4wILjf5fB62RG596quqyUaiO5CoXM,9452
|
|
19
|
+
comate_cli/terminal_agent/message_style.py,sha256=gOVM0BwrVhMynxYIj2AGkblaypkjgLPFV4sy8k_5icg,876
|
|
20
|
+
comate_cli/terminal_agent/models.py,sha256=PJw4_xP7_3a-jUncoCfW-B5pQxZWwUKtTQo3fJ6dMQw,2711
|
|
21
|
+
comate_cli/terminal_agent/question_view.py,sha256=_Ju9kV21urMQgYh9R0d0XW7Sxba5N4BAKTJ0UxWtTuw,22668
|
|
22
|
+
comate_cli/terminal_agent/rewind_store.py,sha256=87HY-1uR69VWe4NJSPB50POU6qsZYemqbPixu3JQc6U,25703
|
|
23
|
+
comate_cli/terminal_agent/rpc_protocol.py,sha256=4_kfBuTA64ccMwfVjXb6Ukys08codCmLilSzyTPB1VU,2962
|
|
24
|
+
comate_cli/terminal_agent/rpc_stdio.py,sha256=Cewv0LaoQvsodcfCS1B2v-F_qH96T4aueTWYmqZsVD8,9308
|
|
25
|
+
comate_cli/terminal_agent/selection_menu.py,sha256=ANS_nAqcY3vJANBSq-axfSNekmNqBkzTzLqPEbeeDsc,9189
|
|
26
|
+
comate_cli/terminal_agent/session_view.py,sha256=sDDKrJIaqeThvAGVeVS40ZcOX-_xXACCWm1imyG_wSA,3213
|
|
27
|
+
comate_cli/terminal_agent/slash_commands.py,sha256=Wv2EPMKIyiMEquJWzwksyoQeBz9lyDyDckEXn-sacXQ,4570
|
|
28
|
+
comate_cli/terminal_agent/startup.py,sha256=KgeQ7TwKSkCvksTGTPEJS6AojndCCKOiBRzWromEZJw,2507
|
|
29
|
+
comate_cli/terminal_agent/status_bar.py,sha256=Aturir9Yqoh-GMATI5NCF0ZRsoch6ltvgBTdj2OKOII,8826
|
|
30
|
+
comate_cli/terminal_agent/text_effects.py,sha256=1FrwEu4oMjySX_6VaA87PxXsxtqOdellq9EIS_XE1AY,753
|
|
31
|
+
comate_cli/terminal_agent/tool_view.py,sha256=G_dvKjwBwmIzr1U27Xx57thCSPN6AIjfUWrzEfVGzoU,21326
|
|
32
|
+
comate_cli/terminal_agent/tui.py,sha256=jw6mviGBDrYcxongig-jigv_qLaUzMuqkbXodlhNv-M,38274
|
|
33
|
+
comate_cli/terminal_agent/tui_parts/__init__.py,sha256=hGrZLgg3tk2dGzhP-69E-OZW2ExJoyvr2eHzpwK4Xlc,721
|
|
34
|
+
comate_cli/terminal_agent/tui_parts/commands.py,sha256=jufrQHXob2nfQ6Vj2wwHz0Vg4C-0ruPrACZ2zcURyfA,28079
|
|
35
|
+
comate_cli/terminal_agent/tui_parts/history_sync.py,sha256=HwMXROshijVXLRIJnJYrUNZUDehdzxLgYnGKFqCMQKA,10291
|
|
36
|
+
comate_cli/terminal_agent/tui_parts/input_behavior.py,sha256=8AuoCDFYP9nVANysQV-shGMrFkgmhbuNx955ugS2ZiE,11585
|
|
37
|
+
comate_cli/terminal_agent/tui_parts/key_bindings.py,sha256=I94I7PEMzb_ssRoUfQDfWeF7sGRWvNZ25NLcIZbIsnw,11643
|
|
38
|
+
comate_cli/terminal_agent/tui_parts/render_panels.py,sha256=kgO63L-_mAvgV-zpskW5hqnWCXVo1o3vlMQerEEF3_s,22010
|
|
39
|
+
comate_cli/terminal_agent/tui_parts/slash_command_registry.py,sha256=0GHRloPJD9H3Xm-_EqltgcCd9OYofOLSIUlCDlAx8o4,1446
|
|
40
|
+
comate_cli/terminal_agent/tui_parts/ui_mode.py,sha256=wNYmvlqhiMTVK2Vnyc6LQwQwHL8gRpByw-YP5lSndnM,156
|
|
41
|
+
comate_cli-0.1.0.dist-info/METADATA,sha256=4acPa69-_nYN_k2-_eeg03BlQ6AWwDOyQi-IN428oxE,957
|
|
42
|
+
comate_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
43
|
+
comate_cli-0.1.0.dist-info/entry_points.txt,sha256=lAhnZ4iLW4lq5M-ULwdxf-fTTxBXC2YtCUV66Nls_C4,48
|
|
44
|
+
comate_cli-0.1.0.dist-info/RECORD,,
|