comate-cli 0.2.9__tar.gz → 0.3.1__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 (101) hide show
  1. {comate_cli-0.2.9 → comate_cli-0.3.1}/PKG-INFO +1 -1
  2. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/main.py +31 -5
  3. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/app.py +50 -4
  4. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/event_renderer.py +63 -14
  5. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/history_printer.py +17 -0
  6. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/models.py +1 -0
  7. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/status_bar.py +27 -0
  8. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/tui.py +58 -4
  9. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/tui_parts/input_behavior.py +10 -3
  10. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/tui_parts/render_panels.py +28 -1
  11. {comate_cli-0.2.9 → comate_cli-0.3.1}/pyproject.toml +1 -1
  12. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_app_mcp_preload.py +1 -1
  13. comate_cli-0.3.1/tests/test_app_print_mode.py +90 -0
  14. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_completion_status_panel.py +4 -0
  15. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_event_renderer.py +142 -1
  16. comate_cli-0.3.1/tests/test_history_printer.py +61 -0
  17. comate_cli-0.3.1/tests/test_input_history.py +145 -0
  18. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_interrupt_exit_semantics.py +1 -1
  19. comate_cli-0.3.1/tests/test_main_args.py +129 -0
  20. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_question_key_bindings.py +1 -1
  21. comate_cli-0.3.1/tests/test_status_bar_transient.py +61 -0
  22. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_task_panel_key_bindings.py +1 -1
  23. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_task_panel_rendering.py +1 -1
  24. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_tui_mcp_init_gate.py +17 -0
  25. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_tui_paste_placeholder.py +6 -6
  26. {comate_cli-0.2.9 → comate_cli-0.3.1}/uv.lock +2 -2
  27. comate_cli-0.2.9/tests/test_main_args.py +0 -52
  28. {comate_cli-0.2.9 → comate_cli-0.3.1}/.gitignore +0 -0
  29. {comate_cli-0.2.9 → comate_cli-0.3.1}/README.md +0 -0
  30. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/__init__.py +0 -0
  31. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/__main__.py +0 -0
  32. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/mcp_cli.py +0 -0
  33. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/__init__.py +0 -0
  34. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/animations.py +0 -0
  35. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/assistant_render.py +0 -0
  36. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/custom_slash_commands.py +0 -0
  37. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/env_utils.py +0 -0
  38. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/error_display.py +0 -0
  39. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/fragment_utils.py +0 -0
  40. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/input_geometry.py +0 -0
  41. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/layout_coordinator.py +0 -0
  42. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/logging_adapter.py +0 -0
  43. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/logo.py +0 -0
  44. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/markdown_render.py +0 -0
  45. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/mention_completer.py +0 -0
  46. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/message_style.py +0 -0
  47. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/preflight.py +0 -0
  48. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/question_view.py +0 -0
  49. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/resume_selector.py +0 -0
  50. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/rewind_store.py +0 -0
  51. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/rpc_protocol.py +0 -0
  52. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/rpc_stdio.py +0 -0
  53. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/selection_menu.py +0 -0
  54. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/session_view.py +0 -0
  55. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/slash_commands.py +0 -0
  56. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/startup.py +0 -0
  57. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/text_effects.py +0 -0
  58. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/tips.py +0 -0
  59. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/tool_view.py +0 -0
  60. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/tui_parts/__init__.py +0 -0
  61. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/tui_parts/commands.py +0 -0
  62. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/tui_parts/history_sync.py +0 -0
  63. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/tui_parts/key_bindings.py +0 -0
  64. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/tui_parts/slash_command_registry.py +0 -0
  65. {comate_cli-0.2.9 → comate_cli-0.3.1}/comate_cli/terminal_agent/tui_parts/ui_mode.py +0 -0
  66. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/conftest.py +0 -0
  67. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_app_preflight_gate.py +0 -0
  68. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_app_shutdown.py +0 -0
  69. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_app_usage_line.py +0 -0
  70. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_cli_project_root.py +0 -0
  71. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_compact_command_semantics.py +0 -0
  72. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_completion_context_activation.py +0 -0
  73. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_context_command.py +0 -0
  74. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_custom_slash_commands.py +0 -0
  75. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_history_sync.py +0 -0
  76. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_input_behavior.py +0 -0
  77. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_layout_coordinator.py +0 -0
  78. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_logging_adapter.py +0 -0
  79. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_logo.py +0 -0
  80. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_mcp_cli.py +0 -0
  81. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_mcp_slash_command.py +0 -0
  82. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_mention_completer.py +0 -0
  83. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_preflight.py +0 -0
  84. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_preflight_copilot.py +0 -0
  85. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_question_view.py +0 -0
  86. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_resume_selector.py +0 -0
  87. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_rewind_command_semantics.py +0 -0
  88. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_rewind_store.py +0 -0
  89. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_rpc_protocol.py +0 -0
  90. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_rpc_stdio_bridge.py +0 -0
  91. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_selection_menu.py +0 -0
  92. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_skills_slash_command.py +0 -0
  93. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_slash_argument_hint.py +0 -0
  94. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_slash_completer.py +0 -0
  95. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_slash_registry.py +0 -0
  96. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_status_bar.py +0 -0
  97. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_task_panel_format.py +0 -0
  98. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_task_poll.py +0 -0
  99. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_tool_view.py +0 -0
  100. {comate_cli-0.2.9 → comate_cli-0.3.1}/tests/test_tui_elapsed_status.py +0 -0
  101. {comate_cli-0.2.9 → comate_cli-0.3.1}/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.9
3
+ Version: 0.3.1
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()
@@ -239,7 +242,7 @@ async def _preload_mcp_in_tui(session: ChatSession) -> None:
239
242
  return
240
243
  await preload_task
241
244
  except Exception as e:
242
- logger.warning(f"MCP init failed: {e}", exc_info=True)
245
+ logger.debug(f"MCP init failed: {e}", exc_info=True)
243
246
  return
244
247
 
245
248
  mgr = runtime._mcp_manager
@@ -247,7 +250,7 @@ async def _preload_mcp_in_tui(session: ChatSession) -> None:
247
250
  return
248
251
 
249
252
  for alias, reason in mgr.failed_servers:
250
- logger.warning(f"MCP server '{alias}' skipped: {reason}")
253
+ logger.debug(f"MCP server '{alias}' skipped: {reason}")
251
254
 
252
255
  loaded = mgr.tool_infos
253
256
  if loaded:
@@ -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
 
@@ -321,6 +360,13 @@ async def run(
321
360
 
322
361
  async def _mcp_loader() -> None:
323
362
  await _preload_mcp_in_tui(session)
363
+ mgr = session.runtime._mcp_manager
364
+ if mgr and mgr.failed_servers:
365
+ aliases = [alias for alias, _ in mgr.failed_servers]
366
+ status_bar.show_transient(
367
+ f"⚠ MCP: {', '.join(aliases)} unavailable",
368
+ duration_s=5.0,
369
+ )
324
370
 
325
371
  usage_line: str | None = None
326
372
  active_session = session
@@ -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:
@@ -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)
@@ -29,6 +29,8 @@ class StatusBar:
29
29
  self._context_left_pct: float = 100.0
30
30
  self._git_diff_stats: GitDiffStats | None = None
31
31
  self._git_diff_cache_time: float = 0.0
32
+ self._transient_message: str | None = None
33
+ self._transient_until: float | None = None
32
34
 
33
35
  @staticmethod
34
36
  def _resolve_model_name(session: ChatSession) -> str:
@@ -254,5 +256,30 @@ class StatusBar:
254
256
  fragments.append(("", " "))
255
257
  return fragments
256
258
 
259
+ def show_transient(self, message: str, duration_s: float = 5.0) -> None:
260
+ """Set a transient message that auto-clears after *duration_s* seconds."""
261
+ self._transient_message = message
262
+ self._transient_until = time.monotonic() + duration_s
263
+
264
+ def clear_transient_if_expired(self) -> bool:
265
+ """Check and clear expired transient message.
266
+
267
+ Returns True if the message was just cleared (state changed, needs repaint).
268
+ Returns False otherwise (no message, or message still active).
269
+ """
270
+ if self._transient_until is not None and time.monotonic() >= self._transient_until:
271
+ self._transient_message = None
272
+ self._transient_until = None
273
+ return True
274
+ return False
275
+
276
+ @property
277
+ def transient_message(self) -> str | None:
278
+ return self._transient_message
279
+
280
+ @property
281
+ def has_transient(self) -> bool:
282
+ return self._transient_message is not None
283
+
257
284
  def helper_toolbar(self) -> list[tuple[str, str]]:
