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,188 @@
1
+ """Shared suppression state for multi-step/agentic loop execution (IG-143).
2
+
3
+ This module provides reusable state tracking and logic for suppressing
4
+ intermediate output during multi-step plan execution and agentic loops.
5
+ Both CLI and TUI renderers use this to prevent redundant intermediate output.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ from soothe_sdk.events import DEFAULT_AGENT_LOOP_MAX_ITERATIONS
13
+
14
+
15
+ @dataclass
16
+ class SuppressionState:
17
+ """Shared state for IG-143 multi-step/agentic suppression.
18
+
19
+ This tracks execution state to suppress intermediate LLM responses,
20
+ tool calls, and tool results during multi-step execution, showing
21
+ only essential progress events and the final aggregated report.
22
+
23
+ Usage:
24
+ # In renderer state:
25
+ suppression: SuppressionState = field(default_factory=SuppressionState)
26
+
27
+ # Check suppression:
28
+ if suppression.should_suppress_output():
29
+ return
30
+
31
+ # Track from events:
32
+ suppression.track_from_event(event_type, data)
33
+
34
+ # Get final report:
35
+ if suppression.should_emit_final_report(event_type, final_stdout):
36
+ text = suppression.get_final_response(final_stdout)
37
+ # ... emit text ...
38
+ """
39
+
40
+ # Multi-step plan suppression flag
41
+ multi_step_active: bool = False
42
+
43
+ # Agentic loop (max_iterations>1): keep suppressing until loop.completed
44
+ # even if status idle/on_turn_end cleared multi_step_active first
45
+ agentic_stdout_suppressed: bool = False
46
+
47
+ # Track if final stdout already emitted (prevent duplicate emission)
48
+ agentic_final_stdout_emitted: bool = False
49
+
50
+ # Accumulated response text for final report
51
+ full_response: list[str] = field(default_factory=list)
52
+
53
+ def should_suppress_output(self) -> bool:
54
+ """Check if output should be suppressed.
55
+
56
+ Returns:
57
+ True if multi-step or agentic loop is active and suppressing.
58
+ """
59
+ return self.multi_step_active or (
60
+ self.agentic_stdout_suppressed and not self.agentic_final_stdout_emitted
61
+ )
62
+
63
+ def should_emit_final_report(
64
+ self,
65
+ event_type: str,
66
+ final_stdout: str,
67
+ ) -> bool:
68
+ """Check if final report should be emitted on loop completion.
69
+
70
+ Args:
71
+ event_type: Event type string.
72
+ final_stdout: Final stdout message from event data.
73
+
74
+ Returns:
75
+ True if final report should be emitted.
76
+ """
77
+ if event_type != "soothe.cognition.agent_loop.completed":
78
+ return False
79
+
80
+ if not final_stdout:
81
+ return False
82
+
83
+ if self.agentic_final_stdout_emitted:
84
+ return False
85
+
86
+ # Mark flag when emitting
87
+ should_emit = self.multi_step_active or self.agentic_stdout_suppressed
88
+ if should_emit:
89
+ self.agentic_final_stdout_emitted = True
90
+
91
+ return should_emit
92
+
93
+ def track_from_event(self, event_type: str, data: dict) -> str:
94
+ """Track suppression state from progress event.
95
+
96
+ Args:
97
+ event_type: Event type string.
98
+ data: Event payload dict.
99
+
100
+ Returns:
101
+ Final stdout message if present (for emission after tracking).
102
+ """
103
+ # Track suppression state from agentic loop start.
104
+ # Suppress intermediate output only when max_iterations > 1 (multi-step mode).
105
+ # In single-step mode (max_iterations == 1), stdout flows normally and final_stdout
106
+ # should NOT be emitted separately to avoid duplication (IG-143 follow-up).
107
+ if event_type == "soothe.cognition.agent_loop.started":
108
+ max_iterations = data.get("max_iterations", DEFAULT_AGENT_LOOP_MAX_ITERATIONS)
109
+ if max_iterations > 1:
110
+ self.multi_step_active = True
111
+ self.agentic_stdout_suppressed = True
112
+ self.agentic_final_stdout_emitted = False
113
+
114
+ # Backup suppression: suppress after iteration 1+ if loop.started was filtered
115
+ if event_type == "soothe.cognition.agent_loop.reasoning":
116
+ try:
117
+ iteration = int(data.get("iteration", 0))
118
+ except (TypeError, ValueError):
119
+ iteration = 0
120
+ if iteration >= 1 and not self.agentic_final_stdout_emitted:
121
+ self.agentic_stdout_suppressed = True
122
+
123
+ # Extract final stdout message from loop completion
124
+ payload = dict(data)
125
+ final_stdout = (payload.pop("final_stdout_message", None) or "").strip()
126
+
127
+ # Note: agentic_final_stdout_emitted flag is set in should_emit_final_report()
128
+ # after checking the condition, not here (order matters for rendering logic)
129
+
130
+ return final_stdout
131
+
132
+ def track_from_plan(self, num_steps: int) -> None:
133
+ """Track suppression state from plan creation.
134
+
135
+ Args:
136
+ num_steps: Number of steps in the plan.
137
+ """
138
+ self.multi_step_active = num_steps > 1
139
+ # max_iterations==1 does not arm agentic_stdout_suppressed in loop.started;
140
+ # multi-step plans still clear multi_step_active on on_turn_end before
141
+ # loop.completed (test-case1).
142
+ if num_steps > 1:
143
+ self.agentic_stdout_suppressed = True
144
+ self.agentic_final_stdout_emitted = False
145
+
146
+ def get_final_response(self, final_stdout: str | None = None) -> str:
147
+ """Get accumulated final response text.
148
+
149
+ Args:
150
+ final_stdout: Optional final stdout message to append.
151
+
152
+ Returns:
153
+ Aggregated response text.
154
+ """
155
+ if final_stdout:
156
+ stripped = final_stdout.strip()
157
+ if stripped:
158
+ self.full_response.append(stripped)
159
+
160
+ # Join accumulated response
161
+ return "".join(self.full_response)
162
+
163
+ def accumulate_text(self, text: str) -> None:
164
+ """Accumulate text for final response.
165
+
166
+ Args:
167
+ text: Text chunk to accumulate.
168
+ """
169
+ self.full_response.append(text)
170
+
171
+ def reset_turn(self) -> None:
172
+ """Reset state for next turn.
173
+
174
+ Called on turn end (status becomes idle/stopped).
175
+ Clears suppression flags and accumulated response.
176
+ """
177
+ self.multi_step_active = False
178
+ self.full_response = []
179
+
180
+ def reset_session(self) -> None:
181
+ """Reset all session state.
182
+
183
+ Called when thread changes.
184
+ """
185
+ self.multi_step_active = False
186
+ self.agentic_stdout_suppressed = False
187
+ self.agentic_final_stdout_emitted = False
188
+ self.full_response = []
@@ -0,0 +1,27 @@
1
+ """Tool-specific formatters for semantic result summarization.
2
+
3
+ This package provides tool-specific formatters that transform raw tool outputs
4
+ into concise, semantic summaries following RFC-0020 event display architecture.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from soothe_cli.shared.tool_formatters.base import BaseFormatter
10
+ from soothe_cli.shared.tool_formatters.execution import ExecutionFormatter
11
+ from soothe_cli.shared.tool_formatters.fallback import FallbackFormatter
12
+ from soothe_cli.shared.tool_formatters.file_ops import FileOpsFormatter
13
+ from soothe_cli.shared.tool_formatters.goal_formatter import GoalFormatter
14
+ from soothe_cli.shared.tool_formatters.media import MediaFormatter
15
+ from soothe_cli.shared.tool_formatters.structured import StructuredFormatter
16
+ from soothe_cli.shared.tool_formatters.web import WebFormatter
17
+
18
+ __all__ = [
19
+ "BaseFormatter",
20
+ "ExecutionFormatter",
21
+ "FallbackFormatter",
22
+ "FileOpsFormatter",
23
+ "GoalFormatter",
24
+ "MediaFormatter",
25
+ "StructuredFormatter",
26
+ "WebFormatter",
27
+ ]
@@ -0,0 +1,109 @@
1
+ """Base formatter interface for tool output summarization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any
7
+
8
+ from soothe_cli.shared.tool_output_formatter import ToolBrief
9
+
10
+ # Byte size thresholds
11
+ _KB = 1024
12
+ _MB = _KB * 1024
13
+ _GB = _MB * 1024
14
+
15
+
16
+ class BaseFormatter(ABC):
17
+ """Abstract base class for tool-specific formatters.
18
+
19
+ Each formatter implements semantic summarization for a category of tools
20
+ (file operations, execution, media, goals, etc.).
21
+ """
22
+
23
+ @abstractmethod
24
+ def format(self, tool_name: str, result: Any) -> ToolBrief:
25
+ """Format tool result into semantic summary.
26
+
27
+ Args:
28
+ tool_name: Name of the tool (e.g., "read_file", "run_command").
29
+ result: Tool result (can be str, dict, ToolOutput, or other).
30
+
31
+ Returns:
32
+ ToolBrief with semantic summary.
33
+
34
+ Raises:
35
+ NotImplementedError: If not implemented by subclass.
36
+ """
37
+ raise NotImplementedError
38
+
39
+ @staticmethod
40
+ def _format_size(size_bytes: int) -> str:
41
+ """Format byte size as human-readable string.
42
+
43
+ Args:
44
+ size_bytes: Size in bytes.
45
+
46
+ Returns:
47
+ Human-readable size string (e.g., "2.3 KB", "1.5 MB").
48
+
49
+ Example:
50
+ >>> BaseFormatter._format_size(1024)
51
+ '1.0 KB'
52
+ >>> BaseFormatter._format_size(1536)
53
+ '1.5 KB'
54
+ >>> BaseFormatter._format_size(1048576)
55
+ '1.0 MB'
56
+ """
57
+ if size_bytes < _KB:
58
+ return f"{size_bytes} B"
59
+ if size_bytes < _MB:
60
+ size_kb = size_bytes / _KB
61
+ return f"{size_kb:.1f} KB"
62
+ if size_bytes < _GB:
63
+ size_mb = size_bytes / _MB
64
+ return f"{size_mb:.1f} MB"
65
+ size_gb = size_bytes / _GB
66
+ return f"{size_gb:.1f} GB"
67
+
68
+ @staticmethod
69
+ def _count_lines(text: str) -> int:
70
+ r"""Count number of lines in text.
71
+
72
+ Args:
73
+ text: Text to count lines in.
74
+
75
+ Returns:
76
+ Number of lines (minimum 1 for non-empty text, 0 for empty text).
77
+
78
+ Example:
79
+ >>> BaseFormatter._count_lines("Hello\nWorld\n")
80
+ 2
81
+ >>> BaseFormatter._count_lines("")
82
+ 0
83
+ >>> BaseFormatter._count_lines("Single line")
84
+ 1
85
+ """
86
+ if not text or not text.strip():
87
+ return 0
88
+ return text.count("\n") + 1 if text.strip() else 0
89
+
90
+ @staticmethod
91
+ def _truncate_text(text: str, max_length: int = 80) -> str:
92
+ """Truncate text to maximum length with ellipsis.
93
+
94
+ Args:
95
+ text: Text to truncate.
96
+ max_length: Maximum length (default 80).
97
+
98
+ Returns:
99
+ Truncated text with "..." if longer than max_length.
100
+
101
+ Example:
102
+ >>> BaseFormatter._truncate_text("Short text", max_length=10)
103
+ 'Short text'
104
+ >>> BaseFormatter._truncate_text("Very long text that needs truncation", max_length=20)
105
+ 'Very long text tha...'
106
+ """
107
+ if len(text) <= max_length:
108
+ return text
109
+ return text[: max_length - 3] + "..."
@@ -0,0 +1,297 @@
1
+ """Formatter for execution tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from soothe_cli.shared.tool_formatters.base import BaseFormatter
8
+ from soothe_cli.shared.tool_output_formatter import ToolBrief
9
+
10
+
11
+ class ExecutionFormatter(BaseFormatter):
12
+ """Formatter for execution tools.
13
+
14
+ Handles: run_command, run_python, run_background, kill_process
15
+
16
+ Provides semantic summaries with success/failure status, PIDs, and error messages.
17
+ """
18
+
19
+ def format(self, tool_name: str, result: Any) -> ToolBrief:
20
+ """Format execution tool result.
21
+
22
+ Args:
23
+ tool_name: Name of the execution tool.
24
+ result: Tool result (string or dict depending on tool).
25
+
26
+ Returns:
27
+ ToolBrief with execution summary.
28
+
29
+ Raises:
30
+ ValueError: If tool_name is not a recognized execution tool.
31
+
32
+ Example:
33
+ >>> formatter = ExecutionFormatter()
34
+ >>> brief = formatter.format("run_command", "output text")
35
+ >>> brief.to_display()
36
+ '✓ Done'
37
+ """
38
+ # Normalize tool name
39
+ normalized = tool_name.lower().replace("-", "_").replace(" ", "_")
40
+
41
+ # Route to specific formatter
42
+ if normalized == "run_command":
43
+ return self._format_run_command(result)
44
+ if normalized == "run_python":
45
+ return self._format_run_python(result)
46
+ if normalized == "run_background":
47
+ return self._format_run_background(result)
48
+ if normalized == "kill_process":
49
+ return self._format_kill_process(result)
50
+ msg = f"Unknown execution tool: {tool_name}"
51
+ raise ValueError(msg)
52
+
53
+ def _format_run_command(self, result: str) -> ToolBrief:
54
+ """Format run_command result.
55
+
56
+ Shows "Done" for success or "Failed: {reason}" for errors.
57
+
58
+ Args:
59
+ result: Command output or error string.
60
+
61
+ Returns:
62
+ ToolBrief with execution status.
63
+
64
+ Example:
65
+ >>> brief = formatter._format_run_command("command output")
66
+ >>> brief.summary
67
+ 'Done'
68
+ >>> brief = formatter._format_run_command("Error: command not found")
69
+ >>> brief.summary
70
+ 'Failed'
71
+ >>> brief.detail
72
+ 'command not found'
73
+ """
74
+ # Check for error indicators
75
+ if result.startswith("Error:"):
76
+ error_msg = result[6:].strip() # Remove "Error:" prefix
77
+ return ToolBrief(
78
+ icon="✗",
79
+ summary="Failed",
80
+ detail=self._truncate_text(error_msg, 80),
81
+ metrics={"error": True},
82
+ )
83
+
84
+ # Check for other error patterns
85
+ error_indicators = ["failed:", "exception:", "traceback", "command not found"]
86
+ result_lower = result.lower()
87
+ if any(indicator in result_lower for indicator in error_indicators):
88
+ # Extract first line as error message
89
+ first_line = result.partition("\n")[0].strip()
90
+ return ToolBrief(
91
+ icon="✗",
92
+ summary="Failed",
93
+ detail=self._truncate_text(first_line, 80),
94
+ metrics={"error": True},
95
+ )
96
+
97
+ # Success
98
+ output_size = len(result)
99
+ detail = None
100
+
101
+ # Show output size if there's substantial output
102
+ if output_size > 0:
103
+ output_chars = f"{output_size} chars"
104
+ detail = f"{output_chars} output"
105
+ else:
106
+ detail = "no output"
107
+
108
+ return ToolBrief(
109
+ icon="✓",
110
+ summary="Done",
111
+ detail=detail,
112
+ metrics={"output_size": output_size},
113
+ )
114
+
115
+ def _format_run_python(self, result: dict[str, Any]) -> ToolBrief:
116
+ """Format run_python result.
117
+
118
+ Shows execution status with return value type.
119
+
120
+ Args:
121
+ result: Dict with 'success', 'output', 'result', 'error' fields.
122
+
123
+ Returns:
124
+ ToolBrief with execution status.
125
+
126
+ Example:
127
+ >>> brief = formatter._format_run_python({"success": True, "result": 42})
128
+ >>> brief.summary
129
+ 'Executed'
130
+ >>> brief.detail
131
+ 'returned: int'
132
+ """
133
+ # Handle dict result
134
+ if isinstance(result, dict):
135
+ success = result.get("success", True)
136
+ error = result.get("error")
137
+ return_value = result.get("result")
138
+
139
+ if not success or error:
140
+ error_msg = str(error) if error else "Execution failed"
141
+ return ToolBrief(
142
+ icon="✗",
143
+ summary="Execution failed",
144
+ detail=self._truncate_text(error_msg, 80),
145
+ metrics={"error": True},
146
+ )
147
+
148
+ # Success
149
+ detail = None
150
+ if return_value is not None:
151
+ return_type = type(return_value).__name__
152
+ detail = f"returned: {return_type}"
153
+ else:
154
+ output = result.get("output", "")
155
+ detail = f"{len(output)} chars output" if output else "no output"
156
+
157
+ return ToolBrief(
158
+ icon="✓",
159
+ summary="Executed",
160
+ detail=detail,
161
+ metrics={"has_return": return_value is not None},
162
+ )
163
+
164
+ # Handle string result (fallback)
165
+ if isinstance(result, str):
166
+ if "error" in result.lower() or "failed" in result.lower():
167
+ return ToolBrief(
168
+ icon="✗",
169
+ summary="Execution failed",
170
+ detail=self._truncate_text(result, 80),
171
+ metrics={"error": True},
172
+ )
173
+
174
+ return ToolBrief(
175
+ icon="✓",
176
+ summary="Executed",
177
+ detail=f"{len(result)} chars output",
178
+ metrics={},
179
+ )
180
+
181
+ # Unknown type
182
+ return ToolBrief(
183
+ icon="✓",
184
+ summary="Executed",
185
+ detail=None,
186
+ metrics={},
187
+ )
188
+
189
+ def _format_run_background(self, result: dict[str, Any]) -> ToolBrief:
190
+ """Format run_background result.
191
+
192
+ Shows PID of started background process.
193
+
194
+ Args:
195
+ result: Dict with 'pid', 'status', 'message' fields.
196
+
197
+ Returns:
198
+ ToolBrief with PID.
199
+
200
+ Example:
201
+ >>> brief = formatter._format_run_background({"pid": 12345, "status": "running"})
202
+ >>> brief.summary
203
+ 'Started PID 12345'
204
+ """
205
+ # Handle dict result
206
+ if isinstance(result, dict):
207
+ pid = result.get("pid")
208
+ status = result.get("status")
209
+ message = result.get("message")
210
+
211
+ if status == "error" or not pid:
212
+ error_msg = message or "Failed to start background process"
213
+ return ToolBrief(
214
+ icon="✗",
215
+ summary="Start failed",
216
+ detail=self._truncate_text(error_msg, 80),
217
+ metrics={"error": True},
218
+ )
219
+
220
+ # Success
221
+ return ToolBrief(
222
+ icon="✓",
223
+ summary=f"Started PID {pid}",
224
+ detail=None,
225
+ metrics={"pid": pid, "status": status},
226
+ )
227
+
228
+ # Handle string result (fallback)
229
+ if isinstance(result, str):
230
+ if "error" in result.lower() or "failed" in result.lower():
231
+ return ToolBrief(
232
+ icon="✗",
233
+ summary="Start failed",
234
+ detail=self._truncate_text(result, 80),
235
+ metrics={"error": True},
236
+ )
237
+
238
+ return ToolBrief(
239
+ icon="✓",
240
+ summary="Started",
241
+ detail=self._truncate_text(result, 80),
242
+ metrics={},
243
+ )
244
+
245
+ # Unknown type
246
+ return ToolBrief(
247
+ icon="✓",
248
+ summary="Started",
249
+ detail=None,
250
+ metrics={},
251
+ )
252
+
253
+ def _format_kill_process(self, result: str) -> ToolBrief:
254
+ """Format kill_process result.
255
+
256
+ Shows termination status with PID.
257
+
258
+ Args:
259
+ result: Success message or error string.
260
+
261
+ Returns:
262
+ ToolBrief with termination status.
263
+
264
+ Example:
265
+ >>> brief = formatter._format_kill_process("Process 12345 terminated")
266
+ >>> brief.summary
267
+ 'Terminated PID 12345'
268
+ """
269
+ # Check for error
270
+ if "error" in result.lower() or "failed" in result.lower() or "not found" in result.lower():
271
+ return ToolBrief(
272
+ icon="✗",
273
+ summary="Termination failed",
274
+ detail=self._truncate_text(result, 80),
275
+ metrics={"error": True},
276
+ )
277
+
278
+ # Try to extract PID from result
279
+ import re
280
+
281
+ pid_match = re.search(r"PID\s+(\d+)", result)
282
+ if pid_match:
283
+ pid = pid_match.group(1)
284
+ return ToolBrief(
285
+ icon="✓",
286
+ summary=f"Terminated PID {pid}",
287
+ detail=None,
288
+ metrics={"pid": int(pid)},
289
+ )
290
+
291
+ # Generic success
292
+ return ToolBrief(
293
+ icon="✓",
294
+ summary="Terminated",
295
+ detail=None,
296
+ metrics={},
297
+ )