aloop 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.

Potentially problematic release.


This version of aloop might be problematic. Click here for more details.

Files changed (62) hide show
  1. agent/__init__.py +0 -0
  2. agent/agent.py +182 -0
  3. agent/base.py +406 -0
  4. agent/context.py +126 -0
  5. agent/todo.py +149 -0
  6. agent/tool_executor.py +54 -0
  7. agent/verification.py +135 -0
  8. aloop-0.1.0.dist-info/METADATA +246 -0
  9. aloop-0.1.0.dist-info/RECORD +62 -0
  10. aloop-0.1.0.dist-info/WHEEL +5 -0
  11. aloop-0.1.0.dist-info/entry_points.txt +2 -0
  12. aloop-0.1.0.dist-info/licenses/LICENSE +21 -0
  13. aloop-0.1.0.dist-info/top_level.txt +9 -0
  14. cli.py +19 -0
  15. config.py +146 -0
  16. interactive.py +865 -0
  17. llm/__init__.py +51 -0
  18. llm/base.py +26 -0
  19. llm/compat.py +226 -0
  20. llm/content_utils.py +309 -0
  21. llm/litellm_adapter.py +450 -0
  22. llm/message_types.py +245 -0
  23. llm/model_manager.py +265 -0
  24. llm/retry.py +95 -0
  25. main.py +246 -0
  26. memory/__init__.py +20 -0
  27. memory/compressor.py +554 -0
  28. memory/manager.py +538 -0
  29. memory/serialization.py +82 -0
  30. memory/short_term.py +88 -0
  31. memory/token_tracker.py +203 -0
  32. memory/types.py +51 -0
  33. tools/__init__.py +6 -0
  34. tools/advanced_file_ops.py +557 -0
  35. tools/base.py +51 -0
  36. tools/calculator.py +50 -0
  37. tools/code_navigator.py +975 -0
  38. tools/explore.py +254 -0
  39. tools/file_ops.py +150 -0
  40. tools/git_tools.py +791 -0
  41. tools/notify.py +69 -0
  42. tools/parallel_execute.py +420 -0
  43. tools/session_manager.py +205 -0
  44. tools/shell.py +147 -0
  45. tools/shell_background.py +470 -0
  46. tools/smart_edit.py +491 -0
  47. tools/todo.py +130 -0
  48. tools/web_fetch.py +673 -0
  49. tools/web_search.py +61 -0
  50. utils/__init__.py +15 -0
  51. utils/logger.py +105 -0
  52. utils/model_pricing.py +49 -0
  53. utils/runtime.py +75 -0
  54. utils/terminal_ui.py +422 -0
  55. utils/tui/__init__.py +39 -0
  56. utils/tui/command_registry.py +49 -0
  57. utils/tui/components.py +306 -0
  58. utils/tui/input_handler.py +393 -0
  59. utils/tui/model_ui.py +204 -0
  60. utils/tui/progress.py +292 -0
  61. utils/tui/status_bar.py +178 -0
  62. utils/tui/theme.py +165 -0
