soothe-cli 0.1.0__py3-none-any.whl

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 (107) hide show
  1. soothe_cli/__init__.py +5 -0
  2. soothe_cli/cli/__init__.py +1 -0
  3. soothe_cli/cli/commands/__init__.py +1 -0
  4. soothe_cli/cli/commands/autopilot_cmd.py +410 -0
  5. soothe_cli/cli/commands/config_cmd.py +277 -0
  6. soothe_cli/cli/commands/run_cmd.py +87 -0
  7. soothe_cli/cli/commands/status_cmd.py +121 -0
  8. soothe_cli/cli/commands/subagent_names.py +17 -0
  9. soothe_cli/cli/commands/thread_cmd.py +657 -0
  10. soothe_cli/cli/execution/__init__.py +6 -0
  11. soothe_cli/cli/execution/daemon.py +194 -0
  12. soothe_cli/cli/execution/headless.py +99 -0
  13. soothe_cli/cli/execution/launcher.py +31 -0
  14. soothe_cli/cli/main.py +509 -0
  15. soothe_cli/cli/renderer.py +444 -0
  16. soothe_cli/cli/stream/__init__.py +17 -0
  17. soothe_cli/cli/stream/context.py +138 -0
  18. soothe_cli/cli/stream/display_line.py +83 -0
  19. soothe_cli/cli/stream/formatter.py +412 -0
  20. soothe_cli/cli/stream/pipeline.py +521 -0
  21. soothe_cli/cli/utils.py +46 -0
  22. soothe_cli/config/__init__.py +5 -0
  23. soothe_cli/config/cli_config.py +155 -0
  24. soothe_cli/plan/__init__.py +5 -0
  25. soothe_cli/plan/rich_tree.py +54 -0
  26. soothe_cli/shared/__init__.py +107 -0
  27. soothe_cli/shared/command_router.py +246 -0
  28. soothe_cli/shared/config_loader.py +68 -0
  29. soothe_cli/shared/display_policy.py +413 -0
  30. soothe_cli/shared/essential_events.py +68 -0
  31. soothe_cli/shared/event_processor.py +823 -0
  32. soothe_cli/shared/message_processing.py +393 -0
  33. soothe_cli/shared/presentation_engine.py +173 -0
  34. soothe_cli/shared/processor_state.py +80 -0
  35. soothe_cli/shared/renderer_protocol.py +158 -0
  36. soothe_cli/shared/rendering.py +43 -0
  37. soothe_cli/shared/slash_commands.py +354 -0
  38. soothe_cli/shared/subagent_routing.py +63 -0
  39. soothe_cli/shared/suppression_state.py +188 -0
  40. soothe_cli/shared/tool_formatters/__init__.py +27 -0
  41. soothe_cli/shared/tool_formatters/base.py +109 -0
  42. soothe_cli/shared/tool_formatters/execution.py +297 -0
  43. soothe_cli/shared/tool_formatters/fallback.py +128 -0
  44. soothe_cli/shared/tool_formatters/file_ops.py +299 -0
  45. soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
  46. soothe_cli/shared/tool_formatters/media.py +291 -0
  47. soothe_cli/shared/tool_formatters/structured.py +202 -0
  48. soothe_cli/shared/tool_formatters/web.py +143 -0
  49. soothe_cli/shared/tool_output_formatter.py +227 -0
  50. soothe_cli/shared/tui_trace_log.py +40 -0
  51. soothe_cli/tui/__init__.py +5 -0
  52. soothe_cli/tui/_ask_user_types.py +50 -0
  53. soothe_cli/tui/_cli_context.py +27 -0
  54. soothe_cli/tui/_env_vars.py +56 -0
  55. soothe_cli/tui/_session_stats.py +114 -0
  56. soothe_cli/tui/_version.py +21 -0
  57. soothe_cli/tui/app.py +4992 -0
  58. soothe_cli/tui/app.tcss +302 -0
  59. soothe_cli/tui/command_registry.py +310 -0
  60. soothe_cli/tui/config.py +2381 -0
  61. soothe_cli/tui/daemon_session.py +233 -0
  62. soothe_cli/tui/file_ops.py +409 -0
  63. soothe_cli/tui/formatting.py +28 -0
  64. soothe_cli/tui/hooks.py +23 -0
  65. soothe_cli/tui/input.py +782 -0
  66. soothe_cli/tui/media_utils.py +471 -0
  67. soothe_cli/tui/model_config.py +518 -0
  68. soothe_cli/tui/output.py +69 -0
  69. soothe_cli/tui/project_utils.py +188 -0
  70. soothe_cli/tui/sessions.py +1248 -0
  71. soothe_cli/tui/skills/__init__.py +5 -0
  72. soothe_cli/tui/skills/invocation.py +74 -0
  73. soothe_cli/tui/skills/load.py +93 -0
  74. soothe_cli/tui/textual_adapter.py +1430 -0
  75. soothe_cli/tui/theme.py +838 -0
  76. soothe_cli/tui/tool_display.py +297 -0
  77. soothe_cli/tui/unicode_security.py +502 -0
  78. soothe_cli/tui/update_check.py +447 -0
  79. soothe_cli/tui/widgets/__init__.py +9 -0
  80. soothe_cli/tui/widgets/_links.py +63 -0
  81. soothe_cli/tui/widgets/approval.py +430 -0
  82. soothe_cli/tui/widgets/ask_user.py +392 -0
  83. soothe_cli/tui/widgets/autocomplete.py +666 -0
  84. soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
  85. soothe_cli/tui/widgets/autopilot_screen.py +64 -0
  86. soothe_cli/tui/widgets/chat_input.py +1834 -0
  87. soothe_cli/tui/widgets/clipboard.py +128 -0
  88. soothe_cli/tui/widgets/diff.py +240 -0
  89. soothe_cli/tui/widgets/editor.py +140 -0
  90. soothe_cli/tui/widgets/history.py +221 -0
  91. soothe_cli/tui/widgets/loading.py +194 -0
  92. soothe_cli/tui/widgets/mcp_viewer.py +352 -0
  93. soothe_cli/tui/widgets/message_store.py +693 -0
  94. soothe_cli/tui/widgets/messages.py +1720 -0
  95. soothe_cli/tui/widgets/model_selector.py +988 -0
  96. soothe_cli/tui/widgets/notification_settings.py +155 -0
  97. soothe_cli/tui/widgets/status.py +403 -0
  98. soothe_cli/tui/widgets/theme_selector.py +158 -0
  99. soothe_cli/tui/widgets/thread_selector.py +1865 -0
  100. soothe_cli/tui/widgets/tool_renderers.py +148 -0
  101. soothe_cli/tui/widgets/tool_widgets.py +254 -0
  102. soothe_cli/tui/widgets/tools.py +165 -0
  103. soothe_cli/tui/widgets/welcome.py +330 -0
  104. soothe_cli-0.1.0.dist-info/METADATA +100 -0
  105. soothe_cli-0.1.0.dist-info/RECORD +107 -0
  106. soothe_cli-0.1.0.dist-info/WHEEL +4 -0
  107. soothe_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,393 @@
