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,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.markdown import Markdown
|
|
5
|
+
|
|
6
|
+
from comate_agent_sdk.agent import ChatSession
|
|
7
|
+
from comate_agent_sdk.context.items import ItemType
|
|
8
|
+
|
|
9
|
+
from comate_cli.terminal_agent.message_style import print_assistant_gap, print_assistant_prefix_line
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _truncate(content: str, max_len: int = 1400) -> str:
|
|
13
|
+
if len(content) <= max_len:
|
|
14
|
+
return content
|
|
15
|
+
return f"{content[:max_len]}..."
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _extract_assistant_text(item) -> str:
|
|
19
|
+
"""从 ContextItem 提取用于显示的文本(不含 tool_calls JSON)"""
|
|
20
|
+
message = getattr(item, "message", None)
|
|
21
|
+
if message is None:
|
|
22
|
+
return ""
|
|
23
|
+
|
|
24
|
+
# 优先使用 message.text(纯文本,不含 tool_calls)
|
|
25
|
+
if hasattr(message, "text"):
|
|
26
|
+
text = message.text
|
|
27
|
+
if isinstance(text, str):
|
|
28
|
+
return text
|
|
29
|
+
|
|
30
|
+
# 回退:处理非标准 content
|
|
31
|
+
msg_content = getattr(message, "content", "")
|
|
32
|
+
if isinstance(msg_content, str):
|
|
33
|
+
return msg_content
|
|
34
|
+
if isinstance(msg_content, list):
|
|
35
|
+
text_parts: list[str] = []
|
|
36
|
+
for part in msg_content:
|
|
37
|
+
if isinstance(part, dict) and part.get("type") == "text":
|
|
38
|
+
text_parts.append(str(part.get("text", "")))
|
|
39
|
+
return "".join(text_parts)
|
|
40
|
+
|
|
41
|
+
return ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _split_first_visible_line(content: str) -> tuple[str, str]:
|
|
45
|
+
lines = content.splitlines()
|
|
46
|
+
if not lines:
|
|
47
|
+
return "", ""
|
|
48
|
+
|
|
49
|
+
idx = 0
|
|
50
|
+
while idx < len(lines) and not lines[idx].strip():
|
|
51
|
+
idx += 1
|
|
52
|
+
|
|
53
|
+
if idx >= len(lines):
|
|
54
|
+
return "", ""
|
|
55
|
+
|
|
56
|
+
first = lines[idx].strip()
|
|
57
|
+
remainder = "\n".join(lines[idx + 1 :]).lstrip("\r\n")
|
|
58
|
+
return first, remainder
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def render_session_header(console: Console, session_id: str, mode: str) -> None:
|
|
62
|
+
console.print(f"[dim]session({mode}): [cyan]{session_id}[/][/]")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def render_user_message(console: Console, content: str) -> None:
|
|
66
|
+
console.print(f"[green]>[/] {_truncate(content, 1000)}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def render_resume_timeline(console: Console, session: ChatSession) -> None:
|
|
70
|
+
items = session._agent._context.get_conversation_items_snapshot()
|
|
71
|
+
history = [
|
|
72
|
+
item
|
|
73
|
+
for item in items
|
|
74
|
+
if item.item_type in (ItemType.USER_MESSAGE, ItemType.ASSISTANT_MESSAGE)
|
|
75
|
+
]
|
|
76
|
+
if not history:
|
|
77
|
+
console.print("[dim]no previous messages[/]")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
console.print(f"[dim]history loaded: {len(history)} messages[/]")
|
|
81
|
+
|
|
82
|
+
for item in history:
|
|
83
|
+
if item.item_type == ItemType.USER_MESSAGE:
|
|
84
|
+
content = item.content_text or ""
|
|
85
|
+
render_user_message(console, str(content))
|
|
86
|
+
continue
|
|
87
|
+
assistant_text = _extract_assistant_text(item).strip()
|
|
88
|
+
if not assistant_text:
|
|
89
|
+
console.print("[dim]⏺ (tool call only message)[/]")
|
|
90
|
+
continue
|
|
91
|
+
trimmed = _truncate(assistant_text)
|
|
92
|
+
first, remainder = _split_first_visible_line(trimmed)
|
|
93
|
+
if not first:
|
|
94
|
+
console.print("[dim]⏺ (tool call only message)[/]")
|
|
95
|
+
continue
|
|
96
|
+
print_assistant_prefix_line(console, first)
|
|
97
|
+
if remainder:
|
|
98
|
+
console.print(Markdown(remainder, code_theme="monokai", hyperlinks=True))
|
|
99
|
+
print_assistant_gap(console)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from prompt_toolkit.completion import (
|
|
7
|
+
Completer,
|
|
8
|
+
Completion,
|
|
9
|
+
FuzzyCompleter,
|
|
10
|
+
WordCompleter,
|
|
11
|
+
)
|
|
12
|
+
from prompt_toolkit.document import Document
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class SlashCommandSpec:
|
|
17
|
+
name: str
|
|
18
|
+
description: str
|
|
19
|
+
aliases: tuple[str, ...] = ()
|
|
20
|
+
|
|
21
|
+
def slash_name(self) -> str:
|
|
22
|
+
if self.aliases:
|
|
23
|
+
return f"/{self.name} ({', '.join(self.aliases)})"
|
|
24
|
+
return f"/{self.name}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class _SlashCommandCall:
|
|
29
|
+
name: str
|
|
30
|
+
args: str
|
|
31
|
+
raw_input: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_slash_command_call(user_input: str) -> _SlashCommandCall | None:
|
|
35
|
+
text = user_input.strip()
|
|
36
|
+
if not text or not text.startswith("/"):
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
match = re.match(r"^\/([a-zA-Z0-9_-]+(?::[a-zA-Z0-9_-]+)*)", text)
|
|
40
|
+
if match is None:
|
|
41
|
+
return None
|
|
42
|
+
if len(text) > match.end() and not text[match.end()].isspace():
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
return _SlashCommandCall(
|
|
46
|
+
name=match.group(1),
|
|
47
|
+
args=text[match.end() :].lstrip(),
|
|
48
|
+
raw_input=text,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
SLASH_COMMAND_SPECS: tuple[SlashCommandSpec, ...] = (
|
|
53
|
+
SlashCommandSpec(
|
|
54
|
+
name="help",
|
|
55
|
+
description="Show available slash commands",
|
|
56
|
+
aliases=("h",),
|
|
57
|
+
),
|
|
58
|
+
SlashCommandSpec(
|
|
59
|
+
name="model",
|
|
60
|
+
description="Switch model level (LOW/MID/HIGH)",
|
|
61
|
+
aliases=("m",),
|
|
62
|
+
),
|
|
63
|
+
SlashCommandSpec(name="session", description="Show current session ID"),
|
|
64
|
+
SlashCommandSpec(name="usage", description="Show token usage summary"),
|
|
65
|
+
SlashCommandSpec(name="context", description="Show context usage summary"),
|
|
66
|
+
SlashCommandSpec(name="compact", description="Trigger manual context compaction"),
|
|
67
|
+
SlashCommandSpec(name="rewind", description="Rewind to a checkpoint"),
|
|
68
|
+
SlashCommandSpec(name="exit", description="Exit terminal agent", aliases=("quit",)),
|
|
69
|
+
)
|
|
70
|
+
SLASH_COMMANDS: tuple[str, ...] = tuple(f"/{cmd.name}" for cmd in SLASH_COMMAND_SPECS)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SlashCommandCompleter(Completer):
|
|
74
|
+
def __init__(self, commands: tuple[SlashCommandSpec, ...]) -> None:
|
|
75
|
+
self._commands = commands
|
|
76
|
+
self._command_lookup: dict[str, list[SlashCommandSpec]] = {}
|
|
77
|
+
words: list[str] = []
|
|
78
|
+
|
|
79
|
+
for cmd in sorted(self._commands, key=lambda item: item.name):
|
|
80
|
+
if cmd.name not in self._command_lookup:
|
|
81
|
+
self._command_lookup[cmd.name] = []
|
|
82
|
+
words.append(cmd.name)
|
|
83
|
+
self._command_lookup[cmd.name].append(cmd)
|
|
84
|
+
for alias in cmd.aliases:
|
|
85
|
+
if alias in self._command_lookup:
|
|
86
|
+
self._command_lookup[alias].append(cmd)
|
|
87
|
+
else:
|
|
88
|
+
self._command_lookup[alias] = [cmd]
|
|
89
|
+
words.append(alias)
|
|
90
|
+
|
|
91
|
+
self._word_pattern = re.compile(r"[^\s]+")
|
|
92
|
+
self._fuzzy_pattern = r"^[^\s]*"
|
|
93
|
+
self._word_completer = WordCompleter(
|
|
94
|
+
words,
|
|
95
|
+
WORD=False,
|
|
96
|
+
pattern=self._word_pattern,
|
|
97
|
+
)
|
|
98
|
+
self._fuzzy = FuzzyCompleter(
|
|
99
|
+
self._word_completer,
|
|
100
|
+
WORD=False,
|
|
101
|
+
pattern=self._fuzzy_pattern,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def get_completions(self, document: Document, complete_event):
|
|
105
|
+
text = document.text_before_cursor
|
|
106
|
+
if document.text_after_cursor.strip():
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
last_space = text.rfind(" ")
|
|
110
|
+
token = text[last_space + 1 :]
|
|
111
|
+
prefix = text[: last_space + 1] if last_space != -1 else ""
|
|
112
|
+
if prefix:
|
|
113
|
+
return
|
|
114
|
+
if not token.startswith("/"):
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
typed = token[1:]
|
|
118
|
+
if typed:
|
|
119
|
+
commands = self._command_lookup.get(typed, [])
|
|
120
|
+
if commands and any(
|
|
121
|
+
typed == cmd.name or typed in cmd.aliases for cmd in commands
|
|
122
|
+
):
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
typed_doc = Document(text=typed, cursor_position=len(typed))
|
|
126
|
+
candidates = list(self._fuzzy.get_completions(typed_doc, complete_event))
|
|
127
|
+
seen: set[str] = set()
|
|
128
|
+
|
|
129
|
+
for candidate in candidates:
|
|
130
|
+
commands = self._command_lookup.get(candidate.text)
|
|
131
|
+
if not commands:
|
|
132
|
+
continue
|
|
133
|
+
for cmd in commands:
|
|
134
|
+
if cmd.name in seen:
|
|
135
|
+
continue
|
|
136
|
+
seen.add(cmd.name)
|
|
137
|
+
yield Completion(
|
|
138
|
+
text=f"/{cmd.name}",
|
|
139
|
+
start_position=-len(token),
|
|
140
|
+
display=cmd.slash_name(),
|
|
141
|
+
display_meta=cmd.description,
|
|
142
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Generic startup status display utilities for CLI applications.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from comate_cli.terminal_agent.startup import print_warning, print_success, print_error
|
|
5
|
+
|
|
6
|
+
print_warning(console, "MCP server 'ctx7' skipped: header is null")
|
|
7
|
+
print_success(console, "MCP loaded: exa_search (4 tools)")
|
|
8
|
+
print_error(console, "Failed to initialize config")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import time
|
|
15
|
+
from contextlib import asynccontextmanager
|
|
16
|
+
from typing import AsyncGenerator
|
|
17
|
+
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
|
|
21
|
+
from comate_cli.terminal_agent.animations import _cyan_sweep_text, breathing_dot_color, breathing_dot_glyph
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def print_status(console: Console, icon: str, style: str, message: str) -> None:
|
|
25
|
+
"""Print a styled status line to the console."""
|
|
26
|
+
console.print(f"[{style}]{icon} {message}[/]")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def print_warning(console: Console, message: str) -> None:
|
|
30
|
+
"""Print a yellow warning line (⚠)."""
|
|
31
|
+
print_status(console, "⚠", "yellow", message)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def print_success(console: Console, message: str) -> None:
|
|
35
|
+
"""Print a dim success line (✓)."""
|
|
36
|
+
print_status(console, "✓", "dim", message)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def print_error(console: Console, message: str) -> None:
|
|
40
|
+
"""Print a red error line (✗)."""
|
|
41
|
+
print_status(console, "✗", "red", message)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def _run_mcp_animation(console: Console, server_names: list[str]) -> None:
|
|
45
|
+
"""Background task: render sweep animation until cancelled."""
|
|
46
|
+
names = server_names if server_names else ["mcp"]
|
|
47
|
+
servers_str = ", ".join(names)
|
|
48
|
+
frame = 0
|
|
49
|
+
while True:
|
|
50
|
+
msg = f"Starting up mcp servers: {servers_str} "
|
|
51
|
+
sweep = _cyan_sweep_text(msg, frame)
|
|
52
|
+
now_monotonic = time.monotonic()
|
|
53
|
+
dot = Text(
|
|
54
|
+
f"{breathing_dot_glyph(now_monotonic)} ",
|
|
55
|
+
style=f"bold {breathing_dot_color(frame)}",
|
|
56
|
+
)
|
|
57
|
+
console.print(Text.assemble(dot, sweep), end="\r")
|
|
58
|
+
frame += 1
|
|
59
|
+
await asyncio.sleep(0.1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@asynccontextmanager
|
|
63
|
+
async def mcp_connecting_animation(
|
|
64
|
+
console: Console, server_names: list[str]
|
|
65
|
+
) -> AsyncGenerator[None, None]:
|
|
66
|
+
"""Async context manager that shows a sweep animation while MCP connects."""
|
|
67
|
+
task = asyncio.create_task(_run_mcp_animation(console, server_names))
|
|
68
|
+
try:
|
|
69
|
+
yield
|
|
70
|
+
finally:
|
|
71
|
+
task.cancel()
|
|
72
|
+
try:
|
|
73
|
+
await task
|
|
74
|
+
except asyncio.CancelledError:
|
|
75
|
+
pass
|
|
76
|
+
# Clear the animation line
|
|
77
|
+
console.print(Text(" " * 60), end="\r")
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
from typing import NamedTuple
|
|
7
|
+
|
|
8
|
+
from prompt_toolkit.application.current import get_app_or_none
|
|
9
|
+
|
|
10
|
+
from comate_agent_sdk.agent import ChatSession
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GitDiffStats(NamedTuple):
|
|
16
|
+
added: int
|
|
17
|
+
removed: int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StatusBar:
|
|
21
|
+
_DEFAULT_TERMINAL_WIDTH: int = 100
|
|
22
|
+
_MIN_TERMINAL_WIDTH: int = 40
|
|
23
|
+
_GIT_DIFF_CACHE_SECONDS: float = 5.0
|
|
24
|
+
|
|
25
|
+
def __init__(self, session: ChatSession):
|
|
26
|
+
self._session = session
|
|
27
|
+
self._model_name: str = self._resolve_model_name(session)
|
|
28
|
+
self._mode: str = "act"
|
|
29
|
+
self._git_branch: str = self._resolve_git_branch()
|
|
30
|
+
self._context_used_pct: float = 0.0
|
|
31
|
+
self._context_left_pct: float = 100.0
|
|
32
|
+
self._git_diff_stats: GitDiffStats | None = None
|
|
33
|
+
self._git_diff_cache_time: float = 0.0
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def _resolve_model_name(session: ChatSession) -> str:
|
|
37
|
+
agent = getattr(session, "_agent", None)
|
|
38
|
+
llm = getattr(agent, "llm", None)
|
|
39
|
+
model = getattr(llm, "model", "")
|
|
40
|
+
normalized = str(model).strip()
|
|
41
|
+
return normalized or "unknown-model"
|
|
42
|
+
|
|
43
|
+
def set_model_name(self, model_name: str) -> None:
|
|
44
|
+
normalized = str(model_name).strip()
|
|
45
|
+
self._model_name = normalized or "unknown-model"
|
|
46
|
+
|
|
47
|
+
def set_session(self, session: ChatSession) -> None:
|
|
48
|
+
self._session = session
|
|
49
|
+
self._model_name = self._resolve_model_name(session)
|
|
50
|
+
try:
|
|
51
|
+
self._mode = str(session.get_mode()).strip().lower() or "act"
|
|
52
|
+
except Exception:
|
|
53
|
+
self._mode = "act"
|
|
54
|
+
|
|
55
|
+
def set_mode(self, mode: str) -> None:
|
|
56
|
+
normalized = str(mode).strip().lower()
|
|
57
|
+
self._mode = normalized or "act"
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _resolve_git_branch() -> str:
|
|
61
|
+
try:
|
|
62
|
+
completed = subprocess.run(
|
|
63
|
+
["git", "branch", "--show-current"],
|
|
64
|
+
check=False,
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
)
|
|
68
|
+
except Exception:
|
|
69
|
+
return "N/A"
|
|
70
|
+
|
|
71
|
+
if completed.returncode != 0:
|
|
72
|
+
return "N/A"
|
|
73
|
+
branch = completed.stdout.strip()
|
|
74
|
+
return branch or "N/A"
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _resolve_git_diff_stats() -> GitDiffStats | None:
|
|
78
|
+
try:
|
|
79
|
+
completed = subprocess.run(
|
|
80
|
+
["git", "diff", "--shortstat", "--no-color"],
|
|
81
|
+
check=False,
|
|
82
|
+
capture_output=True,
|
|
83
|
+
text=True,
|
|
84
|
+
)
|
|
85
|
+
except Exception:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
if completed.returncode != 0:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
output = completed.stdout.strip()
|
|
92
|
+
if not output:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
# Parse output like "3 files changed, 12 insertions(+), 5 deletions(-)"
|
|
96
|
+
# or just "12 insertions(+), 5 deletions(-)"
|
|
97
|
+
added, removed = 0, 0
|
|
98
|
+
for part in output.split(","):
|
|
99
|
+
part = part.strip()
|
|
100
|
+
if "+" in part and "insertion" in part:
|
|
101
|
+
# e.g., "12 insertions(+)"
|
|
102
|
+
num_str = part.split()[0]
|
|
103
|
+
added = int(num_str)
|
|
104
|
+
elif "-" in part and "deletion" in part:
|
|
105
|
+
# e.g., "5 deletions(-)"
|
|
106
|
+
num_str = part.split()[0]
|
|
107
|
+
removed = int(num_str)
|
|
108
|
+
|
|
109
|
+
return GitDiffStats(added=added, removed=removed)
|
|
110
|
+
|
|
111
|
+
def _ensure_git_diff_stats(self) -> None:
|
|
112
|
+
now = time.monotonic()
|
|
113
|
+
if (
|
|
114
|
+
self._git_diff_stats is not None
|
|
115
|
+
and now - self._git_diff_cache_time < self._GIT_DIFF_CACHE_SECONDS
|
|
116
|
+
):
|
|
117
|
+
return
|
|
118
|
+
self._git_diff_stats = self._resolve_git_diff_stats()
|
|
119
|
+
self._git_diff_cache_time = now
|
|
120
|
+
logger.debug(
|
|
121
|
+
f"Git diff stats refreshed: {self._git_diff_stats}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
async def refresh(self) -> None:
|
|
125
|
+
try:
|
|
126
|
+
ctx_info = await self._session.get_context_info()
|
|
127
|
+
utilization = float(getattr(ctx_info, "utilization_percent", 0.0))
|
|
128
|
+
except Exception:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
self._mode = str(self._session.get_mode()).strip().lower() or "act"
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
normalized = max(0.0, min(utilization, 100.0))
|
|
137
|
+
self._context_used_pct = normalized
|
|
138
|
+
self._context_left_pct = max(0.0, 100.0 - normalized)
|
|
139
|
+
|
|
140
|
+
# Refresh git diff stats to keep them up-to-date
|
|
141
|
+
self._ensure_git_diff_stats()
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def _resolve_terminal_width(cls) -> int:
|
|
145
|
+
app = get_app_or_none()
|
|
146
|
+
if app is None:
|
|
147
|
+
return cls._DEFAULT_TERMINAL_WIDTH
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
width = int(app.output.get_size().columns)
|
|
151
|
+
except Exception:
|
|
152
|
+
return cls._DEFAULT_TERMINAL_WIDTH
|
|
153
|
+
return max(width, cls._MIN_TERMINAL_WIDTH)
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _truncate_text(text: str, max_len: int) -> str:
|
|
157
|
+
if max_len <= 0:
|
|
158
|
+
return ""
|
|
159
|
+
if len(text) <= max_len:
|
|
160
|
+
return text
|
|
161
|
+
if max_len <= 3:
|
|
162
|
+
return text[:max_len]
|
|
163
|
+
return f"{text[: max_len - 3]}..."
|
|
164
|
+
|
|
165
|
+
def _right_prompt_budget(self) -> int:
|
|
166
|
+
width = self._resolve_terminal_width()
|
|
167
|
+
return max(24, min(width - 6, 72))
|
|
168
|
+
|
|
169
|
+
def context_left_text(self) -> str:
|
|
170
|
+
return f"{self._context_left_pct:.0f}% context left"
|
|
171
|
+
|
|
172
|
+
def _status_text_for_width(self, width: int) -> str:
|
|
173
|
+
mode_text = f"[{self._mode}]"
|
|
174
|
+
branch_text = f"~{self._git_branch}"
|
|
175
|
+
context_text = self.context_left_text()
|
|
176
|
+
full_text = f"{mode_text} {self._model_name} | {branch_text} / {context_text}"
|
|
177
|
+
budget = max(len(context_text), width)
|
|
178
|
+
if len(full_text) <= budget:
|
|
179
|
+
return full_text
|
|
180
|
+
|
|
181
|
+
prefix = f"{mode_text} {self._model_name} | {branch_text} / "
|
|
182
|
+
prefix_budget = max(0, budget - len(context_text))
|
|
183
|
+
trimmed_prefix = self._truncate_text(prefix, prefix_budget)
|
|
184
|
+
return f"{trimmed_prefix}{context_text}"
|
|
185
|
+
|
|
186
|
+
def get_mode(self) -> str:
|
|
187
|
+
return self._mode
|
|
188
|
+
|
|
189
|
+
def info_status_text(self) -> str:
|
|
190
|
+
"""仅返回 model | ~branch / X% context left(不含 mode 前缀)."""
|
|
191
|
+
branch_text = f"~{self._git_branch}"
|
|
192
|
+
context_text = self.context_left_text()
|
|
193
|
+
full_text = f"{self._model_name} | {branch_text} / {context_text}"
|
|
194
|
+
width = self._resolve_terminal_width()
|
|
195
|
+
budget = max(len(context_text), width - 2)
|
|
196
|
+
if len(full_text) <= budget:
|
|
197
|
+
return full_text
|
|
198
|
+
prefix = f"{self._model_name} | {branch_text} / "
|
|
199
|
+
prefix_budget = max(0, budget - len(context_text))
|
|
200
|
+
return f"{self._truncate_text(prefix, prefix_budget)}{context_text}"
|
|
201
|
+
|
|
202
|
+
def right_prompt_text(self) -> str:
|
|
203
|
+
return self._status_text_for_width(self._right_prompt_budget())
|
|
204
|
+
|
|
205
|
+
def right_prompt_fragments(self) -> list[tuple[str, str]]:
|
|
206
|
+
return [("class:prompt.rprompt", self.right_prompt_text())]
|
|
207
|
+
|
|
208
|
+
def footer_status_text(self) -> str:
|
|
209
|
+
width = self._resolve_terminal_width()
|
|
210
|
+
content_budget = max(16, width - 2)
|
|
211
|
+
return self._status_text_for_width(content_budget)
|
|
212
|
+
|
|
213
|
+
def _git_diff_fragments(self) -> list[tuple[str, str]]:
|
|
214
|
+
self._ensure_git_diff_stats()
|
|
215
|
+
if self._git_diff_stats is None or (
|
|
216
|
+
self._git_diff_stats.added == 0 and self._git_diff_stats.removed == 0
|
|
217
|
+
):
|
|
218
|
+
return []
|
|
219
|
+
|
|
220
|
+
parts: list[tuple[str, str]] = []
|
|
221
|
+
if self._git_diff_stats.added > 0:
|
|
222
|
+
parts.append(("class:git-diff.added", f"+{self._git_diff_stats.added}"))
|
|
223
|
+
if self._git_diff_stats.removed > 0:
|
|
224
|
+
parts.append(("class:git-diff.removed", f"-{self._git_diff_stats.removed}"))
|
|
225
|
+
return parts
|
|
226
|
+
|
|
227
|
+
def git_diff_fragments(self) -> list[tuple[str, str]]:
|
|
228
|
+
"""Prompt-toolkit fragments for git diff stats.
|
|
229
|
+
|
|
230
|
+
Working tree only (unstaged): `git diff --shortstat`.
|
|
231
|
+
"""
|
|
232
|
+
return self._git_diff_fragments()
|
|
233
|
+
|
|
234
|
+
def footer_toolbar(self) -> list[tuple[str, str]]:
|
|
235
|
+
width = self._resolve_terminal_width()
|
|
236
|
+
status_text = self.footer_status_text()
|
|
237
|
+
git_fragments = self._git_diff_fragments()
|
|
238
|
+
|
|
239
|
+
# Calculate total length: status_text + git diff parts
|
|
240
|
+
git_len = sum(len(text) + 1 for _, text in git_fragments) # +1 for space
|
|
241
|
+
left_padding = max(0, width - len(status_text) - git_len - 1)
|
|
242
|
+
|
|
243
|
+
fragments: list[tuple[str, str]] = [
|
|
244
|
+
("", " " * left_padding),
|
|
245
|
+
("", status_text),
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
# Add git diff stats with colors
|
|
249
|
+
if git_fragments:
|
|
250
|
+
fragments.append(("", " ")) # Separator
|
|
251
|
+
for class_name, text in git_fragments:
|
|
252
|
+
fragments.append((class_name, text))
|
|
253
|
+
|
|
254
|
+
fragments.append(("", " "))
|
|
255
|
+
return fragments
|
|
256
|
+
|
|
257
|
+
def helper_toolbar(self) -> list[tuple[str, str]]:
|
|
258
|
+
return []
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from prompt_toolkit.utils import get_cwidth
|
|
4
|
+
|
|
5
|
+
_ELLIPSIS = "…"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def fit_single_line(content: str, width: int) -> str:
|
|
9
|
+
"""Truncate string by terminal display width, handling wide chars correctly."""
|
|
10
|
+
max_width = max(width, 8)
|
|
11
|
+
if get_cwidth(content) <= max_width:
|
|
12
|
+
return content
|
|
13
|
+
if max_width <= 1:
|
|
14
|
+
return _ELLIPSIS
|
|
15
|
+
|
|
16
|
+
result: list[str] = []
|
|
17
|
+
used_width = 0
|
|
18
|
+
ellipsis_width = get_cwidth(_ELLIPSIS)
|
|
19
|
+
target_width = max_width - ellipsis_width
|
|
20
|
+
|
|
21
|
+
for char in content:
|
|
22
|
+
char_width = get_cwidth(char)
|
|
23
|
+
if used_width + char_width > target_width:
|
|
24
|
+
break
|
|
25
|
+
result.append(char)
|
|
26
|
+
used_width += char_width
|
|
27
|
+
|
|
28
|
+
return "".join(result) + _ELLIPSIS
|
|
29
|
+
|
|
30
|
+
|