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,233 @@
1
+ """Daemon-backed session helpers for the Textual TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from langchain_core.messages import messages_from_dict
11
+ from soothe_sdk.client import (
12
+ WebSocketClient,
13
+ bootstrap_thread_session,
14
+ connect_websocket_with_retries,
15
+ websocket_url_from_config,
16
+ )
17
+
18
+ if TYPE_CHECKING:
19
+ pass
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class DaemonStateSnapshot:
26
+ """Minimal `aget_state()` compatible wrapper."""
27
+
28
+ values: dict[str, Any]
29
+
30
+
31
+ class TuiDaemonSession:
32
+ """Own the daemon websocket session used by the TUI."""
33
+
34
+ def __init__(self, cfg: Any) -> None:
35
+ self._cfg = cfg
36
+ self._client = WebSocketClient(url=websocket_url_from_config(cfg))
37
+ self._thread_id: str | None = None
38
+ self._read_lock = asyncio.Lock()
39
+ self._streaming = False
40
+
41
+ @property
42
+ def thread_id(self) -> str | None:
43
+ """Current thread ID known to the session."""
44
+ return self._thread_id
45
+
46
+ async def connect(self, *, resume_thread_id: str | None = None) -> dict[str, Any]:
47
+ """Connect and bootstrap a daemon thread session."""
48
+ await connect_websocket_with_retries(self._client)
49
+ status_event = await self._bootstrap_thread(resume_thread_id=resume_thread_id)
50
+ return status_event
51
+
52
+ async def _bootstrap_thread(self, *, resume_thread_id: str | None = None) -> dict[str, Any]:
53
+ """Create or resume a daemon thread on an already-connected websocket."""
54
+ status_event = await bootstrap_thread_session(
55
+ self._client,
56
+ resume_thread_id=resume_thread_id,
57
+ verbosity=self._cfg.logging.verbosity,
58
+ )
59
+ if status_event.get("type") == "error":
60
+ raise RuntimeError(str(status_event.get("message", "daemon bootstrap failed")))
61
+ self._thread_id = status_event.get("thread_id")
62
+ return status_event
63
+
64
+ async def new_thread(self) -> dict[str, Any]:
65
+ """Switch the session to a new daemon thread."""
66
+ return await self._bootstrap_thread(resume_thread_id=None)
67
+
68
+ async def switch_thread(self, thread_id: str) -> dict[str, Any]:
69
+ """Switch the session to a specific persisted thread."""
70
+ return await self._bootstrap_thread(resume_thread_id=thread_id)
71
+
72
+ async def close(self) -> None:
73
+ """Close the daemon websocket."""
74
+ await self._client.close()
75
+
76
+ async def detach(self) -> None:
77
+ """Detach this client from the daemon."""
78
+ await self._client.send_detach()
79
+
80
+ async def send_turn(
81
+ self,
82
+ text: str,
83
+ *,
84
+ autonomous: bool = False,
85
+ max_iterations: int | None = None,
86
+ subagent: str | None = None,
87
+ interactive: bool = True,
88
+ model: str | None = None,
89
+ model_params: dict[str, Any] | None = None,
90
+ ) -> None:
91
+ """Send a new user turn to the daemon."""
92
+ await self._client.send_input(
93
+ text,
94
+ autonomous=autonomous,
95
+ max_iterations=max_iterations,
96
+ subagent=subagent,
97
+ interactive=interactive,
98
+ model=model,
99
+ model_params=model_params,
100
+ )
101
+
102
+ async def cancel_remote_query(self) -> None:
103
+ """Ask the daemon to cancel the in-flight query (same wire path as ``/cancel``)."""
104
+ await self._client.send_command("/cancel")
105
+
106
+ async def resume_interrupts(self, resume_payload: dict[str, Any]) -> None:
107
+ """Resume a paused interactive turn."""
108
+ if not self._thread_id:
109
+ raise RuntimeError("No active daemon thread")
110
+ await self._client.send_resume_interrupts(self._thread_id, resume_payload)
111
+
112
+ async def iter_turn_chunks(self) -> Any:
113
+ """Yield `(namespace, mode, data)` chunks for the active daemon turn."""
114
+ query_started = False
115
+ self._streaming = True
116
+ async with self._read_lock:
117
+ try:
118
+ while True:
119
+ event = await self._client.read_event()
120
+ if not event:
121
+ break
122
+
123
+ event_type = event.get("type", "")
124
+ if event_type == "error":
125
+ raise RuntimeError(str(event.get("message", "daemon error")))
126
+
127
+ if event_type == "status":
128
+ thread_id = event.get("thread_id")
129
+ if isinstance(thread_id, str) and thread_id:
130
+ self._thread_id = thread_id
131
+ state = event.get("state", "")
132
+ if state == "running":
133
+ query_started = True
134
+ elif query_started and state in {"idle", "stopped"}:
135
+ break
136
+ continue
137
+
138
+ if event_type != "event":
139
+ continue
140
+
141
+ data = event.get("data")
142
+ if (
143
+ isinstance(data, dict)
144
+ and data.get("type") == "soothe.system.daemon.heartbeat"
145
+ ):
146
+ continue
147
+
148
+ namespace = tuple(event.get("namespace", []) or [])
149
+ mode = str(event.get("mode", ""))
150
+ normalized = self._normalize_stream_data(mode, data)
151
+ yield (namespace, mode, normalized)
152
+ if (
153
+ mode == "updates"
154
+ and isinstance(normalized, dict)
155
+ and "__interrupt__" in normalized
156
+ ):
157
+ break
158
+ finally:
159
+ self._streaming = False
160
+
161
+ def _normalize_stream_data(self, mode: str, data: Any) -> Any:
162
+ """Convert daemon wire payloads back to TUI-friendly objects."""
163
+ if mode != "messages":
164
+ return data
165
+
166
+ if not isinstance(data, (list, tuple)) or len(data) != 2:
167
+ return data
168
+
169
+ message, metadata = data
170
+ if isinstance(message, dict):
171
+ try:
172
+ restored = messages_from_dict([message])
173
+ if restored:
174
+ message = restored[0]
175
+ except Exception:
176
+ logger.debug("Failed to restore message from daemon payload", exc_info=True)
177
+ return (message, metadata)
178
+
179
+ async def aget_state(self, config: dict[str, Any]) -> DaemonStateSnapshot:
180
+ """Fetch thread state values through the daemon."""
181
+ thread_id = str(config.get("configurable", {}).get("thread_id", "")).strip()
182
+ if not thread_id:
183
+ return DaemonStateSnapshot(values={})
184
+ async with self._read_lock:
185
+ response = await self._client.request_response(
186
+ {"type": "thread_state", "thread_id": thread_id},
187
+ response_type="thread_state_response",
188
+ )
189
+ values = response.get("values", {})
190
+ if not isinstance(values, dict):
191
+ values = {}
192
+ messages = values.get("messages")
193
+ if isinstance(messages, list) and messages and isinstance(messages[0], dict):
194
+ try:
195
+ values = dict(values)
196
+ values["messages"] = messages_from_dict(messages)
197
+ except Exception:
198
+ logger.debug("Failed to deserialize thread-state messages", exc_info=True)
199
+ return DaemonStateSnapshot(values=values)
200
+
201
+ async def aupdate_state(self, config: dict[str, Any], values: dict[str, Any]) -> None:
202
+ """Persist partial thread state through the daemon."""
203
+ thread_id = str(config.get("configurable", {}).get("thread_id", "")).strip()
204
+ if not thread_id:
205
+ return
206
+ async with self._read_lock:
207
+ await self._client.request_response(
208
+ {
209
+ "type": "thread_update_state",
210
+ "thread_id": thread_id,
211
+ "values": values,
212
+ },
213
+ response_type="thread_update_state_response",
214
+ )
215
+
216
+ async def list_skills(self) -> list[dict[str, Any]]:
217
+ """Return skill rows from the daemon catalog (no filesystem paths)."""
218
+ async with self._read_lock:
219
+ response = await self._client.list_skills(timeout=15.0)
220
+ skills = response.get("skills", [])
221
+ if not isinstance(skills, list):
222
+ return []
223
+ return [s for s in skills if isinstance(s, dict)]
224
+
225
+ async def list_models(self) -> dict[str, Any]:
226
+ """Return daemon ``models_list_response`` (models + default_model from server config)."""
227
+ async with self._read_lock:
228
+ return await self._client.list_models(timeout=15.0)
229
+
230
+ async def invoke_skill(self, skill: str, args: str = "") -> dict[str, Any]:
231
+ """Resolve ``SKILL.md`` on the daemon and receive UI echo before the turn streams."""
232
+ async with self._read_lock:
233
+ return await self._client.invoke_skill(skill, args, timeout=120.0)
@@ -0,0 +1,409 @@
1
+ """Helpers for tracking file operations and computing diffs for CLI display."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import logging
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any, Literal
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ FileOpStatus = Literal["pending", "success", "error"]
14
+
15
+
16
+ @dataclass
17
+ class ApprovalPreview:
18
+ """Data used to render HITL previews."""
19
+
20
+ title: str
21
+ details: list[str]
22
+ diff: str | None = None
23
+ diff_title: str | None = None
24
+ error: str | None = None
25
+
26
+
27
+ def _safe_read(path: Path) -> str | None:
28
+ """Read file content, returning None on failure.
29
+
30
+ Returns:
31
+ File content as string, or None if reading fails.
32
+ """
33
+ try:
34
+ return path.read_text(encoding="utf-8")
35
+ except (OSError, UnicodeDecodeError) as e:
36
+ logger.debug("Failed to read file %s: %s", path, e)
37
+ return None
38
+
39
+
40
+ def _count_lines(text: str) -> int:
41
+ """Count lines in text, treating empty strings as zero lines.
42
+
43
+ Returns:
44
+ Number of lines in the text.
45
+ """
46
+ if not text:
47
+ return 0
48
+ return len(text.splitlines())
49
+
50
+
51
+ def compute_unified_diff(
52
+ before: str,
53
+ after: str,
54
+ display_path: str,
55
+ *,
56
+ max_lines: int | None = 800,
57
+ context_lines: int = 3,
58
+ ) -> str | None:
59
+ """Compute a unified diff between before and after content.
60
+
61
+ Args:
62
+ before: Original content
63
+ after: New content
64
+ display_path: Path for display in diff headers
65
+ max_lines: Maximum number of diff lines (None for unlimited)
66
+ context_lines: Number of context lines around changes (default 3)
67
+
68
+ Returns:
69
+ Unified diff string or None if no changes
70
+ """
71
+ before_lines = before.splitlines()
72
+ after_lines = after.splitlines()
73
+ diff_lines = list(
74
+ difflib.unified_diff(
75
+ before_lines,
76
+ after_lines,
77
+ fromfile=f"{display_path} (before)",
78
+ tofile=f"{display_path} (after)",
79
+ lineterm="",
80
+ n=context_lines,
81
+ )
82
+ )
83
+ if not diff_lines:
84
+ return None
85
+ if max_lines is not None and len(diff_lines) > max_lines:
86
+ truncated = diff_lines[: max_lines - 1]
87
+ truncated.append("...")
88
+ return "\n".join(truncated)
89
+ return "\n".join(diff_lines)
90
+
91
+
92
+ @dataclass
93
+ class FileOpMetrics:
94
+ """Line and byte level metrics for a file operation."""
95
+
96
+ lines_read: int = 0
97
+ start_line: int | None = None
98
+ end_line: int | None = None
99
+ lines_written: int = 0
100
+ lines_added: int = 0
101
+ lines_removed: int = 0
102
+ bytes_written: int = 0
103
+
104
+
105
+ @dataclass
106
+ class FileOperationRecord:
107
+ """Track a single filesystem tool call."""
108
+
109
+ tool_name: str
110
+ display_path: str
111
+ physical_path: Path | None
112
+ tool_call_id: str | None
113
+ args: dict[str, Any] = field(default_factory=dict)
114
+ status: FileOpStatus = "pending"
115
+ error: str | None = None
116
+ metrics: FileOpMetrics = field(default_factory=FileOpMetrics)
117
+ diff: str | None = None
118
+ before_content: str | None = None
119
+ after_content: str | None = None
120
+ read_output: str | None = None
121
+ hitl_approved: bool = False
122
+
123
+
124
+ def resolve_physical_path(path_str: str | None, assistant_id: str | None) -> Path | None:
125
+ """Convert a virtual/relative path to a physical filesystem path.
126
+
127
+ Returns:
128
+ Resolved physical Path, or None if path is empty or resolution fails.
129
+ """
130
+ if not path_str:
131
+ return None
132
+ try:
133
+ if assistant_id and path_str.startswith("/memories/"):
134
+ from soothe_cli.tui.config import settings
135
+
136
+ agent_dir = settings.get_agent_dir(assistant_id)
137
+ suffix = path_str.removeprefix("/memories/").lstrip("/")
138
+ return (agent_dir / suffix).resolve()
139
+ path = Path(path_str)
140
+ if path.is_absolute():
141
+ return path
142
+ return (Path.cwd() / path).resolve()
143
+ except (OSError, ValueError):
144
+ return None
145
+
146
+
147
+ def format_display_path(path_str: str | None) -> str:
148
+ """Format a path for display.
149
+
150
+ Returns:
151
+ Formatted path string suitable for display.
152
+ """
153
+ if not path_str:
154
+ return "(unknown)"
155
+ try:
156
+ path = Path(path_str)
157
+ if path.is_absolute():
158
+ return path.name or str(path)
159
+ return str(path)
160
+ except (OSError, ValueError):
161
+ return str(path_str)
162
+
163
+
164
+ def build_approval_preview(
165
+ tool_name: str,
166
+ args: dict[str, Any],
167
+ assistant_id: str | None,
168
+ ) -> ApprovalPreview | None:
169
+ """Collect summary info and diff for HITL approvals.
170
+
171
+ Returns:
172
+ ApprovalPreview with diff and details, or None if tool not supported.
173
+ """
174
+ path_str = str(args.get("file_path") or args.get("path") or "")
175
+ display_path = format_display_path(path_str)
176
+ physical_path = resolve_physical_path(path_str, assistant_id)
177
+
178
+ if tool_name == "write_file":
179
+ content = str(args.get("content", ""))
180
+ before = _safe_read(physical_path) if physical_path and physical_path.exists() else ""
181
+ after = content
182
+ diff = compute_unified_diff(before or "", after, display_path, max_lines=100)
183
+ additions = 0
184
+ if diff:
185
+ additions = sum(
186
+ 1
187
+ for line in diff.splitlines()
188
+ if line.startswith("+") and not line.startswith("+++")
189
+ )
190
+ total_lines = _count_lines(after)
191
+ details = [
192
+ f"File: {path_str}",
193
+ "Action: Create new file" + (" (overwrites existing content)" if before else ""),
194
+ f"Lines to write: {additions or total_lines}",
195
+ ]
196
+ return ApprovalPreview(
197
+ title=f"Write {display_path}",
198
+ details=details,
199
+ diff=diff,
200
+ diff_title=f"Diff {display_path}",
201
+ )
202
+
203
+ if tool_name == "edit_file":
204
+ if physical_path is None:
205
+ return ApprovalPreview(
206
+ title=f"Update {display_path}",
207
+ details=[f"File: {path_str}", "Action: Replace text"],
208
+ error="Unable to resolve file path.",
209
+ )
210
+ before = _safe_read(physical_path)
211
+ if before is None:
212
+ return ApprovalPreview(
213
+ title=f"Update {display_path}",
214
+ details=[f"File: {path_str}", "Action: Replace text"],
215
+ error="Unable to read current file contents.",
216
+ )
217
+ old_string = str(args.get("old_string", ""))
218
+ new_string = str(args.get("new_string", ""))
219
+ replace_all = bool(args.get("replace_all"))
220
+
221
+ # Preview string replacement locally
222
+ if replace_all:
223
+ after = before.replace(old_string, new_string)
224
+ else:
225
+ after = before.replace(old_string, new_string, 1)
226
+
227
+ diff = compute_unified_diff(before, after, display_path, max_lines=100)
228
+ additions = 0
229
+ deletions = 0
230
+ if diff:
231
+ additions = sum(
232
+ 1
233
+ for line in diff.splitlines()
234
+ if line.startswith("+") and not line.startswith("+++")
235
+ )
236
+ deletions = sum(
237
+ 1
238
+ for line in diff.splitlines()
239
+ if line.startswith("-") and not line.startswith("---")
240
+ )
241
+ scope = "all occurrences" if replace_all else "first occurrence"
242
+ details = [
243
+ f"File: {path_str}",
244
+ f"Action: Replace text ({scope})",
245
+ f"+{additions} / -{deletions} lines",
246
+ ]
247
+ return ApprovalPreview(
248
+ title=f"Update {display_path}",
249
+ details=details,
250
+ diff=diff,
251
+ diff_title=f"Diff {display_path}",
252
+ )
253
+
254
+ return None
255
+
256
+
257
+ class FileOpTracker:
258
+ """Collect file operation metrics during a CLI interaction."""
259
+
260
+ def __init__(self, *, assistant_id: str | None) -> None:
261
+ """Initialize the tracker."""
262
+ self.assistant_id = assistant_id
263
+ self.active: dict[str | None, FileOperationRecord] = {}
264
+ self.completed: list[FileOperationRecord] = []
265
+
266
+ def start_operation(
267
+ self, tool_name: str, args: dict[str, Any], tool_call_id: str | None
268
+ ) -> None:
269
+ """Begin tracking a file operation.
270
+
271
+ Creates a record for the operation and, for write/edit operations,
272
+ captures the file's content before modification.
273
+ """
274
+ if tool_name not in {"read_file", "write_file", "edit_file"}:
275
+ return
276
+ path_str = str(args.get("file_path") or args.get("path") or "")
277
+ display_path = format_display_path(path_str)
278
+ record = FileOperationRecord(
279
+ tool_name=tool_name,
280
+ display_path=display_path,
281
+ physical_path=resolve_physical_path(path_str, self.assistant_id),
282
+ tool_call_id=tool_call_id,
283
+ args=args,
284
+ )
285
+ if tool_name in {"write_file", "edit_file"}:
286
+ if record.physical_path:
287
+ record.before_content = _safe_read(record.physical_path) or ""
288
+ self.active[tool_call_id] = record
289
+
290
+ def complete_with_message(self, tool_message: Any) -> FileOperationRecord | None: # noqa: ANN401 # Tool message type is dynamic
291
+ """Complete a file operation with the tool message result.
292
+
293
+ Returns:
294
+ The completed FileOperationRecord, or None if no matching operation.
295
+ """
296
+ tool_call_id = getattr(tool_message, "tool_call_id", None)
297
+ record = self.active.get(tool_call_id)
298
+ if record is None:
299
+ return None
300
+
301
+ content = tool_message.content
302
+ if isinstance(content, list):
303
+ # Some tool messages may return list segments; join them for analysis.
304
+ joined = []
305
+ for item in content:
306
+ if isinstance(item, str):
307
+ joined.append(item)
308
+ else:
309
+ joined.append(str(item))
310
+ content_text = "\n".join(joined)
311
+ else:
312
+ content_text = str(content) if content is not None else ""
313
+
314
+ if getattr(
315
+ tool_message, "status", "success"
316
+ ) != "success" or content_text.lower().startswith("error"):
317
+ record.status = "error"
318
+ record.error = content_text
319
+ self._finalize(record)
320
+ return record
321
+
322
+ record.status = "success"
323
+
324
+ if record.tool_name == "read_file":
325
+ record.read_output = content_text
326
+ lines = _count_lines(content_text)
327
+ record.metrics.lines_read = lines
328
+ offset = record.args.get("offset")
329
+ limit = record.args.get("limit")
330
+ if isinstance(offset, int):
331
+ if offset > lines:
332
+ offset = 0
333
+ record.metrics.start_line = offset + 1
334
+ if lines:
335
+ record.metrics.end_line = offset + lines
336
+ elif lines:
337
+ record.metrics.start_line = 1
338
+ record.metrics.end_line = lines
339
+ if isinstance(limit, int) and lines > limit:
340
+ record.metrics.end_line = (record.metrics.start_line or 1) + limit - 1
341
+ else:
342
+ # For write/edit operations, read back from local filesystem
343
+ self._populate_after_content(record)
344
+ if record.after_content is None:
345
+ record.status = "error"
346
+ record.error = "Could not read updated file content."
347
+ self._finalize(record)
348
+ return record
349
+ record.metrics.lines_written = _count_lines(record.after_content)
350
+ before_lines = _count_lines(record.before_content or "")
351
+ diff = compute_unified_diff(
352
+ record.before_content or "",
353
+ record.after_content,
354
+ record.display_path,
355
+ max_lines=100,
356
+ )
357
+ record.diff = diff
358
+ if diff:
359
+ additions = sum(
360
+ 1
361
+ for line in diff.splitlines()
362
+ if line.startswith("+") and not line.startswith("+++")
363
+ )
364
+ deletions = sum(
365
+ 1
366
+ for line in diff.splitlines()
367
+ if line.startswith("-") and not line.startswith("---")
368
+ )
369
+ record.metrics.lines_added = additions
370
+ record.metrics.lines_removed = deletions
371
+ elif record.tool_name == "write_file" and not (record.before_content or ""):
372
+ record.metrics.lines_added = record.metrics.lines_written
373
+ record.metrics.bytes_written = len(record.after_content.encode("utf-8"))
374
+ if record.diff is None and (record.before_content or "") != record.after_content:
375
+ record.diff = compute_unified_diff(
376
+ record.before_content or "",
377
+ record.after_content,
378
+ record.display_path,
379
+ max_lines=100,
380
+ )
381
+ if record.diff is None and before_lines != record.metrics.lines_written:
382
+ record.metrics.lines_added = max(record.metrics.lines_written - before_lines, 0)
383
+
384
+ self._finalize(record)
385
+ return record
386
+
387
+ def mark_hitl_approved(self, tool_name: str, args: dict[str, Any]) -> None:
388
+ """Mark operations matching tool_name and file_path as HIL-approved."""
389
+ file_path = args.get("file_path") or args.get("path")
390
+ if not file_path:
391
+ return
392
+
393
+ # Mark all active records that match
394
+ for record in self.active.values():
395
+ if record.tool_name == tool_name:
396
+ record_path = record.args.get("file_path") or record.args.get("path")
397
+ if record_path == file_path:
398
+ record.hitl_approved = True
399
+
400
+ def _populate_after_content(self, record: FileOperationRecord) -> None:
401
+ """Read the file content after the operation for diff computation."""
402
+ if record.physical_path is None:
403
+ record.after_content = None
404
+ return
405
+ record.after_content = _safe_read(record.physical_path)
406
+
407
+ def _finalize(self, record: FileOperationRecord) -> None:
408
+ self.completed.append(record)
409
+ self.active.pop(record.tool_call_id, None)
@@ -0,0 +1,28 @@
1
+ """Lightweight text-formatting helpers.
2
+
3
+ Keep this module free of heavy dependencies so it can be imported anywhere
4
+ in the CLI without pulling in large frameworks.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ def format_duration(seconds: float) -> str:
11
+ """Format a duration in seconds into a human-readable string.
12
+
13
+ Args:
14
+ seconds: Duration in seconds.
15
+
16
+ Returns:
17
+ Formatted string like `"5s"`, `"2.3s"`, `"5m 12s"`, or `"1h 23m 4s"`.
18
+ """
19
+ rounded = round(seconds, 1)
20
+ if rounded < 60: # noqa: PLR2004
21
+ if rounded % 1 == 0:
22
+ return f"{int(rounded)}s"
23
+ return f"{rounded:.1f}s"
24
+ minutes, secs = divmod(int(rounded), 60)
25
+ if minutes < 60: # noqa: PLR2004
26
+ return f"{minutes}m {secs}s"
27
+ hours, minutes = divmod(minutes, 60)
28
+ return f"{hours}h {minutes}m {secs}s"
@@ -0,0 +1,23 @@
1
+ """Hook system for TUI events (stub implementation from deepagents-cli migration).
2
+
3
+ This module provides hook dispatch functionality for TUI events.
4
+ Full implementation should integrate with Soothe's event system.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ async def dispatch_hook(hook_name: str, payload: dict[str, Any]) -> None:
14
+ """Dispatch a hook event asynchronously.
15
+
16
+ Stub implementation - no hooks are currently registered.
17
+ Full implementation should integrate with Soothe's event system.
18
+
19
+ Args:
20
+ hook_name: Name of the hook event (e.g., "user.prompt", "session.end").
21
+ payload: Event payload data.
22
+ """
23
+ logger.debug("Hook dispatch (stub): %s with payload %s", hook_name, payload)