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,243 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import unicodedata
5
+ from typing import Literal
6
+
7
+ from rich.console import Group, RenderableType
8
+ from rich.markdown import Markdown
9
+ from rich.text import Text
10
+
11
+ from comate_cli.terminal_agent.message_style import (
12
+ ASSISTANT_PREFIX,
13
+ ASSISTANT_PREFIX_STYLE,
14
+ USER_PREFIX,
15
+ USER_PREFIX_STYLE,
16
+ )
17
+
18
+ _LEADING_NOISE_CHARS = frozenset(
19
+ {
20
+ "\ufeff", # BOM
21
+ "\u200b", # zero width space
22
+ "\u200c", # zero width non-joiner
23
+ "\u200d", # zero width joiner
24
+ "\u2060", # word joiner
25
+ "\u00ad", # soft hyphen
26
+ }
27
+ )
28
+ _BLOCK_MARKDOWN_PREFIX_RE = re.compile(
29
+ r"^\s*(#{1,6}\s+|[-*+]\s+|\d+\.\s+|>\s+|```|~~~)",
30
+ re.MULTILINE,
31
+ )
32
+ _AMBIGUOUS_BLOCK_PREFIX_RE = re.compile(r"^\s*(#{1,6}|[-*+]|>|\d+\.|`{1,2}|~{1,2})$")
33
+ _SegmentMode = Literal["prefixed_plain_first_line", "markdown_only"]
34
+ _Block = RenderableType | tuple[str, Text]
35
+
36
+
37
+ def _is_noise_char(ch: str) -> bool:
38
+ if not ch:
39
+ return True
40
+ if ch.isspace() or ch in _LEADING_NOISE_CHARS:
41
+ return True
42
+ category = unicodedata.category(ch)
43
+ # C*: control/format classes; M*: combining marks (often invisible without base)
44
+ if category.startswith("C") or category in {"Mn", "Me"}:
45
+ return True
46
+ return False
47
+
48
+
49
+ def _strip_leading_noise(text: str) -> str:
50
+ """Strip leading whitespace and invisible formatting noise."""
51
+ idx = 0
52
+ total = len(text)
53
+ while idx < total:
54
+ ch = text[idx]
55
+ if _is_noise_char(ch):
56
+ idx += 1
57
+ continue
58
+ break
59
+ return text[idx:]
60
+
61
+
62
+ def _first_visible_line(content: str) -> str:
63
+ lines = content.splitlines()
64
+ if not lines:
65
+ return ""
66
+ for line in lines:
67
+ candidate = _strip_leading_noise(line)
68
+ if candidate:
69
+ return candidate
70
+ return ""
71
+
72
+
73
+ def _split_first_line(content: str) -> tuple[str, str]:
74
+ if not content:
75
+ return "", ""
76
+ if "\n" not in content:
77
+ return content, ""
78
+ first_line, remainder = content.split("\n", 1)
79
+ return first_line.rstrip("\r"), remainder
80
+
81
+
82
+ def _is_block_markdown_start(first_line: str) -> bool:
83
+ if not first_line:
84
+ return False
85
+ return _BLOCK_MARKDOWN_PREFIX_RE.match(first_line) is not None
86
+
87
+
88
+ def _is_ambiguous_block_start(first_line: str) -> bool:
89
+ if not first_line:
90
+ return False
91
+ return _AMBIGUOUS_BLOCK_PREFIX_RE.match(first_line) is not None
92
+
93
+
94
+ class AssistantStreamRenderer:
95
+ """Turn-scoped assistant renderer that exposes renderables for a parent Live layout."""
96
+
97
+ def __init__(self) -> None:
98
+ self._trim_segment_leading_noise_pending = True
99
+ self._segment_chunks: list[str] = []
100
+ self._segment_mode: _SegmentMode | None = None
101
+ self._blocks: list[_Block] = []
102
+ self._last_block_external = False
103
+ self._pending_gap_before_next_segment = False
104
+
105
+ def _reset_segment_state(self) -> None:
106
+ self._trim_segment_leading_noise_pending = True
107
+ self._segment_chunks = []
108
+ self._segment_mode = None
109
+
110
+ def start_turn(self) -> None:
111
+ self._blocks = []
112
+ self._last_block_external = False
113
+ self._pending_gap_before_next_segment = False
114
+ self._reset_segment_state()
115
+
116
+ def _resolve_segment_mode(self, content: str) -> _SegmentMode | None:
117
+ first_line = _first_visible_line(content)
118
+ if not first_line:
119
+ return None
120
+ if _is_ambiguous_block_start(first_line):
121
+ return None
122
+ if _is_block_markdown_start(first_line):
123
+ return "markdown_only"
124
+ return "prefixed_plain_first_line"
125
+
126
+ @staticmethod
127
+ def _build_segment_renderable(mode: _SegmentMode, content: str) -> RenderableType:
128
+ if mode == "markdown_only":
129
+ return Markdown(content, code_theme="monokai", hyperlinks=True)
130
+
131
+ first_line, remainder = _split_first_line(content)
132
+ prefixed = Text()
133
+ prefixed.append(f"{ASSISTANT_PREFIX} ", style=ASSISTANT_PREFIX_STYLE)
134
+ prefixed.append(first_line)
135
+
136
+ if not remainder:
137
+ return prefixed
138
+ return Group(
139
+ prefixed,
140
+ Markdown(remainder, code_theme="monokai", hyperlinks=True),
141
+ )
142
+
143
+ @staticmethod
144
+ def _is_gap_block(renderable: RenderableType) -> bool:
145
+ return isinstance(renderable, Text) and not renderable.plain
146
+
147
+ @staticmethod
148
+ def _materialize_block(block: _Block) -> RenderableType:
149
+ if isinstance(block, tuple) and len(block) == 2 and block[0] == "tool_ref":
150
+ return block[1]
151
+ return block
152
+
153
+ def _append_gap_if_needed(self) -> None:
154
+ if self._blocks and not self._is_gap_block(self._blocks[-1]):
155
+ self._blocks.append(Text(""))
156
+
157
+ def _render_unresolved_segment_if_needed(self) -> None:
158
+ if not self._segment_chunks or self._segment_mode is not None:
159
+ return
160
+ # Segment ended before ambiguous markdown prefix was disambiguated.
161
+ # Fall back to visible plain-text prefixed rendering to avoid content loss.
162
+ self._segment_mode = "prefixed_plain_first_line"
163
+
164
+ def _flush_active_segment(self, *, inter_block_gap: bool) -> bool:
165
+ self._render_unresolved_segment_if_needed()
166
+ content = "".join(self._segment_chunks)
167
+ mode = self._segment_mode
168
+ self._reset_segment_state()
169
+ if not content or mode is None:
170
+ return False
171
+
172
+ self._blocks.append(self._build_segment_renderable(mode, content))
173
+ self._last_block_external = False
174
+ if inter_block_gap:
175
+ self._append_gap_if_needed()
176
+ return True
177
+
178
+ def append_external_lines(self, lines: list[tuple[str, str]]) -> None:
179
+ if not lines:
180
+ return
181
+ self._flush_active_segment(inter_block_gap=True)
182
+ if not self._last_block_external:
183
+ self._append_gap_if_needed()
184
+ for content, style in lines:
185
+ self._blocks.append(Text(content, style=style))
186
+ self._last_block_external = True
187
+
188
+ def append_tool_line(self, text_ref: Text) -> None:
189
+ self._flush_active_segment(inter_block_gap=True)
190
+ if not self._last_block_external:
191
+ self._append_gap_if_needed()
192
+ self._blocks.append(("tool_ref", text_ref))
193
+ self._last_block_external = True
194
+
195
+ def append_user_message(self, content: str) -> None:
196
+ text = Text()
197
+ text.append(f"{USER_PREFIX} ", style=USER_PREFIX_STYLE)
198
+ text.append(content)
199
+ self._blocks.append(text)
200
+ self._append_gap_if_needed()
201
+ self._last_block_external = True
202
+
203
+ def append_text(self, text: str) -> None:
204
+ if self._trim_segment_leading_noise_pending:
205
+ text = _strip_leading_noise(text)
206
+ if not text:
207
+ return
208
+ self._trim_segment_leading_noise_pending = False
209
+ if not text:
210
+ return
211
+
212
+ if not self._segment_chunks and self._pending_gap_before_next_segment:
213
+ self._append_gap_if_needed()
214
+ self._pending_gap_before_next_segment = False
215
+
216
+ self._segment_chunks.append(text)
217
+ content = "".join(self._segment_chunks)
218
+ if self._segment_mode is None:
219
+ self._segment_mode = self._resolve_segment_mode(content)
220
+ self._last_block_external = False
221
+
222
+ def insert_gap_before_next_segment(self) -> None:
223
+ if self._segment_chunks:
224
+ return
225
+ if not self._blocks:
226
+ return
227
+ self._pending_gap_before_next_segment = True
228
+
229
+ def flush_line_for_external_event(self) -> None:
230
+ self._flush_active_segment(inter_block_gap=True)
231
+
232
+ def finalize_turn(self) -> None:
233
+ self._flush_active_segment(inter_block_gap=False)
234
+ self._pending_gap_before_next_segment = False
235
+
236
+ def renderable(self) -> RenderableType:
237
+ blocks = [self._materialize_block(block) for block in self._blocks]
238
+ content = "".join(self._segment_chunks)
239
+ if content and self._segment_mode is not None:
240
+ blocks.append(self._build_segment_renderable(self._segment_mode, content))
241
+ if not blocks:
242
+ return Text("")
243
+ return Group(*blocks)
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ def read_env_int(name: str, default: int) -> int:
10
+ raw = os.getenv(name)
11
+ if raw is None or not raw.strip():
12
+ return default
13
+ try:
14
+ value = int(raw.strip())
15
+ except ValueError:
16
+ logger.warning(f"Invalid env var {name}={raw!r}; using default {default}.")
17
+ return default
18
+ if value <= 0:
19
+ logger.warning(f"Invalid env var {name}={raw!r}; using default {default}.")
20
+ return default
21
+ return value
22
+
23
+
24
+ def read_env_float(name: str, default: float) -> float:
25
+ raw = os.getenv(name)
26
+ if raw is None or not raw.strip():
27
+ return default
28
+ try:
29
+ value = float(raw.strip())
30
+ except ValueError:
31
+ logger.warning(f"Invalid env var {name}={raw!r}; using default {default}.")
32
+ return default
33
+ if value <= 0:
34
+ logger.warning(f"Invalid env var {name}={raw!r}; using default {default}.")
35
+ return default
36
+ return value
37
+
@@ -0,0 +1,46 @@
1
+ """Unified error formatter for terminal agent"""
2
+ from __future__ import annotations
3
+
4
+
5
+ def format_error(exc: Exception) -> tuple[str, str | None]:
6
+ """Convert exception to user-friendly message.
7
+
8
+ Returns:
9
+ (error_message, suggestion) - Error message and optional suggestion
10
+ """
11
+ exc_type = type(exc).__name__
12
+ exc_msg = str(exc)
13
+
14
+ # LLM Provider errors
15
+ if exc_type == "ModelRateLimitError":
16
+ return "⚠️ Rate limit exceeded", "Wait a moment, or use /model to switch"
17
+
18
+ if exc_type == "ModelProviderError":
19
+ code = getattr(exc, 'status_code', None)
20
+ if code == 404:
21
+ return "⚠️ Model not found or invalid API path", "Check .agent/settings.json"
22
+ if code == 401:
23
+ return "⚠️ Invalid or expired API key", "Check api_key in .agent/settings.json"
24
+ if code == 403:
25
+ return "⚠️ Access denied to this model", "Check API key permissions"
26
+ if code and code >= 500:
27
+ return f"⚠️ Server error ({code})", "Try again later"
28
+ return f"⚠️ API error: {_truncate(exc_msg, 80)}", None
29
+
30
+ # Session errors
31
+ if exc_type == "ChatSessionClosedError":
32
+ return "⚠️ Session closed", "Please restart the CLI"
33
+
34
+ # Network errors (generic detection)
35
+ lower_msg = exc_msg.lower()
36
+ if "timeout" in lower_msg or "timed out" in lower_msg:
37
+ return "⚠️ Request timed out", "Check network connection, or try again"
38
+ if "connection" in lower_msg:
39
+ return "⚠️ Connection failed", "Check network and API endpoint"
40
+
41
+ # Generic fallback
42
+ return f"⚠️ Error: {_truncate(exc_msg, 60)}", "You can continue typing"
43
+
44
+
45
+ def _truncate(s: str, max_len: int) -> str:
46
+ return s[:max_len] + "..." if len(s) > max_len else s