comate-cli 0.2.7__tar.gz → 0.3.0__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 (100) hide show
  1. {comate_cli-0.2.7 → comate_cli-0.3.0}/PKG-INFO +1 -1
  2. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/main.py +31 -5
  3. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/app.py +41 -2
  4. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/event_renderer.py +66 -14
  5. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/history_printer.py +17 -0
  6. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/models.py +1 -0
  7. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tui.py +45 -2
  8. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tui_parts/input_behavior.py +10 -3
  9. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tui_parts/render_panels.py +1 -1
  10. {comate_cli-0.2.7 → comate_cli-0.3.0}/pyproject.toml +1 -1
  11. comate_cli-0.3.0/tests/test_app_print_mode.py +90 -0
  12. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_event_renderer.py +142 -1
  13. comate_cli-0.3.0/tests/test_history_printer.py +61 -0
  14. comate_cli-0.3.0/tests/test_input_history.py +136 -0
  15. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_interrupt_exit_semantics.py +1 -1
  16. comate_cli-0.3.0/tests/test_main_args.py +129 -0
  17. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_question_key_bindings.py +1 -1
  18. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_task_panel_key_bindings.py +1 -1
  19. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_task_panel_rendering.py +1 -1
  20. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_tui_paste_placeholder.py +6 -6
  21. {comate_cli-0.2.7 → comate_cli-0.3.0}/uv.lock +13 -35
  22. comate_cli-0.2.7/tests/test_main_args.py +0 -52
  23. {comate_cli-0.2.7 → comate_cli-0.3.0}/.gitignore +0 -0
  24. {comate_cli-0.2.7 → comate_cli-0.3.0}/README.md +0 -0
  25. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/__init__.py +0 -0
  26. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/__main__.py +0 -0
  27. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/mcp_cli.py +0 -0
  28. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/__init__.py +0 -0
  29. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/animations.py +0 -0
  30. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/assistant_render.py +0 -0
  31. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/custom_slash_commands.py +0 -0
  32. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/env_utils.py +0 -0
  33. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/error_display.py +0 -0
  34. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/fragment_utils.py +0 -0
  35. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/input_geometry.py +0 -0
  36. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/layout_coordinator.py +0 -0
  37. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/logging_adapter.py +0 -0
  38. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/logo.py +0 -0
  39. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/markdown_render.py +0 -0
  40. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/mention_completer.py +0 -0
  41. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/message_style.py +0 -0
  42. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/preflight.py +0 -0
  43. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/question_view.py +0 -0
  44. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/resume_selector.py +0 -0
  45. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/rewind_store.py +0 -0
  46. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/rpc_protocol.py +0 -0
  47. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/rpc_stdio.py +0 -0
  48. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/selection_menu.py +0 -0
  49. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/session_view.py +0 -0
  50. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/slash_commands.py +0 -0
  51. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/startup.py +0 -0
  52. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/status_bar.py +0 -0
  53. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/text_effects.py +0 -0
  54. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tips.py +0 -0
  55. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tool_view.py +0 -0
  56. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tui_parts/__init__.py +0 -0
  57. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tui_parts/commands.py +0 -0
  58. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tui_parts/history_sync.py +0 -0
  59. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tui_parts/key_bindings.py +0 -0
  60. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tui_parts/slash_command_registry.py +0 -0
  61. {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tui_parts/ui_mode.py +0 -0
  62. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/conftest.py +0 -0
  63. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_app_mcp_preload.py +0 -0
  64. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_app_preflight_gate.py +0 -0
  65. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_app_shutdown.py +0 -0
  66. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_app_usage_line.py +0 -0
  67. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_cli_project_root.py +0 -0
  68. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_compact_command_semantics.py +0 -0
  69. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_completion_context_activation.py +0 -0
  70. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_completion_status_panel.py +0 -0
  71. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_context_command.py +0 -0
  72. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_custom_slash_commands.py +0 -0
  73. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_history_sync.py +0 -0
  74. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_input_behavior.py +0 -0
  75. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_layout_coordinator.py +0 -0
  76. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_logging_adapter.py +0 -0
  77. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_logo.py +0 -0
  78. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_mcp_cli.py +0 -0
  79. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_mcp_slash_command.py +0 -0
  80. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_mention_completer.py +0 -0
  81. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_preflight.py +0 -0
  82. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_preflight_copilot.py +0 -0
  83. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_question_view.py +0 -0
  84. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_resume_selector.py +0 -0
  85. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_rewind_command_semantics.py +0 -0
  86. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_rewind_store.py +0 -0
  87. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_rpc_protocol.py +0 -0
  88. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_rpc_stdio_bridge.py +0 -0
  89. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_selection_menu.py +0 -0
  90. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_skills_slash_command.py +0 -0
  91. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_slash_argument_hint.py +0 -0
  92. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_slash_completer.py +0 -0
  93. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_slash_registry.py +0 -0
  94. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_status_bar.py +0 -0
  95. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_task_panel_format.py +0 -0
  96. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_task_poll.py +0 -0
  97. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_tool_view.py +0 -0
  98. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_tui_elapsed_status.py +0 -0
  99. {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_tui_mcp_init_gate.py +0 -0
  100. {comate_cli-0.2.7 → comate_cli-0.3.0}/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.2.7
3
+ Version: 0.3.0
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
@@ -101,24 +101,37 @@ def _usage_text() -> str:
101
101
  return (
102
102
  "Usage:\n"
103
103
  " comate [--rpc-stdio]\n"
104
+ " comate -p <prompt>\n"
104
105
  " comate resume [<session_id>] [--rpc-stdio]\n"
105
106
  " comate mcp <subcommand> [options]"
106
107
  )
107
108
 
108
109
 
109
- def _parse_args(argv: list[str]) -> tuple[bool, str | None, bool]:
110
+ def _parse_args(argv: list[str]) -> tuple[bool, str | None, bool, str | None]:
110
111
  rpc_stdio = False
112
+ print_mode = False
111
113
  positionals: list[str] = []
112
114
  for arg in argv:
113
115
  if arg == "--rpc-stdio":
114
116
  rpc_stdio = True
115
117
  continue
118
+ if arg in ("-p", "--print"):
119
+ print_mode = True
120
+ continue
116
121
  if arg.startswith("-"):
117
122
  raise _ArgumentError(f"Unknown option: {arg}")
118
123
  positionals.append(arg)
119
124
 
125
+ # -p mode: all positionals become the prompt
126
+ if print_mode:
127
+ if rpc_stdio:
128
+ raise _ArgumentError("-p/--print and --rpc-stdio are mutually exclusive")
129
+ print_prompt = " ".join(positionals) if positionals else ""
130
+ return rpc_stdio, None, False, print_prompt
131
+
132
+ # Non -p mode: original logic
120
133
  if not positionals:
121
- return rpc_stdio, None, False
134
+ return rpc_stdio, None, False, None
122
135
 
123
136
  command = positionals[0]
124
137
  if command != "resume":
@@ -129,10 +142,10 @@ def _parse_args(argv: list[str]) -> tuple[bool, str | None, bool]:
129
142
  raise _ArgumentError(
130
143
  "resume without <session_id> does not support --rpc-stdio"
131
144
  )
132
- return rpc_stdio, None, True
145
+ return rpc_stdio, None, True, None
133
146
 
134
147
  if len(positionals) == 2:
135
- return rpc_stdio, positionals[1], False
148
+ return rpc_stdio, positionals[1], False, None
136
149
 
137
150
  raise _ArgumentError("resume accepts at most one <session_id>")
138
151
 
@@ -160,17 +173,30 @@ def main(argv: list[str] | None = None) -> None:
160
173
  from comate_cli.terminal_agent.app import run
161
174
 
162
175
  try:
163
- rpc_stdio, resume_session_id, resume_select = _parse_args(run_argv)
176
+ rpc_stdio, resume_session_id, resume_select, print_prompt = _parse_args(run_argv)
164
177
  except _ArgumentError as exc:
165
178
  sys.stderr.write(f"{exc}\n{_usage_text()}\n")
166
179
  raise SystemExit(2) from exc
167
180
 
181
+ # Assemble print mode message
182
+ print_message: str | None = None
183
+ if print_prompt is not None:
184
+ stdin_text = ""
185
+ if not sys.stdin.isatty():
186
+ stdin_text = sys.stdin.read()
187
+ parts = [p for p in (stdin_text.strip(), print_prompt.strip()) if p]
188
+ if not parts:
189
+ sys.stderr.write("Error: -p requires a prompt argument or stdin input\n")
190
+ raise SystemExit(1)
191
+ print_message = "\n\n".join(parts)
192
+
168
193
  try:
169
194
  asyncio.run(
170
195
  run(
171
196
  rpc_stdio=rpc_stdio,
172
197
  resume_session_id=resume_session_id,
173
198
  resume_select=resume_select,
199
+ print_message=print_message,
174
200
  )
175
201
  )
176
202
  except KeyboardInterrupt:
@@ -7,6 +7,7 @@ import logging
7
7
  import os
8
8
  import random
9
9
  import signal
10
+ import sys
10
11
  import threading
11
12
  import time
12
13
  from collections.abc import Iterator
@@ -15,7 +16,7 @@ from pathlib import Path
15
16
 
16
17
  from rich.console import Console
17
18
 
18
- from comate_agent_sdk.agent import Agent, AgentConfig, ChatSession
19
+ from comate_agent_sdk.agent import Agent, AgentConfig, ChatSession, TextEvent
19
20
  from comate_agent_sdk.context import EnvOptions
20
21
  from comate_agent_sdk.tools import tool
21
22
 
@@ -100,6 +101,8 @@ async def _check_update() -> str | None:
100
101
 
101
102
  def _flush_langfuse_if_configured() -> None:
102
103
  """Flush Langfuse pending events synchronously to prevent atexit thread-join errors on Ctrl+C."""
104
+ if not os.environ.get("LANGFUSE_PUBLIC_KEY"):
105
+ return
103
106
  try:
104
107
  from langfuse import get_client
105
108
  get_client().flush()
@@ -256,19 +259,50 @@ async def _preload_mcp_in_tui(session: ChatSession) -> None:
256
259
  logger.info(f"MCP Server loaded: {', '.join(aliases)} ({count} tools)")
257
260
 
258
261
 
262
+ async def _run_print_mode(
263
+ agent: Agent,
264
+ message: str,
265
+ *,
266
+ project_root: Path,
267
+ ) -> None:
268
+ session = ChatSession(agent, cwd=project_root, persistent=False)
269
+ try:
270
+ await _preload_mcp_in_tui(session)
271
+
272
+ final_text = ""
273
+ async for event in session.query_stream(message):
274
+ if isinstance(event, TextEvent):
275
+ final_text = event.content
276
+
277
+ if final_text:
278
+ sys.stdout.write(final_text)
279
+ if not final_text.endswith("\n"):
280
+ sys.stdout.write("\n")
281
+ sys.stdout.flush()
282
+ except Exception as exc:
283
+ logger.error(f"Print mode failed: {exc}", exc_info=True)
284
+ sys.stderr.write(f"Error: {exc}\n")
285
+ raise SystemExit(1) from exc
286
+ finally:
287
+ await _graceful_shutdown(session)
288
+
289
+
259
290
  async def run(
260
291
  *,
261
292
  rpc_stdio: bool = False,
262
293
  resume_session_id: str | None = None,
263
294
  resume_select: bool = False,
295
+ print_message: str | None = None,
264
296
  ) -> None:
265
297
  project_root = _resolve_cli_project_root()
266
298
  preflight_result = await run_preflight_if_needed(
267
299
  console=console,
268
300
  project_root=project_root,
269
- interactive=not rpc_stdio,
301
+ interactive=not rpc_stdio and print_message is None,
270
302
  )
271
303
  if preflight_result.should_abort_launch:
304
+ if print_message is not None:
305
+ raise SystemExit(1)
272
306
  return
273
307
 
274
308
  agent = _build_agent(project_root=project_root)
@@ -286,6 +320,11 @@ async def run(
286
320
  await _graceful_shutdown(session)
287
321
  return
288
322
 
323
+ # --- Print mode branch ---
324
+ if print_message is not None:
325
+ await _run_print_mode(agent, print_message, project_root=project_root)
326
+ return
327
+
289
328
  print_logo(console, project_root=project_root)
290
329
  console.print(f"[dim]💡 Tip: {random.choice(TIPS)}[/dim]")
291
330
 
@@ -508,6 +508,60 @@ class EventRenderer:
508
508
  def _append_tool_call(self, tool_name: str, args: dict[str, Any], tool_call_id: str) -> None:
509
509
  self._running_tools[tool_call_id] = self._make_running_tool(tool_name, args)
510
510
 
511
+ def _build_tool_subtitle(
512
+ self, tool_name: str, result: str, metadata: dict[str, Any] | None = None
513
+ ) -> str | None:
514
+ """Build a subtitle string for tool result display.
515
+
516
+ Returns None for tools that don't need a subtitle line.
517
+ """
518
+ import re
519
+
520
+ lowered = tool_name.lower()
521
+
522
+ if lowered == "skill":
523
+ return "Successfully loaded skill"
524
+
525
+ if lowered == "read":
526
+ lines = result.split("\n") if result else []
527
+ if lines and lines[-1] == "":
528
+ lines = lines[:-1]
529
+ return f"Read {len(lines)} lines"
530
+
531
+ if lowered == "write":
532
+ lines = result.split("\n") if result else []
533
+ if lines and lines[-1] == "":
534
+ lines = lines[:-1]
535
+ return f"Wrote {len(lines)} lines"
536
+
537
+ if lowered in ("edit", "multiedit"):
538
+ if not metadata:
539
+ return None
540
+ diff_lines = metadata.get("diff")
541
+ if not isinstance(diff_lines, list) or not diff_lines:
542
+ return None
543
+ added = sum(1 for line in diff_lines if line.startswith("+") and not line.startswith("+++"))
544
+ removed = sum(1 for line in diff_lines if line.startswith("-") and not line.startswith("---"))
545
+ a_unit = "line" if added == 1 else "lines"
546
+ r_unit = "line" if removed == 1 else "lines"
547
+ return f"Added {added} {a_unit}, removed {removed} {r_unit}"
548
+
549
+ if lowered == "bash":
550
+ match = re.search(r"Exit code (\d+)", result or "")
551
+ if match:
552
+ return f"Exit code {match.group(1)}"
553
+ return "Completed"
554
+
555
+ if lowered in ("glob", "ls"):
556
+ lines = [line for line in (result or "").splitlines() if line.strip()]
557
+ return f"Found {len(lines)} files"
558
+
559
+ if lowered == "grep":
560
+ lines = [line for line in (result or "").splitlines() if line.strip()]
561
+ return f"Found matches in {len(lines)} files"
562
+
563
+ return None
564
+
511
565
  def append_static_tool_result(
512
566
  self,
513
567
  signature: str,
@@ -585,14 +639,15 @@ class EventRenderer:
585
639
  metadata: dict[str, Any] | None = None,
586
640
  ) -> None:
587
641
  sev: Literal["info", "warning", "error"] = "error" if is_error else "info"
642
+ if is_error:
643
+ subtitle = _truncate(_one_line(result), self._tool_error_summary_max_len)
644
+ else:
645
+ subtitle = self._build_tool_subtitle(tool_name, str(result), metadata)
588
646
  state = self._running_tools.pop(tool_call_id, None)
589
647
  if state is None:
590
648
  display_name = resolve_display_tool_name(tool_name, {})
591
649
  signature = _tool_signature(display_name, "")
592
- error_suffix = ""
593
- if is_error:
594
- error_suffix = f" · {_truncate(_one_line(result), self._tool_error_summary_max_len)}"
595
- self._history.append(HistoryEntry(entry_type="tool_result", text=f"{signature}{error_suffix}", severity=sev))
650
+ self._history.append(HistoryEntry(entry_type="tool_result", text=signature, severity=sev, subtitle=subtitle))
596
651
  return
597
652
 
598
653
  if state.is_task:
@@ -628,10 +683,6 @@ class EventRenderer:
628
683
  signature = _tool_signature(display_name, summary)
629
684
  base = f"{signature}"
630
685
 
631
- error_suffix = ""
632
- if is_error:
633
- error_suffix = f" · {_truncate(_one_line(result), self._tool_error_summary_max_len)}"
634
-
635
686
  # Render diff for Edit/MultiEdit if metadata contains diff lines
636
687
  if not is_error and metadata:
637
688
  diff_lines = metadata.get("diff")
@@ -640,20 +691,18 @@ class EventRenderer:
640
691
  if isinstance(base, Text):
641
692
  text_obj = base
642
693
  else:
643
- text_obj = Text(f"{base}{error_suffix}")
694
+ text_obj = Text(base)
644
695
  text_obj.append("\n")
645
696
  text_obj.append(self._render_diff_text(diff_lines, max_lines=self._diff_max_lines))
646
697
  self._history.append(
647
- HistoryEntry(entry_type="tool_result", text=text_obj, severity="info")
698
+ HistoryEntry(entry_type="tool_result", text=text_obj, severity="info", subtitle=subtitle)
648
699
  )
649
700
  return
650
701
 
651
702
  if isinstance(base, Text):
652
- if error_suffix:
653
- base.append(error_suffix)
654
- self._history.append(HistoryEntry(entry_type="tool_result", text=base, severity=sev))
703
+ self._history.append(HistoryEntry(entry_type="tool_result", text=base, severity=sev, subtitle=subtitle))
655
704
  else:
656
- self._history.append(HistoryEntry(entry_type="tool_result", text=f"{base}{error_suffix}", severity=sev))
705
+ self._history.append(HistoryEntry(entry_type="tool_result", text=base, severity=sev, subtitle=subtitle))
657
706
 
658
707
  @staticmethod
659
708
  def _render_diff_text(diff_lines: list[str], max_lines: int = 50) -> Text:
@@ -790,6 +839,9 @@ class EventRenderer:
790
839
  return
791
840
 
792
841
  # All completed: hide panel and write a summary entry once.
842
+ # 守卫:_current_tasks 已空说明完成总结已写过,跳过重复写入
843
+ if not self._current_tasks:
844
+ return
793
845
  started = self._task_started_at_monotonic
794
846
  elapsed_suffix = ""
795
847
  if started is not None:
@@ -10,6 +10,17 @@ from comate_cli.terminal_agent.message_style import ASSISTANT_PREFIX
10
10
  from comate_cli.terminal_agent.models import HistoryEntry
11
11
 
12
12
 
13
+ def _render_subtitle_line(subtitle: str, *, error: bool = False) -> Text:
14
+ """Render a ⎿ subtitle line for tool results."""
15
+ line = Text()
16
+ line.append(" ⎿", style="#555555")
17
+ if error:
18
+ line.append(f" {subtitle}", style="bold red")
19
+ else:
20
+ line.append(f" {subtitle}", style="dim")
21
+ return line
22
+
23
+
13
24
  def _entry_prefix(entry: HistoryEntry) -> str:
14
25
  if entry.entry_type == "user":
15
26
  return ">"
@@ -93,12 +104,16 @@ def render_history_group(
93
104
  prefix_char = "✖" if entry.severity == "error" else "●"
94
105
  prefix_style = "bold red" if entry.severity == "error" else "bold green"
95
106
 
107
+ is_error = entry.severity == "error"
108
+
96
109
  if isinstance(entry.text, Text):
97
110
  # Rich Text object — preserve styled content (e.g. colored diff)
98
111
  line_text = Text()
99
112
  line_text.append(f"{prefix_char} ", style=prefix_style)
100
113
  line_text.append_text(entry.text)
101
114
  renderables.append(line_text)
115
+ if entry.subtitle:
116
+ renderables.append(_render_subtitle_line(entry.subtitle, error=is_error))
102
117
  renderables.append(Text(""))
103
118
  continue
104
119
 
@@ -114,6 +129,8 @@ def render_history_group(
114
129
  line_text.append(line)
115
130
 
116
131
  renderables.append(line_text)
132
+ if entry.subtitle:
133
+ renderables.append(_render_subtitle_line(entry.subtitle, error=is_error))
117
134
  renderables.append(Text(""))
118
135
  continue
119
136
 
@@ -80,6 +80,7 @@ class HistoryEntry:
80
80
  entry_type: HistoryEntryType
81
81
  text: str | Text # 支持普通字符串或 Rich Text 对象
82
82
  severity: Literal["info", "warning", "error"] = "info"
83
+ subtitle: str | None = None
83
84
 
84
85
 
85
86
  @dataclass(frozen=True)
@@ -21,7 +21,7 @@ from prompt_toolkit.completion import (
21
21
  merge_completers,
22
22
  )
23
23
  from prompt_toolkit.filters import Condition, has_completions, has_focus
24
- from prompt_toolkit.history import InMemoryHistory
24
+ from prompt_toolkit.history import FileHistory, InMemoryHistory
25
25
  from prompt_toolkit.layout import FloatContainer, HSplit, Layout, Window
26
26
  from prompt_toolkit.layout.containers import ConditionalContainer
27
27
  from prompt_toolkit.layout.controls import FormattedTextControl
@@ -76,6 +76,37 @@ from comate_cli.terminal_agent.tui_parts import (
76
76
  logger = logging.getLogger(__name__)
77
77
 
78
78
  _TASK_POLL_INTERVAL_S = 2.0
79
+ _INPUT_HISTORY_DIR = Path.home() / ".agent" / "history"
80
+ _INPUT_HISTORY_MAX_ENTRIES = 200
81
+
82
+
83
+ def _get_input_history_path(cwd: str) -> Path:
84
+ """Compute FileHistory path for a given cwd, ensuring parent dir exists."""
85
+ import hashlib
86
+
87
+ slug = cwd.replace("/", "_").lstrip("_")
88
+ if len(slug) > 200:
89
+ hash_suffix = hashlib.sha1(cwd.encode()).hexdigest()[:8]
90
+ slug = f"{slug[:100]}_{hash_suffix}"
91
+ _INPUT_HISTORY_DIR.mkdir(parents=True, exist_ok=True)
92
+ return _INPUT_HISTORY_DIR / f"{slug}.txt"
93
+
94
+
95
+ def _truncate_file_history(path: Path, max_entries: int = 200) -> None:
96
+ """Truncate a prompt_toolkit FileHistory file to at most max_entries."""
97
+ if not path.exists():
98
+ return
99
+ content = path.read_text()
100
+ if not content.strip():
101
+ return
102
+ # FileHistory format: entries are +prefixed line blocks separated by blank lines
103
+ blocks = content.split("\n\n")
104
+ # Filter out empty blocks
105
+ blocks = [b for b in blocks if b.strip()]
106
+ if len(blocks) <= max_entries:
107
+ return
108
+ kept = blocks[-max_entries:]
109
+ path.write_text("\n\n".join(kept) + "\n\n")
79
110
 
80
111
 
81
112
  class TerminalAgentTUI(
@@ -289,7 +320,7 @@ class TerminalAgentTUI(
289
320
  completer=self._input_completer,
290
321
  auto_suggest=self._slash_argument_hint,
291
322
  complete_while_typing=False, # 通过 Tab/上下键手动触发补全
292
- history=InMemoryHistory(),
323
+ history=self._create_input_history(),
293
324
  style="class:input.line",
294
325
  get_line_prefix=_input_line_prefix,
295
326
  )
@@ -609,6 +640,18 @@ class TerminalAgentTUI(
609
640
  return
610
641
  self._schedule_background(self._submit_user_message(queued))
611
642
 
643
+ def _create_input_history(self) -> FileHistory | InMemoryHistory:
644
+ """Create persistent FileHistory based on cwd, with fallback to InMemoryHistory."""
645
+ try:
646
+ session_cwd = getattr(self._session, "_cwd", None)
647
+ cwd = str(Path(session_cwd).expanduser().resolve()) if session_cwd else str(Path.cwd().resolve())
648
+ history_path = _get_input_history_path(cwd)
649
+ _truncate_file_history(history_path, _INPUT_HISTORY_MAX_ENTRIES)
650
+ return FileHistory(str(history_path))
651
+ except Exception:
652
+ logger.debug("Failed to create FileHistory, falling back to InMemoryHistory", exc_info=True)
653
+ return InMemoryHistory()
654
+
612
655
  def _input_placeholder_hint(self) -> str | None:
613
656
  if self._ui_mode != UIMode.NORMAL:
614
657
  return None
@@ -149,7 +149,7 @@ class InputBehaviorMixin:
149
149
  self._refresh_layers()
150
150
  self._schedule_background(self._submit_user_message(normalized))
151
151
 
152
- def _clear_input_area(self) -> None:
152
+ def _clear_input_area(self, *, save_to_history: bool = False) -> None:
153
153
  self._clear_paste_state()
154
154
  self._last_input_len = 0
155
155
  self._last_input_text = ""
@@ -158,7 +158,14 @@ class InputBehaviorMixin:
158
158
  buffer.cancel_completion()
159
159
  self._suppress_input_change_hook = True
160
160
  try:
161
- buffer.set_document(Document("", cursor_position=0), bypass_readonly=True)
161
+ if save_to_history:
162
+ buffer.reset(
163
+ Document("", cursor_position=0), append_to_history=True
164
+ )
165
+ else:
166
+ buffer.set_document(
167
+ Document("", cursor_position=0), bypass_readonly=True
168
+ )
162
169
  finally:
163
170
  self._suppress_input_change_hook = False
164
171
 
@@ -310,7 +317,7 @@ class InputBehaviorMixin:
310
317
 
311
318
  if self._input_area.buffer.complete_state is not None:
312
319
  self._input_area.buffer.cancel_completion()
313
- self._clear_input_area()
320
+ self._clear_input_area(save_to_history=True)
314
321
 
315
322
  # busy 或 initializing 时:非斜杠命令 → 入队,斜杠命令 → 交由命令分发决定
316
323
  is_busy = self._busy or self._initializing
@@ -576,7 +576,7 @@ class RenderPanelsMixin:
576
576
  if clipped.startswith("✓ "):
577
577
  symbol, text = "✓", clipped[2:]
578
578
  symbol_style = "fg:#86EFAC"
579
- text_style = "fg:#6B7280 strike"
579
+ text_style = "fg:#4B5563 strike"
580
580
  elif clipped.startswith("◼ "):
581
581
  symbol, text = "◼", clipped[2:]
582
582
  symbol_style = "fg:#F97316"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "comate-cli"
7
- version = "0.2.7"
7
+ version = "0.3.0"
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,90 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import unittest
5
+ from io import StringIO
6
+ from unittest.mock import AsyncMock, MagicMock, patch
7
+
8
+ from comate_agent_sdk.agent import TextEvent, StopEvent, SessionInitEvent
9
+
10
+
11
+ class TestRunPrintMode(unittest.TestCase):
12
+ """Tests for _run_print_mode in app.py."""
13
+
14
+ def test_print_mode_outputs_last_text_event(self):
15
+ """Should output only the last TextEvent.content to stdout."""
16
+ from comate_cli.terminal_agent.app import _run_print_mode
17
+
18
+ mock_session = MagicMock()
19
+ mock_session.query_stream = MagicMock(return_value=_async_iter([
20
+ SessionInitEvent(session_id="test"),
21
+ TextEvent(content="first answer"),
22
+ TextEvent(content="final answer"),
23
+ StopEvent(reason="completed"),
24
+ ]))
25
+ mock_session.close = AsyncMock()
26
+ mock_session.shutdown = AsyncMock()
27
+
28
+ captured = StringIO()
29
+ with patch("comate_cli.terminal_agent.app.ChatSession", return_value=mock_session):
30
+ with patch("comate_cli.terminal_agent.app._preload_mcp_in_tui", new_callable=AsyncMock):
31
+ with patch("comate_cli.terminal_agent.app._graceful_shutdown", new_callable=AsyncMock):
32
+ with patch("sys.stdout", captured):
33
+ asyncio.run(_run_print_mode(
34
+ MagicMock(), "test prompt", project_root=MagicMock()
35
+ ))
36
+
37
+ self.assertIn("final answer", captured.getvalue())
38
+ self.assertNotIn("first answer", captured.getvalue())
39
+
40
+ def test_print_mode_no_text_event_silent_exit(self):
41
+ """No TextEvent → silent exit, no output."""
42
+ from comate_cli.terminal_agent.app import _run_print_mode
43
+
44
+ mock_session = MagicMock()
45
+ mock_session.query_stream = MagicMock(return_value=_async_iter([
46
+ SessionInitEvent(session_id="test"),
47
+ StopEvent(reason="completed"),
48
+ ]))
49
+ mock_session.close = AsyncMock()
50
+ mock_session.shutdown = AsyncMock()
51
+
52
+ captured = StringIO()
53
+ with patch("comate_cli.terminal_agent.app.ChatSession", return_value=mock_session):
54
+ with patch("comate_cli.terminal_agent.app._preload_mcp_in_tui", new_callable=AsyncMock):
55
+ with patch("comate_cli.terminal_agent.app._graceful_shutdown", new_callable=AsyncMock):
56
+ with patch("sys.stdout", captured):
57
+ asyncio.run(_run_print_mode(
58
+ MagicMock(), "test", project_root=MagicMock()
59
+ ))
60
+
61
+ self.assertEqual(captured.getvalue(), "")
62
+
63
+ def test_print_mode_appends_newline_if_missing(self):
64
+ """Output should end with newline even if content doesn't."""
65
+ from comate_cli.terminal_agent.app import _run_print_mode
66
+
67
+ mock_session = MagicMock()
68
+ mock_session.query_stream = MagicMock(return_value=_async_iter([
69
+ SessionInitEvent(session_id="test"),
70
+ TextEvent(content="no newline"),
71
+ StopEvent(reason="completed"),
72
+ ]))
73
+ mock_session.close = AsyncMock()
74
+ mock_session.shutdown = AsyncMock()
75
+
76
+ captured = StringIO()
77
+ with patch("comate_cli.terminal_agent.app.ChatSession", return_value=mock_session):
78
+ with patch("comate_cli.terminal_agent.app._preload_mcp_in_tui", new_callable=AsyncMock):
79
+ with patch("comate_cli.terminal_agent.app._graceful_shutdown", new_callable=AsyncMock):
80
+ with patch("sys.stdout", captured):
81
+ asyncio.run(_run_print_mode(
82
+ MagicMock(), "test", project_root=MagicMock()
83
+ ))
84
+
85
+ self.assertTrue(captured.getvalue().endswith("\n"))
86
+
87
+
88
+ async def _async_iter(items):
89
+ for item in items:
90
+ yield item