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,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
|