258
285
  return []
@@ -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,38 @@ 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
+ import re
87
+
88
+ slug = re.sub(r'[^a-zA-Z0-9_.\-]', '_', cwd).lstrip("_")
89
+ if len(slug) > 200:
90
+ hash_suffix = hashlib.sha1(cwd.encode()).hexdigest()[:8]
91
+ slug = f"{slug[:100]}_{hash_suffix}"
92
+ _INPUT_HISTORY_DIR.mkdir(parents=True, exist_ok=True)
93
+ return _INPUT_HISTORY_DIR / f"{slug}.txt"
94
+
95
+
96
+ def _truncate_file_history(path: Path, max_entries: int = 200) -> None:
97
+ """Truncate a prompt_toolkit FileHistory file to at most max_entries."""
98
+ if not path.exists():
99
+ return
100
+ content = path.read_text()
101
+ if not content.strip():
102
+ return
103
+ # FileHistory format: entries are +prefixed line blocks separated by blank lines
104
+ blocks = content.split("\n\n")
105
+ # Filter out empty blocks
106
+ blocks = [b for b in blocks if b.strip()]
107
+ if len(blocks) <= max_entries:
108
+ return
109
+ kept = blocks[-max_entries:]
110
+ path.write_text("\n\n".join(kept) + "\n\n")
79
111
 
80
112
 
