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,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()
|