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,25 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Iterable
4
+
5
+
6
+ def clip_fragments(
7
+ fragments: list[tuple[str, str]],
8
+ max_len: int,
9
+ ) -> list[tuple[str, str]]:
10
+ if max_len <= 0:
11
+ return []
12
+
13
+ remaining = max_len
14
+ clipped: list[tuple[str, str]] = []
15
+ for style, text in fragments:
16
+ if remaining <= 0:
17
+ break
18
+ if len(text) <= remaining:
19
+ clipped.append((style, text))
20
+ remaining -= len(text)
21
+ continue
22
+ clipped.append((style, text[:remaining]))
23
+ break
24
+
25
+ return clipped
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from rich.console import Console, Group
7
+ from rich.text import Text
8
+
9
+ from comate_cli.terminal_agent.models import HistoryEntry
10
+
11
+
12
+ def _entry_prefix(entry: HistoryEntry) -> str:
13
+ if entry.entry_type == "user":
14
+ return ">"
15
+ if entry.entry_type == "assistant":
16
+ return "โบ"
17
+ if entry.entry_type == "tool_call":
18
+ return "โ†’"
19
+ if entry.entry_type == "tool_result":
20
+ return "โ—"
21
+ if entry.entry_type == "thinking":
22
+ return "๐Ÿ’ญ"
23
+ return "โ€ข"
24
+
25
+
26
+ def _entry_content(
27
+ entry: HistoryEntry,
28
+ *,
29
+ terminal_width: int,
30
+ render_markdown_to_plain: Callable[..., str],
31
+ ) -> str:
32
+ if entry.entry_type != "assistant":
33
+ return str(entry.text)
34
+ width = max(terminal_width - 6, 40)
35
+ return render_markdown_to_plain(str(entry.text), width=width)
36
+
37
+
38
+ def render_history_group(
39
+ console: Console,
40
+ entries: list[HistoryEntry],
41
+ *,
42
+ terminal_width: int,
43
+ render_markdown_to_plain: Callable[..., str],
44
+ ) -> Group | None:
45
+ if not entries:
46
+ return None
47
+
48
+ renderables: list[Any] = []
49
+ for entry in entries:
50
+ # Thinking entries: ็ฐ่‰ฒๆ˜พ็คบ๏ผŒๆ— ๅ‰็ผ€
51
+ if entry.entry_type == "thinking":
52
+ content = str(entry.text)
53
+ content_lines = content.splitlines() or [""]
54
+ line_text = Text()
55
+ line_text.append(" ", style="dim")
56
+ line_text.append(content_lines[0], style="dim")
57
+ for line in content_lines[1:]:
58
+ line_text.append("\n")
59
+ line_text.append(" ", style="dim")
60
+ line_text.append(line, style="dim")
61
+ renderables.append(line_text)
62
+ renderables.append(Text(""))
63
+ continue
64
+
65
+ # Elapsed entries: ็ฐ่‰ฒๆ˜พ็คบ๏ผŒๆ— ๅ‰็ผ€
66
+ if entry.entry_type == "elapsed":
67
+ line_text = Text()
68
+ line_text.append(str(entry.text), style="dim")
69
+ renderables.append(line_text)
70
+ renderables.append(Text(""))
71
+ continue
72
+
73
+ # System entries: ๆŒ‰ severity ๅŒบๅˆ†่ง†่ง‰ๆ ทๅผ
74
+ if entry.entry_type == "system":
75
+ if entry.severity == "error":
76
+ prefix_char = "โœ–"
77
+ prefix_style = "#FF9FC6"
78
+ elif entry.severity == "warning":
79
+ prefix_char = "โš "
80
+ prefix_style = "#B8B630"
81
+ else:
82
+ prefix_char = "โ€ข"
83
+ prefix_style = "dim"
84
+ line_text = Text()
85
+ line_text.append(f"{prefix_char} ", style=prefix_style)
86
+ line_text.append(str(entry.text), style=prefix_style)
87
+ renderables.append(line_text)
88
+ renderables.append(Text(""))
89
+ continue
90
+
91
+ if entry.entry_type == "tool_result":
92
+ prefix_char = "โœ–" if entry.severity == "error" else "โ—"
93
+ prefix_style = "bold red" if entry.severity == "error" else "bold green"
94
+
95
+ if isinstance(entry.text, Text):
96
+ # Rich Text object โ€” preserve styled content (e.g. colored diff)
97
+ line_text = Text()
98
+ line_text.append(f"{prefix_char} ", style=prefix_style)
99
+ line_text.append_text(entry.text)
100
+ renderables.append(line_text)
101
+ renderables.append(Text(""))
102
+ continue
103
+
104
+ content = str(entry.text)
105
+ content_lines = content.splitlines() or [""]
106
+
107
+ line_text = Text()
108
+ line_text.append(f"{prefix_char} ", style=prefix_style)
109
+ line_text.append(content_lines[0])
110
+ for line in content_lines[1:]:
111
+ line_text.append("\n")
112
+ line_text.append(" ")
113
+ line_text.append(line)
114
+
115
+ renderables.append(line_text)
116
+ renderables.append(Text(""))
117
+ continue
118
+
119
+ if hasattr(entry.text, "__rich_console__"):
120
+ prefix = _entry_prefix(entry)
121
+ prefixed = Text(f"{prefix} ", style="bold")
122
+ prefixed.append_text(entry.text) # type: ignore[arg-type]
123
+ renderables.append(prefixed)
124
+ else:
125
+ prefix = _entry_prefix(entry)
126
+ content = _entry_content(
127
+ entry,
128
+ terminal_width=terminal_width,
129
+ render_markdown_to_plain=render_markdown_to_plain,
130
+ )
131
+ content_lines = content.splitlines() or [""]
132
+ lines = [f"{prefix} {content_lines[0]}"]
133
+ for line in content_lines[1:]:
134
+ lines.append(f" {line}")
135
+ renderables.append("\n".join(lines))
136
+
137
+ renderables.append(Text(""))
138
+
139
+ if not renderables:
140
+ return None
141
+
142
+ return Group(*renderables)
143
+
144
+
145
+ async def print_history_group_async(console: Console, group: Group) -> None:
146
+ console.print(group)
147
+
148
+
149
+ def print_history_group_sync(console: Console, group: Group) -> None:
150
+ console.print(group)
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ from prompt_toolkit.utils import get_cwidth
4
+
5
+
6
+ def compute_visual_line_ranges(text: str, max_cols: int) -> list[tuple[int, int]]:
7
+ """
8
+ Compute visual line ranges [start, end) by wrapping `text` at `max_cols`
9
+ display cells, respecting explicit newlines.
10
+ """
11
+ if max_cols <= 0:
12
+ max_cols = 1
13
+
14
+ ranges: list[tuple[int, int]] = []
15
+ start = 0
16
+ col = 0
17
+ for idx, ch in enumerate(text):
18
+ if ch == "\n":
19
+ ranges.append((start, idx))
20
+ start = idx + 1
21
+ col = 0
22
+ continue
23
+
24
+ ch_width = get_cwidth(ch)
25
+ if ch_width <= 0:
26
+ ch_width = 1
27
+ if ch_width > max_cols:
28
+ ch_width = max_cols
29
+
30
+ if col + ch_width > max_cols:
31
+ ranges.append((start, idx))
32
+ start = idx
33
+ col = 0
34
+
35
+ col += ch_width
36
+
37
+ ranges.append((start, len(text)))
38
+ return ranges
39
+
40
+
41
+ def visual_col_for_index(
42
+ text: str,
43
+ start: int,
44
+ end: int,
45
+ max_cols: int,
46
+ index: int,
47
+ ) -> int:
48
+ if max_cols <= 0:
49
+ max_cols = 1
50
+ if index < start:
51
+ return 0
52
+ if index > end:
53
+ index = end
54
+ col = 0
55
+ for ch in text[start:index]:
56
+ ch_width = get_cwidth(ch)
57
+ if ch_width <= 0:
58
+ ch_width = 1
59
+ if ch_width > max_cols:
60
+ ch_width = max_cols
61
+ if col + ch_width > max_cols:
62
+ break
63
+ col += ch_width
64
+ return col
65
+
66
+
67
+ def index_for_visual_col(
68
+ text: str,
69
+ start: int,
70
+ end: int,
71
+ max_cols: int,
72
+ target_col: int,
73
+ ) -> int:
74
+ if max_cols <= 0:
75
+ max_cols = 1
76
+ if target_col <= 0:
77
+ return start
78
+ if target_col > max_cols:
79
+ target_col = max_cols
80
+ col = 0
81
+ for idx in range(start, end):
82
+ ch = text[idx]
83
+ ch_width = get_cwidth(ch)
84
+ if ch_width <= 0:
85
+ ch_width = 1
86
+ if ch_width > max_cols:
87
+ ch_width = max_cols
88
+ if col + ch_width > target_col:
89
+ return idx
90
+ col += ch_width
91
+ return end
92
+
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ from rich.console import Console, Group, RenderableType
4
+ from rich.live import Live
5
+ from rich.segment import Segment, Segments
6
+ from rich.text import Text
7
+
8
+ _TODO_MIN_LINES = 6
9
+
10
+
11
+ def _is_empty_text(renderable: RenderableType | None) -> bool:
12
+ return isinstance(renderable, Text) and not renderable.plain
13
+
14
+
15
+ def _renderable_to_lines(console: Console, renderable: RenderableType | None) -> list[list[Segment]]:
16
+ if renderable is None:
17
+ return []
18
+ if _is_empty_text(renderable):
19
+ return []
20
+ return console.render_lines(renderable, console.options, pad=False, new_lines=False)
21
+
22
+
23
+ def _lines_to_renderable(lines: list[list[Segment]]) -> RenderableType | None:
24
+ if not lines:
25
+ return None
26
+ segments: list[Segment] = []
27
+ last_index = len(lines) - 1
28
+ for idx, line in enumerate(lines):
29
+ segments.extend(line)
30
+ if idx != last_index:
31
+ segments.append(Segment.line())
32
+ return Segments(segments, new_lines=False)
33
+
34
+
35
+ def _total_height(*layers: list[list[Segment]]) -> int:
36
+ non_empty = [layer for layer in layers if layer]
37
+ if not non_empty:
38
+ return 0
39
+ return sum(len(layer) for layer in non_empty) + max(0, len(non_empty) - 1)
40
+
41
+
42
+ class TerminalLayoutCoordinator:
43
+ """Single Live owner for loading/message/todo layers."""
44
+
45
+ def __init__(
46
+ self,
47
+ console: Console,
48
+ *,
49
+ target_fps: int = 12,
50
+ ) -> None:
51
+ normalized_fps = max(4, min(int(target_fps), 24))
52
+ self._console = console
53
+ self._target_fps = normalized_fps
54
+ self._turn_active = False
55
+ self._live: Live | None = None
56
+ self._dirty = False
57
+ self._loading_layer: RenderableType | None = None
58
+ self._message_layer: RenderableType | None = Text("")
59
+ self._todo_layer: RenderableType | None = None
60
+
61
+ def start_turn(self) -> None:
62
+ self._turn_active = True
63
+ self._dirty = True
64
+ self._ensure_live()
65
+ self.refresh(force=True)
66
+
67
+ def stop_turn(self) -> None:
68
+ if not self._turn_active:
69
+ return
70
+ self.refresh(force=True)
71
+ self._stop_live()
72
+ self._turn_active = False
73
+
74
+ def close(self) -> None:
75
+ self._stop_live()
76
+ self._turn_active = False
77
+
78
+ def update_layers(
79
+ self,
80
+ *,
81
+ loading: RenderableType | None,
82
+ message: RenderableType | None,
83
+ todo: RenderableType | None,
84
+ ) -> None:
85
+ self._loading_layer = loading
86
+ self._message_layer = message
87
+ self._todo_layer = todo
88
+ self._dirty = True
89
+
90
+ def refresh(self, *, force: bool = False) -> None:
91
+ if not self._turn_active:
92
+ return
93
+ if not force and not self._dirty:
94
+ return
95
+
96
+ self._ensure_live()
97
+ if self._live is None:
98
+ return
99
+ self._live.update(self._compose(), refresh=True)
100
+ self._dirty = False
101
+
102
+ def _ensure_live(self) -> None:
103
+ if self._live is not None:
104
+ return
105
+ self._live = Live(
106
+ Text(""),
107
+ console=self._console,
108
+ transient=False,
109
+ refresh_per_second=self._target_fps,
110
+ vertical_overflow="crop",
111
+ redirect_stdout=False,
112
+ redirect_stderr=False,
113
+ )
114
+ self._live.start()
115
+
116
+ def _stop_live(self) -> None:
117
+ if self._live is None:
118
+ return
119
+ self._live.stop()
120
+ self._live = None
121
+
122
+ def _compose(self) -> RenderableType:
123
+ message_lines = _renderable_to_lines(self._console, self._message_layer)
124
+ todo_lines = _renderable_to_lines(self._console, self._todo_layer)
125
+ loading_lines = _renderable_to_lines(self._console, self._loading_layer)
126
+
127
+ max_height = max(int(self._console.size.height), 1)
128
+ while True:
129
+ total = _total_height(message_lines, todo_lines, loading_lines)
130
+ if total <= max_height:
131
+ break
132
+
133
+ overflow = total - max_height
134
+ progressed = False
135
+
136
+ if message_lines and overflow > 0:
137
+ drop = min(overflow, len(message_lines))
138
+ message_lines = message_lines[drop:]
139
+ progressed = progressed or drop > 0
140
+
141
+ total = _total_height(message_lines, todo_lines, loading_lines)
142
+ overflow = max(0, total - max_height)
143
+ if todo_lines and overflow > 0 and len(todo_lines) > _TODO_MIN_LINES:
144
+ max_drop = len(todo_lines) - _TODO_MIN_LINES
145
+ drop = min(overflow, max_drop)
146
+ todo_lines = todo_lines[: len(todo_lines) - drop]
147
+ progressed = progressed or drop > 0
148
+
149
+ total = _total_height(message_lines, todo_lines, loading_lines)
150
+ overflow = max(0, total - max_height)
151
+ if loading_lines and overflow > 0:
152
+ original_len = len(loading_lines)
153
+ keep = max(original_len - overflow, 1)
154
+ dropped = original_len - keep
155
+ loading_lines = loading_lines[-keep:]
156
+ progressed = progressed or dropped > 0
157
+
158
+ total = _total_height(message_lines, todo_lines, loading_lines)
159
+ overflow = max(0, total - max_height)
160
+ if todo_lines and overflow > 0:
161
+ original_len = len(todo_lines)
162
+ keep = max(original_len - overflow, 1)
163
+ dropped = original_len - keep
164
+ todo_lines = todo_lines[:keep]
165
+ progressed = progressed or dropped > 0
166
+
167
+ if not progressed:
168
+ break
169
+
170
+ message = _lines_to_renderable(message_lines)
171
+ todo = _lines_to_renderable(todo_lines)
172
+ loading = _lines_to_renderable(loading_lines)
173
+
174
+ parts: list[RenderableType] = []
175
+ if message is not None:
176
+ parts.append(message)
177
+ if todo is not None:
178
+ if parts:
179
+ parts.append(Text(""))
180
+ parts.append(todo)
181
+ if loading is not None:
182
+ if parts:
183
+ parts.append(Text(""))
184
+ parts.append(loading)
185
+
186
+ if not parts:
187
+ return Text("")
188
+ return Group(*parts)
@@ -0,0 +1,147 @@
1
+ """TUI logging adapter - ๅฐ†ๆ—ฅๅฟ—่พ“ๅ‡บๅˆฐ prompt_toolkit TUI ่€Œไธ็ ดๅ็•Œ้ข"""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import TYPE_CHECKING
6
+
7
+ from prompt_toolkit.application import run_in_terminal
8
+
9
+ if TYPE_CHECKING:
10
+ from comate_cli.terminal_agent.event_renderer import EventRenderer
11
+
12
+
13
+ class TUILoggingHandler(logging.Handler):
14
+ """่‡ชๅฎšไน‰ logging handler๏ผŒๅฐ†ๆ—ฅๅฟ—ๅ‹ๅฅฝๅœฐๆ˜พ็คบๅœจ TUI ไธญ
15
+
16
+ ็‰นๆ€ง๏ผš
17
+ - WARNING/ERROR ๆ˜พ็คบ็ป™็”จๆˆท๏ผŒๅธฆ้ขœ่‰ฒๆ ‡่ฏ†
18
+ - ้ฆ–ๆฌกๆ˜พ็คบๆœบๅˆถ๏ผš็›ธๅŒๆถˆๆฏๅชๆ˜พ็คบไธ€ๆฌก
19
+ - ไฝฟ็”จ run_in_terminal() ไธ็ ดๅ prompt_toolkit ็•Œ้ข
20
+ """
21
+
22
+ def __init__(self, renderer: EventRenderer) -> None:
23
+ super().__init__()
24
+ self._renderer = renderer
25
+ self._shown_messages: set[str] = set() # ่ทŸ่ธชๅทฒๆ˜พ็คบ็š„ๆถˆๆฏ๏ผˆ้ฆ–ๆฌกๆ˜พ็คบ๏ผ‰
26
+
27
+ def emit(self, record: logging.LogRecord) -> None:
28
+ """ๅค„็†ๆ—ฅๅฟ—่ฎฐๅฝ•"""
29
+ try:
30
+ # ๅชๅค„็† WARNING ๅ’Œ ERROR
31
+ if record.levelno < logging.WARNING:
32
+ return
33
+
34
+ # ็”Ÿๆˆๅ‹ๅฅฝๆถˆๆฏ
35
+ msg = self._format_message(record)
36
+ if not msg:
37
+ return
38
+
39
+ # ้ฆ–ๆฌกๆ˜พ็คบๆฃ€ๆŸฅ
40
+ msg_key = self._get_message_key(record)
41
+ if msg_key in self._shown_messages:
42
+ return
43
+ self._shown_messages.add(msg_key)
44
+
45
+ # ๆŒ‰ๆ—ฅๅฟ—็บงๅˆซๆ˜ ๅฐ„ severity๏ผŒๆธฒๆŸ“ๅฑ‚่ดŸ่ดฃ่ง†่ง‰ๅŒบๅˆ†
46
+ if record.levelno >= logging.ERROR:
47
+ severity = "error"
48
+ else:
49
+ severity = "warning"
50
+ self._append_to_tui(msg, severity=severity)
51
+
52
+ except Exception:
53
+ self.handleError(record)
54
+
55
+ def _format_message(self, record: logging.LogRecord) -> str:
56
+ """ๆ ผๅผๅŒ–ๆ—ฅๅฟ—ๆถˆๆฏไธบ็”จๆˆทๅ‹ๅฅฝๆ ผๅผ๏ผˆไธๅตŒๅ…ฅ emoji๏ผŒ็”ฑๆธฒๆŸ“ๅฑ‚่ดŸ่ดฃ่ง†่ง‰ๆ ทๅผ๏ผ‰"""
57
+ msg = record.getMessage()
58
+
59
+ # ็‰นๆฎŠๅค„็†๏ผštoken count fallback
60
+ if "token count failed" in msg.lower() and "fallback" in msg.lower():
61
+ return "Token estimation using fallback (working normally)"
62
+
63
+ return self._simplify_message(msg)
64
+
65
+ def _simplify_message(self, msg: str) -> str:
66
+ """็ฎ€ๅŒ–ๆŠ€ๆœฏ็ป†่Š‚"""
67
+ # ๅŽปๆމๅธธ่ง็š„ๆŠ€ๆœฏๅ‚ๆ•ฐ
68
+ if "exc_type=" in msg:
69
+ # ๆˆชๆ–ญๅˆฐ็ฌฌไธ€ไธชๆŠ€ๆœฏๅ‚ๆ•ฐไน‹ๅ‰
70
+ parts = msg.split("exc_type=")
71
+ msg = parts[0].rstrip(": ,")
72
+
73
+ if "timeout_ms=" in msg:
74
+ parts = msg.split("timeout_ms=")
75
+ msg = parts[0].rstrip(": ,")
76
+
77
+ # ้™ๅˆถ้•ฟๅบฆ
78
+ max_len = 100
79
+ if len(msg) > max_len:
80
+ msg = msg[:max_len] + "..."
81
+
82
+ return msg
83
+
84
+ def _get_message_key(self, record: logging.LogRecord) -> str:
85
+ """็”Ÿๆˆๆถˆๆฏ็š„ๅ”ฏไธ€้”ฎ๏ผˆ็”จไบŽ้ฆ–ๆฌกๆ˜พ็คบๆฃ€ๆŸฅ๏ผ‰"""
86
+ # ไฝฟ็”จ logger ๅ็งฐ + ๆถˆๆฏๅ†…ๅฎน็š„ๅ‰ 50 ไธชๅญ—็ฌฆ
87
+ msg = record.getMessage()
88
+ # ๅฏนไบŽ token count๏ผŒไฝฟ็”จๅ›บๅฎš key
89
+ if "token count failed" in msg.lower():
90
+ return "token_count_fallback"
91
+ # ๅ…ถไป–ๆถˆๆฏ็”จๅฎŒๆ•ดๅ†…ๅฎนไฝœไธบ key
92
+ return f"{record.name}:{msg[:50]}"
93
+
94
+ def _append_to_tui(self, msg: str, *, severity: str = "warning") -> None:
95
+ """ๅฐ†ๆถˆๆฏ่ฟฝๅŠ ๅˆฐ TUI๏ผˆ้€š่ฟ‡ run_in_terminal ้ฟๅ…็ ดๅ็•Œ้ข๏ผ‰"""
96
+ def _append() -> None:
97
+ self._renderer.append_system_message(msg, severity=severity) # type: ignore[arg-type]
98
+
99
+ # ไฝฟ็”จ run_in_terminal ็กฎไฟไธ็ ดๅ prompt_toolkit ็š„่พ“ๅ…ฅ
100
+ # ๅฆ‚ๆžœๆฒกๆœ‰่ฟ่กŒไธญ็š„็œŸๅฎž Application๏ผŒ็›ดๆŽฅ่ฐƒ็”จ๏ผˆๆต‹่ฏ•ๆˆ–ๅˆๅง‹ๅŒ–้˜ถๆฎต๏ผ‰
101
+ try:
102
+ from prompt_toolkit.application import get_app
103
+ from prompt_toolkit.application.dummy import DummyApplication
104
+ app = get_app()
105
+ if isinstance(app, DummyApplication):
106
+ # DummyApplication๏ผŒ็›ดๆŽฅ่ฐƒ็”จ
107
+ _append()
108
+ else:
109
+ # ็œŸๅฎž app๏ผŒไฝฟ็”จ run_in_terminal
110
+ run_in_terminal(_append, in_executor=False)
111
+ except Exception:
112
+ # ๆฒกๆœ‰ app ๆˆ–ๅฏผๅ…ฅๅคฑ่ดฅ๏ผŒ็›ดๆŽฅ่ฐƒ็”จ
113
+ _append()
114
+
115
+
116
+ def setup_tui_logging(renderer: EventRenderer) -> None:
117
+ """็ปŸไธ€ๆ—ฅๅฟ—ๅˆๅง‹ๅŒ–๏ผšๆ–‡ไปถ + TUI ๅŒ้€š้“ใ€‚
118
+
119
+ - ๆ‰€ๆœ‰ๆ—ฅๅฟ—๏ผˆๅซ traceback๏ผ‰ๅ†™ๅ…ฅ ~/.comate/logs/agent.log๏ผˆRotatingFileHandler๏ผ‰
120
+ - WARNING/ERROR ไปฅ็”จๆˆทๅ‹ๅฅฝๆ ผๅผๆ˜พ็คบๅœจ TUI scrollback
121
+ - ๆธ…้™ค root logger ้ป˜่ฎค็š„ stderr handler๏ผŒ้˜ปๆญข traceback ๆณ„ๆผๅˆฐ็ปˆ็ซฏ
122
+ """
123
+ import os
124
+ from logging.handlers import RotatingFileHandler
125
+
126
+ # 1. ๆ—ฅๅฟ—ๆ–‡ไปถ handler๏ผˆๅฎŒๆ•ด่ฐƒ่ฏ•ไฟกๆฏ๏ผŒๅซ traceback๏ผ‰
127
+ log_dir = os.path.join(os.path.expanduser("~"), ".comate", "logs")
128
+ os.makedirs(log_dir, exist_ok=True)
129
+ log_path = os.path.join(log_dir, "agent.log")
130
+ file_handler = RotatingFileHandler(
131
+ log_path, maxBytes=10 * 1024 * 1024, backupCount=3, encoding="utf-8",
132
+ )
133
+ file_handler.setLevel(logging.DEBUG)
134
+ file_handler.setFormatter(
135
+ logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
136
+ )
137
+
138
+ # 2. ๆธ…้™ค root logger ็š„ๆ‰€ๆœ‰ handler๏ผŒ้˜ปๆญข stderr ๆณ„ๆผ
139
+ root = logging.getLogger()
140
+ root.handlers.clear()
141
+ root.addHandler(file_handler)
142
+ root.setLevel(logging.DEBUG)
143
+
144
+ # 3. TUI handler ๆŒ‚ๅˆฐ root๏ผˆ่ฆ†็›–ๆ‰€ๆœ‰ๅ‘ฝๅ็ฉบ้—ด็š„ WARNING/ERROR๏ผ‰
145
+ tui_handler = TUILoggingHandler(renderer)
146
+ tui_handler.setLevel(logging.WARNING)
147
+ root.addHandler(tui_handler)
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from rich.console import Console
6
+ from rich.text import Text
7
+
8
+ _LOGO_LINES: tuple[str, ...] = (
9
+ "",
10
+ " โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—",
11
+ " โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ•šโ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•",
12
+ " โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ–ˆโ–ˆโ–ˆโ–ˆโ•”โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— ",
13
+ " โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•”โ•โ•โ• ",
14
+ " โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘ โ•šโ•โ• โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—",
15
+ " โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ• โ•šโ•โ•โ•šโ•โ• โ•šโ•โ• โ•šโ•โ• โ•šโ•โ•โ•โ•โ•โ•โ•",
16
+ )
17
+
18
+
19
+ def _lerp_rgb(
20
+ start_rgb: tuple[int, int, int],
21
+ end_rgb: tuple[int, int, int],
22
+ ratio: float,
23
+ ) -> tuple[int, int, int]:
24
+ clamped = max(0.0, min(1.0, ratio))
25
+ r = int(start_rgb[0] + (end_rgb[0] - start_rgb[0]) * clamped)
26
+ g = int(start_rgb[1] + (end_rgb[1] - start_rgb[1]) * clamped)
27
+ b = int(start_rgb[2] + (end_rgb[2] - start_rgb[2]) * clamped)
28
+ return r, g, b
29
+
30
+
31
+ def _resolve_version() -> str:
32
+ try:
33
+ return f"v{version('comate-cli')}"
34
+ except PackageNotFoundError:
35
+ try:
36
+ return f"v{version('comate-agent-sdk')}"
37
+ except PackageNotFoundError:
38
+ return "v0.1.0-dev"
39
+
40
+
41
+ def print_logo(console: Console) -> None:
42
+ start_rgb = (67, 114, 240)
43
+ end_rgb = (54, 214, 220)
44
+ line_count = len(_LOGO_LINES)
45
+
46
+ logo_text = Text()
47
+ for idx, line in enumerate(_LOGO_LINES):
48
+ ratio = idx / max(line_count - 1, 1)
49
+ r, g, b = _lerp_rgb(start_rgb, end_rgb, ratio)
50
+ logo_text.append(line, style=f"bold rgb({r},{g},{b})")
51
+ if idx < line_count - 1:
52
+ logo_text.append("\n")
53
+
54
+ console.print(logo_text)
55
+ console.print(
56
+ Text(f" {_resolve_version()} Product-grade terminal agent UI", style="dim")
57
+ )
58
+ console.print()
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from io import StringIO
4
+
5
+ from rich.console import Console
6
+ from rich.markdown import Markdown
7
+
8
+
9
+ def render_markdown_to_plain(content: str, *, width: int) -> str:
10
+ normalized_width = max(int(width), 40)
11
+ try:
12
+ sink = StringIO()
13
+ console = Console(
14
+ file=sink,
15
+ width=normalized_width,
16
+ force_terminal=False,
17
+ color_system=None,
18
+ soft_wrap=True,
19
+ )
20
+ console.print(Markdown(content, code_theme="monokai", hyperlinks=False))
21
+ rendered = sink.getvalue().rstrip("\n")
22
+ return rendered
23
+ except Exception:
24
+ return content