81
113
  class TerminalAgentTUI(
@@ -289,7 +321,7 @@ class TerminalAgentTUI(
289
321
  completer=self._input_completer,
290
322
  auto_suggest=self._slash_argument_hint,
291
323
  complete_while_typing=False, # 通过 Tab/上下键手动触发补全
292
- history=InMemoryHistory(),
324
+ history=self._create_input_history(),
293
325
  style="class:input.line",
294
326
  get_line_prefix=_input_line_prefix,
295
327
  )
@@ -452,6 +484,7 @@ class TerminalAgentTUI(
452
484
  "status.mode.act": "bg:default #60a5fa bold",
453
485
  "status.mode.plan": "bg:default #7AC9CA bold",
454
486
  "status.hint": "bg:default #6B7280",
487
+ "status.transient": "bg:default italic fg:ansiyellow",
455
488
  "input.placeholder": "bg:default #9CA3AF",
456
489
  "auto-suggestion": "bg:default #94a3b8",
457
490
  "queue": "bg:#1d222a #d8dee9",
@@ -609,6 +642,18 @@ class TerminalAgentTUI(
609
642
  return
610
643
  self._schedule_background(self._submit_user_message(queued))
611
644
 
645
+ def _create_input_history(self) -> FileHistory | InMemoryHistory:
646
+ """Create persistent FileHistory based on cwd, with fallback to InMemoryHistory."""
647
+ try:
648
+ session_cwd = getattr(self._session, "_cwd", None)
649
+ cwd = str(Path(session_cwd).expanduser().resolve()) if session_cwd else str(Path.cwd().resolve())
650
+ history_path = _get_input_history_path(cwd)
651
+ _truncate_file_history(history_path, _INPUT_HISTORY_MAX_ENTRIES)
652
+ return FileHistory(str(history_path))
653
+ except Exception:
654
+ logger.debug("Failed to create FileHistory, falling back to InMemoryHistory", exc_info=True)
655
+ return InMemoryHistory()
656
+
612
657
  def _input_placeholder_hint(self) -> str | None:
613
658
  if self._ui_mode != UIMode.NORMAL:
614
659
  return None
@@ -1098,6 +1143,12 @@ class TerminalAgentTUI(
1098
1143
  else:
1099
1144
  self._render_dirty = True
1100
1145
 
1146
+ # 瞬态消息过期检查
1147
+ if self._status_bar.clear_transient_if_expired():
1148
+ self._render_dirty = True
1149
+ elif self._status_bar.has_transient:
1150
+ self._render_dirty = True
1151
+
1101
1152
  loading_line = self._renderer.loading_line().strip()
1102
1153
  loading_changed = loading_line != self._last_loading_line
1103
1154
  if loading_changed:
@@ -1233,10 +1284,13 @@ class TerminalAgentTUI(
1233
1284
  timeout=self._mcp_init_gate_timeout_s,
1234
1285
  )
1235
1286
  except asyncio.TimeoutError:
1236
- logger.warning(
1287
+ logger.debug(
1237
1288
  "MCP init timed out after "
1238
1289
  f"{self._mcp_init_gate_timeout_s:.1f}s; degrade and continue",
1239
1290
  )
1291
+ self._status_bar.show_transient(
1292
+ "⚠ MCP: init timed out", duration_s=5.0,
1293
+ )
1240
1294
  await self._cancel_task_with_timeout(
1241
1295
  init_task,
1242
1296
  timeout_s=self._mcp_init_cancel_timeout_s,
@@ -1250,7 +1304,7 @@ class TerminalAgentTUI(
1250
1304
  )
1251
1305
  return
1252
1306
  except Exception as exc:
1253
- logger.warning(f"MCP init failed in TUI bootstrap: {exc}", exc_info=True)
1307
+ logger.debug(f"MCP init failed in TUI bootstrap: {exc}", exc_info=True)
1254
1308
  finally:
1255
1309
  self._initializing = False
1256
1310
  if self._queued_messages and not self._busy:
@@ -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
@@ -49,6 +49,33 @@ class RenderPanelsMixin:
49
49
  def _status_text(self) -> list[tuple[str, str]]:
50
50
  width = self._terminal_width()
51
51
 
52
+ # 瞬态消息优先于 mode 显示
53
+ transient = self._status_bar.transient_message
54
+ if transient:
55
+ # 右栏:model | ~branch / X% left (不变)
56
+ right_text = self._status_bar.info_status_text()
57
+ if self._busy or self._initializing:
58
+ git_frags = []
59
+ else:
60
+ git_frags = self._status_bar.git_diff_fragments()
61
+
62
+ right_w = sum(get_cwidth(c) for c in right_text)
63
+ if git_frags:
64
+ right_w += 2 + sum(get_cwidth(c) for _, t in git_frags for c in t)
65
+
66
+ left_w = sum(get_cwidth(c) for c in transient)
67
+ padding = max(1, width - left_w - right_w - 2)
68
+
69
+ frags: list[tuple[str, str]] = [
70
+ ("class:status.transient", transient),
71
+ ("class:status", " " * padding),
72
+ ("class:status", right_text),
73
+ ]
74
+ if git_frags:
75
+ frags.append(("class:status", " "))
76
+ frags.extend(git_frags)
77
+ return frags
78
+
52
79
  # 左栏:mode 图标 + 提示文字
53
80
  mode = self._status_bar.get_mode()
54
81
  hint_text = "(shift+tab to cycle)"
@@ -576,7 +603,7 @@ class RenderPanelsMixin:
576
603
  if clipped.startswith("✓ "):
577
604
  symbol, text = "✓", clipped[2:]
578
605
  symbol_style = "fg:#86EFAC"
579
- text_style = "fg:#6B7280 strike"
606
+ text_style = "fg:#4B5563 strike"
580
607
  elif clipped.startswith("◼ "):
581
608
  symbol, text = "◼", clipped[2:]
582
609
  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.9"
7
+ version = "0.3.1"
8
8
  description = "Comate terminal CLI built on comate-agent-sdk"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -9,7 +9,7 @@ from comate_cli.terminal_agent.app import _preload_mcp_in_tui
9
9
 
10
10
  class _FakeRuntime:
11
11
  def __init__(self) -> None:
12
- self.options = types.SimpleNamespace(mcp_enabled=True)
12
+ self.config = types.SimpleNamespace(mcp_enabled=True)
13
13
  self._mcp_manager = types.SimpleNamespace(
14
14
  failed_servers=[],
15
15
  tool_infos=[types.SimpleNamespace(server_alias="ctx7")],