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.
- soothe_cli/__init__.py +5 -0
- soothe_cli/cli/__init__.py +1 -0
- soothe_cli/cli/commands/__init__.py +1 -0
- soothe_cli/cli/commands/autopilot_cmd.py +410 -0
- soothe_cli/cli/commands/config_cmd.py +277 -0
- soothe_cli/cli/commands/run_cmd.py +87 -0
- soothe_cli/cli/commands/status_cmd.py +121 -0
- soothe_cli/cli/commands/subagent_names.py +17 -0
- soothe_cli/cli/commands/thread_cmd.py +657 -0
- soothe_cli/cli/execution/__init__.py +6 -0
- soothe_cli/cli/execution/daemon.py +194 -0
- soothe_cli/cli/execution/headless.py +99 -0
- soothe_cli/cli/execution/launcher.py +31 -0
- soothe_cli/cli/main.py +509 -0
- soothe_cli/cli/renderer.py +444 -0
- soothe_cli/cli/stream/__init__.py +17 -0
- soothe_cli/cli/stream/context.py +138 -0
- soothe_cli/cli/stream/display_line.py +83 -0
- soothe_cli/cli/stream/formatter.py +412 -0
- soothe_cli/cli/stream/pipeline.py +521 -0
- soothe_cli/cli/utils.py +46 -0
- soothe_cli/config/__init__.py +5 -0
- soothe_cli/config/cli_config.py +155 -0
- soothe_cli/plan/__init__.py +5 -0
- soothe_cli/plan/rich_tree.py +54 -0
- soothe_cli/shared/__init__.py +107 -0
- soothe_cli/shared/command_router.py +246 -0
- soothe_cli/shared/config_loader.py +68 -0
- soothe_cli/shared/display_policy.py +413 -0
- soothe_cli/shared/essential_events.py +68 -0
- soothe_cli/shared/event_processor.py +823 -0
- soothe_cli/shared/message_processing.py +393 -0
- soothe_cli/shared/presentation_engine.py +173 -0
- soothe_cli/shared/processor_state.py +80 -0
- soothe_cli/shared/renderer_protocol.py +158 -0
- soothe_cli/shared/rendering.py +43 -0
- soothe_cli/shared/slash_commands.py +354 -0
- soothe_cli/shared/subagent_routing.py +63 -0
- soothe_cli/shared/suppression_state.py +188 -0
- soothe_cli/shared/tool_formatters/__init__.py +27 -0
- soothe_cli/shared/tool_formatters/base.py +109 -0
- soothe_cli/shared/tool_formatters/execution.py +297 -0
- soothe_cli/shared/tool_formatters/fallback.py +128 -0
- soothe_cli/shared/tool_formatters/file_ops.py +299 -0
- soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
- soothe_cli/shared/tool_formatters/media.py +291 -0
- soothe_cli/shared/tool_formatters/structured.py +202 -0
- soothe_cli/shared/tool_formatters/web.py +143 -0
- soothe_cli/shared/tool_output_formatter.py +227 -0
- soothe_cli/shared/tui_trace_log.py +40 -0
- soothe_cli/tui/__init__.py +5 -0
- soothe_cli/tui/_ask_user_types.py +50 -0
- soothe_cli/tui/_cli_context.py +27 -0
- soothe_cli/tui/_env_vars.py +56 -0
- soothe_cli/tui/_session_stats.py +114 -0
- soothe_cli/tui/_version.py +21 -0
- soothe_cli/tui/app.py +4992 -0
- soothe_cli/tui/app.tcss +302 -0
- soothe_cli/tui/command_registry.py +310 -0
- soothe_cli/tui/config.py +2381 -0
- soothe_cli/tui/daemon_session.py +233 -0
- soothe_cli/tui/file_ops.py +409 -0
- soothe_cli/tui/formatting.py +28 -0
- soothe_cli/tui/hooks.py +23 -0
- soothe_cli/tui/input.py +782 -0
- soothe_cli/tui/media_utils.py +471 -0
- soothe_cli/tui/model_config.py +518 -0
- soothe_cli/tui/output.py +69 -0
- soothe_cli/tui/project_utils.py +188 -0
- soothe_cli/tui/sessions.py +1248 -0
- soothe_cli/tui/skills/__init__.py +5 -0
- soothe_cli/tui/skills/invocation.py +74 -0
- soothe_cli/tui/skills/load.py +93 -0
- soothe_cli/tui/textual_adapter.py +1430 -0
- soothe_cli/tui/theme.py +838 -0
- soothe_cli/tui/tool_display.py +297 -0
- soothe_cli/tui/unicode_security.py +502 -0
- soothe_cli/tui/update_check.py +447 -0
- soothe_cli/tui/widgets/__init__.py +9 -0
- soothe_cli/tui/widgets/_links.py +63 -0
- soothe_cli/tui/widgets/approval.py +430 -0
- soothe_cli/tui/widgets/ask_user.py +392 -0
- soothe_cli/tui/widgets/autocomplete.py +666 -0
- soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
- soothe_cli/tui/widgets/autopilot_screen.py +64 -0
- soothe_cli/tui/widgets/chat_input.py +1834 -0
- soothe_cli/tui/widgets/clipboard.py +128 -0
- soothe_cli/tui/widgets/diff.py +240 -0
- soothe_cli/tui/widgets/editor.py +140 -0
- soothe_cli/tui/widgets/history.py +221 -0
- soothe_cli/tui/widgets/loading.py +194 -0
- soothe_cli/tui/widgets/mcp_viewer.py +352 -0
- soothe_cli/tui/widgets/message_store.py +693 -0
- soothe_cli/tui/widgets/messages.py +1720 -0
- soothe_cli/tui/widgets/model_selector.py +988 -0
- soothe_cli/tui/widgets/notification_settings.py +155 -0
- soothe_cli/tui/widgets/status.py +403 -0
- soothe_cli/tui/widgets/theme_selector.py +158 -0
- soothe_cli/tui/widgets/thread_selector.py +1865 -0
- soothe_cli/tui/widgets/tool_renderers.py +148 -0
- soothe_cli/tui/widgets/tool_widgets.py +254 -0
- soothe_cli/tui/widgets/tools.py +165 -0
- soothe_cli/tui/widgets/welcome.py +330 -0
- soothe_cli-0.1.0.dist-info/METADATA +100 -0
- soothe_cli-0.1.0.dist-info/RECORD +107 -0
- soothe_cli-0.1.0.dist-info/WHEEL +4 -0
- soothe_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Formatter for ToolOutput structured results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from soothe_sdk.protocol import preview_first
|
|
8
|
+
|
|
9
|
+
from soothe_cli.shared.tool_formatters.base import BaseFormatter
|
|
10
|
+
from soothe_cli.shared.tool_output_formatter import ToolBrief
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StructuredFormatter(BaseFormatter):
|
|
14
|
+
"""Formatter for ToolOutput structured results.
|
|
15
|
+
|
|
16
|
+
Handles results from the agentic loop (RFC-0008) that use ToolOutput schema
|
|
17
|
+
with success/error classification and error types.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def format(self, tool_name: str, result: Any) -> ToolBrief:
|
|
21
|
+
"""Format ToolOutput structured result.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
tool_name: Name of the tool.
|
|
25
|
+
result: ToolOutput object with success, data, error, error_type fields.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
ToolBrief with structured summary.
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
>>> from soothe_sdk import ToolOutput
|
|
32
|
+
>>> formatter = StructuredFormatter()
|
|
33
|
+
>>> output = ToolOutput.ok(data="file content")
|
|
34
|
+
>>> brief = formatter.format("read_file", output)
|
|
35
|
+
>>> brief.icon
|
|
36
|
+
'✓'
|
|
37
|
+
"""
|
|
38
|
+
# Import ToolOutput (may not be available in all contexts)
|
|
39
|
+
try:
|
|
40
|
+
from soothe_sdk import ToolOutput
|
|
41
|
+
|
|
42
|
+
if not isinstance(result, ToolOutput):
|
|
43
|
+
# Not a ToolOutput - should not happen if classifier works correctly
|
|
44
|
+
# Fallback to simple formatting
|
|
45
|
+
return self._format_unknown(result)
|
|
46
|
+
|
|
47
|
+
# Handle silent failure (success=True but no data)
|
|
48
|
+
if result.is_silent_failure():
|
|
49
|
+
return ToolBrief(
|
|
50
|
+
icon="⚠",
|
|
51
|
+
summary="No result",
|
|
52
|
+
detail="Tool succeeded but returned no data",
|
|
53
|
+
metrics={"silent_failure": True},
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Handle failure
|
|
57
|
+
if not result.success:
|
|
58
|
+
error_msg = preview_first(result.error, 80) if result.error else "Unknown error"
|
|
59
|
+
error_type = result.error_type or "unknown"
|
|
60
|
+
|
|
61
|
+
return ToolBrief(
|
|
62
|
+
icon="✗",
|
|
63
|
+
summary="Failed",
|
|
64
|
+
detail=error_msg,
|
|
65
|
+
metrics={"error": True, "error_type": error_type},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Success - try to extract meaningful summary from data
|
|
69
|
+
return self._format_success(tool_name, result.data)
|
|
70
|
+
|
|
71
|
+
except ImportError:
|
|
72
|
+
# ToolOutput not available - fallback
|
|
73
|
+
return self._format_unknown(result)
|
|
74
|
+
|
|
75
|
+
def _format_success(self, tool_name: str, data: Any) -> ToolBrief: # noqa: ARG002
|
|
76
|
+
"""Format successful ToolOutput result.
|
|
77
|
+
|
|
78
|
+
Attempts to extract meaningful summary from data.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
tool_name: Name of the tool (unused, for future tool-specific formatting).
|
|
82
|
+
data: Result data (can be any type).
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
ToolBrief with success summary.
|
|
86
|
+
"""
|
|
87
|
+
# Handle None
|
|
88
|
+
if data is None:
|
|
89
|
+
return ToolBrief(
|
|
90
|
+
icon="✓",
|
|
91
|
+
summary="Completed",
|
|
92
|
+
detail="no data",
|
|
93
|
+
metrics={"has_data": False},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Handle string data
|
|
97
|
+
if isinstance(data, str):
|
|
98
|
+
size_bytes = len(data.encode("utf-8"))
|
|
99
|
+
size_str = self._format_size(size_bytes)
|
|
100
|
+
lines = self._count_lines(data)
|
|
101
|
+
|
|
102
|
+
summary = f"Read {size_str}"
|
|
103
|
+
detail = f"{lines} lines" if lines > 0 else "empty"
|
|
104
|
+
|
|
105
|
+
return ToolBrief(
|
|
106
|
+
icon="✓",
|
|
107
|
+
summary=summary,
|
|
108
|
+
detail=detail,
|
|
109
|
+
metrics={"size_bytes": size_bytes, "lines": lines},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Handle dict data
|
|
113
|
+
if isinstance(data, dict):
|
|
114
|
+
field_count = len(data)
|
|
115
|
+
|
|
116
|
+
# Try to extract common fields
|
|
117
|
+
if "id" in data:
|
|
118
|
+
obj_id = data["id"]
|
|
119
|
+
return ToolBrief(
|
|
120
|
+
icon="✓",
|
|
121
|
+
summary="Completed",
|
|
122
|
+
detail=f"id: {obj_id}",
|
|
123
|
+
metrics={"has_id": True},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return ToolBrief(
|
|
127
|
+
icon="✓",
|
|
128
|
+
summary="Completed",
|
|
129
|
+
detail=f"{field_count} fields",
|
|
130
|
+
metrics={"field_count": field_count},
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Handle list data
|
|
134
|
+
if isinstance(data, list):
|
|
135
|
+
count = len(data)
|
|
136
|
+
return ToolBrief(
|
|
137
|
+
icon="✓",
|
|
138
|
+
summary="Completed",
|
|
139
|
+
detail=f"{count} items",
|
|
140
|
+
metrics={"count": count},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Handle other types
|
|
144
|
+
data_type = type(data).__name__
|
|
145
|
+
return ToolBrief(
|
|
146
|
+
icon="✓",
|
|
147
|
+
summary="Completed",
|
|
148
|
+
detail=f"data: {data_type}",
|
|
149
|
+
metrics={"data_type": data_type},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def _format_unknown(self, result: Any) -> ToolBrief:
|
|
153
|
+
"""Format unknown result type.
|
|
154
|
+
|
|
155
|
+
Fallback for non-ToolOutput results.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
result: Unknown result type.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
ToolBrief with generic summary.
|
|
162
|
+
"""
|
|
163
|
+
if isinstance(result, str):
|
|
164
|
+
if "error" in result.lower() or "failed" in result.lower():
|
|
165
|
+
return ToolBrief(
|
|
166
|
+
icon="✗",
|
|
167
|
+
summary="Failed",
|
|
168
|
+
detail=self._truncate_text(result, 80),
|
|
169
|
+
metrics={"error": True},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return ToolBrief(
|
|
173
|
+
icon="✓",
|
|
174
|
+
summary="Completed",
|
|
175
|
+
detail=f"{len(result)} chars",
|
|
176
|
+
metrics={},
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if isinstance(result, dict):
|
|
180
|
+
if "error" in result:
|
|
181
|
+
error_msg = preview_first(str(result["error"]), 80)
|
|
182
|
+
return ToolBrief(
|
|
183
|
+
icon="✗",
|
|
184
|
+
summary="Failed",
|
|
185
|
+
detail=error_msg,
|
|
186
|
+
metrics={"error": True},
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return ToolBrief(
|
|
190
|
+
icon="✓",
|
|
191
|
+
summary="Completed",
|
|
192
|
+
detail=f"{len(result)} fields",
|
|
193
|
+
metrics={},
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Generic fallback
|
|
197
|
+
return ToolBrief(
|
|
198
|
+
icon="✓",
|
|
199
|
+
summary="Completed",
|
|
200
|
+
detail=None,
|
|
201
|
+
metrics={},
|
|
202
|
+
)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Formatter for web search and crawl 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 WebFormatter(BaseFormatter):
|
|
12
|
+
"""Formatter for web operation tools.
|
|
13
|
+
|
|
14
|
+
Handles: search_web, crawl_web
|
|
15
|
+
|
|
16
|
+
Provides semantic summaries with result counts, URLs, and content metrics.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def format(self, tool_name: str, result: Any) -> ToolBrief:
|
|
20
|
+
r"""Format web tool result.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
tool_name: Name of the web tool.
|
|
24
|
+
result: Tool result (typically string with search results or crawled content).
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
ToolBrief with web operation summary.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
ValueError: If tool_name is not a recognized web tool.
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
>>> formatter = WebFormatter()
|
|
34
|
+
>>> brief = formatter.format("search_web", "1. Result\n2. Result")
|
|
35
|
+
>>> brief.summary
|
|
36
|
+
'Found 2 results'
|
|
37
|
+
"""
|
|
38
|
+
# Normalize tool name
|
|
39
|
+
normalized = tool_name.lower().replace("-", "_").replace(" ", "_")
|
|
40
|
+
|
|
41
|
+
# Route to specific formatter
|
|
42
|
+
if normalized == "search_web":
|
|
43
|
+
return self._format_search_web(result)
|
|
44
|
+
if normalized == "crawl_web":
|
|
45
|
+
return self._format_crawl_web(result)
|
|
46
|
+
|
|
47
|
+
msg = f"Unknown web tool: {tool_name}"
|
|
48
|
+
raise ValueError(msg)
|
|
49
|
+
|
|
50
|
+
def _format_search_web(self, result: str) -> ToolBrief:
|
|
51
|
+
r"""Format search_web result.
|
|
52
|
+
|
|
53
|
+
Shows count of search results found.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
result: Search results string with titles, URLs, snippets.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
ToolBrief with result count.
|
|
60
|
+
|
|
61
|
+
Example:
|
|
62
|
+
>>> brief = formatter._format_search_web("1. Example\n2. Another")
|
|
63
|
+
>>> brief.summary
|
|
64
|
+
'Found 2 results'
|
|
65
|
+
"""
|
|
66
|
+
# Check for error
|
|
67
|
+
if "error" in result.lower() or "failed" in result.lower():
|
|
68
|
+
return ToolBrief(
|
|
69
|
+
icon="✗",
|
|
70
|
+
summary="Search failed",
|
|
71
|
+
detail=self._truncate_text(result, 80),
|
|
72
|
+
metrics={"error": True},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Count results (non-empty lines or result sections)
|
|
76
|
+
lines = [line for line in result.split("\n") if line.strip()]
|
|
77
|
+
|
|
78
|
+
# Try to detect result count patterns
|
|
79
|
+
# Pattern: numbered results "1. Title" "2. Title"
|
|
80
|
+
numbered_results = [
|
|
81
|
+
line for line in lines if len(line) > 0 and line[0].isdigit() and "." in line[:3]
|
|
82
|
+
]
|
|
83
|
+
count = len(numbered_results) if numbered_results else max(1, len(lines) // 3)
|
|
84
|
+
|
|
85
|
+
summary = f"Found {count} result{'s' if count != 1 else ''}"
|
|
86
|
+
|
|
87
|
+
# Show first URL or title as detail if available
|
|
88
|
+
first_line = lines[0] if lines else ""
|
|
89
|
+
detail = self._truncate_text(first_line, 80) if first_line else None
|
|
90
|
+
|
|
91
|
+
return ToolBrief(
|
|
92
|
+
icon="✓",
|
|
93
|
+
summary=summary,
|
|
94
|
+
detail=detail,
|
|
95
|
+
metrics={"count": count},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def _format_crawl_web(self, result: str) -> ToolBrief:
|
|
99
|
+
"""Format crawl_web result.
|
|
100
|
+
|
|
101
|
+
Shows content size and word/line count.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
result: Crawled content string.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
ToolBrief with content metrics.
|
|
108
|
+
|
|
109
|
+
Example:
|
|
110
|
+
>>> brief = formatter._format_crawl_web("Article content...")
|
|
111
|
+
>>> brief.summary
|
|
112
|
+
'Crawled 2.3 KB'
|
|
113
|
+
>>> brief.detail
|
|
114
|
+
'450 words'
|
|
115
|
+
"""
|
|
116
|
+
# Check for error
|
|
117
|
+
if "error" in result.lower() or "failed" in result.lower():
|
|
118
|
+
return ToolBrief(
|
|
119
|
+
icon="✗",
|
|
120
|
+
summary="Crawl failed",
|
|
121
|
+
detail=self._truncate_text(result, 80),
|
|
122
|
+
metrics={"error": True},
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Calculate metrics
|
|
126
|
+
size_bytes = len(result.encode("utf-8"))
|
|
127
|
+
size_str = self._format_size(size_bytes)
|
|
128
|
+
|
|
129
|
+
# Count words and lines
|
|
130
|
+
words = len(result.split())
|
|
131
|
+
lines = self._count_lines(result)
|
|
132
|
+
|
|
133
|
+
summary = f"Crawled {size_str}"
|
|
134
|
+
|
|
135
|
+
# Show word count as detail
|
|
136
|
+
detail = f"{words} words"
|
|
137
|
+
|
|
138
|
+
return ToolBrief(
|
|
139
|
+
icon="✓",
|
|
140
|
+
summary=summary,
|
|
141
|
+
detail=detail,
|
|
142
|
+
metrics={"size_bytes": size_bytes, "words": words, "lines": lines},
|
|
143
|
+
)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Tool output formatter for semantic result summarization (RFC-0020).
|
|
2
|
+
|
|
3
|
+
This module provides a formatter-based pipeline that transforms raw tool outputs
|
|
4
|
+
into concise, semantic summaries following RFC-0020 event display architecture.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ToolBrief:
|
|
18
|
+
"""Structured summary of tool execution result.
|
|
19
|
+
|
|
20
|
+
Follows RFC-0020 two-level tree display pattern with maximum lengths
|
|
21
|
+
enforced for terminal display.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
icon: Status indicator (✓, ✗, ⚠).
|
|
25
|
+
summary: One-line summary (max 50 characters).
|
|
26
|
+
detail: Optional detail line (max 80 characters).
|
|
27
|
+
metrics: Optional metadata (size, duration, count, etc.).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
icon: str
|
|
31
|
+
summary: str
|
|
32
|
+
detail: str | None = None
|
|
33
|
+
metrics: dict[str, Any] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
def to_display(self) -> str:
|
|
36
|
+
"""Format as RFC-0020 display string.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Formatted string: "icon summary (detail)" or "icon summary".
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
>>> brief = ToolBrief(icon="✓", summary="Read 2.3 KB", detail="42 lines")
|
|
43
|
+
>>> brief.to_display()
|
|
44
|
+
'✓ Read 2.3 KB (42 lines)'
|
|
45
|
+
"""
|
|
46
|
+
result = f"{self.icon} {self.summary}"
|
|
47
|
+
if self.detail:
|
|
48
|
+
result += f" ({self.detail})"
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
"""Enforce RFC-0020 length constraints."""
|
|
53
|
+
# Maximum summary length: 50 characters
|
|
54
|
+
max_summary_len = 50
|
|
55
|
+
if len(self.summary) > max_summary_len:
|
|
56
|
+
self.summary = self.summary[: max_summary_len - 3] + "..."
|
|
57
|
+
|
|
58
|
+
# Maximum detail length: 80 characters
|
|
59
|
+
max_detail_len = 80
|
|
60
|
+
if self.detail and len(self.detail) > max_detail_len:
|
|
61
|
+
self.detail = self.detail[: max_detail_len - 3] + "..."
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Tool category mapping for classifier
|
|
65
|
+
TOOL_CATEGORIES: dict[str, str] = {
|
|
66
|
+
# File operations
|
|
67
|
+
"read_file": "file_ops",
|
|
68
|
+
"write_file": "file_ops",
|
|
69
|
+
"delete_file": "file_ops",
|
|
70
|
+
"list_files": "file_ops",
|
|
71
|
+
"search_files": "file_ops",
|
|
72
|
+
"glob": "file_ops",
|
|
73
|
+
"ls": "file_ops",
|
|
74
|
+
# Execution
|
|
75
|
+
"run_command": "execution",
|
|
76
|
+
"run_python": "execution",
|
|
77
|
+
"run_background": "execution",
|
|
78
|
+
"kill_process": "execution",
|
|
79
|
+
# Media
|
|
80
|
+
"transcribe_audio": "media",
|
|
81
|
+
"get_video_info": "media",
|
|
82
|
+
"analyze_image": "media",
|
|
83
|
+
# Goals
|
|
84
|
+
"create_goal": "goals",
|
|
85
|
+
"list_goals": "goals",
|
|
86
|
+
"complete_goal": "goals",
|
|
87
|
+
"fail_goal": "goals",
|
|
88
|
+
# Web
|
|
89
|
+
"search_web": "web",
|
|
90
|
+
"crawl_web": "web",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def classify_tool(tool_name: str) -> str:
|
|
95
|
+
"""Classify tool into category based on name.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
tool_name: Name of the tool (e.g., "read_file", "run_command").
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Tool category (e.g., "file_ops", "execution", "media", "goals", "web", "unknown").
|
|
102
|
+
|
|
103
|
+
Example:
|
|
104
|
+
>>> classify_tool("read_file")
|
|
105
|
+
'file_ops'
|
|
106
|
+
>>> classify_tool("unknown_tool")
|
|
107
|
+
'unknown'
|
|
108
|
+
"""
|
|
109
|
+
# Normalize tool name to snake_case (handle variations)
|
|
110
|
+
normalized = tool_name.lower().replace("-", "_").replace(" ", "_")
|
|
111
|
+
|
|
112
|
+
# Look up in category mapping
|
|
113
|
+
return TOOL_CATEGORIES.get(normalized, "unknown")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def detect_result_type(result: Any) -> str:
|
|
117
|
+
"""Detect result type for routing to appropriate formatter.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
result: Tool result (can be str, dict, ToolOutput, or other).
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Result type string: "tool_output", "dict", "str", or "unknown".
|
|
124
|
+
|
|
125
|
+
Example:
|
|
126
|
+
>>> detect_result_type("some string")
|
|
127
|
+
'str'
|
|
128
|
+
>>> detect_result_type({"success": True})
|
|
129
|
+
'dict'
|
|
130
|
+
"""
|
|
131
|
+
# Check for ToolOutput (from agentic loop)
|
|
132
|
+
# Import here to avoid circular imports
|
|
133
|
+
try:
|
|
134
|
+
from soothe_sdk import ToolOutput
|
|
135
|
+
|
|
136
|
+
if isinstance(result, ToolOutput):
|
|
137
|
+
return "tool_output"
|
|
138
|
+
except ImportError:
|
|
139
|
+
pass # ToolOutput not available, skip check
|
|
140
|
+
|
|
141
|
+
# Check standard types
|
|
142
|
+
if isinstance(result, dict):
|
|
143
|
+
return "dict"
|
|
144
|
+
if isinstance(result, str):
|
|
145
|
+
return "str"
|
|
146
|
+
return "unknown"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ToolOutputFormatter:
|
|
150
|
+
"""Main formatter for tool output summarization.
|
|
151
|
+
|
|
152
|
+
Coordinates classification and routing to tool-specific formatters
|
|
153
|
+
with fallback handling for unknown tools.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
def format(self, tool_name: str, result: Any) -> ToolBrief:
|
|
157
|
+
r"""Format tool result into semantic summary.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
tool_name: Name of the tool (e.g., "read_file", "run_command").
|
|
161
|
+
result: Tool result (can be str, dict, ToolOutput, or other).
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
ToolBrief with semantic summary.
|
|
165
|
+
|
|
166
|
+
Example:
|
|
167
|
+
>>> formatter = ToolOutputFormatter()
|
|
168
|
+
>>> brief = formatter.format("read_file", "Hello\nWorld\n")
|
|
169
|
+
>>> brief.to_display()
|
|
170
|
+
'✓ Read 12 B (2 lines)'
|
|
171
|
+
"""
|
|
172
|
+
# Classify tool
|
|
173
|
+
category = classify_tool(tool_name)
|
|
174
|
+
|
|
175
|
+
# Detect result type
|
|
176
|
+
result_type = detect_result_type(result)
|
|
177
|
+
|
|
178
|
+
# Route to appropriate formatter
|
|
179
|
+
try:
|
|
180
|
+
# Import formatters (lazy import to avoid circular dependencies)
|
|
181
|
+
from soothe_cli.shared.tool_formatters import (
|
|
182
|
+
ExecutionFormatter,
|
|
183
|
+
FallbackFormatter,
|
|
184
|
+
FileOpsFormatter,
|
|
185
|
+
GoalFormatter,
|
|
186
|
+
MediaFormatter,
|
|
187
|
+
StructuredFormatter,
|
|
188
|
+
WebFormatter,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Handle ToolOutput first (highest priority)
|
|
192
|
+
if result_type == "tool_output":
|
|
193
|
+
formatter = StructuredFormatter()
|
|
194
|
+
return formatter.format(tool_name, result)
|
|
195
|
+
|
|
196
|
+
# Route by category
|
|
197
|
+
if category == "file_ops":
|
|
198
|
+
formatter = FileOpsFormatter()
|
|
199
|
+
return formatter.format(tool_name, result)
|
|
200
|
+
if category == "execution":
|
|
201
|
+
formatter = ExecutionFormatter()
|
|
202
|
+
return formatter.format(tool_name, result)
|
|
203
|
+
if category == "media":
|
|
204
|
+
formatter = MediaFormatter()
|
|
205
|
+
return formatter.format(tool_name, result)
|
|
206
|
+
if category == "goals":
|
|
207
|
+
formatter = GoalFormatter()
|
|
208
|
+
return formatter.format(tool_name, result)
|
|
209
|
+
if category == "web":
|
|
210
|
+
formatter = WebFormatter()
|
|
211
|
+
return formatter.format(tool_name, result)
|
|
212
|
+
# Unknown category - use fallback
|
|
213
|
+
formatter = FallbackFormatter()
|
|
214
|
+
return formatter.format(tool_name, result)
|
|
215
|
+
|
|
216
|
+
except Exception as e:
|
|
217
|
+
# Log error and fallback to simple formatting
|
|
218
|
+
logger.warning(
|
|
219
|
+
"Formatter error for tool %s: %s. Using fallback.",
|
|
220
|
+
tool_name,
|
|
221
|
+
e,
|
|
222
|
+
exc_info=True,
|
|
223
|
+
)
|
|
224
|
+
from soothe_cli.shared.tool_formatters import FallbackFormatter
|
|
225
|
+
|
|
226
|
+
formatter = FallbackFormatter()
|
|
227
|
+
return formatter.format(tool_name, result)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Structured INFO logs for TUI debugging when ``SootheConfig.tui_debug`` is true.
|
|
2
|
+
|
|
3
|
+
Enable via ``SOOTHE_TUI_DEBUG=true`` or ``tui_debug: true`` in config. Logs use logger
|
|
4
|
+
``soothe.ux.tui.trace`` so you can filter with ``SOOTHE_LOG_LEVEL=INFO`` and grep for
|
|
5
|
+
``tui_trace``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from soothe_sdk import log_preview
|
|
14
|
+
|
|
15
|
+
_logger = logging.getLogger("soothe.ux.tui.trace")
|
|
16
|
+
|
|
17
|
+
_PREVIEW_CHARS = 180
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _fmt_field(key: str, value: Any) -> str:
|
|
21
|
+
if isinstance(value, str) and len(value) > _PREVIEW_CHARS:
|
|
22
|
+
value = log_preview(value, _PREVIEW_CHARS)
|
|
23
|
+
return f"{key}={value!r}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def log_tui_trace(*, tui_debug: bool, event: str, **fields: Any) -> None:
|
|
27
|
+
"""Emit a single INFO log line when TUI debug mode is enabled.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
tui_debug: Whether tracing is enabled (from config).
|
|
31
|
+
event: Short event name (e.g. ``renderer.assistant_text``).
|
|
32
|
+
fields: Key/value pairs appended as ``key='value'`` (strings truncated).
|
|
33
|
+
"""
|
|
34
|
+
if not tui_debug:
|
|
35
|
+
return
|
|
36
|
+
if fields:
|
|
37
|
+
tail = " ".join(_fmt_field(k, v) for k, v in fields.items())
|
|
38
|
+
_logger.info("tui_trace | %s | %s", event, tail)
|
|
39
|
+
else:
|
|
40
|
+
_logger.info("tui_trace | %s", event)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Types for ask_user widget interactions (stub from deepagents-cli migration).
|
|
2
|
+
|
|
3
|
+
This module provides type definitions for interactive user prompts.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing_extensions import TypedDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Choice(TypedDict):
|
|
10
|
+
"""A choice option for user selection.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
label: Display label for the choice.
|
|
14
|
+
value: Value to return if this choice is selected.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
label: str
|
|
18
|
+
value: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Question(TypedDict):
|
|
22
|
+
"""A question to ask the user.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
question: The question text to display.
|
|
26
|
+
choices: Optional list of choices for selection.
|
|
27
|
+
other: Whether to allow "Other" as a choice option.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
question: str
|
|
31
|
+
choices: list[Choice] | None
|
|
32
|
+
other: bool
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Result type from ask_user widget
|
|
36
|
+
AskUserWidgetResult = dict[str, str]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AskUserRequest(TypedDict):
|
|
40
|
+
"""Request to ask user interactive questions.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
questions: List of questions to ask.
|
|
44
|
+
timeout_seconds: Optional timeout for the prompt.
|
|
45
|
+
prompt_id: Unique identifier for this prompt.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
questions: list[Question]
|
|
49
|
+
timeout_seconds: int | None
|
|
50
|
+
prompt_id: str
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Lightweight runtime context type for CLI model overrides.
|
|
2
|
+
|
|
3
|
+
Extracted from `configurable_model` so hot-path modules (`app`,
|
|
4
|
+
`textual_adapter`) can import `CLIContext` without pulling in the langchain
|
|
5
|
+
middleware stack.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from typing_extensions import TypedDict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CLIContext(TypedDict, total=False):
|
|
16
|
+
"""Runtime context passed via `context=` to the LangGraph graph.
|
|
17
|
+
|
|
18
|
+
Carries per-invocation overrides that `ConfigurableModelMiddleware`
|
|
19
|
+
reads from `request.runtime.context`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
model: str | None
|
|
23
|
+
"""Model spec to swap at runtime (e.g. `'openai:gpt-4o'`)."""
|
|
24
|
+
|
|
25
|
+
model_params: dict[str, Any]
|
|
26
|
+
"""Invocation params (e.g. `temperature`, `max_tokens`) to merge
|
|
27
|
+
into `model_settings`."""
|