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