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.
- {comate_cli-0.2.7 → comate_cli-0.3.0}/PKG-INFO +1 -1
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/main.py +31 -5
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/app.py +41 -2
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/event_renderer.py +66 -14
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/history_printer.py +17 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/models.py +1 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tui.py +45 -2
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tui_parts/input_behavior.py +10 -3
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tui_parts/render_panels.py +1 -1
- {comate_cli-0.2.7 → comate_cli-0.3.0}/pyproject.toml +1 -1
- comate_cli-0.3.0/tests/test_app_print_mode.py +90 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_event_renderer.py +142 -1
- comate_cli-0.3.0/tests/test_history_printer.py +61 -0
- comate_cli-0.3.0/tests/test_input_history.py +136 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_interrupt_exit_semantics.py +1 -1
- comate_cli-0.3.0/tests/test_main_args.py +129 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_question_key_bindings.py +1 -1
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_task_panel_key_bindings.py +1 -1
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_task_panel_rendering.py +1 -1
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_tui_paste_placeholder.py +6 -6
- {comate_cli-0.2.7 → comate_cli-0.3.0}/uv.lock +13 -35
- comate_cli-0.2.7/tests/test_main_args.py +0 -52
- {comate_cli-0.2.7 → comate_cli-0.3.0}/.gitignore +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/README.md +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/__init__.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/__main__.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/mcp_cli.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/__init__.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/animations.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/assistant_render.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/custom_slash_commands.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/env_utils.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/error_display.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/fragment_utils.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/input_geometry.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/layout_coordinator.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/logging_adapter.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/logo.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/markdown_render.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/mention_completer.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/message_style.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/preflight.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/question_view.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/resume_selector.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/rewind_store.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/rpc_protocol.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/rpc_stdio.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/selection_menu.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/session_view.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/slash_commands.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/startup.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/status_bar.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/text_effects.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tips.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tool_view.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tui_parts/__init__.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tui_parts/commands.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tui_parts/history_sync.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tui_parts/key_bindings.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tui_parts/slash_command_registry.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/comate_cli/terminal_agent/tui_parts/ui_mode.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/conftest.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_app_mcp_preload.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_app_preflight_gate.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_app_shutdown.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_app_usage_line.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_cli_project_root.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_compact_command_semantics.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_completion_context_activation.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_completion_status_panel.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_context_command.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_custom_slash_commands.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_history_sync.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_input_behavior.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_layout_coordinator.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_logging_adapter.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_logo.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_mcp_cli.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_mcp_slash_command.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_mention_completer.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_preflight.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_preflight_copilot.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_question_view.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_resume_selector.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_rewind_command_semantics.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_rewind_store.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_rpc_protocol.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_rpc_stdio_bridge.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_selection_menu.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_skills_slash_command.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_slash_argument_hint.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_slash_completer.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_slash_registry.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_status_bar.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_task_panel_format.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_task_poll.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_tool_view.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_tui_elapsed_status.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_tui_mcp_init_gate.py +0 -0
- {comate_cli-0.2.7 → comate_cli-0.3.0}/tests/test_tui_split_invariance.py +0 -0
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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=
|
|
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
|
|
|
@@ -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=
|
|
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
|
-
|
|
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:#
|
|
579
|
+
text_style = "fg:#4B5563 strike"
|
|
580
580
|
elif clipped.startswith("◼ "):
|
|
581
581
|
symbol, text = "◼", clipped[2:]
|
|
582
582
|
symbol_style = "fg:#F97316"
|
|
@@ -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
|