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,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"
|
soothe_cli/tui/hooks.py
ADDED
|
@@ -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)
|