1
+ """Shared message processing utilities for CLI and TUI modes.
2
+
3
+ This module provides helper functions for message handling logic to ensure
4
+ consistent behavior between headless CLI mode and the TUI interface.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import json
11
+ import re
12
+ from typing import Any
13
+
14
+ from soothe_sdk.internal import strip_internal_tags # noqa: F401 — re-exported via shared.__init__
15
+
16
+ # ============================================================================
17
+ # Shared Tool Call Streaming Helpers (IG-053)
18
+ # ============================================================================
19
+
20
+
21
+ def accumulate_tool_call_chunks(
22
+ pending_tool_calls: dict[str, dict[str, Any]],
23
+ tool_call_chunks: list[dict[str, Any]],
24
+ *,
25
+ is_main: bool = True,
26
+ ) -> None:
27
+ """Accumulate streaming tool call chunks into pending_tool_calls.
28
+
29
+ LangChain streams tool args in chunks - first chunk has the tool name but
30
+ empty args, subsequent chunks contain partial JSON strings. This function
31
+ tracks and accumulates them.
32
+
33
+ Args:
34
+ pending_tool_calls: Dict to store pending tool calls (tool_call_id -> state).
35
+ tool_call_chunks: List of tool_call_chunk dicts from AIMessageChunk.
36
+ is_main: Whether this is from the main agent.
37
+ """
38
+ for tcc in tool_call_chunks:
39
+ if not isinstance(tcc, dict):
40
+ continue
41
+ tc_id = tcc.get("id", "")
42
+ tc_name = tcc.get("name")
43
+ tc_args = tcc.get("args", "")
44
+
45
+ # First chunk with a tool name: register the pending tool call
46
+ if tc_name and tc_id and tc_id not in pending_tool_calls:
47
+ pending_tool_calls[tc_id] = {
48
+ "name": tc_name,
49
+ "args_str": tc_args if isinstance(tc_args, str) else "",
50
+ "emitted": False,
51
+ "is_main": is_main,
52
+ }
53
+ # Subsequent chunks: accumulate args
54
+ elif tc_args and isinstance(tc_args, str):
55
+ # Find the tool call to accumulate args for (by order, first non-emitted)
56
+ for pending in pending_tool_calls.values():
57
+ if not pending["emitted"]:
58
+ pending["args_str"] += tc_args
59
+ break
60
+
61
+
62
+ def try_parse_pending_tool_call_args(
63
+ pending: dict[str, Any],
64
+ ) -> dict[str, Any] | None:
65
+ """Try to parse the accumulated args_str as JSON.
66
+
67
+ Args:
68
+ pending: Pending tool call state dict with 'args_str' key.
69
+
70
+ Returns:
71
+ Parsed args dict if valid JSON, None otherwise.
72
+ """
73
+ args_str = pending.get("args_str", "")
74
+ if not args_str:
75
+ return None
76
+ try:
77
+ parsed = json.loads(args_str)
78
+ return parsed if isinstance(parsed, dict) else None
79
+ except json.JSONDecodeError:
80
+ return None
81
+
82
+
83
+ def finalize_pending_tool_call(
84
+ pending_tool_calls: dict[str, dict[str, Any]],
85
+ tool_call_id: str,
86
+ ) -> tuple[dict[str, Any] | None, dict[str, Any], bool, str]:
87
+ """Finalize and remove a pending tool call when its result arrives.
88
+
89
+ Args:
90
+ pending_tool_calls: Dict of pending tool calls.
91
+ tool_call_id: ID of the tool call to finalize.
92
+
93
+ Returns:
94
+ Tuple of (parsed_args, pending_state dict, needs_emit, raw_args_str).
95
+ - parsed_args: Parsed args dict if valid JSON, None otherwise.
96
+ - pending_state: The pending tool call state dict.
97
+ - needs_emit: True if the tool call wasn't emitted yet.
98
+ - raw_args_str: Raw args string for display fallback.
99
+ If not found, returns (None, {}, False, "").
100
+ """
101
+ str_id = str(tool_call_id) if tool_call_id else ""
102
+ if not str_id or str_id not in pending_tool_calls:
103
+ return None, {}, False, ""
104
+
105
+ pending = pending_tool_calls[str_id]
106
+ parsed_args = None
107
+ needs_emit = not pending.get("emitted", False)
108
+ raw_args_str = pending.get("args_str", "")
109
+
110
+ if needs_emit:
111
+ # Try to parse args one more time
112
+ if raw_args_str:
113
+ with contextlib.suppress(json.JSONDecodeError):
114
+ result = json.loads(raw_args_str)
115
+ if isinstance(result, dict):
116
+ parsed_args = result
117
+ pending["emitted"] = True
118
+
119
+ # Clean up the pending entry
120
+ del pending_tool_calls[str_id]
121
+ return parsed_args, pending, needs_emit, raw_args_str
122
+
123
+
124
+ def extract_tool_brief(tool_name: str, content: str | dict | Any, max_length: int = 120) -> str:
125
+ r"""Extract a concise one-line summary from tool result content.
126
+
127
+ Uses semantic formatters to provide tool-specific summaries with meaningful
128
+ metrics (size, count, status) instead of simple truncation.
129
+
130
+ Args:
131
+ tool_name: Name of the tool that produced the content.
132
+ content: Tool result content (string, dict, or ToolOutput).
133
+ max_length: Maximum length of the brief (default 120, unused for semantic formatting).
134
+
135
+ Returns:
136
+ Semantic brief suitable for display.
137
+
138
+ Example:
139
+ >>> extract_tool_brief("read_file", "Hello\\nWorld\\n")
140
+ "✓ Read 12 B (2 lines)"
141
+ >>> extract_tool_brief("run_command", "output")
142
+ "✓ Done (6 chars output)"
143
+ """
144
+ # Use semantic formatter for tool-specific summarization
145
+ from soothe_cli.shared.tool_output_formatter import ToolOutputFormatter
146
+
147
+ try:
148
+ formatter = ToolOutputFormatter()
149
+ brief = formatter.format(tool_name, content)
150
+ return brief.to_display()
151
+ except Exception:
152
+ # Fallback to simple truncation if formatter fails
153
+ if isinstance(content, str):
154
+ # Web search/crawl tools return structured output with summary on first line
155
+ web_tools = {"search_web", "crawl_web"}
156
+ if tool_name in web_tools:
157
+ first_line = content.split("\n", 1)[0].strip()
158
+ if first_line:
159
+ return first_line[:max_length]
160
+ return content.replace("\n", " ")[:max_length]
161
+ if isinstance(content, dict):
162
+ # Simple dict formatting
163
+ return f"Dict with {len(content)} fields"
164
+ return str(content)[:max_length]
165
+
166
+
167
+ def coerce_tool_call_args_to_dict(raw: Any) -> dict[str, Any]:
168
+ """Normalize tool arguments for display.
169
+
170
+ ``tool_call_chunk`` content blocks use a JSON string; merged ``tool_calls`` use dicts
171
+ (see LangChain ``ToolCall`` / ``ToolCallChunk``).
172
+ """
173
+ if isinstance(raw, dict):
174
+ return raw
175
+ if isinstance(raw, str):
176
+ s = raw.strip()
177
+ if not s:
178
+ return {}
179
+ try:
180
+ parsed = json.loads(s)
181
+ except json.JSONDecodeError:
182
+ return {}
183
+ if isinstance(parsed, dict):
184
+ return parsed
185
+ return {}
186
+ return {}
187
+
188
+
189
+ def coerce_tool_call_entry_to_dict(tc: Any) -> dict[str, Any] | None:
190
+ """Normalize a ``tool_calls`` entry to a plain dict (handles Pydantic models)."""
191
+ if isinstance(tc, dict):
192
+ return tc
193
+ model_dump = getattr(tc, "model_dump", None)
194
+ if callable(model_dump):
195
+ try:
196
+ dumped = model_dump()
197
+ if isinstance(dumped, dict):
198
+ return dumped
199
+ except Exception:
200
+ return None
201
+ return None
202
+
203
+
204
+ def normalize_tool_calls_list(raw: list[Any]) -> list[dict[str, Any]]:
205
+ """Coerce ``msg.tool_calls`` to ``dict`` entries for display logic."""
206
+ out: list[dict[str, Any]] = []
207
+ for tc in raw:
208
+ coerced = coerce_tool_call_entry_to_dict(tc)
209
+ if coerced:
210
+ out.append(coerced)
211
+ return out
212
+
213
+
214
+ def tool_calls_have_any_arg_dict(tc_list: list[Any]) -> bool:
215
+ """True if any tool call has non-empty coerced argument dict."""
216
+ return any(
217
+ coerce_tool_call_args_to_dict(tc.get("args")) for tc in normalize_tool_calls_list(tc_list)
218
+ )
219
+
220
+
221
+ # Argument display mapping for tool calls (see IG-053)
222
+ # Maps tool name to list of argument keys to display (supports multiple args)
223
+ _ARG_DISPLAY_MAP: dict[str, list[str]] = {
224
+ # File operations — deepagents uses ``file_path`` for read/write/edit (see IG-053)
225
+ "read_file": ["file_path", "path"],
226
+ "write_file": ["file_path", "path"],
227
+ "delete_file": ["file_path", "path"],
228
+ "file_info": ["path", "file_path"],
229
+ "edit_file_lines": ["path", "file_path"],
230
+ "insert_lines": ["path", "file_path"],
231
+ "delete_lines": ["path", "file_path"],
232
+ "apply_diff": ["path", "file_path"],
233
+ "edit_file": ["file_path", "path"],
234
+ "ls": ["path", "pattern"], # Multiple args support
235
+ "glob": ["pattern", "path"], # Glob tool
236
+ "list_files": ["pattern"],
237
+ "search_files": ["pattern"],
238
+ # Execution - show command/code
239
+ "run_command": ["command", "args"], # Multiple args support
240
+ "run_python": ["code"],
241
+ "run_background": ["command"],
242
+ "kill_process": ["pid"],
243
+ "execute": ["command"], # Alias for run_command
244
+ # Search - show pattern/query
245
+ "search_web": ["query"],
246
+ "crawl_web": ["url"],
247
+ # Research - show topic
248
+ "research": ["topic", "domain"],
249
+ # Media - show file path
250
+ "analyze_image": ["image_path"],
251
+ "analyze_video": ["video_path"],
252
+ "transcribe_audio": ["audio_path"],
253
+ # Goals - show description or ID
254
+ "create_goal": ["description"],
255
+ "complete_goal": ["goal_id"],
256
+ "fail_goal": ["goal_id"],
257
+ }
258
+
259
+
260
+ def _normalize_tool_name_for_arg_map(tool_name: str) -> str:
261
+ """Map API tool names (any casing) to snake_case for `_ARG_DISPLAY_MAP` lookup."""
262
+ if not tool_name:
263
+ return tool_name
264
+ # PascalCase / camelCase -> snake_case; already-snake names pass through
265
+ return re.sub(r"(?<!^)(?=[A-Z])", "_", tool_name).lower()
266
+
267
+
268
+ def format_tool_call_args(tool_name: str, tool_call: dict[str, Any]) -> str:
269
+ """Format key tool arguments for display (see IG-053).
270
+
271
+ Extracts the most relevant argument(s) for each tool type to show
272
+ in activity events. Supports multiple arguments per tool.
273
+
274
+ Path arguments are converted from deepagents workspace-relative format
275
+ to actual OS paths and abbreviated for display.
276
+
277
+ Args:
278
+ tool_name: Tool name as emitted by the model (snake_case or PascalCase).
279
+ tool_call: Tool call dict with 'args' key containing arguments.
280
+ May also contain '_raw' key with raw args string for fallback display.
281
+
282
+ Returns:
283
+ Formatted argument string like "file_name.md" or "/Users/dev/.../file.md, pattern"
284
+ (without outer parentheses - caller adds them).
285
+ Returns "..." when args are empty but tool is known.
286
+ Returns "" if tool is unknown and no args.
287
+
288
+ Examples:
289
+ >>> format_tool_call_args("read_file", {"args": {"path": "config.yml"}})
290
+ 'config.yml'
291
+ >>> format_tool_call_args("run_command", {"args": {"command": "ls", "args": "-la"}})
292
+ 'ls, -la'
293
+ >>> format_tool_call_args("read_file", {"args": {}})
294
+ '...'
295
+ >>> format_tool_call_args("read_file", {"args": {}, "_raw": '{"path": "file.txt"}'})
296
+ 'file.txt'
297
+ """
298
+ from soothe_sdk import convert_and_abbreviate_path, is_path_argument
299
+
300
+ max_value_length = 40 # Max length for displayed values
301
+
302
+ args = coerce_tool_call_args_to_dict(tool_call.get("args"))
303
+ internal = _normalize_tool_name_for_arg_map(tool_name)
304
+ key_args = _ARG_DISPLAY_MAP.get(internal)
305
+
306
+ # Check for raw args string fallback
307
+ raw_args_str = tool_call.get("_raw") or tool_call.get("raw_args_str", "")
308
+
309
+ # If args are empty, try to extract from raw args string
310
+ if not args and raw_args_str:
311
+ # Try to parse raw string as JSON
312
+ with contextlib.suppress(json.JSONDecodeError):
313
+ parsed_raw = json.loads(raw_args_str)
314
+ if isinstance(parsed_raw, dict):
315
+ args = parsed_raw
316
+
317
+ # If args are still empty but tool is known, show placeholder
318
+ if not args:
319
+ if key_args:
320
+ # Try to extract value from partial raw args string
321
+ if raw_args_str:
322
+ # Try regex extraction for common patterns like "path": "value" or "path":"value"
323
+ for key_arg in key_args:
324
+ # Match patterns like "key": "value" or "key":"value"
325
+ pattern = '"' + key_arg + '"\\s*:\\s*"([^"]+)"'
326
+ match = re.search(pattern, raw_args_str)
327
+ if match:
328
+ value = match.group(1)
329
+ if is_path_argument(key_arg):
330
+ value = convert_and_abbreviate_path(value, max_length=max_value_length)
331
+ return value
332
+ # Also match non-string values like "key": 123 or "key": true
333
+ pattern2 = '"' + key_arg + '"\\s*:\\s*([^,\\}]+)'
334
+ match2 = re.search(pattern2, raw_args_str)
335
+ if match2:
336
+ val = match2.group(1).strip()
337
+ # Truncate if too long
338
+ if len(val) > max_value_length:
339
+ val = val[: max_value_length - 3] + "..."
340
+ return val
341
+ return "..."
342
+ return ""
343
+
344
+ if not key_args:
345
+ return ""
346
+
347
+ # Extract values for all configured argument keys
348
+ values = []
349
+ for key_arg in key_args:
350
+ if key_arg in args:
351
+ value = str(args[key_arg])
352
+ # Convert and abbreviate path arguments
353
+ if is_path_argument(key_arg):
354
+ value = convert_and_abbreviate_path(value, max_value_length)
355
+ elif len(value) > max_value_length:
356
+ # Truncate non-path long values
357
+ value = value[: max_value_length - 3] + "..."
358
+ values.append(value)
359
+
360
+ if not values:
361
+ # Model may use different arg names than _ARG_DISPLAY_MAP; still show something useful.
362
+ if args:
363
+ parts: list[str] = []
364
+ # Skip internal keys like _raw, _internal, etc.
365
+ skip_keys = {"_raw", "_internal", "raw_args_str"}
366
+ for k, v in list(args.items())[:3]:
367
+ if k in skip_keys:
368
+ continue
369
+ s = str(v)
370
+ # Convert and abbreviate path arguments
371
+ if is_path_argument(k):
372
+ s = convert_and_abbreviate_path(s, max_value_length)
373
+ elif len(s) > max_value_length:
374
+ s = s[: max_value_length - 3] + "..."
375
+ parts.append(s)
376
+ if parts:
377
+ return ", ".join(parts)
378
+ # All args were internal keys, check for raw_args_str
379
+ raw = args.get("_raw") or args.get("raw_args_str", "")
380
+ if raw:
381
+ # Try to extract from raw JSON
382
+ for key_arg in key_args:
383
+ pattern = '"' + key_arg + '"\\s*:\\s*"([^"]+)"'
384
+ match = re.search(pattern, raw)
385
+ if match:
386
+ value = match.group(1)
387
+ if is_path_argument(key_arg):
388
+ value = convert_and_abbreviate_path(value, max_value_length)
389
+ return value
390
+ # Known tool but no matching args found
391
+ return "..."
392
+
393
+ return ", ".join(values)
@@ -0,0 +1,173 @@
1
+ """Unified presentation decisions for CLI/TUI surfaces.
2
+
3
+ This module centralizes display-time suppression and summarization rules so
4
+ renderers stay focused on output transport (stdout/stderr or widgets).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import time
11
+ from dataclasses import dataclass
12
+
13
+ from soothe_sdk import log_preview
14
+ from soothe_sdk.verbosity import VerbosityTier, should_show
15
+
16
+ from soothe_cli.shared.display_policy import VerbosityLevel
17
+
18
+
19
+ @dataclass
20
+ class PresentationState:
21
+ """Stateful suppression metadata for presentation decisions."""
22
+
23
+ last_reason_key: str = ""
24
+ last_reason_at_s: float = 0.0
25
+ last_reason_by_step: dict[str, float] | None = None
26
+ final_answer_locked: bool = False
27
+
28
+ # Action deduplication tracking (IG-143)
29
+ last_action_text: str = ""
30
+ last_action_time: float = 0.0
31
+
32
+ def __post_init__(self) -> None:
33
+ """Initialize mutable map defaults safely."""
34
+ if self.last_reason_by_step is None:
35
+ self.last_reason_by_step = {}
36
+
37
+
38
+ class PresentationEngine:
39
+ """Small policy engine for presentation-specific decisions."""
40
+
41
+ _REASON_DEDUP_WINDOW_S = 8.0
42
+ _REASON_STEP_RATE_LIMIT_S = 5.0
43
+ _TOOL_RESULT_MAX_CHARS = 180
44
+ _ACTION_DEDUP_WINDOW_S = 5.0
45
+
46
+ def __init__(self) -> None:
47
+ """Initialize presentation state."""
48
+ self._state = PresentationState()
49
+
50
+ @property
51
+ def final_answer_locked(self) -> bool:
52
+ """True after a custom final/chitchat response was emitted for this turn."""
53
+ return self._state.final_answer_locked
54
+
55
+ def mark_final_answer_locked(self) -> None:
56
+ """Record that the final user-visible answer was already emitted (e.g. custom event)."""
57
+ self._state.final_answer_locked = True
58
+
59
+ def reset_turn(self) -> None:
60
+ """Clear per-turn presentation state (reason dedup, final lock, action dedup)."""
61
+ self._state.last_reason_key = ""
62
+ self._state.last_reason_at_s = 0.0
63
+ self._state.last_reason_by_step.clear()
64
+ self._state.final_answer_locked = False
65
+ self._state.last_action_text = ""
66
+ self._state.last_action_time = 0.0
67
+
68
+ def reset_session(self) -> None:
69
+ """Clear presentation state for a new session (e.g. thread change)."""
70
+ self.reset_turn()
71
+
72
+ def tier_visible(self, tier: VerbosityTier, verbosity: VerbosityLevel) -> bool:
73
+ """Return whether content at the given verbosity tier should display."""
74
+ return should_show(tier, verbosity)
75
+
76
+ def should_emit_reason(
77
+ self,
78
+ *,
79
+ content: str,
80
+ step_id: str | None = None,
81
+ now_s: float | None = None,
82
+ ) -> bool:
83
+ """Decide whether a reason line should be emitted.
84
+
85
+ Applies short-window de-duplication and per-step rate limiting.
86
+ """
87
+ now = now_s if now_s is not None else time.monotonic()
88
+ normalized = self._normalize_reason(content)
89
+ if not normalized:
90
+ return False
91
+
92
+ # Global dedup window for near-identical reason updates.
93
+ if (
94
+ normalized == self._state.last_reason_key
95
+ and (now - self._state.last_reason_at_s) < self._REASON_DEDUP_WINDOW_S
96
+ ):
97
+ return False
98
+
99
+ # Per-step rate limit for noisy repeated updates.
100
+ if step_id:
101
+ last_step_at = self._state.last_reason_by_step.get(step_id, 0.0)
102
+ if (now - last_step_at) < self._REASON_STEP_RATE_LIMIT_S:
103
+ return False
104
+ self._state.last_reason_by_step[step_id] = now
105
+
106
+ self._state.last_reason_key = normalized
107
+ self._state.last_reason_at_s = now
108
+ return True
109
+
110
+ def summarize_tool_result(self, text: str) -> str:
111
+ """Convert noisy tool results into concise one-line summaries."""
112
+ compact = " ".join(text.split())
113
+ if not compact:
114
+ return compact
115
+
116
+ # If payload looks like a huge list/dict dump, replace with a compact marker.
117
+ if (compact.startswith("[") and compact.endswith("]")) or (
118
+ compact.startswith("{") and compact.endswith("}")
119
+ ):
120
+ return "Tool result received (structured payload)."
121
+
122
+ return log_preview(compact, self._TOOL_RESULT_MAX_CHARS)
123
+
124
+ @staticmethod
125
+ def _normalize_reason(content: str) -> str:
126
+ lowered = content.lower().strip()
127
+ lowered = re.sub(r"\(\d+%\s+sure\)", "", lowered)
128
+ return re.sub(r"\s+", " ", lowered)
129
+
130
+ def should_emit_action(
131
+ self,
132
+ *,
133
+ action_text: str,
134
+ now_s: float | None = None,
135
+ ) -> bool:
136
+ """Deduplicate repeated action summaries within 5s window.
137
+
138
+ Args:
139
+ action_text: Action summary text (may include confidence).
140
+ now_s: Optional timestamp (defaults to monotonic time).
141
+
142
+ Returns:
143
+ True if action should be emitted, False if duplicate.
144
+ """
145
+ normalized = self._normalize_action(action_text)
146
+ now = now_s if now_s is not None else time.monotonic()
147
+
148
+ # Dedup identical actions within window
149
+ if (
150
+ normalized == self._state.last_action_text
151
+ and (now - self._state.last_action_time) < self._ACTION_DEDUP_WINDOW_S
152
+ ):
153
+ return False
154
+
155
+ # Update state
156
+ self._state.last_action_text = normalized
157
+ self._state.last_action_time = now
158
+ return True
159
+
160
+ @staticmethod
161
+ def _normalize_action(text: str) -> str:
162
+ """Strip confidence and whitespace for action comparison.
163
+
164
+ Args:
165
+ text: Action text to normalize.
166
+
167
+ Returns:
168
+ Normalized text for deduplication comparison.
169
+ """
170
+ lowered = text.lower().strip()
171
+ # Remove "(XX% sure)" or "(XX% confident)" suffix
172
+ lowered = re.sub(r"\(\d+%\s+(?:sure|confident)\)", "", lowered)
173
+ return re.sub(r"\s+", " ", lowered)
@@ -0,0 +1,80 @@
1
+ """Processor state for unified event handling.
2
+
3
+ This module defines the internal state managed by EventProcessor.
4
+ Renderers should not modify this state directly.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from soothe_sdk import Plan
14
+
15
+
16
+ @dataclass
17
+ class ProcessorState:
18
+ """Internal state for EventProcessor.
19
+
20
+ This state is owned by the processor and should not be
21
+ modified directly by renderers. Renderers can read state
22
+ via processor properties.
23
+ """
24
+
25
+ # Message deduplication - tracks seen message IDs
26
+ seen_message_ids: set[str] = field(default_factory=set)
27
+
28
+ # Streaming tool call arg accumulation (IG-053)
29
+ # Maps tool_call_id -> {'name': str, 'args_str': str, 'emitted': bool, 'is_main': bool}
30
+ pending_tool_calls: dict[str, dict[str, Any]] = field(default_factory=dict)
31
+
32
+ # Namespace -> display name mapping for subagents
33
+ name_map: dict[str, str] = field(default_factory=dict)
34
+
35
+ # Current plan state (updated on plan events)
36
+ current_plan: Plan | None = None
37
+
38
+ # Thread identifier from daemon
39
+ thread_id: str = ""
40
+
41
+ # Multi-step plan suppression flag (suppress step text, show final report)
42
+ multi_step_active: bool = False
43
+
44
+ # Internal context tracking (suppress internal LLM responses)
45
+ internal_context_active: bool = False
46
+
47
+ # Tool call timing for duration display (RFC-0020)
48
+ # Maps tool_call_id -> start_timestamp
49
+ tool_call_start_times: dict[str, float] = field(default_factory=dict)
50
+
51
+ # Deduplication for tool calls (prevents duplicate display)
52
+ emitted_tool_call_ids: set[str] = field(default_factory=set)
53
+
54
+ # Deduplication for tool results (prevents duplicate display)
55
+ emitted_tool_result_ids: set[str] = field(default_factory=set)
56
+
57
+ def reset_turn(self) -> None:
58
+ """Reset per-turn state.
59
+
60
+ Called when a turn ends (status becomes idle/stopped).
61
+ Clears streaming buffers but preserves session state.
62
+ """
63
+ self.pending_tool_calls.clear()
64
+ self.tool_call_start_times.clear()
65
+ self.emitted_tool_call_ids.clear()
66
+ self.emitted_tool_result_ids.clear()
67
+
68
+ def clear_session(self) -> None:
69
+ """Clear all session state.
70
+
71
+ Called when thread changes. Resets everything for fresh session.
72
+ """
73
+ self.seen_message_ids.clear()
74
+ self.pending_tool_calls.clear()
75
+ self.current_plan = None
76
+ self.multi_step_active = False
77
+ self.internal_context_active = False
78
+ self.tool_call_start_times.clear()
79
+ self.emitted_tool_call_ids.clear()
80
+ self.emitted_tool_result_ids.clear()