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,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,9 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class UIMode(Enum):
7
+ NORMAL = "normal"
8
+ QUESTION = "question"
9
+ SELECTION = "selection"
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ comate = comate_cli.main:main