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,221 @@
1
+ """Command history manager for input persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import TYPE_CHECKING
8
+
9
+ from soothe_sdk import GlobalInputHistory
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class HistoryManager:
18
+ """Manages command history with file persistence.
19
+
20
+ Uses append-only writes for concurrent safety. Multiple agents can
21
+ safely write to the same history file without corruption.
22
+ """
23
+
24
+ def __init__(self, history_file: Path, max_entries: int = 100) -> None:
25
+ """Initialize the history manager.
26
+
27
+ Args:
28
+ history_file: Path to the JSON-lines history file
29
+ max_entries: Maximum number of entries to keep
30
+ """
31
+ self.history_file = history_file
32
+ self.max_entries = max_entries
33
+ self._entries: list[str] = []
34
+ self._current_index: int = -1
35
+ self._temp_input: str = ""
36
+ self._query: str = ""
37
+ self._use_global_history_format = self.history_file.name == "history.jsonl"
38
+ self._global_history_writer: GlobalInputHistory | None = None
39
+ if self._use_global_history_format:
40
+ self._global_history_writer = GlobalInputHistory(history_file=str(self.history_file))
41
+ self._load_history()
42
+
43
+ def _load_history(self) -> None:
44
+ """Load history from file."""
45
+ if not self.history_file.exists():
46
+ return
47
+
48
+ try:
49
+ entries: list[str] = []
50
+ with self.history_file.open("r", encoding="utf-8") as f:
51
+ for raw_line in f:
52
+ line = raw_line.rstrip("\n\r")
53
+ if not line:
54
+ continue
55
+ try:
56
+ entry = json.loads(line)
57
+ except json.JSONDecodeError:
58
+ entry = line
59
+
60
+ text = ""
61
+ if isinstance(entry, dict):
62
+ text = str(entry.get("text", "")).strip()
63
+ elif isinstance(entry, str):
64
+ text = entry.strip()
65
+ else:
66
+ text = str(entry).strip()
67
+
68
+ if not text:
69
+ continue
70
+ entries.append(text)
71
+
72
+ # Keep only latest non-consecutive duplicates to avoid noisy repeats
73
+ compacted: list[str] = []
74
+ for text in entries:
75
+ if compacted and compacted[-1] == text:
76
+ continue
77
+ compacted.append(text)
78
+ self._entries = compacted[-self.max_entries :]
79
+ except (OSError, UnicodeDecodeError):
80
+ logger.warning(
81
+ "Failed to load history from %s; starting with empty history",
82
+ self.history_file,
83
+ exc_info=True,
84
+ )
85
+ self._entries = []
86
+
87
+ def _append_to_file(self, text: str) -> None:
88
+ """Append a single entry to history file (concurrent-safe)."""
89
+ if self._global_history_writer is not None:
90
+ self._global_history_writer.add(
91
+ text,
92
+ thread_id="tui",
93
+ metadata={"source": "tui"},
94
+ )
95
+ return
96
+ try:
97
+ self.history_file.parent.mkdir(parents=True, exist_ok=True)
98
+ with self.history_file.open("a", encoding="utf-8") as f:
99
+ f.write(json.dumps(text) + "\n")
100
+ except OSError:
101
+ logger.warning(
102
+ "Failed to append history entry to %s",
103
+ self.history_file,
104
+ exc_info=True,
105
+ )
106
+
107
+ def _compact_history(self) -> None:
108
+ """Rewrite history file to remove old entries.
109
+
110
+ Only called when entries exceed 2x max_entries to minimize rewrites.
111
+ """
112
+ if self._global_history_writer is not None:
113
+ return
114
+ try:
115
+ self.history_file.parent.mkdir(parents=True, exist_ok=True)
116
+ with self.history_file.open("w", encoding="utf-8") as f:
117
+ for entry in self._entries:
118
+ f.write(json.dumps(entry) + "\n")
119
+ except OSError:
120
+ logger.warning(
121
+ "Failed to compact history file %s",
122
+ self.history_file,
123
+ exc_info=True,
124
+ )
125
+
126
+ def add(self, text: str) -> None:
127
+ """Add a command to history.
128
+
129
+ Args:
130
+ text: The command text to add
131
+ """
132
+ text = text.strip()
133
+ # Skip empty or slash commands
134
+ if not text or text.startswith("/"):
135
+ return
136
+
137
+ # Skip duplicates of the last entry
138
+ if self._entries and self._entries[-1] == text:
139
+ return
140
+
141
+ self._entries.append(text)
142
+
143
+ # Append to file (fast, concurrent-safe)
144
+ self._append_to_file(text)
145
+
146
+ # Compact only when we have 2x max entries (rare operation)
147
+ if len(self._entries) > self.max_entries * 2:
148
+ self._entries = self._entries[-self.max_entries :]
149
+ self._compact_history()
150
+
151
+ self.reset_navigation()
152
+
153
+ def get_previous(self, current_input: str, *, query: str = "") -> str | None:
154
+ """Get the previous history entry matching a substring query.
155
+
156
+ The query is captured on the first call of a navigation session
157
+ (when `_current_index == -1`) and reused for all subsequent calls until
158
+ `reset_navigation`. Passing a different value on later calls has
159
+ no effect.
160
+
161
+ Args:
162
+ current_input: Current input text. Saved only on the first call of a
163
+ navigation session; ignored on subsequent calls.
164
+ query: Substring to match against history entries.
165
+ Captured once on the first call of a navigation session.
166
+
167
+ Returns:
168
+ Previous matching entry or `None`.
169
+ """
170
+ if not self._entries:
171
+ return None
172
+
173
+ # Save current input and capture query on first navigation
174
+ if self._current_index == -1:
175
+ self._temp_input = current_input
176
+ self._current_index = len(self._entries)
177
+ self._query = query.strip().lower()
178
+
179
+ # Search backwards for matching entry
180
+ for i in range(self._current_index - 1, -1, -1):
181
+ if not self._query or self._query in self._entries[i].lower():
182
+ self._current_index = i
183
+ return self._entries[i]
184
+
185
+ return None
186
+
187
+ def get_next(self) -> str | None:
188
+ """Get the next history entry matching the stored query.
189
+
190
+ Uses the query captured by the most recent `get_previous` call.
191
+
192
+ Returns:
193
+ The next matching entry, or the original input when past the newest
194
+ match.
195
+
196
+ `None` if not currently navigating history.
197
+ """
198
+ if self._current_index == -1:
199
+ return None
200
+
201
+ # Search forwards for matching entry
202
+ for i in range(self._current_index + 1, len(self._entries)):
203
+ if not self._query or self._query in self._entries[i].lower():
204
+ self._current_index = i
205
+ return self._entries[i]
206
+
207
+ # Return to original input at the end
208
+ result = self._temp_input
209
+ self.reset_navigation()
210
+ return result
211
+
212
+ @property
213
+ def in_history(self) -> bool:
214
+ """Whether currently navigating history entries."""
215
+ return self._current_index >= 0
216
+
217
+ def reset_navigation(self) -> None:
218
+ """Reset navigation state."""
219
+ self._current_index = -1
220
+ self._temp_input = ""
221
+ self._query = ""
@@ -0,0 +1,194 @@
1
+ """Loading widget with animated spinner for agent activity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from time import time
6
+ from typing import TYPE_CHECKING
7
+
8
+ from textual.containers import Horizontal
9
+ from textual.content import Content
10
+ from textual.widgets import Static
11
+
12
+ from soothe_cli.tui.config import get_glyphs
13
+ from soothe_cli.tui.formatting import format_duration
14
+
15
+ if TYPE_CHECKING:
16
+ from textual.app import ComposeResult
17
+ from textual.await_remove import AwaitRemove
18
+ from textual.timer import Timer
19
+
20
+
21
+ class Spinner:
22
+ """Animated spinner using charset-appropriate frames."""
23
+
24
+ def __init__(self) -> None:
25
+ """Initialize spinner."""
26
+ self._position = 0
27
+
28
+ @property
29
+ def frames(self) -> tuple[str, ...]:
30
+ """Get spinner frames from glyphs config."""
31
+ return get_glyphs().spinner_frames
32
+
33
+ def next_frame(self) -> str:
34
+ """Get next animation frame.
35
+
36
+ Returns:
37
+ The next spinner character in the animation sequence.
38
+ """
39
+ frames = self.frames
40
+ frame = frames[self._position]
41
+ self._position = (self._position + 1) % len(frames)
42
+ return frame
43
+
44
+ def current_frame(self) -> str:
45
+ """Get current frame without advancing.
46
+
47
+ Returns:
48
+ The current spinner character.
49
+ """
50
+ return self.frames[self._position]
51
+
52
+
53
+ class LoadingWidget(Static):
54
+ """Animated loading indicator with status text and elapsed time.
55
+
56
+ Displays: <spinner> Thinking... (3s, esc to interrupt)
57
+ """
58
+
59
+ DEFAULT_CSS = """
60
+ LoadingWidget {
61
+ height: auto;
62
+ padding: 0 1;
63
+ margin-top: 1;
64
+ }
65
+
66
+ LoadingWidget .loading-container {
67
+ height: auto;
68
+ width: 100%;
69
+ }
70
+
71
+ LoadingWidget .loading-spinner {
72
+ width: auto;
73
+ color: $primary;
74
+ }
75
+
76
+ LoadingWidget .loading-status {
77
+ width: auto;
78
+ color: $primary;
79
+ }
80
+
81
+ LoadingWidget .loading-hint {
82
+ width: auto;
83
+ color: $text-muted;
84
+ margin-left: 1;
85
+ }
86
+ """
87
+
88
+ def __init__(self, status: str = "Thinking") -> None:
89
+ """Initialize loading widget.
90
+
91
+ Args:
92
+ status: Initial status text to display
93
+ """
94
+ super().__init__()
95
+ self._status = status
96
+ self._spinner = Spinner()
97
+ self._start_time: float | None = None
98
+ self._spinner_widget: Static | None = None
99
+ self._status_widget: Static | None = None
100
+ self._hint_widget: Static | None = None
101
+ self._animation_timer: Timer | None = None
102
+ self._paused = False
103
+ self._paused_elapsed: int = 0
104
+
105
+ def compose(self) -> ComposeResult:
106
+ """Compose the loading widget layout.
107
+
108
+ Yields:
109
+ Widgets for spinner, status text, and hint.
110
+ """
111
+ with Horizontal(classes="loading-container"):
112
+ self._spinner_widget = Static(self._spinner.current_frame(), classes="loading-spinner")
113
+ yield self._spinner_widget
114
+
115
+ self._status_widget = Static(f" {self._status}... ", classes="loading-status")
116
+ yield self._status_widget
117
+
118
+ self._hint_widget = Static("(0s, esc to interrupt)", classes="loading-hint")
119
+ yield self._hint_widget
120
+
121
+ def on_mount(self) -> None:
122
+ """Start animation on mount."""
123
+ self._start_time = time()
124
+ self._animation_timer = self.set_interval(0.1, self._update_animation)
125
+
126
+ def on_unmount(self) -> None:
127
+ """Stop the animation timer when the widget leaves the DOM."""
128
+ self._stop_timer()
129
+
130
+ def remove(self) -> AwaitRemove:
131
+ """Stop animation before delegating DOM removal to Textual.
132
+
133
+ Returns:
134
+ Awaitable that completes once the widget is removed from the DOM.
135
+ """
136
+ self._stop_timer()
137
+ return super().remove()
138
+
139
+ def _stop_timer(self) -> None:
140
+ """Stop the animation timer if it is running."""
141
+ if self._animation_timer is not None:
142
+ self._animation_timer.stop()
143
+ self._animation_timer = None
144
+
145
+ def _update_animation(self) -> None:
146
+ """Update spinner and elapsed time."""
147
+ if self._paused:
148
+ return
149
+
150
+ if self._spinner_widget:
151
+ frame = self._spinner.next_frame()
152
+ self._spinner_widget.update(frame)
153
+
154
+ if self._hint_widget and self._start_time is not None:
155
+ elapsed = int(time() - self._start_time)
156
+ self._hint_widget.update(f"({format_duration(elapsed)}, esc to interrupt)")
157
+
158
+ def set_status(self, status: str) -> None:
159
+ """Update the status text.
160
+
161
+ Args:
162
+ status: New status text
163
+ """
164
+ self._status = status
165
+ if self._status_widget:
166
+ self._status_widget.update(f" {self._status}... ")
167
+
168
+ def pause(self, status: str = "Awaiting decision") -> None:
169
+ """Pause the animation and update status.
170
+
171
+ Args:
172
+ status: Status to show while paused
173
+ """
174
+ self._paused = True
175
+ if self._start_time is not None:
176
+ self._paused_elapsed = int(time() - self._start_time)
177
+ self._status = status
178
+ if self._status_widget:
179
+ self._status_widget.update(f" {status}... ")
180
+ if self._hint_widget:
181
+ self._hint_widget.update(f"(paused at {format_duration(self._paused_elapsed)})")
182
+ if self._spinner_widget:
183
+ self._spinner_widget.update(Content.styled(get_glyphs().pause, "dim"))
184
+
185
+ def resume(self) -> None:
186
+ """Resume the animation."""
187
+ self._paused = False
188
+ self._status = "Thinking"
189
+ if self._status_widget:
190
+ self._status_widget.update(f" {self._status}... ")
191
+
192
+ def stop(self) -> None:
193
+ """Stop the animation (widget will be removed by caller)."""
194
+ self._stop_timer()