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.
Files changed (97) hide show
  1. {comate_cli-0.2.4 → comate_cli-0.2.5}/.gitignore +1 -0
  2. {comate_cli-0.2.4 → comate_cli-0.2.5}/PKG-INFO +1 -1
  3. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/event_renderer.py +45 -31
  4. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tool_view.py +42 -0
  5. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tui.py +42 -0
  6. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tui_parts/history_sync.py +8 -0
  7. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tui_parts/render_panels.py +37 -8
  8. {comate_cli-0.2.4 → comate_cli-0.2.5}/pyproject.toml +1 -1
  9. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_completion_status_panel.py +1 -1
  10. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_event_renderer.py +63 -6
  11. comate_cli-0.2.5/tests/test_task_panel_format.py +96 -0
  12. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_task_panel_key_bindings.py +8 -8
  13. comate_cli-0.2.5/tests/test_task_panel_rendering.py +116 -0
  14. comate_cli-0.2.5/tests/test_task_poll.py +64 -0
  15. comate_cli-0.2.5/tests/test_tool_view.py +149 -0
  16. {comate_cli-0.2.4 → comate_cli-0.2.5}/uv.lock +2 -2
  17. comate_cli-0.2.4/tests/test_tool_view.py +0 -64
  18. {comate_cli-0.2.4 → comate_cli-0.2.5}/README.md +0 -0
  19. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/__init__.py +0 -0
  20. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/__main__.py +0 -0
  21. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/main.py +0 -0
  22. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/mcp_cli.py +0 -0
  23. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/__init__.py +0 -0
  24. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/animations.py +0 -0
  25. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/app.py +0 -0
  26. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/assistant_render.py +0 -0
  27. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/custom_slash_commands.py +0 -0
  28. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/env_utils.py +0 -0
  29. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/error_display.py +0 -0
  30. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/fragment_utils.py +0 -0
  31. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/history_printer.py +0 -0
  32. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/input_geometry.py +0 -0
  33. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/layout_coordinator.py +0 -0
  34. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/logging_adapter.py +0 -0
  35. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/logo.py +0 -0
  36. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/markdown_render.py +0 -0
  37. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/mention_completer.py +0 -0
  38. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/message_style.py +0 -0
  39. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/models.py +0 -0
  40. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/preflight.py +0 -0
  41. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/question_view.py +0 -0
  42. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/resume_selector.py +0 -0
  43. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/rewind_store.py +0 -0
  44. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/rpc_protocol.py +0 -0
  45. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/rpc_stdio.py +0 -0
  46. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/selection_menu.py +0 -0
  47. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/session_view.py +0 -0
  48. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/slash_commands.py +0 -0
  49. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/startup.py +0 -0
  50. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/status_bar.py +0 -0
  51. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/text_effects.py +0 -0
  52. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tips.py +0 -0
  53. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tui_parts/__init__.py +0 -0
  54. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tui_parts/commands.py +0 -0
  55. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tui_parts/input_behavior.py +0 -0
  56. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tui_parts/key_bindings.py +0 -0
  57. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tui_parts/slash_command_registry.py +0 -0
  58. {comate_cli-0.2.4 → comate_cli-0.2.5}/comate_cli/terminal_agent/tui_parts/ui_mode.py +0 -0
  59. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/conftest.py +0 -0
  60. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_app_mcp_preload.py +0 -0
  61. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_app_preflight_gate.py +0 -0
  62. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_app_shutdown.py +0 -0
  63. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_app_usage_line.py +0 -0
  64. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_cli_project_root.py +0 -0
  65. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_compact_command_semantics.py +0 -0
  66. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_completion_context_activation.py +0 -0
  67. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_context_command.py +0 -0
  68. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_custom_slash_commands.py +0 -0
  69. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_history_sync.py +0 -0
  70. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_input_behavior.py +0 -0
  71. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_interrupt_exit_semantics.py +0 -0
  72. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_layout_coordinator.py +0 -0
  73. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_logging_adapter.py +0 -0
  74. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_logo.py +0 -0
  75. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_main_args.py +0 -0
  76. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_mcp_cli.py +0 -0
  77. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_mcp_slash_command.py +0 -0
  78. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_mention_completer.py +0 -0
  79. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_preflight.py +0 -0
  80. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_preflight_copilot.py +0 -0
  81. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_question_key_bindings.py +0 -0
  82. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_question_view.py +0 -0
  83. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_resume_selector.py +0 -0
  84. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_rewind_command_semantics.py +0 -0
  85. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_rewind_store.py +0 -0
  86. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_rpc_protocol.py +0 -0
  87. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_rpc_stdio_bridge.py +0 -0
  88. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_selection_menu.py +0 -0
  89. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_skills_slash_command.py +0 -0
  90. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_slash_argument_hint.py +0 -0
  91. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_slash_completer.py +0 -0
  92. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_slash_registry.py +0 -0
  93. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_status_bar.py +0 -0
  94. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_tui_elapsed_status.py +0 -0
  95. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_tui_mcp_init_gate.py +0 -0
  96. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_tui_paste_placeholder.py +0 -0
  97. {comate_cli-0.2.4 → comate_cli-0.2.5}/tests/test_tui_split_invariance.py +0 -0
