comate-cli 0.3.2__tar.gz → 0.3.3__tar.gz

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 (104) hide show
  1. {comate_cli-0.3.2 → comate_cli-0.3.3}/PKG-INFO +1 -1
  2. comate_cli-0.3.3/comate_cli/terminal_agent/codenames.py +14 -0
  3. comate_cli-0.3.3/comate_cli/terminal_agent/error_display.py +83 -0
  4. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/history_printer.py +4 -8
  5. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/logo.py +10 -0
  6. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/status_bar.py +9 -1
  7. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/tui.py +22 -21
  8. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/tui_parts/input_behavior.py +3 -3
  9. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/tui_parts/render_panels.py +7 -1
  10. {comate_cli-0.3.2 → comate_cli-0.3.3}/pyproject.toml +1 -1
  11. comate_cli-0.3.3/tests/test_format_error.py +153 -0
  12. comate_cli-0.3.3/tests/test_handle_error.py +115 -0
  13. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_interrupt_exit_semantics.py +5 -0
  14. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_status_bar_transient.py +27 -0
  15. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_tui_mcp_init_gate.py +5 -1
  16. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_tui_paste_placeholder.py +52 -0
  17. {comate_cli-0.3.2 → comate_cli-0.3.3}/uv.lock +2 -2
  18. comate_cli-0.3.2/comate_cli/terminal_agent/error_display.py +0 -46
  19. {comate_cli-0.3.2 → comate_cli-0.3.3}/.gitignore +0 -0
  20. {comate_cli-0.3.2 → comate_cli-0.3.3}/README.md +0 -0
  21. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/__init__.py +0 -0
  22. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/__main__.py +0 -0
  23. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/main.py +0 -0
  24. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/mcp_cli.py +0 -0
  25. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/__init__.py +0 -0
  26. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/animations.py +0 -0
  27. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/app.py +0 -0
  28. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/assistant_render.py +0 -0
  29. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/custom_slash_commands.py +0 -0
  30. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/env_utils.py +0 -0
  31. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/event_renderer.py +0 -0
  32. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/fragment_utils.py +0 -0
  33. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/input_geometry.py +0 -0
  34. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/layout_coordinator.py +0 -0
  35. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/logging_adapter.py +0 -0
  36. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/markdown_render.py +0 -0
  37. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/mention_completer.py +0 -0
  38. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/message_style.py +0 -0
  39. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/models.py +0 -0
  40. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/preflight.py +0 -0
  41. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/question_view.py +0 -0
  42. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/resume_selector.py +0 -0
  43. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/rewind_store.py +0 -0
  44. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/rpc_protocol.py +0 -0
  45. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/rpc_stdio.py +0 -0
  46. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/selection_menu.py +0 -0
  47. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/session_view.py +0 -0
  48. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/slash_commands.py +0 -0
  49. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/startup.py +0 -0
  50. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/text_effects.py +0 -0
  51. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/tips.py +0 -0
  52. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/tool_view.py +0 -0
  53. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/tui_parts/__init__.py +0 -0
  54. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/tui_parts/commands.py +0 -0
  55. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/tui_parts/history_sync.py +0 -0
  56. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/tui_parts/key_bindings.py +0 -0
  57. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/tui_parts/slash_command_registry.py +0 -0
  58. {comate_cli-0.3.2 → comate_cli-0.3.3}/comate_cli/terminal_agent/tui_parts/ui_mode.py +0 -0
  59. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/conftest.py +0 -0
  60. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_app_mcp_preload.py +0 -0
  61. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_app_preflight_gate.py +0 -0
  62. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_app_print_mode.py +0 -0
  63. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_app_shutdown.py +0 -0
  64. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_app_usage_line.py +0 -0
  65. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_cli_project_root.py +0 -0
  66. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_compact_command_semantics.py +0 -0
  67. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_completion_context_activation.py +0 -0
  68. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_completion_status_panel.py +0 -0
  69. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_context_command.py +0 -0
  70. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_custom_slash_commands.py +0 -0
  71. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_event_renderer.py +0 -0
  72. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_history_printer.py +0 -0
  73. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_history_sync.py +0 -0
  74. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_input_behavior.py +0 -0
  75. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_input_history.py +0 -0
  76. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_layout_coordinator.py +0 -0
  77. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_logging_adapter.py +0 -0
  78. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_logo.py +0 -0
  79. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_main_args.py +0 -0
  80. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_mcp_cli.py +0 -0
  81. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_mcp_slash_command.py +0 -0
  82. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_mention_completer.py +0 -0
  83. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_preflight.py +0 -0
  84. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_preflight_copilot.py +0 -0
  85. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_question_key_bindings.py +0 -0
  86. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_question_view.py +0 -0
  87. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_resume_selector.py +0 -0
  88. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_rewind_command_semantics.py +0 -0
  89. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_rewind_store.py +0 -0
  90. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_rpc_protocol.py +0 -0
  91. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_rpc_stdio_bridge.py +0 -0
  92. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_selection_menu.py +0 -0
  93. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_skills_slash_command.py +0 -0
  94. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_slash_argument_hint.py +0 -0
  95. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_slash_completer.py +0 -0
  96. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_slash_registry.py +0 -0
  97. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_status_bar.py +0 -0
  98. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_task_panel_format.py +0 -0
  99. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_task_panel_key_bindings.py +0 -0
  100. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_task_panel_rendering.py +0 -0
  101. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_task_poll.py +0 -0
  102. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_tool_view.py +0 -0
  103. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_tui_elapsed_status.py +0 -0
  104. {comate_cli-0.3.2 → comate_cli-0.3.3}/tests/test_tui_split_invariance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: comate-cli
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Comate terminal CLI built on comate-agent-sdk
5
5
  Project-URL: Homepage, https://github.com/AndyLee1024/agent-sdk
6
6
  Project-URL: Repository, https://github.com/AndyLee1024/agent-sdk
@@ -0,0 +1,14 @@
1
+ """Version codenames — one entry per memorable release."""
2
+
3
+ from __future__ import annotations
4
+
5
+ CODENAMES: dict[str, str] = {
6
+ "0.3.3": "Update for My Brothers",
7
+ "0.3.2": "We are with Ukraine",
8
+ }
9
+
10
+
11
+ def get_codename(ver: str) -> str | None:
12
+ """Return codename for a version string like '0.3.2' or 'v0.3.2'."""
13
+ stripped = ver.lstrip("v")
14
+ return CODENAMES.get(stripped)
@@ -0,0 +1,83 @@
1
+ """Unified error formatter for terminal agent."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+
7
+ def format_error(exc: Exception) -> tuple[str, str, str]:
8
+ """Convert exception to user-friendly (message, transient_summary, severity).
9
+
10
+ - message: full error text for scrollback (plain English, no icons)
11
+ - transient_summary: short text for status bar transient notification
12
+ - severity: "error" or "warning"
13
+ """
14
+ exc_type = type(exc).__name__
15
+ exc_msg = str(exc)
16
+
17
+ # LLM Provider errors
18
+ if exc_type == "ModelRateLimitError":
19
+ return "Rate limit exceeded", "Rate limited", "warning"
20
+
21
+ if exc_type == "ModelProviderError":
22
+ code = getattr(exc, "status_code", None)
23
+
24
+ # Context size exceeded (400 with specific body pattern)
25
+ if code == 400:
26
+ ctx = _parse_context_size_error(exc_msg)
27
+ if ctx:
28
+ return (
29
+ f"Request ({ctx[0]} tokens) exceeds context limit ({ctx[1]})",
30
+ "Context limit exceeded",
31
+ "error",
32
+ )
33
+ return f"API error: {_truncate(exc_msg, 80)}", "API error", "error"
34
+
35
+ if code == 401:
36
+ return "Invalid or expired API key", "Auth failed", "error"
37
+ if code == 403:
38
+ return "Access denied to this model", "Access denied", "error"
39
+ if code == 404:
40
+ return "Model not found or invalid API path", "Model not found", "error"
41
+ if code and code >= 500:
42
+ return f"Server error ({code})", "Server error", "warning"
43
+ return f"API error: {_truncate(exc_msg, 80)}", "API error", "error"
44
+
45
+ # Session errors
46
+ if exc_type == "ChatSessionClosedError":
47
+ return "Session closed", "Session closed", "error"
48
+
49
+ # Network errors (heuristic)
50
+ lower_msg = exc_msg.lower()
51
+ if "timeout" in lower_msg or "timed out" in lower_msg:
52
+ return "Request timed out", "Timed out", "warning"
53
+ if "connection" in lower_msg:
54
+ return "Connection failed", "Connection failed", "error"
55
+
56
+ # Generic fallback
57
+ return f"Error: {_truncate(exc_msg, 80)}", "Error occurred", "error"
58
+
59
+
60
+ def _parse_context_size_error(msg: str) -> tuple[str, str] | None:
61
+ """Extract (prompt_tokens, ctx_limit) from context size error body.
62
+
63
+ Matches patterns like 'n_prompt_tokens': 35099, 'n_ctx': 32768
64
+ or 'request (35099 tokens) exceeds the available context size (32768 tokens)'.
65
+ """
66
+ m = re.search(r"n_prompt_tokens['\"]?:\s*(\d+)", msg)
67
+ n = re.search(r"n_ctx['\"]?:\s*(\d+)", msg)
68
+ if m and n:
69
+ return m.group(1), n.group(1)
70
+
71
+ m2 = re.search(r"request\s*\((\d+)\s*tokens?\)", msg, re.IGNORECASE)
72
+ n2 = re.search(r"context\s*size\s*\((\d+)\s*tokens?\)", msg, re.IGNORECASE)
73
+ if m2 and n2:
74
+ return m2.group(1), n2.group(1)
75
+
76
+ if "exceed_context_size_error" in msg:
77
+ return None # Type matches but can't parse numbers — fall through to generic 400
78
+
79
+ return None
80
+
81
+
82
+ def _truncate(s: str, max_len: int) -> str:
83
+ return s[:max_len] + "..." if len(s) > max_len else s
@@ -85,17 +85,13 @@ def render_history_group(
85
85
  # System entries: 按 severity 区分视觉样式
86
86
  if entry.entry_type == "system":
87
87
  if entry.severity == "error":
88
- prefix_char = ""
89
- prefix_style = "#FF9FC6"
88
+ text_style = "bold #FF6B6B"
90
89
  elif entry.severity == "warning":
91
- prefix_char = ""
92
- prefix_style = "#B8B630"
90
+ text_style = "#E8B830"
93
91
  else:
94
- prefix_char = ""
95
- prefix_style = "dim"
92
+ text_style = "dim"
96
93
  line_text = Text()
97
- line_text.append(f"{prefix_char} ", style=prefix_style)
98
- line_text.append(str(entry.text), style=prefix_style)
94
+ line_text.append(str(entry.text), style=text_style)
99
95
  renderables.append(line_text)
100
96
  renderables.append(Text(""))
101
97
  continue
@@ -8,6 +8,8 @@ from rich.text import Text
8
8
 
9
9
  from comate_agent_sdk.utils.paths import PathInput, normalize_path_input
10
10
 
11
+ from .codenames import get_codename
12
+
11
13
  _LOGO_LINES: tuple[str, ...] = (
12
14
  "",
13
15
  " ██████╗ ██████╗ ███╗ ███╗ █████╗ ████████╗███████╗",
@@ -59,6 +61,7 @@ def print_logo(console: Console, *, project_root: PathInput | None = None) -> No
59
61
  line_count = len(_LOGO_LINES)
60
62
 
61
63
  ver = _resolve_version()
64
+ codename = get_codename(ver)
62
65
  logo_text = Text()
63
66
  for idx, line in enumerate(_LOGO_LINES):
64
67
  ratio = idx / max(line_count - 1, 1)
@@ -66,6 +69,13 @@ def print_logo(console: Console, *, project_root: PathInput | None = None) -> No
66
69
  logo_text.append(line, style=f"bold rgb({r},{g},{b})")
67
70
  if idx == line_count - 1:
68
71
  logo_text.append(f" {ver}", style="dim")
72
+ if codename:
73
+ er, eg, eb = end_rgb
74
+ logo_text.append(": ", style="dim")
75
+ logo_text.append(
76
+ codename,
77
+ style=f"italic rgb({er},{eg},{eb})",
78
+ )
69
79
  if idx < line_count - 1:
70
80
  logo_text.append("\n")
71
81
 
@@ -30,6 +30,7 @@ class StatusBar:
30
30
  self._git_diff_stats: GitDiffStats | None = None
31
31
  self._git_diff_cache_time: float = 0.0
32
32
  self._transient_message: str | None = None
33
+ self._transient_severity: str = "info"
33
34
  self._transient_until: float | None = None
34
35
 
35
36
  @staticmethod
@@ -258,9 +259,11 @@ class StatusBar:
258
259
  fragments.append(("", " "))
259
260
  return fragments
260
261
 
261
- def show_transient(self, message: str, duration_s: float = 5.0) -> None:
262
+ def show_transient(self, message: str, duration_s: float = 5.0,
263
+ severity: str = "info") -> None:
262
264
  """Set a transient message that auto-clears after *duration_s* seconds."""
263
265
  self._transient_message = message
266
+ self._transient_severity = severity
264
267
  self._transient_until = time.monotonic() + duration_s
265
268
 
266
269
  def clear_transient_if_expired(self) -> bool:
@@ -271,6 +274,7 @@ class StatusBar:
271
274
  """