utils/tui/model_ui.py ADDED
@@ -0,0 +1,204 @@
1
+ """TUI helpers for model selection and configuration editing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import shlex
8
+ import shutil
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Protocol, Sequence
12
+
13
+ import aiofiles.os
14
+ from prompt_toolkit.application import Application
15
+ from prompt_toolkit.key_binding import KeyBindings
16
+ from prompt_toolkit.layout import HSplit, Layout, Window
17
+ from prompt_toolkit.layout.controls import FormattedTextControl
18
+ from prompt_toolkit.styles import Style
19
+
20
+ from utils.tui.theme import Theme
21
+
22
+
23
+ class _Model(Protocol):
24
+ model_id: str
25
+
26
+
27
+ class _ModelManager(Protocol):
28
+ config_path: str
29
+
30
+ def list_models(self) -> Sequence[_Model]: ...
31
+
32
+ def get_current_model(self) -> _Model | None: ...
33
+
34
+
35
+ async def open_in_editor(path: str) -> tuple[bool, bool]:
36
+ """Open a file in an editor (best-effort).
37
+
38
+ Returns:
39
+ (opened, waited): waited indicates we blocked until editing likely finished.
40
+ """
41
+ path = str(Path(path))
42
+
43
+ editor = os.environ.get("EDITOR") or os.environ.get("VISUAL")
44
+ if editor:
45
+ cmd = shlex.split(editor) + [path]
46
+ try:
47
+ proc = await asyncio.create_subprocess_exec(*cmd)
48
+ except FileNotFoundError:
49
+ return False, False
50
+ return (await proc.wait()) == 0, True
51
+
52
+ if shutil.which("vi"):
53
+ proc = await asyncio.create_subprocess_exec("vi", path)
54
+ return (await proc.wait()) == 0, True
55
+
56
+ if shutil.which("code"):
57
+ # Don't use `-w` here; we prefer to return to the TUI after the file is saved
58
+ # (and auto-reloaded), without requiring the user to close the editor tab.
59
+ proc = await asyncio.create_subprocess_exec("code", "--reuse-window", path)
60
+ return (await proc.wait()) == 0, False
61
+
62
+ if sys.platform == "darwin" and shutil.which("open"):
63
+ # `open` returns immediately; we can't reliably wait for editing completion.
64
+ proc = await asyncio.create_subprocess_exec("open", "-t", path)
65
+ return (await proc.wait()) == 0, False
66
+
67
+ if shutil.which("xdg-open"):
68
+ # xdg-open returns immediately; we can't reliably wait for editing completion.
69
+ proc = await asyncio.create_subprocess_exec("xdg-open", path)
70
+ return (await proc.wait()) == 0, False
71
+
72
+ return False, False
73
+
74
+
75
+ async def get_mtime(path: str) -> tuple[int, int] | None:
76
+ try:
77
+ stat = await aiofiles.os.stat(path)
78
+ return stat.st_mtime_ns, stat.st_size
79
+ except FileNotFoundError:
80
+ return None
81
+
82
+
83
+ async def wait_for_file_change(path: str, old_mtime: tuple[int, int] | None) -> None:
84
+ while True:
85
+ new_mtime = await get_mtime(path)
86
+ if old_mtime is None:
87
+ if new_mtime is not None:
88
+ return
89
+ elif new_mtime is not None and new_mtime != old_mtime:
90
+ return
91
+ await asyncio.sleep(0.25)
92
+
93
+
94
+ async def open_config_and_wait_for_save(config_path: str) -> bool:
95
+ """Open config file and return when it is likely saved at least once."""
96
+ before = await get_mtime(config_path)
97
+ ok, waited = await open_in_editor(config_path)
98
+ if not ok:
99
+ return False
100
+ if not waited:
101
+ await wait_for_file_change(config_path, before)
102
+ return True
103
+
104
+
105
+ async def pick_model_id(model_manager: _ModelManager, title: str) -> str | None:
106
+ """Pick a model_id using a keyboard-only list (Codex-style)."""
107
+ models = list(model_manager.list_models())
108
+ if not models:
109
+ return None
110
+
111
+ colors = Theme.get_colors()
112
+ current = model_manager.get_current_model()
113
+ current_id = current.model_id if current else None
114
+
115
+ selected_index = 0
116
+ if current_id:
117
+ for i, m in enumerate(models):
118
+ if m.model_id == current_id:
119
+ selected_index = i
120
+ break
121
+
122
+ kb = KeyBindings()
123
+
124
+ @kb.add("up")
125
+ @kb.add("k")
126
+ def _up(event) -> None:
127
+ nonlocal selected_index
128
+ selected_index = (selected_index - 1) % len(models)
129
+
130
+ @kb.add("down")
131
+ @kb.add("j")
132
+ def _down(event) -> None:
133
+ nonlocal selected_index
134
+ selected_index = (selected_index + 1) % len(models)
135
+
136
+ @kb.add("enter")
137
+ def _enter(event) -> None:
138
+ event.app.exit(result=models[selected_index].model_id)
139
+
140
+ @kb.add("escape")
141
+ @kb.add("c-c")
142
+ def _cancel(event) -> None:
143
+ event.app.exit(result=None)
144
+
145
+ def _render() -> list[tuple[str, str]]:
146
+ lines: list[tuple[str, str]] = []
147
+ lines.append(("class:title", f"{title}\n"))
148
+ lines.append(("class:hint", "Use ↑/↓ and Enter to select, Esc to cancel.\n\n"))
149
+
150
+ for idx, m in enumerate(models, start=1):
151
+ is_selected = (idx - 1) == selected_index
152
+ is_current = m.model_id == current_id
153
+
154
+ prefix = "› " if is_selected else " "
155
+ marker = "(current) " if is_current else ""
156
+ text = f"{prefix}{idx}. {marker}{m.model_id}\n"
157
+ style = "class:selected" if is_selected else "class:item"
158
+ lines.append((style, text))
159
+
160
+ return lines
161
+
162
+ control = FormattedTextControl(_render, focusable=True)
163
+ window = Window(content=control, dont_extend_height=True, always_hide_cursor=True)
164
+ layout = Layout(HSplit([window]))
165
+
166
+ style_dict = Theme.get_prompt_toolkit_style()
167
+ style_dict.update(
168
+ {
169
+ "title": f"{colors.primary} bold",
170
+ "hint": colors.text_muted,
171
+ "item": colors.text_primary,
172
+ "selected": f"bg:{colors.primary} {colors.bg_primary}",
173
+ }
174
+ )
175
+
176
+ app = Application(
177
+ layout=layout,
178
+ key_bindings=kb,
179
+ style=Style.from_dict(style_dict),
180
+ full_screen=False,
181
+ mouse_support=False,
182
+ )
183
+ return await app.run_async()
184
+
185
+
186
+ def mask_secret(value: str | None) -> str:
187
+ if not value:
188
+ return "(not set)"
189
+ v = value.strip()
190
+ if len(v) <= 8:
191
+ return "*" * len(v)
192
+ return f"{v[:4]}…{v[-4:]}"
193
+
194
+
195
+ def parse_kv_args(tokens: list[str]) -> tuple[dict[str, str], list[str]]:
196
+ kv: dict[str, str] = {}
197
+ rest: list[str] = []
198
+ for token in tokens:
199
+ if "=" in token:
200
+ k, _, v = token.partition("=")
201
+ kv[k.strip()] = v
202
+ else:
203
+ rest.append(token)
204
+ return kv, rest
utils/tui/progress.py ADDED
@@ -0,0 +1,292 @@
1
+ """Progress indicators and spinners for the TUI."""
2
+
3
+ import time
4
+ from contextlib import contextmanager
5
+ from typing import Generator, Optional
6
+
7
+ from rich import box
8
+ from rich.console import Console
9
+ from rich.live import Live
10
+ from rich.panel import Panel
11
+ from rich.spinner import Spinner as RichSpinner
12
+
13
+ from utils.tui.theme import Theme
14
+
15
+
16
+ class Spinner:
17
+ """Animated spinner with context information."""
18
+
19
+ # Spinner animation frames
20
+ FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
21
+
22
+ def __init__(
23
+ self,
24
+ console: Console,
25
+ message: str = "Processing...",
26
+ show_duration: bool = True,
27
+ ):
28
+ """Initialize spinner.
29
+
30
+ Args:
31
+ console: Rich console instance
32
+ message: Message to display with spinner
33
+ show_duration: Whether to show elapsed time
34
+ """
35
+ self.console = console
36
+ self.message = message
37
+ self.show_duration = show_duration
38
+ self._start_time: Optional[float] = None
39
+ self._live: Optional[Live] = None
40
+
41
+ def _render(self) -> Panel:
42
+ """Render the spinner panel.
43
+
44
+ Returns:
45
+ Rich Panel with spinner content
46
+ """
47
+ colors = Theme.get_colors()
48
+
49
+ # Build spinner text with optional duration
50
+ text = f" {self.message}"
51
+ if self.show_duration and self._start_time is not None:
52
+ elapsed = time.time() - self._start_time
53
+ text += f"\n └─ Duration: {elapsed:.1f}s"
54
+
55
+ # Create spinner with message
56
+ spinner = RichSpinner("dots", text=text, style=colors.primary)
57
+
58
+ return Panel(
59
+ spinner,
60
+ title=f"[{colors.thinking_accent}]Thinking[/{colors.thinking_accent}]",
61
+ title_align="left",
62
+ border_style=colors.text_muted,
63
+ box=box.ROUNDED,
64
+ padding=(0, 1),
65
+ )
66
+
67
+ @contextmanager
68
+ def __call__(self, message: Optional[str] = None) -> Generator[None, None, None]:
69
+ """Context manager for spinner display.
70
+
71
+ Args:
72
+ message: Optional message override
73
+
74
+ Yields:
75
+ None
76
+ """
77
+ if message:
78
+ self.message = message
79
+
80
+ self._start_time = time.time()
81
+ self._live = Live(
82
+ self._render(),
83
+ console=self.console,
84
+ refresh_per_second=10,
85
+ transient=True,
86
+ )
87
+
88
+ try:
89
+ with self._live:
90
+ yield
91
+ finally:
92
+ self._start_time = None
93
+ self._live = None
94
+
95
+ def update_message(self, message: str) -> None:
96
+ """Update the spinner message.
97
+
98
+ Args:
99
+ message: New message to display
100
+ """
101
+ self.message = message
102
+ if self._live is not None:
103
+ self._live.update(self._render())
104
+
105
+
106
+ class ProgressContext:
107
+ """Context manager for showing progress during long operations."""
108
+
109
+ def __init__(
110
+ self,
111
+ console: Console,
112
+ title: str = "Processing",
113
+ show_steps: bool = True,
114
+ ):
115
+ """Initialize progress context.
116
+
117
+ Args:
118
+ console: Rich console instance
119
+ title: Title for the progress display
120
+ show_steps: Whether to show step count
121
+ """
122
+ self.console = console
123
+ self.title = title
124
+ self.show_steps = show_steps
125
+ self._current_step = 0
126
+ self._total_steps = 0
127
+ self._current_message = ""
128
+ self._start_time: Optional[float] = None
129
+ self._live: Optional[Live] = None
130
+
131
+ def _render(self) -> Panel:
132
+ """Render the progress panel.
133
+
134
+ Returns:
135
+ Rich Panel with progress content
136
+ """
137
+ colors = Theme.get_colors()
138
+
139
+ lines = []
140
+
141
+ # Current message
142
+ lines.append(f" {self._current_message}")
143
+
144
+ # Step count
145
+ if self.show_steps and self._total_steps > 0:
146
+ lines.append(f" Step {self._current_step}/{self._total_steps}")
147
+
148
+ # Duration
149
+ if self._start_time is not None:
150
+ elapsed = time.time() - self._start_time
151
+ lines.append(f" [dim]Duration: {elapsed:.1f}s[/dim]")
152
+
153
+ content = "\n".join(lines)
154
+
155
+ return Panel(
156
+ content,
157
+ title=f"[{colors.primary}]{self.title}[/{colors.primary}]",
158
+ title_align="left",
159
+ border_style=colors.text_muted,
160
+ box=box.ROUNDED,
161
+ padding=(0, 1),
162
+ )
163
+
164
+ def set_total_steps(self, total: int) -> None:
165
+ """Set the total number of steps.
166
+
167
+ Args:
168
+ total: Total number of steps
169
+ """
170
+ self._total_steps = total
171
+
172
+ def advance(self, message: str) -> None:
173
+ """Advance to the next step.
174
+
175
+ Args:
176
+ message: Message for this step
177
+ """
178
+ self._current_step += 1
179
+ self._current_message = message
180
+ if self._live is not None:
181
+ self._live.update(self._render())
182
+
183
+ def update_message(self, message: str) -> None:
184
+ """Update the current message without advancing.
185
+
186
+ Args:
187
+ message: New message
188
+ """
189
+ self._current_message = message
190
+ if self._live is not None:
191
+ self._live.update(self._render())
192
+
193
+ @contextmanager
194
+ def __call__(
195
+ self, message: str = "Starting...", total_steps: int = 0
196
+ ) -> Generator["ProgressContext", None, None]:
197
+ """Context manager for progress display.
198
+
199
+ Args:
200
+ message: Initial message
201
+ total_steps: Total number of steps (0 for indeterminate)
202
+
203
+ Yields:
204
+ Self for updating progress
205
+ """
206
+ self._current_message = message
207
+ self._total_steps = total_steps
208
+ self._current_step = 0
209
+ self._start_time = time.time()
210
+
211
+ self._live = Live(
212
+ self._render(),
213
+ console=self.console,
214
+ refresh_per_second=4,
215
+ transient=True,
216
+ )
217
+
218
+ try:
219
+ with self._live:
220
+ yield self
221
+ finally:
222
+ self._start_time = None
223
+ self._live = None
224
+
225
+
226
+ class AsyncSpinner:
227
+ """Async-compatible spinner for use in async contexts."""
228
+
229
+ def __init__(
230
+ self,
231
+ console: Console,
232
+ message: str = "Processing...",
233
+ ):
234
+ """Initialize async spinner.
235
+
236
+ Args:
237
+ console: Rich console instance
238
+ message: Message to display
239
+ """
240
+ self.console = console
241
+ self.message = message
242
+ self._start_time: Optional[float] = None
243
+ self._live: Optional[Live] = None
244
+ self._running = False
245
+
246
+ def _render(self) -> Panel:
247
+ """Render the spinner panel."""
248
+ colors = Theme.get_colors()
249
+
250
+ spinner = RichSpinner("dots", text=f" {self.message}", style=colors.primary)
251
+
252
+ return Panel(
253
+ spinner,
254
+ title=f"[{colors.thinking_accent}]Thinking[/{colors.thinking_accent}]",
255
+ title_align="left",
256
+ border_style=colors.text_muted,
257
+ box=box.ROUNDED,
258
+ padding=(0, 1),
259
+ )
260
+
261
+ async def __aenter__(self) -> "AsyncSpinner":
262
+ """Async context manager entry."""
263
+ if self.console.quiet:
264
+ return self
265
+ self._start_time = time.time()
266
+ self._running = True
267
+ self._live = Live(
268
+ self._render(),
269
+ console=self.console,
270
+ refresh_per_second=10,
271
+ transient=True,
272
+ )
273
+ self._live.start()
274
+ return self
275
+
276
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
277
+ """Async context manager exit."""
278
+ self._running = False
279
+ if self._live is not None:
280
+ self._live.stop()
281
+ self._live = None
282
+ self._start_time = None
283
+
284
+ def update_message(self, message: str) -> None:
285
+ """Update the spinner message.
286
+
287
+ Args:
288
+ message: New message
289
+ """
290
+ self.message = message
291
+ if self._live is not None and self._running:
292
+ self._live.update(self._render())
@@ -0,0 +1,178 @@
1
+ """Persistent status bar for the TUI."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+ from rich import box
7
+ from rich.console import Console
8
+ from rich.live import Live
9
+ from rich.panel import Panel
10
+ from rich.text import Text
11
+
12
+ from utils.tui.theme import Theme
13
+
14
+
15
+ @dataclass
16
+ class StatusBarState:
17
+ """State for the status bar."""
18
+
19
+ mode: str = "REACT"
20
+ input_tokens: int = 0
21
+ output_tokens: int = 0
22
+ context_tokens: int = 0
23
+ cost: float = 0.0
24
+ is_processing: bool = False
25
+ status_message: str = ""
26
+ model_name: str = ""
27
+
28
+
29
+ class StatusBar:
30
+ """Persistent status bar displayed at the bottom of the terminal."""
31
+
32
+ def __init__(self, console: Console):
33
+ """Initialize status bar.
34
+
35
+ Args:
36
+ console: Rich console instance
37
+ """
38
+ self.console = console
39
+ self.state = StatusBarState()
40
+ self._live: Optional[Live] = None
41
+
42
+ def _format_tokens(self, count: int) -> str:
43
+ """Format token count for display.
44
+
45
+ Args:
46
+ count: Token count
47
+
48
+ Returns:
49
+ Formatted string (e.g., "12.5K" or "1.2M")
50
+ """
51
+ if count >= 1_000_000:
52
+ return f"{count / 1_000_000:.1f}M"
53
+ elif count >= 1_000:
54
+ return f"{count / 1_000:.1f}K"
55
+ else:
56
+ return str(count)
57
+
58
+ def _render(self) -> Panel:
59
+ """Render the status bar panel.
60
+
61
+ Returns:
62
+ Rich Panel with status bar content
63
+ """
64
+ colors = Theme.get_colors()
65
+
66
+ # Build status items
67
+ items = []
68
+
69
+ # Model name (if set)
70
+ if self.state.model_name:
71
+ items.append(
72
+ f"[{colors.text_secondary}]Model:[/{colors.text_secondary}] [{colors.primary}]{self.state.model_name}[/{colors.primary}]"
73
+ )
74
+
75
+ # Mode
76
+ items.append(
77
+ f"[{colors.text_secondary}]Mode:[/{colors.text_secondary}] [{colors.primary}]{self.state.mode}[/{colors.primary}]"
78
+ )
79
+
80
+ # Total Tokens (in/out)
81
+ total_in = self._format_tokens(self.state.input_tokens)
82
+ total_out = self._format_tokens(self.state.output_tokens)
83
+ items.append(
84
+ f"[{colors.text_secondary}]Total:[/{colors.text_secondary}] {total_in}↓ {total_out}↑"
85
+ )
86
+
87
+ # Context Tokens
88
+ ctx_tokens = self._format_tokens(self.state.context_tokens)
89
+ items.append(f"[{colors.text_secondary}]Context:[/{colors.text_secondary}] {ctx_tokens}")
90
+
91
+ # Cost
92
+ items.append(
93
+ f"[{colors.text_secondary}]Cost:[/{colors.text_secondary}] ${self.state.cost:.4f}"
94
+ )
95
+
96
+ # Processing indicator
97
+ if self.state.is_processing:
98
+ items.append(f"[{colors.warning}]●[/{colors.warning}]")
99
+ else:
100
+ items.append(f"[{colors.success}]◉[/{colors.success}]")
101
+
102
+ # Join with separator
103
+ content = " │ ".join(items)
104
+
105
+ return Panel(
106
+ Text.from_markup(content),
107
+ box=box.DOUBLE,
108
+ border_style=colors.text_muted,
109
+ padding=(0, 1),
110
+ )
111
+
112
+ def update(
113
+ self,
114
+ mode: Optional[str] = None,
115
+ input_tokens: Optional[int] = None,
116
+ output_tokens: Optional[int] = None,
117
+ context_tokens: Optional[int] = None,
118
+ cost: Optional[float] = None,
119
+ is_processing: Optional[bool] = None,
120
+ status_message: Optional[str] = None,
121
+ model_name: Optional[str] = None,
122
+ ) -> None:
123
+ """Update status bar state.
124
+
125
+ Args:
126
+ mode: Agent mode (REACT, PLAN, etc.)
127
+ input_tokens: Total input tokens used
128
+ output_tokens: Total output tokens used
129
+ context_tokens: Current context window tokens
130
+ cost: Current cost
131
+ is_processing: Whether currently processing
132
+ status_message: Optional status message
133
+ model_name: Current model name
134
+ """
135
+ if mode is not None:
136
+ self.state.mode = mode
137
+ if input_tokens is not None:
138
+ self.state.input_tokens = input_tokens
139
+ if output_tokens is not None:
140
+ self.state.output_tokens = output_tokens
141
+ if context_tokens is not None:
142
+ self.state.context_tokens = context_tokens
143
+ if cost is not None:
144
+ self.state.cost = cost
145
+ if is_processing is not None:
146
+ self.state.is_processing = is_processing
147
+ if status_message is not None:
148
+ self.state.status_message = status_message
149
+ if model_name is not None:
150
+ self.state.model_name = model_name
151
+
152
+ # Refresh live display if active
153
+ if self._live is not None:
154
+ self._live.update(self._render())
155
+
156
+ def show(self) -> None:
157
+ """Display the status bar (non-live version)."""
158
+ self.console.print(self._render())
159
+
160
+ def start_live(self) -> Live:
161
+ """Start live updating status bar.
162
+
163
+ Returns:
164
+ Live context manager
165
+ """
166
+ self._live = Live(
167
+ self._render(),
168
+ console=self.console,
169
+ refresh_per_second=4,
170
+ transient=True,
171
+ )
172
+ return self._live
173
+
174
+ def stop_live(self) -> None:
175
+ """Stop live updating."""
176
+ if self._live is not None:
177
+ self._live.stop()
178
+ self._live = None