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,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,5 @@
1
+ """TUI interface for Soothe."""
2
+
3
+ from soothe_cli.tui.app import SootheApp, run_textual_tui
4
+
5
+ __all__ = ["SootheApp", "run_textual_tui"]
@@ -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`."""