272
275
  if self._transient_until is not None and time.monotonic() >= self._transient_until:
273
276
  self._transient_message = None
277
+ self._transient_severity = "info"
274
278
  self._transient_until = None
275
279
  return True
276
280
  return False
@@ -283,5 +287,9 @@ class StatusBar:
283
287
  def has_transient(self) -> bool:
284
288
  return self._transient_message is not None
285
289
 
290
+ @property
291
+ def transient_severity(self) -> str:
292
+ return self._transient_severity if self._transient_message else "info"
293
+
286
294
  def helper_toolbar(self) -> list[tuple[str, str]]:
287
295
  return []
@@ -485,6 +485,8 @@ class TerminalAgentTUI(
485
485
  "status.mode.plan": "bg:default #7AC9CA bold",
486
486
  "status.hint": "bg:default #6B7280",
487
487
  "status.transient": "bg:default italic fg:ansiyellow",
488
+ "status.transient.error": "bg:default bold fg:ansired",
489
+ "status.transient.warning": "bg:default italic fg:ansiyellow",
488
490
  "input.placeholder": "bg:default #9CA3AF",
489
491
  "auto-suggestion": "bg:default #94a3b8",
490
492
  "queue": "bg:#1d222a #d8dee9",
@@ -732,6 +734,24 @@ class TerminalAgentTUI(
732
734
  self._render_dirty = True
733
735
  self._invalidate()
734
736
 
737
+ async def _handle_error(self, exc: Exception) -> None:
738
+ """Unified error cleanup: format → display → stop animation → reset state."""
739
+ from comate_cli.terminal_agent.error_display import format_error
740
+
741
+ message, transient_summary, severity = format_error(exc)
742
+
743
+ self._renderer.append_system_message(message, severity=severity)
744
+ self._renderer.interrupt_turn()
745
+ await self._animation_controller.shutdown()
746
+ self._status_bar.show_transient(transient_summary, severity=severity)
747
+ self._last_turn_user_preview = None
748
+ self._append_run_elapsed_to_history(stop_reason="error")
749
+ self._run_start_time = None
750
+ self._interrupt_requested_at = None
751
+ self._set_busy(False)
752
+ await self._status_bar.refresh()
753
+ self._refresh_layers()
754
+
735
755
  async def _submit_user_message(
736
756
  self,
737
757
  text: str,
@@ -775,21 +795,7 @@ class TerminalAgentTUI(
775
795
  await self._session.send(text)
776
796
  except Exception as exc:
777
797
  logger.exception("send failed")
778
- from comate_cli.terminal_agent.error_display import format_error
779
-
780
- error_msg, suggestion = format_error(exc)
781
- self._renderer.append_system_message(error_msg, severity="error")
782
- if suggestion:
783
- self._renderer.append_system_message(f"💡 {suggestion}")
784
- self._renderer.interrupt_turn()
785
- await self._animation_controller.shutdown()
786
- self._last_turn_user_preview = None
787
- self._append_run_elapsed_to_history(stop_reason="error")
788
- self._run_start_time = None
789
- self._interrupt_requested_at = None
790
- self._set_busy(False)
791
- await self._status_bar.refresh()
792
- self._refresh_layers()
798
+ await self._handle_error(exc)
793
799
 
794
800
  async def _consume_event_stream(self) -> None:
795
801
  waiting_for_input = False
@@ -899,12 +905,7 @@ class TerminalAgentTUI(
899
905
  raise
900
906
  except Exception as exc:
901
907
  logger.exception("session event pump failed")
902
- self._renderer.append_system_message(
903
- f"会话事件流异常终止: {exc}",
904
- severity="error",
905
- )
906
- self._set_busy(False)
907
- self._refresh_layers()
908
+ await self._handle_error(exc)
908
909
 
909
910
  def _open_plan_approval_menu(self, approval: dict[str, str]) -> None:
910
911
  plan_path = str(approval.get("plan_path", "")).strip()
@@ -322,7 +322,7 @@ class InputBehaviorMixin:
322
322
  # busy 或 initializing 时:非斜杠命令 → 入队,斜杠命令 → 交由命令分发决定
323
323
  is_busy = self._busy or self._initializing
324
324
  if is_busy:
325
- if display_text.lstrip().startswith("/"):
325
+ if raw_text.startswith("/"):
326
326
  normalized_slash = display_text.strip()
327
327
  parsed = parse_slash_command_call(normalized_slash)
328
328
  registry = getattr(self, "_slash_registry", None)
@@ -351,7 +351,7 @@ class InputBehaviorMixin:
351
351
  self._schedule_background(self._execute_command(normalized_slash))
352
352
  return
353
353
 
354
- if not display_text.lstrip().startswith("/"):
354
+ if not raw_text.startswith("/"):
355
355
  queue_size = len(self._queued_messages)
356
356
  if queue_size >= int(self._message_queue_max_size):
357
357
  self._renderer.append_system_message(
@@ -364,7 +364,7 @@ class InputBehaviorMixin:
364
364
  self._invalidate()
365
365
  return
366
366
 
367
- if display_text.lstrip().startswith("/"):
367
+ if raw_text.startswith("/"):
368
368
  self._schedule_background(self._execute_command(display_text.strip()))
369
369
  return
370
370
 
@@ -67,7 +67,13 @@ class RenderPanelsMixin:
67
67
  padding = max(1, width - left_w - right_w - 2)
68
68
 
69
69
  frags: list[tuple[str, str]] = [
70
- ("class:status.transient", transient),
70
+ (
71
+ {
72
+ "error": "class:status.transient.error",
73
+ "warning": "class:status.transient.warning",
74
+ }.get(self._status_bar.transient_severity, "class:status.transient"),
75
+ transient,
76
+ ),
71
77
  ("class:status", " " * padding),
72
78
  ("class:status", right_text),
73
79
  ]
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "comate-cli"
7
- version = "0.3.2"
7
+ version = "0.3.3"
8
8
  description = "Comate terminal CLI built on comate-agent-sdk"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,153 @@
1
+ """Tests for unified error formatter."""
2
+ from __future__ import annotations
3
+
4
+ import unittest
5
+
6
+
7
+ class TestFormatError(unittest.TestCase):
8
+ """Each test verifies (message, transient_summary, severity) for one error type."""
9
+
10
+ def _make_exc(self, cls_name: str, msg: str = "", **attrs):
11
+ """Create a fake exception with a given class name and attributes."""
12
+ exc = type(cls_name, (Exception,), {})(msg)
13
+ for k, v in attrs.items():
14
+ setattr(exc, k, v)
15
+ return exc
16
+
17
+ def test_rate_limit(self):
18
+ from comate_cli.terminal_agent.error_display import format_error
19
+
20
+ exc = self._make_exc("ModelRateLimitError")
21
+ msg, transient, severity = format_error(exc)
22
+ assert severity == "warning"
23
+ assert "Rate limit" in msg
24
+ assert transient == "Rate limited"
25
+
26
+ def test_provider_401(self):
27
+ from comate_cli.terminal_agent.error_display import format_error
28
+
29
+ exc = self._make_exc("ModelProviderError", status_code=401)
30
+ msg, transient, severity = format_error(exc)
31
+ assert severity == "error"
32
+ assert "API key" in msg
33
+ assert transient == "Auth failed"
34
+
35
+ def test_provider_403(self):
36
+ from comate_cli.terminal_agent.error_display import format_error
37
+
38
+ exc = self._make_exc("ModelProviderError", status_code=403)
39
+ msg, transient, severity = format_error(exc)
40
+ assert severity == "error"
41
+ assert "Access denied" in msg
42
+ assert transient == "Access denied"
43
+
44
+ def test_provider_404(self):
45
+ from comate_cli.terminal_agent.error_display import format_error
46
+
47
+ exc = self._make_exc("ModelProviderError", status_code=404)
48
+ msg, transient, severity = format_error(exc)
49
+ assert severity == "error"
50
+ assert "not found" in msg.lower()
51
+ assert transient == "Model not found"
52
+
53
+ def test_provider_5xx(self):
54
+ from comate_cli.terminal_agent.error_display import format_error
55
+
56
+ exc = self._make_exc("ModelProviderError", status_code=502)
57
+ msg, transient, severity = format_error(exc)
58
+ assert severity == "warning"
59
+ assert "502" in msg
60
+ assert transient == "Server error"
61
+
62
+ def test_context_size_exceeded(self):
63
+ from comate_cli.terminal_agent.error_display import format_error
64
+
65
+ body = (
66
+ "Error code: 400 - {'error': {'code': 400, "
67
+ "'message': 'request (35099 tokens) exceeds the available context size (32768 tokens)', "
68
+ "'type': 'exceed_context_size_error', 'n_prompt_tokens': 35099, 'n_ctx': 32768}}"
69
+ )
70
+ exc = self._make_exc("ModelProviderError", body, status_code=400)
71
+ msg, transient, severity = format_error(exc)
72
+ assert severity == "error"
73
+ assert "35099" in msg
74
+ assert "32768" in msg
75
+ assert transient == "Context limit exceeded"
76
+
77
+ def test_context_size_type_without_parseable_numbers(self):
78
+ from comate_cli.terminal_agent.error_display import format_error
79
+
80
+ exc = self._make_exc(
81
+ "ModelProviderError",
82
+ "exceed_context_size_error without parseable numbers",
83
+ status_code=400,
84
+ )
85
+ msg, transient, severity = format_error(exc)
86
+ assert severity == "error"
87
+ assert transient == "API error" # Falls through to generic 400
88
+
89
+ def test_provider_400_generic(self):
90
+ from comate_cli.terminal_agent.error_display import format_error
91
+
92
+ exc = self._make_exc("ModelProviderError", "bad request", status_code=400)
93
+ msg, transient, severity = format_error(exc)
94
+ assert severity == "error"
95
+ assert "API error" in msg
96
+ assert transient == "API error"
97
+
98
+ def test_session_closed(self):
99
+ from comate_cli.terminal_agent.error_display import format_error
100
+
101
+ exc = self._make_exc("ChatSessionClosedError")
102
+ msg, transient, severity = format_error(exc)
103
+ assert severity == "error"
104
+ assert "Session closed" in msg
105
+ assert transient == "Session closed"
106
+
107
+ def test_timeout(self):
108
+ from comate_cli.terminal_agent.error_display import format_error
109
+
110
+ exc = Exception("request timed out after 30s")
111
+ msg, transient, severity = format_error(exc)
112
+ assert severity == "warning"
113
+ assert "timed out" in msg.lower()
114
+ assert transient == "Timed out"
115
+
116
+ def test_connection_error(self):
117
+ from comate_cli.terminal_agent.error_display import format_error
118
+
119
+ exc = Exception("Connection refused")
120
+ msg, transient, severity = format_error(exc)
121
+ assert severity == "error"
122
+ assert "Connection failed" in msg
123
+ assert transient == "Connection failed"
124
+
125
+ def test_generic_fallback(self):
126
+ from comate_cli.terminal_agent.error_display import format_error
127
+
128
+ exc = Exception("something unexpected")
129
+ msg, transient, severity = format_error(exc)
130
+ assert severity == "error"
131
+ assert "something unexpected" in msg
132
+ assert transient == "Error occurred"
133
+
134
+ def test_no_icons_in_messages(self):
135
+ """All messages must be plain text, no emoji icons."""
136
+ from comate_cli.terminal_agent.error_display import format_error
137
+
138
+ test_cases = [
139
+ self._make_exc("ModelRateLimitError"),
140
+ self._make_exc("ModelProviderError", status_code=401),
141
+ self._make_exc("ChatSessionClosedError"),
142
+ Exception("timeout"),
143
+ Exception("generic error"),
144
+ ]
145
+ for exc in test_cases:
146
+ msg, transient, severity = format_error(exc)
147
+ assert "⚠" not in msg, f"Icon found in: {msg}"
148
+ assert "✖" not in msg, f"Icon found in: {msg}"
149
+ assert "💡" not in msg, f"Icon found in: {msg}"
150
+
151
+
152
+ if __name__ == "__main__":
153
+ unittest.main(verbosity=2)
@@ -0,0 +1,115 @@
1
+ """Tests for unified _handle_error() in TUI."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import unittest
6
+ from unittest.mock import AsyncMock, MagicMock
7
+
8
+
9
+ def _make_tui_stub():
10
+ """Create a minimal TUI stub with mocked dependencies."""
11
+ tui = MagicMock()
12
+ tui._renderer = MagicMock()
13
+ tui._animation_controller = AsyncMock()
14
+ tui._status_bar = MagicMock()
15
+ tui._status_bar.refresh = AsyncMock()
16
+ tui._last_turn_user_preview = "some preview"
17
+ tui._run_start_time = 12345.0
18
+ tui._interrupt_requested_at = 67890.0
19
+ return tui
20
+
21
+
22
+ class TestHandleError(unittest.TestCase):
23
+ def test_handle_error_stops_animation(self):
24
+ """Regression: animation must be stopped on any error."""
25
+ from comate_cli.terminal_agent.tui import TerminalAgentTUI
26
+
27
+ tui = _make_tui_stub()
28
+ exc = Exception("test error")
29
+
30
+ asyncio.run(TerminalAgentTUI._handle_error(tui, exc))
31
+
32
+ tui._animation_controller.shutdown.assert_awaited_once()
33
+
34
+ def test_handle_error_sets_busy_false(self):
35
+ from comate_cli.terminal_agent.tui import TerminalAgentTUI
36
+
37
+ tui = _make_tui_stub()
38
+ exc = Exception("test error")
39
+
40
+ asyncio.run(TerminalAgentTUI._handle_error(tui, exc))
41
+
42
+ tui._set_busy.assert_called_once_with(False)
43
+
44
+ def test_handle_error_writes_to_scrollback(self):
45
+ from comate_cli.terminal_agent.tui import TerminalAgentTUI
46
+
47
+ tui = _make_tui_stub()
48
+ exc = Exception("something broke")
49
+
50
+ asyncio.run(TerminalAgentTUI._handle_error(tui, exc))
51
+
52
+ tui._renderer.append_system_message.assert_called_once()
53
+ call_args = tui._renderer.append_system_message.call_args
54
+ assert "something broke" in call_args[0][0]
55
+ assert call_args[1]["severity"] == "error"
56
+
57
+ def test_handle_error_sets_transient_with_severity(self):
58
+ from comate_cli.terminal_agent.tui import TerminalAgentTUI
59
+
60
+ tui = _make_tui_stub()
61
+ exc = Exception("something broke")
62
+
63
+ asyncio.run(TerminalAgentTUI._handle_error(tui, exc))
64
+
65
+ tui._status_bar.show_transient.assert_called_once()
66
+ call_args = tui._status_bar.show_transient.call_args
67
+ assert call_args[1]["severity"] == "error"
68
+
69
+ def test_handle_error_resets_run_state(self):
70
+ from comate_cli.terminal_agent.tui import TerminalAgentTUI
71
+
72
+ tui = _make_tui_stub()
73
+ exc = Exception("test")
74
+
75
+ asyncio.run(TerminalAgentTUI._handle_error(tui, exc))
76
+
77
+ assert tui._last_turn_user_preview is None
78
+ assert tui._run_start_time is None
79
+ assert tui._interrupt_requested_at is None
80
+
81
+ def test_handle_error_calls_interrupt_turn(self):
82
+ from comate_cli.terminal_agent.tui import TerminalAgentTUI
83
+
84
+ tui = _make_tui_stub()
85
+ exc = Exception("test")
86
+
87
+ asyncio.run(TerminalAgentTUI._handle_error(tui, exc))
88
+
89
+ tui._renderer.interrupt_turn.assert_called_once()
90
+
91
+ def test_handle_error_refreshes_layers(self):
92
+ from comate_cli.terminal_agent.tui import TerminalAgentTUI
93
+
94
+ tui = _make_tui_stub()
95
+ exc = Exception("test")
96
+
97
+ asyncio.run(TerminalAgentTUI._handle_error(tui, exc))
98
+
99
+ tui._refresh_layers.assert_called_once()
100
+
101
+ def test_handle_error_warning_severity_for_rate_limit(self):
102
+ """Rate limit errors should propagate warning severity to transient."""
103
+ from comate_cli.terminal_agent.tui import TerminalAgentTUI
104
+
105
+ tui = _make_tui_stub()
106
+ exc = type("ModelRateLimitError", (Exception,), {})("rate limited")
107
+
108
+ asyncio.run(TerminalAgentTUI._handle_error(tui, exc))
109
+
110
+ call_args = tui._status_bar.show_transient.call_args
111
+ assert call_args[1]["severity"] == "warning"
112
+
113
+
114
+ if __name__ == "__main__":
115
+ unittest.main(verbosity=2)
@@ -109,6 +109,9 @@ class _StubStatusBar:
109
109
  def set_mode(self, _mode: str) -> None:
110
110
  return None
111
111
 
112
+ def show_transient(self, _message: str, *, severity: str = "error") -> None:
113
+ return None
114
+
112
115
 
113
116
  class _StubKeyBindingHost(KeyBindingsMixin):
114
117
  def __init__(self) -> None:
@@ -285,6 +288,8 @@ class TestInterruptExitSemantics(unittest.IsolatedAsyncioTestCase):
285
288
  tui._open_plan_approval_menu = lambda approval: None
286
289
  tui._enter_question_mode = lambda questions: None
287
290
  tui._exit_question_mode = lambda: None
291
+ tui._queued_messages = []
292
+ tui._append_run_elapsed_to_history = lambda stop_reason=None: None
288
293
 
289
294
  await tui._consume_event_stream()
290
295
 
@@ -56,6 +56,33 @@ class TestStatusBarTransient(unittest.TestCase):
56
56
  sb.show_transient("second", duration_s=10.0)
57
57
  assert sb.transient_message == "second"
58
58
 
59
+ def test_show_transient_default_severity_is_info(self) -> None:
60
+ sb = _make_status_bar()
61
+ sb.show_transient("hello")
62
+ assert sb.transient_severity == "info"
63
+
64
+ def test_show_transient_with_error_severity(self) -> None:
65
+ sb = _make_status_bar()
66
+ sb.show_transient("fail", severity="error")
67
+ assert sb.transient_message == "fail"
68
+ assert sb.transient_severity == "error"
69
+
70
+ def test_show_transient_with_warning_severity(self) -> None:
71
+ sb = _make_status_bar()
72
+ sb.show_transient("warn", severity="warning")
73
+ assert sb.transient_severity == "warning"
74
+
75
+ def test_transient_severity_returns_info_when_no_message(self) -> None:
76
+ sb = _make_status_bar()
77
+ assert sb.transient_severity == "info"
78
+
79
+ def test_clear_transient_resets_severity(self) -> None:
80
+ sb = _make_status_bar()
81
+ sb.show_transient("err", duration_s=0.0, severity="error")
82
+ import time; time.sleep(0.01)
83
+ sb.clear_transient_if_expired()
84
+ assert sb.transient_severity == "info"
85
+
59
86
 
60
87
  if __name__ == "__main__":
61
88
  unittest.main(verbosity=2)
@@ -55,13 +55,17 @@ class TestTUIMcpInitGate(unittest.IsolatedAsyncioTestCase):
55
55
  class _FakeStatusBar:
56
56
  def __init__(self) -> None:
57
57
  self.transient_calls: list[tuple[str, float]] = []
58
- def show_transient(self, msg: str, duration_s: float = 5.0) -> None:
58
+ def show_transient(self, msg: str, duration_s: float = 5.0,
59
+ severity: str = "info") -> None:
59
60
  self.transient_calls.append((msg, duration_s))
60
61
  def clear_transient_if_expired(self) -> bool:
61
62
  return False
62
63
  @property
63
64
  def has_transient(self) -> bool:
64
65
  return False
66
+ @property
67
+ def transient_severity(self) -> str:
68
+ return "info"
65
69
 
66
70
  fake_status_bar = _FakeStatusBar()
67
71
  tui._status_bar = fake_status_bar
@@ -414,5 +414,57 @@ class TestTUIPastePlaceholder(unittest.TestCase):
414
414
  self.assertIsNone(hint)
415
415
 
416
416
 
417
+ def test_leading_space_escapes_slash_command(self) -> None:
418
+ """Leading space before / should send as user message, not slash command."""
419
+ tui = _build_tui(threshold=5)
420
+ tui._busy = False
421
+ tui._ui_mode = UIMode.NORMAL
422
+ tui._clear_input_area = lambda **_kw: None
423
+
424
+ captured: dict[str, str | None] = {"text": None, "display_text": None}
425
+
426
+ async def _fake_submit_user_message(
427
+ text: str,
428
+ *,
429
+ display_text: str | None = None,
430
+ ) -> None:
431
+ captured["text"] = text
432
+ captured["display_text"] = display_text
433
+
434
+ dispatched: list[str] = []
435
+
436
+ async def _fake_execute_command(command: str) -> None:
437
+ dispatched.append(command)
438
+
439
+ tui._submit_user_message = _fake_submit_user_message
440
+ tui._execute_command = _fake_execute_command
441
+
442
+ def _run_immediately(coro: object) -> None:
443
+ asyncio.run(coro)
444
+
445
+ tui._schedule_background = _run_immediately
446
+ tui._input_area = _FakeInputArea(" /aaa")
447
+
448
+ tui._submit_from_input()
449
+
450
+ self.assertEqual(dispatched, [])
451
+ self.assertEqual(captured["text"], "/aaa")
452
+
453
+ def test_leading_space_escapes_slash_command_when_busy(self) -> None:
454
+ """Leading space before / should enqueue as user message when busy."""
455
+ tui = _build_tui(threshold=5)
456
+ tui._busy = True
457
+ tui._ui_mode = UIMode.NORMAL
458
+ tui._clear_input_area = lambda **_kw: None
459
+ tui._execute_command = lambda _: None
460
+ tui._schedule_background = lambda _: None
461
+
462
+ tui._input_area = _FakeInputArea(" /help")
463
+ tui._submit_from_input()
464
+
465
+ self.assertEqual(len(tui._queued_messages), 1)
466
+ self.assertEqual(tui._queued_messages[0], "/help")
467
+
468
+
417
469
  if __name__ == "__main__":
418
470
  unittest.main(verbosity=2)
@@ -364,7 +364,7 @@ wheels = [
364
364
 
365
365
  [[package]]
366
366
  name = "comate-agent-sdk"
367
- version = "0.3.1"
367
+ version = "0.3.2"
368
368
  source = { editable = "../../" }
369
369
  dependencies = [
370
370
  { name = "aiohttp" },
@@ -429,7 +429,7 @@ dev = [
429
429
 
430
430
  [[package]]
431
431
  name = "comate-cli"
432
- version = "0.3.1"
432
+ version = "0.3.2"
433
433
  source = { editable = "." }
434
434
  dependencies = [
435
435
  { name = "comate-agent-sdk" },
@@ -1,46 +0,0 @@
1
- """Unified error formatter for terminal agent"""
2
- from __future__ import annotations
3
-
4
-
5
- def format_error(exc: Exception) -> tuple[str, str | None]:
6
- """Convert exception to user-friendly message.
7
-
8
- Returns:
9
- (error_message, suggestion) - Error message and optional suggestion
10
- """
11
- exc_type = type(exc).__name__
12
- exc_msg = str(exc)
13
-
14
- # LLM Provider errors
15
- if exc_type == "ModelRateLimitError":
16
- return "⚠️ Rate limit exceeded", "Wait a moment, or use /model to switch"
17
-
18
- if exc_type == "ModelProviderError":
19
- code = getattr(exc, 'status_code', None)
20
- if code == 404:
21
- return "⚠️ Model not found or invalid API path", "Check .agent/settings.json"
22
- if code == 401:
23
- return "⚠️ Invalid or expired API key", "Check api_key in .agent/settings.json"
24
- if code == 403:
25
- return "⚠️ Access denied to this model", "Check API key permissions"
26
- if code and code >= 500:
27
- return f"⚠️ Server error ({code})", "Try again later"
28
- return f"⚠️ API error: {_truncate(exc_msg, 80)}", None
29
-
30
- # Session errors
31
- if exc_type == "ChatSessionClosedError":
32
- return "⚠️ Session closed", "Please restart the CLI"
33
-
34
- # Network errors (generic detection)
35
- lower_msg = exc_msg.lower()
36
- if "timeout" in lower_msg or "timed out" in lower_msg:
37
- return "⚠️ Request timed out", "Check network connection, or try again"
38
- if "connection" in lower_msg:
39
- return "⚠️ Connection failed", "Check network and API endpoint"
40
-
41
- # Generic fallback
42
- return f"⚠️ Error: {_truncate(exc_msg, 60)}", "You can continue typing"
43
-
44
-
45
- def _truncate(s: str, max_len: int) -> str:
46
- return s[:max_len] + "..." if len(s) > max_len else s
File without changes
File without changes
File without changes