comate-cli 0.2.4__tar.gz → 0.2.5__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.4 → comate_cli-0.2.5}/.gitignore +1 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/PKG-INFO +1 -1
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/event_renderer.py +45 -31
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tool_view.py +42 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tui.py +42 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tui_parts/history_sync.py +8 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tui_parts/render_panels.py +37 -8
- {comate_cli-0.2.4 → comate_cli-0.2.5}/pyproject.toml +1 -1
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_completion_status_panel.py +1 -1
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_event_renderer.py +63 -6
- comate_cli-0.2.5/tests/test_task_panel_format.py +96 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_task_panel_key_bindings.py +8 -8
- comate_cli-0.2.5/tests/test_task_panel_rendering.py +116 -0
- comate_cli-0.2.5/tests/test_task_poll.py +64 -0
- comate_cli-0.2.5/tests/test_tool_view.py +149 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/uv.lock +2 -2
- comate_cli-0.2.4/tests/test_tool_view.py +0 -64
- {comate_cli-0.2.4 → comate_cli-0.2.5}/README.md +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/__init__.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/__main__.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/main.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/mcp_cli.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/__init__.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/animations.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/app.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/assistant_render.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/custom_slash_commands.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/env_utils.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/error_display.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/fragment_utils.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/history_printer.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/input_geometry.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/layout_coordinator.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/logging_adapter.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/logo.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/markdown_render.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/mention_completer.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/message_style.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/models.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/preflight.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/question_view.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/resume_selector.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/rewind_store.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/rpc_protocol.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/rpc_stdio.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/selection_menu.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/session_view.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/slash_commands.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/startup.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/status_bar.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/text_effects.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tips.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tui_parts/__init__.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tui_parts/commands.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tui_parts/input_behavior.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tui_parts/key_bindings.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tui_parts/slash_command_registry.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tui_parts/ui_mode.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/conftest.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_app_mcp_preload.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_app_preflight_gate.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_app_shutdown.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_app_usage_line.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_cli_project_root.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_compact_command_semantics.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_completion_context_activation.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_context_command.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_custom_slash_commands.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_history_sync.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_input_behavior.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_interrupt_exit_semantics.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_layout_coordinator.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_logging_adapter.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_logo.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_main_args.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_mcp_cli.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_mcp_slash_command.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_mention_completer.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_preflight.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_preflight_copilot.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_question_key_bindings.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_question_view.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_resume_selector.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_rewind_command_semantics.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_rewind_store.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_rpc_protocol.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_rpc_stdio_bridge.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_selection_menu.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_skills_slash_command.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_slash_argument_hint.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_slash_completer.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_slash_registry.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_status_bar.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_tui_elapsed_status.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_tui_mcp_init_gate.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_tui_paste_placeholder.py +0 -0
- {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_tui_split_invariance.py +0 -0
|
@@ -32,7 +32,7 @@ from rich.console import RenderableType
|
|
|
32
32
|
from rich.text import Text
|
|
33
33
|
|
|
34
34
|
from comate_cli.terminal_agent.models import HistoryEntry, LoadingState
|
|
35
|
-
from comate_cli.terminal_agent.tool_view import summarize_tool_args, resolve_display_tool_name
|
|
35
|
+
from comate_cli.terminal_agent.tool_view import summarize_tool_args, resolve_display_tool_name, should_show_tool_in_scrollback
|
|
36
36
|
from comate_cli.terminal_agent.env_utils import read_env_int
|
|
37
37
|
|
|
38
38
|
logger = logging.getLogger(__name__)
|
|
@@ -111,23 +111,25 @@ def _task_sort_key(task: dict[str, Any]) -> tuple[int, int]:
|
|
|
111
111
|
|
|
112
112
|
|
|
113
113
|
def _format_task_row(task: dict[str, Any]) -> str:
|
|
114
|
-
|
|
114
|
+
"""格式化单条 task 行。
|
|
115
|
+
|
|
116
|
+
符号约定:
|
|
117
|
+
- ✓ completed
|
|
118
|
+
- ◼ in_progress
|
|
119
|
+
- ▢ pending (unblocked)
|
|
120
|
+
- ▫ pending (blocked) — 视觉相似的中间符号,渲染层统一显示为 ▢ 但应用不同颜色
|
|
121
|
+
"""
|
|
115
122
|
subject = str(task.get("subject", "")).strip() or "(untitled)"
|
|
116
|
-
owner = str(task.get("owner", "")).strip()
|
|
117
123
|
status = str(task.get("status", "pending")).strip().lower()
|
|
118
124
|
open_blocked_by = task.get("open_blocked_by", [])
|
|
119
|
-
owner_suffix = f" owner={owner}" if owner else ""
|
|
120
|
-
blocked_suffix = ""
|
|
121
|
-
if isinstance(open_blocked_by, list) and open_blocked_by:
|
|
122
|
-
blocked_suffix = f" blocked by #{','.join(str(item) for item in open_blocked_by)}"
|
|
123
125
|
|
|
124
126
|
if status == "completed":
|
|
125
|
-
return f"
|
|
127
|
+
return f"✓ {subject}"
|
|
126
128
|
if status == "in_progress":
|
|
127
|
-
return f"
|
|
128
|
-
if
|
|
129
|
-
return f"
|
|
130
|
-
return f"
|
|
129
|
+
return f"◼ {subject}"
|
|
130
|
+
if isinstance(open_blocked_by, list) and open_blocked_by:
|
|
131
|
+
return f"▫ {subject}"
|
|
132
|
+
return f"▢ {subject}"
|
|
131
133
|
|
|
132
134
|
|
|
133
135
|
@dataclass
|
|
@@ -162,6 +164,7 @@ class EventRenderer:
|
|
|
162
164
|
def __init__(self, project_root: Path | None = None) -> None:
|
|
163
165
|
self._history: list[HistoryEntry] = []
|
|
164
166
|
self._running_tools: dict[str, _RunningTool] = {}
|
|
167
|
+
self._tool_call_args: dict[str, dict[str, Any]] = {}
|
|
165
168
|
self._thinking_content: str = ""
|
|
166
169
|
self._assistant_buffer = ""
|
|
167
170
|
self._loading_state: LoadingState = LoadingState.idle()
|
|
@@ -237,6 +240,7 @@ class EventRenderer:
|
|
|
237
240
|
self._history = []
|
|
238
241
|
self._recent_team_event_keys.clear()
|
|
239
242
|
self._running_tools.clear()
|
|
243
|
+
self._tool_call_args.clear()
|
|
240
244
|
self._thinking_content = ""
|
|
241
245
|
self._assistant_buffer = ""
|
|
242
246
|
self._loading_state = LoadingState.idle()
|
|
@@ -464,7 +468,7 @@ class EventRenderer:
|
|
|
464
468
|
return lines
|
|
465
469
|
|
|
466
470
|
clipped = lines[: normalized_limit - 1]
|
|
467
|
-
clipped.append(f"
|
|
471
|
+
clipped.append(f"… (+{len(lines) - (normalized_limit - 1)})")
|
|
468
472
|
return clipped
|
|
469
473
|
|
|
470
474
|
def full_task_panel_lines(self) -> list[str]:
|
|
@@ -730,7 +734,16 @@ class EventRenderer:
|
|
|
730
734
|
return
|
|
731
735
|
|
|
732
736
|
all_completed = all(str(item.get("status", "")).strip().lower() == "completed" for item in normalized)
|
|
733
|
-
|
|
737
|
+
# 标题:显示 ID 最小的 in_progress task 的 subject,否则 "Tasks"
|
|
738
|
+
in_progress_tasks = [
|
|
739
|
+
t for t in normalized
|
|
740
|
+
if str(t.get("status", "")).strip().lower() == "in_progress"
|
|
741
|
+
]
|
|
742
|
+
if in_progress_tasks:
|
|
743
|
+
in_progress_tasks.sort(key=lambda t: int(t.get("id", 0)))
|
|
744
|
+
header = str(in_progress_tasks[0].get("subject", "")).strip() or "Tasks"
|
|
745
|
+
else:
|
|
746
|
+
header = "Tasks"
|
|
734
747
|
if not all_completed:
|
|
735
748
|
if not self._current_tasks:
|
|
736
749
|
self._task_started_at_monotonic = time.monotonic()
|
|
@@ -776,17 +789,21 @@ class EventRenderer:
|
|
|
776
789
|
|
|
777
790
|
for task in tasks:
|
|
778
791
|
line = _format_task_row(task)
|
|
779
|
-
if line.startswith("
|
|
792
|
+
if line.startswith("✓ "):
|
|
780
793
|
result.append(" ✓ ")
|
|
781
|
-
result.append(line[
|
|
782
|
-
elif line.startswith("
|
|
783
|
-
result.append("
|
|
784
|
-
result.append(line[
|
|
785
|
-
elif line.startswith("
|
|
786
|
-
|
|
787
|
-
result.append(
|
|
794
|
+
result.append(line[2:], style="green strike")
|
|
795
|
+
elif line.startswith("◼ "):
|
|
796
|
+
result.append(" ◼ ")
|
|
797
|
+
result.append(line[2:], style="#F97316")
|
|
798
|
+
elif line.startswith("▫ "):
|
|
799
|
+
# blocked pending: display as ▢ + dim
|
|
800
|
+
result.append(" ▢ ")
|
|
801
|
+
result.append(line[2:], style="dim")
|
|
802
|
+
elif line.startswith("▢ "):
|
|
803
|
+
result.append(" ▢ ")
|
|
804
|
+
result.append(line[2:])
|
|
788
805
|
else:
|
|
789
|
-
result.append(line)
|
|
806
|
+
result.append(f" {line}")
|
|
790
807
|
result.append("\n")
|
|
791
808
|
|
|
792
809
|
# 移除末尾的换行
|
|
@@ -861,18 +878,15 @@ class EventRenderer:
|
|
|
861
878
|
case ToolCallEvent(tool=tool_name, args=arguments, tool_call_id=tool_call_id):
|
|
862
879
|
args_dict = arguments if isinstance(arguments, dict) else {"_raw": str(arguments)}
|
|
863
880
|
self._thinking_content = ""
|
|
864
|
-
#
|
|
865
|
-
|
|
881
|
+
# Store args for ToolResult phase lookup.
|
|
882
|
+
self._tool_call_args[tool_call_id] = args_dict
|
|
883
|
+
if not should_show_tool_in_scrollback(tool_name, args_dict):
|
|
866
884
|
self._rebuild_loading_line()
|
|
867
885
|
return (False, None)
|
|
868
886
|
self._append_tool_call(tool_name, args_dict, tool_call_id)
|
|
869
887
|
case ToolResultEvent(tool=tool_name, result=result, tool_call_id=tool_call_id, is_error=is_error, metadata=metadata):
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
self._rebuild_loading_line()
|
|
873
|
-
return (False, None)
|
|
874
|
-
if tool_name.lower() in ("askuserquestion", "exitplanmode") and not is_error:
|
|
875
|
-
# Success is already visible via UserQuestionEvent / PlanApprovalRequiredEvent.
|
|
888
|
+
stored_args = self._tool_call_args.pop(tool_call_id, {})
|
|
889
|
+
if not should_show_tool_in_scrollback(tool_name, stored_args, is_result=True, is_error=is_error):
|
|
876
890
|
self._running_tools.pop(tool_call_id, None)
|
|
877
891
|
self._rebuild_loading_line()
|
|
878
892
|
return (False, None)
|
|
@@ -60,6 +60,48 @@ _MAX_FANCY_LINE_LEN = 100
|
|
|
60
60
|
_ALLOWED_STATUS = {"pending", "in_progress", "completed"}
|
|
61
61
|
_ALLOWED_PRIORITY = {"high", "medium", "low"}
|
|
62
62
|
|
|
63
|
+
# Tools that are always hidden from scrollback (have dedicated event channels).
|
|
64
|
+
_ALWAYS_HIDDEN_TOOLS: frozenset[str] = frozenset({"askuserquestion", "exitplanmode"})
|
|
65
|
+
|
|
66
|
+
# Task coordination tools hidden from scrollback unless erroring.
|
|
67
|
+
_SILENT_TASK_TOOLS: frozenset[str] = frozenset({"tasklist", "taskget"})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def should_show_tool_in_scrollback(
|
|
71
|
+
tool_name: str,
|
|
72
|
+
args: dict[str, Any],
|
|
73
|
+
is_result: bool = False,
|
|
74
|
+
is_error: bool = False,
|
|
75
|
+
) -> bool:
|
|
76
|
+
"""Determine whether a tool call/result should appear in scrollback.
|
|
77
|
+
|
|
78
|
+
Centralises the visibility rules so that both the live event pipeline
|
|
79
|
+
(event_renderer) and the resume replay pipeline (history_sync) share
|
|
80
|
+
one source of truth.
|
|
81
|
+
"""
|
|
82
|
+
lowered = tool_name.lower()
|
|
83
|
+
|
|
84
|
+
# AskUserQuestion / ExitPlanMode: only show errors.
|
|
85
|
+
if lowered in _ALWAYS_HIDDEN_TOOLS:
|
|
86
|
+
return is_result and is_error
|
|
87
|
+
|
|
88
|
+
# TaskCreate: always visible.
|
|
89
|
+
if lowered == "taskcreate":
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
# TaskUpdate: visible only when status=completed, or on error.
|
|
93
|
+
if lowered == "taskupdate":
|
|
94
|
+
if str(args.get("status", "")).lower() == "completed":
|
|
95
|
+
return True
|
|
96
|
+
return is_result and is_error
|
|
97
|
+
|
|
98
|
+
# TaskList / TaskGet: only show errors.
|
|
99
|
+
if lowered in _SILENT_TASK_TOOLS:
|
|
100
|
+
return is_result and is_error
|
|
101
|
+
|
|
102
|
+
# All other tools: always visible.
|
|
103
|
+
return True
|
|
104
|
+
|
|
63
105
|
|
|
64
106
|
def _parse_todos_value(value: Any) -> list[dict[str, Any]] | None:
|
|
65
107
|
if isinstance(value, list):
|
|
@@ -75,6 +75,8 @@ from comate_cli.terminal_agent.tui_parts import (
|
|
|
75
75
|
|
|
76
76
|
logger = logging.getLogger(__name__)
|
|
77
77
|
|
|
78
|
+
_TASK_POLL_INTERVAL_S = 2.0
|
|
79
|
+
|
|
78
80
|
|
|
79
81
|
class TerminalAgentTUI(
|
|
80
82
|
KeyBindingsMixin,
|
|
@@ -96,6 +98,7 @@ class TerminalAgentTUI(
|
|
|
96
98
|
except Exception:
|
|
97
99
|
pass
|
|
98
100
|
self._renderer = renderer
|
|
101
|
+
self._task_poll_next_at = time.monotonic() + _TASK_POLL_INTERVAL_S
|
|
99
102
|
self._rewind_store = RewindStore(session=self._session, project_root=Path.cwd())
|
|
100
103
|
|
|
101
104
|
# Team inbox 消息直连 scrollback(绕开 session_event_queue,实现实时显示)
|
|
@@ -1037,6 +1040,34 @@ class TerminalAgentTUI(
|
|
|
1037
1040
|
self._sync_focus_for_mode()
|
|
1038
1041
|
self._render_dirty = True
|
|
1039
1042
|
|
|
1043
|
+
def _fetch_tasks_from_store(self) -> tuple[list[dict], str] | None:
|
|
1044
|
+
"""从 TaskStore 读取最新 task 列表(纯 I/O,线程安全)。
|
|
1045
|
+
|
|
1046
|
+
返回 (task_dicts, list_id) 或 None。
|
|
1047
|
+
不修改任何共享状态——状态更新由调用方在主线程完成。
|
|
1048
|
+
"""
|
|
1049
|
+
try:
|
|
1050
|
+
agent = getattr(self._session, "_agent", None)
|
|
1051
|
+
if agent is None:
|
|
1052
|
+
return None
|
|
1053
|
+
store = getattr(agent, "_task_store", None)
|
|
1054
|
+
if store is None:
|
|
1055
|
+
return None
|
|
1056
|
+
tasks = store.list_tasks()
|
|
1057
|
+
if not tasks:
|
|
1058
|
+
return None
|
|
1059
|
+
# 转换 TaskItem 为 dict(与 chat_session.py 格式一致)
|
|
1060
|
+
task_dicts: list[dict] = []
|
|
1061
|
+
for task in tasks:
|
|
1062
|
+
payload = task.model_dump(mode="json")
|
|
1063
|
+
payload["open_blocked_by"] = store.get_open_blocked_by(task.id)
|
|
1064
|
+
task_dicts.append(payload)
|
|
1065
|
+
list_id = store.list_id_value()
|
|
1066
|
+
return task_dicts, list_id
|
|
1067
|
+
except Exception:
|
|
1068
|
+
logger.debug("task store fetch failed", exc_info=True)
|
|
1069
|
+
return None
|
|
1070
|
+
|
|
1040
1071
|
async def _ui_tick(self) -> None:
|
|
1041
1072
|
try:
|
|
1042
1073
|
while not self._closing:
|
|
@@ -1091,6 +1122,17 @@ class TerminalAgentTUI(
|
|
|
1091
1122
|
or self._renderer.has_running_tools()
|
|
1092
1123
|
)
|
|
1093
1124
|
sleep_s = 1 / 6 if fast else 1 / 4
|
|
1125
|
+
|
|
1126
|
+
# 定时轮询 TaskStore 刷新 task 面板
|
|
1127
|
+
now_poll = time.monotonic()
|
|
1128
|
+
if now_poll >= self._task_poll_next_at:
|
|
1129
|
+
self._task_poll_next_at = now_poll + _TASK_POLL_INTERVAL_S
|
|
1130
|
+
poll_result = await asyncio.to_thread(self._fetch_tasks_from_store)
|
|
1131
|
+
if poll_result is not None:
|
|
1132
|
+
task_dicts, list_id = poll_result
|
|
1133
|
+
self._renderer._update_tasks(task_dicts, list_id=list_id)
|
|
1134
|
+
self._render_dirty = True
|
|
1135
|
+
|
|
1094
1136
|
await asyncio.sleep(sleep_s)
|
|
1095
1137
|
except asyncio.CancelledError:
|
|
1096
1138
|
return
|
|
@@ -18,6 +18,7 @@ from comate_cli.terminal_agent.history_printer import (
|
|
|
18
18
|
from comate_cli.terminal_agent.logo import print_logo
|
|
19
19
|
from comate_cli.terminal_agent.markdown_render import render_markdown_to_plain
|
|
20
20
|
from comate_cli.terminal_agent.models import HistoryEntry
|
|
21
|
+
from comate_cli.terminal_agent.tool_view import should_show_tool_in_scrollback
|
|
21
22
|
|
|
22
23
|
console = Console()
|
|
23
24
|
logger = logging.getLogger(__name__)
|
|
@@ -76,6 +77,7 @@ class HistorySyncMixin:
|
|
|
76
77
|
|
|
77
78
|
# 维护 tool_call_id 映射,用于匹配工具调用和结果
|
|
78
79
|
tool_call_info: dict[str, tuple[str, str]] = {} # tool_call_id → (tool_name, signature)
|
|
80
|
+
tool_call_args: dict[str, dict[str, Any]] = {}
|
|
79
81
|
|
|
80
82
|
for item in history:
|
|
81
83
|
if item.item_type == ItemType.USER_MESSAGE:
|
|
@@ -103,6 +105,7 @@ class HistorySyncMixin:
|
|
|
103
105
|
signature = self._build_resume_tool_signature(tool_name, args_dict)
|
|
104
106
|
# 存储映射,不调用 restore_tool_call(避免加入 _running_tools)
|
|
105
107
|
tool_call_info[tc.id] = (tool_name, signature)
|
|
108
|
+
tool_call_args[tc.id] = args_dict
|
|
106
109
|
|
|
107
110
|
# 显示 assistant text
|
|
108
111
|
assistant_text = self._extract_assistant_text(item).strip()
|
|
@@ -127,6 +130,11 @@ class HistorySyncMixin:
|
|
|
127
130
|
tool_name = getattr(message, "tool_name", item.tool_name or "UnknownTool")
|
|
128
131
|
signature = f"{tool_name}()"
|
|
129
132
|
|
|
133
|
+
# Apply shared visibility filter.
|
|
134
|
+
resume_args = tool_call_args.get(tool_call_id, {}) if tool_call_id else {}
|
|
135
|
+
if not should_show_tool_in_scrollback(tool_name, resume_args, is_result=True, is_error=is_error):
|
|
136
|
+
continue
|
|
137
|
+
|
|
130
138
|
# Extract diff from raw_envelope for Edit/MultiEdit
|
|
131
139
|
diff_lines: list[str] | None = None
|
|
132
140
|
if tool_name in ("Edit", "MultiEdit") and not is_error:
|
|
@@ -239,7 +239,7 @@ class RenderPanelsMixin:
|
|
|
239
239
|
)
|
|
240
240
|
if len(all_lines) > len(collapsed_lines) and collapsed_lines:
|
|
241
241
|
last_line = collapsed_lines[-1]
|
|
242
|
-
if last_line.startswith("
|
|
242
|
+
if last_line.startswith("… (+"):
|
|
243
243
|
collapsed_lines[-1] = f"{last_line}, Ctrl+Y to expand"
|
|
244
244
|
return collapsed_lines
|
|
245
245
|
|
|
@@ -558,13 +558,42 @@ class RenderPanelsMixin:
|
|
|
558
558
|
if title_elapsed_suffix:
|
|
559
559
|
fragments.append(("fg:#6B7280", title_elapsed_suffix))
|
|
560
560
|
else:
|
|
561
|
-
clipped = fit_single_line(line, width -
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
561
|
+
clipped = fit_single_line(line, width - 3) # 留空间给 prefix + space
|
|
562
|
+
# prefix 列:第一条任务行用 ⎿,后续用空格
|
|
563
|
+
is_first_task_row = idx == 1
|
|
564
|
+
prefix_char = "⎿" if is_first_task_row else " "
|
|
565
|
+
prefix_style = "fg:#555555"
|
|
566
|
+
|
|
567
|
+
# 解析符号和文本,应用分色
|
|
568
|
+
if clipped.startswith("✓ "):
|
|
569
|
+
symbol, text = "✓", clipped[2:]
|
|
570
|
+
symbol_style = "fg:#86EFAC"
|
|
571
|
+
text_style = "fg:#6B7280 strike"
|
|
572
|
+
elif clipped.startswith("◼ "):
|
|
573
|
+
symbol, text = "◼", clipped[2:]
|
|
574
|
+
symbol_style = "fg:#F97316"
|
|
575
|
+
text_style = "fg:#E2E8F0"
|
|
576
|
+
elif clipped.startswith("▫ "):
|
|
577
|
+
# blocked pending: ▫ 是中间符号,渲染时显示为 ▢
|
|
578
|
+
symbol, text = "▢", clipped[2:]
|
|
579
|
+
symbol_style = "fg:#4B5563"
|
|
580
|
+
text_style = "fg:#4B5563"
|
|
581
|
+
elif clipped.startswith("▢ "):
|
|
582
|
+
symbol, text = "▢", clipped[2:]
|
|
583
|
+
symbol_style = "fg:#6B7280"
|
|
584
|
+
text_style = "fg:#6B7280"
|
|
585
|
+
else:
|
|
586
|
+
# 溢出行 "… (+N)" 等
|
|
587
|
+
symbol, text = "", clipped
|
|
588
|
+
symbol_style = ""
|
|
589
|
+
text_style = "fg:#6B7280"
|
|
590
|
+
|
|
591
|
+
fragments.append((prefix_style, prefix_char))
|
|
592
|
+
fragments.append(("", " "))
|
|
593
|
+
if symbol:
|
|
594
|
+
fragments.append((symbol_style, symbol))
|
|
595
|
+
fragments.append(("", " "))
|
|
596
|
+
fragments.append((text_style, text))
|
|
568
597
|
if idx != last_index:
|
|
569
598
|
fragments.append(("", "\n"))
|
|
570
599
|
return fragments
|
|
@@ -68,7 +68,7 @@ class _FakeRenderer:
|
|
|
68
68
|
if len(self.todo_lines) <= max_lines:
|
|
69
69
|
return list(self.todo_lines)
|
|
70
70
|
clipped = list(self.todo_lines[: max_lines - 1])
|
|
71
|
-
clipped.append(f"
|
|
71
|
+
clipped.append(f"… (+{len(self.todo_lines) - (max_lines - 1)})")
|
|
72
72
|
return clipped
|
|
73
73
|
|
|
74
74
|
def todo_all_lines(self) -> list[str]:
|
|
@@ -233,7 +233,7 @@ class TestEventRenderer(unittest.TestCase):
|
|
|
233
233
|
self.assertLessEqual(len(task_lines), 6)
|
|
234
234
|
self.assertIn("…", task_lines[-1])
|
|
235
235
|
|
|
236
|
-
def
|
|
236
|
+
def test_task_header_uses_in_progress_subject_without_list_id(self) -> None:
|
|
237
237
|
renderer = EventRenderer()
|
|
238
238
|
renderer.start_turn()
|
|
239
239
|
renderer.handle_event(
|
|
@@ -248,7 +248,9 @@ class TestEventRenderer(unittest.TestCase):
|
|
|
248
248
|
|
|
249
249
|
task_lines = renderer.task_lines()
|
|
250
250
|
self.assertTrue(task_lines)
|
|
251
|
-
|
|
251
|
+
# Title shows the in_progress task's subject, not list_id
|
|
252
|
+
self.assertEqual(task_lines[0], "b")
|
|
253
|
+
self.assertNotIn("release", task_lines[0])
|
|
252
254
|
self.assertNotIn("completed", task_lines[0])
|
|
253
255
|
self.assertNotIn("in_progress", task_lines[0])
|
|
254
256
|
|
|
@@ -275,9 +277,10 @@ class TestEventRenderer(unittest.TestCase):
|
|
|
275
277
|
self.assertIn("open-2", joined)
|
|
276
278
|
self.assertIn("open-3", joined)
|
|
277
279
|
self.assertNotIn("done-2", joined)
|
|
278
|
-
self.assertTrue(lines[-1].startswith("
|
|
280
|
+
self.assertTrue(lines[-1].startswith("… (+"))
|
|
279
281
|
|
|
280
|
-
def
|
|
282
|
+
def test_task_row_omits_owner_from_display(self) -> None:
|
|
283
|
+
"""Owner field is no longer shown in task rows (visual redesign)."""
|
|
281
284
|
renderer = EventRenderer()
|
|
282
285
|
renderer.start_turn()
|
|
283
286
|
renderer.handle_event(
|
|
@@ -304,8 +307,8 @@ class TestEventRenderer(unittest.TestCase):
|
|
|
304
307
|
|
|
305
308
|
lines = renderer.task_panel_lines(max_lines=6)
|
|
306
309
|
joined = "\n".join(lines)
|
|
307
|
-
self.assertIn("claim task ownership
|
|
308
|
-
self.assertNotIn("
|
|
310
|
+
self.assertIn("claim task ownership", joined)
|
|
311
|
+
self.assertNotIn("owner=", joined)
|
|
309
312
|
|
|
310
313
|
def test_team_task_row_omits_empty_owner_suffix(self) -> None:
|
|
311
314
|
renderer = EventRenderer()
|
|
@@ -618,5 +621,59 @@ class TestEventRenderer(unittest.TestCase):
|
|
|
618
621
|
self.assertIn("step from file", assistant_entries[-1].text)
|
|
619
622
|
|
|
620
623
|
|
|
624
|
+
class TestTaskToolScrollbackDisplay(unittest.TestCase):
|
|
625
|
+
"""Verify TaskCreate and TaskUpdate(completed) appear in scrollback."""
|
|
626
|
+
|
|
627
|
+
def test_taskcreate_toolcall_produces_history_entry(self) -> None:
|
|
628
|
+
renderer = EventRenderer()
|
|
629
|
+
renderer.start_turn()
|
|
630
|
+
renderer.handle_event(ToolCallEvent(tool="TaskCreate", args={"subject": "do X"}, tool_call_id="tc1"))
|
|
631
|
+
renderer.handle_event(ToolResultEvent(tool="TaskCreate", result="ok", tool_call_id="tc1", is_error=False))
|
|
632
|
+
entries = [e for e in renderer.history_entries() if e.entry_type == "tool_result"]
|
|
633
|
+
self.assertEqual(len(entries), 1)
|
|
634
|
+
self.assertIn("TaskCreate", str(entries[0].text))
|
|
635
|
+
|
|
636
|
+
def test_taskupdate_completed_produces_history_entry(self) -> None:
|
|
637
|
+
renderer = EventRenderer()
|
|
638
|
+
renderer.start_turn()
|
|
639
|
+
renderer.handle_event(ToolCallEvent(tool="TaskUpdate", args={"taskId": "1", "status": "completed"}, tool_call_id="tc2"))
|
|
640
|
+
renderer.handle_event(ToolResultEvent(tool="TaskUpdate", result="ok", tool_call_id="tc2", is_error=False))
|
|
641
|
+
entries = [e for e in renderer.history_entries() if e.entry_type == "tool_result"]
|
|
642
|
+
self.assertEqual(len(entries), 1)
|
|
643
|
+
self.assertIn("TaskUpdate", str(entries[0].text))
|
|
644
|
+
|
|
645
|
+
def test_taskupdate_in_progress_hidden_from_scrollback(self) -> None:
|
|
646
|
+
renderer = EventRenderer()
|
|
647
|
+
renderer.start_turn()
|
|
648
|
+
renderer.handle_event(ToolCallEvent(tool="TaskUpdate", args={"taskId": "1", "status": "in_progress"}, tool_call_id="tc3"))
|
|
649
|
+
renderer.handle_event(ToolResultEvent(tool="TaskUpdate", result="ok", tool_call_id="tc3", is_error=False))
|
|
650
|
+
entries = [e for e in renderer.history_entries() if e.entry_type == "tool_result"]
|
|
651
|
+
self.assertEqual(len(entries), 0)
|
|
652
|
+
|
|
653
|
+
def test_tasklist_hidden_from_scrollback(self) -> None:
|
|
654
|
+
renderer = EventRenderer()
|
|
655
|
+
renderer.start_turn()
|
|
656
|
+
renderer.handle_event(ToolCallEvent(tool="TaskList", args={}, tool_call_id="tc4"))
|
|
657
|
+
renderer.handle_event(ToolResultEvent(tool="TaskList", result="ok", tool_call_id="tc4", is_error=False))
|
|
658
|
+
entries = [e for e in renderer.history_entries() if e.entry_type == "tool_result"]
|
|
659
|
+
self.assertEqual(len(entries), 0)
|
|
660
|
+
|
|
661
|
+
def test_tasklist_error_shown_in_scrollback(self) -> None:
|
|
662
|
+
renderer = EventRenderer()
|
|
663
|
+
renderer.start_turn()
|
|
664
|
+
renderer.handle_event(ToolCallEvent(tool="TaskList", args={}, tool_call_id="tc5"))
|
|
665
|
+
renderer.handle_event(ToolResultEvent(tool="TaskList", result="store error", tool_call_id="tc5", is_error=True))
|
|
666
|
+
entries = [e for e in renderer.history_entries() if e.entry_type == "tool_result"]
|
|
667
|
+
self.assertEqual(len(entries), 1)
|
|
668
|
+
|
|
669
|
+
def test_reset_history_view_clears_tool_call_args(self) -> None:
|
|
670
|
+
renderer = EventRenderer()
|
|
671
|
+
renderer.start_turn()
|
|
672
|
+
renderer.handle_event(ToolCallEvent(tool="TaskCreate", args={"subject": "x"}, tool_call_id="tc6"))
|
|
673
|
+
self.assertTrue(renderer._tool_call_args)
|
|
674
|
+
renderer.reset_history_view()
|
|
675
|
+
self.assertFalse(renderer._tool_call_args)
|
|
676
|
+
|
|
677
|
+
|
|
621
678
|
if __name__ == "__main__":
|
|
622
679
|
unittest.main(verbosity=2)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Tests for _format_task_row after visual redesign."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from comate_cli.terminal_agent.event_renderer import _format_task_row
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_completed_task_row():
|
|
8
|
+
task = {"id": "1", "subject": "Set up project", "status": "completed", "owner": "alice", "open_blocked_by": []}
|
|
9
|
+
result = _format_task_row(task)
|
|
10
|
+
assert result == "✓ Set up project"
|
|
11
|
+
assert "[#" not in result
|
|
12
|
+
assert "owner=" not in result
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_in_progress_task_row():
|
|
16
|
+
task = {"id": "2", "subject": "Implement auth", "status": "in_progress", "owner": "bob", "open_blocked_by": []}
|
|
17
|
+
result = _format_task_row(task)
|
|
18
|
+
assert result == "◼ Implement auth"
|
|
19
|
+
assert "[#" not in result
|
|
20
|
+
assert "owner=" not in result
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_pending_unblocked_task_row():
|
|
24
|
+
task = {"id": "3", "subject": "Write tests", "status": "pending", "owner": "", "open_blocked_by": []}
|
|
25
|
+
result = _format_task_row(task)
|
|
26
|
+
assert result == "▢ Write tests"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_pending_blocked_task_row():
|
|
30
|
+
"""Blocked pending uses ▫ (U+25AB) as intermediate symbol for color differentiation."""
|
|
31
|
+
task = {"id": "4", "subject": "Deploy", "status": "pending", "owner": "", "open_blocked_by": ["1", "2"]}
|
|
32
|
+
result = _format_task_row(task)
|
|
33
|
+
assert result == "▫ Deploy"
|
|
34
|
+
assert "blocked by" not in result
|
|
35
|
+
assert "owner=" not in result
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_untitled_task_row():
|
|
39
|
+
task = {"id": "5", "subject": "", "status": "pending", "owner": "", "open_blocked_by": []}
|
|
40
|
+
result = _format_task_row(task)
|
|
41
|
+
assert result == "▢ (untitled)"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
from comate_cli.terminal_agent.event_renderer import EventRenderer
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _make_renderer_for_title_test() -> EventRenderer:
|
|
48
|
+
"""创建最小化 EventRenderer 用于测试 _update_tasks 的标题逻辑。"""
|
|
49
|
+
renderer = object.__new__(EventRenderer)
|
|
50
|
+
renderer._current_tasks = []
|
|
51
|
+
renderer._current_task_title = None
|
|
52
|
+
renderer._task_started_at_monotonic = None
|
|
53
|
+
renderer._history = []
|
|
54
|
+
return renderer
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_title_shows_in_progress_subject():
|
|
58
|
+
renderer = _make_renderer_for_title_test()
|
|
59
|
+
tasks = [
|
|
60
|
+
{"id": "1", "subject": "Set up project", "status": "completed", "open_blocked_by": []},
|
|
61
|
+
{"id": "2", "subject": "Implement auth", "status": "in_progress", "open_blocked_by": []},
|
|
62
|
+
{"id": "3", "subject": "Write tests", "status": "pending", "open_blocked_by": []},
|
|
63
|
+
]
|
|
64
|
+
renderer._update_tasks(tasks, list_id="default")
|
|
65
|
+
assert renderer._current_task_title == "Implement auth"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_title_shows_smallest_id_in_progress():
|
|
69
|
+
renderer = _make_renderer_for_title_test()
|
|
70
|
+
tasks = [
|
|
71
|
+
{"id": "3", "subject": "Task C", "status": "in_progress", "open_blocked_by": []},
|
|
72
|
+
{"id": "1", "subject": "Task A", "status": "in_progress", "open_blocked_by": []},
|
|
73
|
+
{"id": "2", "subject": "Task B", "status": "pending", "open_blocked_by": []},
|
|
74
|
+
]
|
|
75
|
+
renderer._update_tasks(tasks, list_id="default")
|
|
76
|
+
assert renderer._current_task_title == "Task A"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_title_falls_back_to_tasks_when_no_in_progress():
|
|
80
|
+
renderer = _make_renderer_for_title_test()
|
|
81
|
+
tasks = [
|
|
82
|
+
{"id": "1", "subject": "Set up project", "status": "pending", "open_blocked_by": []},
|
|
83
|
+
{"id": "2", "subject": "Write tests", "status": "pending", "open_blocked_by": []},
|
|
84
|
+
]
|
|
85
|
+
renderer._update_tasks(tasks, list_id="default")
|
|
86
|
+
assert renderer._current_task_title == "Tasks"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_title_ignores_list_id():
|
|
90
|
+
renderer = _make_renderer_for_title_test()
|
|
91
|
+
tasks = [
|
|
92
|
+
{"id": "1", "subject": "Task A", "status": "pending", "open_blocked_by": []},
|
|
93
|
+
]
|
|
94
|
+
renderer._update_tasks(tasks, list_id="team-alpha")
|
|
95
|
+
assert renderer._current_task_title == "Tasks"
|
|
96
|
+
assert "team-alpha" not in (renderer._current_task_title or "")
|
|
@@ -36,14 +36,14 @@ class _StubRenderer:
|
|
|
36
36
|
self.active_todos = True
|
|
37
37
|
self.todo_lines = [
|
|
38
38
|
"Tasks",
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
39
|
+
"◼ task-1",
|
|
40
|
+
"▢ task-2",
|
|
41
|
+
"▢ task-3",
|
|
42
|
+
"▢ task-4",
|
|
43
|
+
"▢ task-5",
|
|
44
|
+
"▢ task-6",
|
|
45
|
+
"▢ task-7",
|
|
46
|
+
"▢ task-8",
|
|
47
47
|
]
|
|
48
48
|
|
|
49
49
|
def has_active_todos(self) -> bool:
|