@@ -5,6 +5,7 @@ __pycache__/
5
5
  .agent
6
6
  .agents
7
7
  set_env.sh
8
+ .worktrees/
8
9
  # C extensions
9
10
  *.so
10
11
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: comate-cli
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: Comate terminal CLI built on comate-agent-sdk
5
5
  Project-URL: Homepage, https://github.com/AndyLee1024/agent-sdk
6
6
  Project-URL: Repository, https://github.com/AndyLee1024/agent-sdk
@@ -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
- task_id = str(task.get("id", "?")).strip() or "?"
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" [#{task_id}] {subject}{owner_suffix}"
127
+ return f"✓ {subject}"
126
128
  if status == "in_progress":
127
- return f" [#{task_id}] {subject}{owner_suffix}"
128
- if blocked_suffix:
129
- return f" [#{task_id}] {subject}{owner_suffix}{blocked_suffix}"
130
- return f" [#{task_id}] {subject}{owner_suffix}"
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" … (+{len(lines) - (normalized_limit - 1)})")
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
- header = "Tasks" if list_id == "default" else f"Tasks · {list_id}"
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[4:], style="green strike")
782
- elif line.startswith(" "):
783
- result.append(" ")
784
- result.append(line[4:], style="yellow")
785
- elif line.startswith(" "):
786
- result.append(" ◌ ")
787
- result.append(line[4:], style="dim")
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
- # Task* / AskUserQuestion / ExitPlanMode: skip normal tool tracking.
865
- if tool_name.lower() in ("taskcreate", "tasklist", "taskget", "taskupdate", "askuserquestion", "exitplanmode"):
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
- if tool_name.lower() in ("taskcreate", "tasklist", "taskupdate") and not is_error:
871
- # Successful task coordination updates should not spam scrollback.
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 - 1)
562
- style = "fg:#CBD5E1"
563
- if "✓" in line:
564
- style = "fg:#86EFAC"
565
- elif "" in line:
566
- style = "fg:#FDE68A"
567
- fragments.append((style, clipped))
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "comate-cli"
7
- version = "0.2.4"
7
+ version = "0.2.5"
8
8
  description = "Comate terminal CLI built on comate-agent-sdk"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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" … (+{len(self.todo_lines) - (max_lines - 1)})")
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 test_task_header_uses_list_id_without_status_summary(self) -> None:
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
- self.assertEqual(task_lines[0], "Tasks · release")
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 test_task_row_renders_owner_when_present(self) -> None:
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 owner=andy", joined)
308
- self.assertNotIn("keep row compact owner=", joined)
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
- " task-1",
40
- " task-2",
41
- " task-3",
42
- " task-4",
43
- " task-5",
44
- " task-6",
45
- " task-7",
46
- " task-8",
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: