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