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