python-agent-harness 1.5.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.
- python_agent_harness/__init__.py +20 -0
- python_agent_harness/__main__.py +5 -0
- python_agent_harness/agent.py +703 -0
- python_agent_harness/cli.py +273 -0
- python_agent_harness/client.py +832 -0
- python_agent_harness/commands.py +181 -0
- python_agent_harness/config.py +464 -0
- python_agent_harness/context_manager.py +100 -0
- python_agent_harness/diffrender.py +84 -0
- python_agent_harness/mcp/__init__.py +21 -0
- python_agent_harness/mcp/client.py +161 -0
- python_agent_harness/mcp/config.py +130 -0
- python_agent_harness/mcp/manager.py +290 -0
- python_agent_harness/models.py +149 -0
- python_agent_harness/persistence.py +297 -0
- python_agent_harness/planmode.py +112 -0
- python_agent_harness/prompts/agent.md +362 -0
- python_agent_harness/prompts/build-switch.md +5 -0
- python_agent_harness/prompts/commands/explain.md +13 -0
- python_agent_harness/prompts/compact.md +33 -0
- python_agent_harness/prompts/initialize.md +66 -0
- python_agent_harness/prompts/plan-mode.md +70 -0
- python_agent_harness/prompts/plan.md +26 -0
- python_agent_harness/prompts/review.md +100 -0
- python_agent_harness/prompts/subagent.md +208 -0
- python_agent_harness/prompts/summary.md +11 -0
- python_agent_harness/prompts/task-completion-rules.md +50 -0
- python_agent_harness/prompts/title.md +44 -0
- python_agent_harness/prompts.py +498 -0
- python_agent_harness/session.py +781 -0
- python_agent_harness/subagent.py +61 -0
- python_agent_harness/token_estimator.py +125 -0
- python_agent_harness/tool_runner.py +247 -0
- python_agent_harness/tools/__init__.py +56 -0
- python_agent_harness/tools/agent_tool.py +75 -0
- python_agent_harness/tools/base.py +147 -0
- python_agent_harness/tools/bash.py +298 -0
- python_agent_harness/tools/edit.py +272 -0
- python_agent_harness/tools/filesystem.py +180 -0
- python_agent_harness/tools/glob.py +161 -0
- python_agent_harness/tools/grep.py +149 -0
- python_agent_harness/tools/insert.py +61 -0
- python_agent_harness/tools/mcp.py +203 -0
- python_agent_harness/tools/mkdir.py +30 -0
- python_agent_harness/tools/planexit.py +45 -0
- python_agent_harness/tools/question.py +70 -0
- python_agent_harness/tools/read.py +104 -0
- python_agent_harness/tools/skill.py +32 -0
- python_agent_harness/tools/todo.py +60 -0
- python_agent_harness/tools/write.py +56 -0
- python_agent_harness/tui/__init__.py +68 -0
- python_agent_harness/tui/commands.py +652 -0
- python_agent_harness/tui/core.py +385 -0
- python_agent_harness/tui/input.py +412 -0
- python_agent_harness/tui/render.py +535 -0
- python_agent_harness-1.5.0.dist-info/METADATA +251 -0
- python_agent_harness-1.5.0.dist-info/RECORD +61 -0
- python_agent_harness-1.5.0.dist-info/WHEEL +5 -0
- python_agent_harness-1.5.0.dist-info/entry_points.txt +2 -0
- python_agent_harness-1.5.0.dist-info/licenses/LICENSE +21 -0
- python_agent_harness-1.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Core TUI class — the coordinator that owns shared state, the main
|
|
2
|
+
event loop, agent run lifecycle, and session callbacks.
|
|
3
|
+
|
|
4
|
+
Rendering, input, and slash commands are mixed in from their respective
|
|
5
|
+
modules; this module provides the glue: ``__init__``, ``run``,
|
|
6
|
+
``_start_agent``, ``_run_live``, ``_run_dumb``, ``_run_agent``, and the
|
|
7
|
+
session callbacks (``_on_delta``, ``_on_notify``, ``_on_log``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from prompt_toolkit.history import FileHistory
|
|
19
|
+
from rich import box
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
from rich.live import Live
|
|
22
|
+
from rich.panel import Panel
|
|
23
|
+
from rich.text import Text
|
|
24
|
+
|
|
25
|
+
from .. import config
|
|
26
|
+
from ..agent import run_agent_loop
|
|
27
|
+
from ..models import Message
|
|
28
|
+
from ..session import Session
|
|
29
|
+
from .commands import CommandMixin
|
|
30
|
+
from .input import InputMixin, SlashCompleter, UiQuestion, _history_path, _make_prompt_session
|
|
31
|
+
from .render import RenderMixin
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Tui(RenderMixin, InputMixin, CommandMixin):
|
|
35
|
+
"""Interactive Rich TUI for the agent harness.
|
|
36
|
+
|
|
37
|
+
Layout: conversation panel + status bar + input line. The agent loop
|
|
38
|
+
runs in a worker thread; the main thread renders with rich Live and
|
|
39
|
+
services interactive questions (Question tool, PlanExit confirmation).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, session: Session, console: Console | None = None) -> None:
|
|
43
|
+
self.session = session
|
|
44
|
+
self.console = console or Console()
|
|
45
|
+
self.stream_text = ""
|
|
46
|
+
self.lock = threading.Lock()
|
|
47
|
+
self.question: UiQuestion | None = None
|
|
48
|
+
self.agent_running = False
|
|
49
|
+
self.status = " idle"
|
|
50
|
+
self._current_tool = ""
|
|
51
|
+
self.run_seq = 0
|
|
52
|
+
self._restore: Callable[[], None] | None = None
|
|
53
|
+
self.conversation_history: list[Message] = []
|
|
54
|
+
# Index into ``session.last_messages`` where the current round
|
|
55
|
+
# begins: the live panel renders only from here on (the latest
|
|
56
|
+
# round of interactions), while the end-of-run scrollback dump
|
|
57
|
+
# prints the full conversation. 0 means "show everything" —
|
|
58
|
+
# the default until the first run sets a boundary.
|
|
59
|
+
self.round_start = 0
|
|
60
|
+
# Text the user submitted for the current round, shown as a live
|
|
61
|
+
# user row until it is mirrored into ``session.last_messages``
|
|
62
|
+
# (which only happens once the first assistant response lands).
|
|
63
|
+
self.round_user_text = ""
|
|
64
|
+
self._data_event = threading.Event()
|
|
65
|
+
self._history_cache: list[Any] | None = None
|
|
66
|
+
self._history_dirty = True
|
|
67
|
+
# wall-clock start of each agent run (one per user round), used
|
|
68
|
+
# by the end-of-run dump to timestamp the round separators
|
|
69
|
+
self._round_times: list[float] = []
|
|
70
|
+
# wall-clock start of the current run, used to report the total
|
|
71
|
+
# time spent once the run finishes
|
|
72
|
+
self._run_start: float | None = None
|
|
73
|
+
self.prompt_session = _make_prompt_session(
|
|
74
|
+
FileHistory(_history_path()),
|
|
75
|
+
SlashCompleter(lambda: str(self.session.project_dir)),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
session.on_delta = self._on_delta
|
|
79
|
+
session.notify_fn = self._on_notify
|
|
80
|
+
session.log_fn = self._on_log
|
|
81
|
+
session.confirm_fn = self._ui_confirm
|
|
82
|
+
session.ask_fn = self._ui_ask
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
# session callbacks (called from the worker thread)
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
def _on_delta(self, text: str) -> None:
|
|
88
|
+
with self.lock:
|
|
89
|
+
self.stream_text += text
|
|
90
|
+
# bound the buffer so every frame's tail-slicing is
|
|
91
|
+
# constant-time, no matter how long the generation runs
|
|
92
|
+
if len(self.stream_text) > 100_000:
|
|
93
|
+
self.stream_text = self.stream_text[-100_000:]
|
|
94
|
+
# wake the render loop immediately: streaming text pushes the
|
|
95
|
+
# display without waiting for the next fixed tick
|
|
96
|
+
self._data_event.set()
|
|
97
|
+
|
|
98
|
+
def _on_notify(self, kind: str, data: Any = None) -> None:
|
|
99
|
+
if kind == "tool_start":
|
|
100
|
+
# Tool execution is starting: clear the stale stream text
|
|
101
|
+
# (the assistant message is committed to messages and will
|
|
102
|
+
# appear in history once delivered) and show which tools are
|
|
103
|
+
# running, so the display stays alive during long operations.
|
|
104
|
+
with self.lock:
|
|
105
|
+
self.stream_text = ""
|
|
106
|
+
names = data if isinstance(data, list) else []
|
|
107
|
+
label = ", ".join(names) if names else "tools"
|
|
108
|
+
self._current_tool = label
|
|
109
|
+
self.status = f" ⏳ {label}"
|
|
110
|
+
self._history_dirty = True
|
|
111
|
+
elif kind == "tool_running":
|
|
112
|
+
# Per-tool notification: update the current tool name shown
|
|
113
|
+
# beside the spinner as each sync tool starts executing.
|
|
114
|
+
name = data if isinstance(data, str) else ""
|
|
115
|
+
self._current_tool = name
|
|
116
|
+
self.status = f" ⏳ {name}" if name else " ⏳ tools"
|
|
117
|
+
elif kind == "tools":
|
|
118
|
+
self._current_tool = ""
|
|
119
|
+
self.status = " running tools"
|
|
120
|
+
# tool round finished: session.last_messages now contains the
|
|
121
|
+
# tool-call + result rows — rebuild the cached history so
|
|
122
|
+
# they show up live instead of after the run ends
|
|
123
|
+
self._history_dirty = True
|
|
124
|
+
elif kind == "compact":
|
|
125
|
+
self.status = " compacted"
|
|
126
|
+
self._history_dirty = True
|
|
127
|
+
elif kind == "retry":
|
|
128
|
+
# A connection error mid-stream: the client discarded the
|
|
129
|
+
# partial response and is retrying on a fresh connection —
|
|
130
|
+
# drop the partial stream text so the restarted stream
|
|
131
|
+
# doesn't duplicate it on screen.
|
|
132
|
+
with self.lock:
|
|
133
|
+
self.stream_text = ""
|
|
134
|
+
self.status = " connection lost — retrying"
|
|
135
|
+
elif kind == "todos":
|
|
136
|
+
# TodoWrite updated the task list: the cached history rows
|
|
137
|
+
# (which include the Todos panel) must be rebuilt
|
|
138
|
+
self._history_dirty = True
|
|
139
|
+
elif kind == "error":
|
|
140
|
+
self.status = " error"
|
|
141
|
+
elif kind == "save-error":
|
|
142
|
+
self.status = " auto-save failed"
|
|
143
|
+
else:
|
|
144
|
+
self.status = " running"
|
|
145
|
+
self._data_event.set()
|
|
146
|
+
|
|
147
|
+
def _on_log(self, msg: str) -> None:
|
|
148
|
+
self.status = f" {msg}"
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
# main loop
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
def run(self) -> None:
|
|
154
|
+
self.console.print(
|
|
155
|
+
Panel(
|
|
156
|
+
Text.from_markup(
|
|
157
|
+
"[bold]Commands:[/bold] /plan /build /init /review /explain "
|
|
158
|
+
"/compact /save /summary /sessions /restore /help /exit\n\n"
|
|
159
|
+
"Ctrl-C cancels the current execution (the app stays open); "
|
|
160
|
+
"Ctrl-D or /exit quits.\n"
|
|
161
|
+
"Type a message — Enter for a new line, Esc then Enter "
|
|
162
|
+
"(or Alt+Enter) to submit. Up/Down recall history.\n\n"
|
|
163
|
+
"[dim]Type [bold]/help[/bold] for the full command reference.[/dim]"
|
|
164
|
+
),
|
|
165
|
+
title="[bold cyan]python-agent-harness — interactive AI coding agent[/bold cyan]",
|
|
166
|
+
border_style="cyan",
|
|
167
|
+
box=box.ROUNDED,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
if config.LLM_LOG_ENABLED:
|
|
171
|
+
self.console.print(f"[dim]LLM logs: {self.session.client.log_path}[/dim]")
|
|
172
|
+
while True:
|
|
173
|
+
try:
|
|
174
|
+
if self.question is not None:
|
|
175
|
+
self._ask_question_blocking()
|
|
176
|
+
continue
|
|
177
|
+
self.console.print(self._status_bar())
|
|
178
|
+
self._flush()
|
|
179
|
+
text = self._read_multiline()
|
|
180
|
+
if text is None:
|
|
181
|
+
break
|
|
182
|
+
if not text.strip():
|
|
183
|
+
continue
|
|
184
|
+
if text.startswith("/"):
|
|
185
|
+
if self._handle_slash(text):
|
|
186
|
+
break
|
|
187
|
+
continue
|
|
188
|
+
self._start_agent(text)
|
|
189
|
+
except KeyboardInterrupt:
|
|
190
|
+
# stray Ctrl-C outside input/execution: stay in the app
|
|
191
|
+
self.console.print("[dim]cancelled — Ctrl-D or /exit to quit[/dim]")
|
|
192
|
+
|
|
193
|
+
def _start_agent(
|
|
194
|
+
self,
|
|
195
|
+
text: str,
|
|
196
|
+
system: str | None = None,
|
|
197
|
+
restore: Callable[[], None] | None = None,
|
|
198
|
+
) -> None:
|
|
199
|
+
"""Run the agent loop on TEXT in a worker thread.
|
|
200
|
+
|
|
201
|
+
SYSTEM overrides the session's system prompt for this run only.
|
|
202
|
+
RESTORE (if given) runs when the run finishes — used by the
|
|
203
|
+
slash commands to put back state they borrowed (e.g. project_dir).
|
|
204
|
+
"""
|
|
205
|
+
self.stream_text = ""
|
|
206
|
+
self.status = " running"
|
|
207
|
+
self._current_tool = ""
|
|
208
|
+
# Mark where this round begins in the shared history: the live
|
|
209
|
+
# panel renders only from here on (the latest round), while the
|
|
210
|
+
# end-of-run dump prints the full conversation. Captured before
|
|
211
|
+
# the run so it points just past the previous round's messages —
|
|
212
|
+
# the new user message will be the first mirrored row.
|
|
213
|
+
self.round_start = len(self.session.last_messages or [])
|
|
214
|
+
self.round_user_text = text
|
|
215
|
+
self._round_times.append(time.time())
|
|
216
|
+
self._run_start = time.time()
|
|
217
|
+
# keep the persisted metadata in sync so auto-save /save
|
|
218
|
+
# capture the round timestamps (restore reads them back)
|
|
219
|
+
self.session.store.round_times = list(self._round_times)
|
|
220
|
+
# A new top-level run starts here: drop any todo list left over
|
|
221
|
+
# from a previous run so a finished task's todos don't stay
|
|
222
|
+
# pinned into the next task.
|
|
223
|
+
self.session.clear_todos()
|
|
224
|
+
# A new top-level run starts here: invalidate any worker still
|
|
225
|
+
# unwinding from a previous run — from this point on it is stale
|
|
226
|
+
# and must never touch shared state. Bump before clearing the
|
|
227
|
+
# event so there is no instant where an old worker sees "not
|
|
228
|
+
# cancelled".
|
|
229
|
+
self.session.run_generation += 1
|
|
230
|
+
self.session.cancel_event.clear()
|
|
231
|
+
self._data_event.clear()
|
|
232
|
+
self._history_dirty = True
|
|
233
|
+
self.run_seq += 1
|
|
234
|
+
seq = self.run_seq
|
|
235
|
+
self._restore = restore
|
|
236
|
+
self.agent_running = True
|
|
237
|
+
worker = threading.Thread(
|
|
238
|
+
target=self._run_agent, args=(text, seq, system, restore), daemon=True
|
|
239
|
+
)
|
|
240
|
+
worker.start()
|
|
241
|
+
cancelled = False
|
|
242
|
+
try:
|
|
243
|
+
if self.console.is_dumb_terminal:
|
|
244
|
+
cancelled = self._run_dumb(worker)
|
|
245
|
+
else:
|
|
246
|
+
cancelled = self._run_live(worker)
|
|
247
|
+
except KeyboardInterrupt:
|
|
248
|
+
# Ctrl-C during execution: cancel the run, keep the app open.
|
|
249
|
+
# The worker is a daemon and cancel-aware; don't join it — a
|
|
250
|
+
# hung HTTP read may take a while, and the UI must return to
|
|
251
|
+
# the input prompt immediately.
|
|
252
|
+
cancelled = True
|
|
253
|
+
self.session.cancel()
|
|
254
|
+
if self.question is not None:
|
|
255
|
+
# a pending question wedges the worker in its wait: the
|
|
256
|
+
# run is cancelled, so release it now — it must not be
|
|
257
|
+
# re-prompted after the run is over
|
|
258
|
+
self.question.answer = ""
|
|
259
|
+
self.question.event.set()
|
|
260
|
+
self.question = None
|
|
261
|
+
if self._restore is not None:
|
|
262
|
+
# release state the cancelled run borrowed (e.g. a slash
|
|
263
|
+
# command's project dir) now: the worker may finish late
|
|
264
|
+
# (stale) and its own finally must not touch it then
|
|
265
|
+
self._restore()
|
|
266
|
+
self._restore = None
|
|
267
|
+
self.console.print("\n[dim]execution cancelled — add more messages or /exit[/dim]")
|
|
268
|
+
self._flush()
|
|
269
|
+
finally:
|
|
270
|
+
self.agent_running = False
|
|
271
|
+
if not cancelled:
|
|
272
|
+
self.console.print()
|
|
273
|
+
self._flush()
|
|
274
|
+
|
|
275
|
+
def _run_live(self, worker: threading.Thread) -> bool:
|
|
276
|
+
"""Live-based display (real terminal). Returns True if cancelled.
|
|
277
|
+
|
|
278
|
+
Event-driven: the render loop blocks on `_data_event` and wakes
|
|
279
|
+
the moment new stream text arrives, so the text pushes the
|
|
280
|
+
scroll immediately instead of waiting for a fixed tick. The
|
|
281
|
+
short timeout keeps the spinner animating between data bursts.
|
|
282
|
+
"""
|
|
283
|
+
with Live(
|
|
284
|
+
self._render_frame(),
|
|
285
|
+
console=self.console,
|
|
286
|
+
refresh_per_second=30,
|
|
287
|
+
screen=False,
|
|
288
|
+
) as live:
|
|
289
|
+
while worker.is_alive():
|
|
290
|
+
if self.question is not None:
|
|
291
|
+
live.stop()
|
|
292
|
+
self._ask_question_blocking()
|
|
293
|
+
live.start()
|
|
294
|
+
continue
|
|
295
|
+
self._data_event.wait(timeout=0.1)
|
|
296
|
+
self._data_event.clear()
|
|
297
|
+
live.update(self._render_frame())
|
|
298
|
+
self._flush()
|
|
299
|
+
live.update(self._render_frame())
|
|
300
|
+
self._flush()
|
|
301
|
+
# run finished: the last Live frame stays on screen, but the
|
|
302
|
+
# in-place redraws never reached the scrollback — print the
|
|
303
|
+
# full conversation so the user can scroll back through it
|
|
304
|
+
self._dump_conversation()
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
def _run_dumb(self, worker: threading.Thread) -> bool:
|
|
308
|
+
"""Dumb-terminal fallback: print each frame as a normal line.
|
|
309
|
+
|
|
310
|
+
rich's Live intentionally renders nothing on dumb terminals
|
|
311
|
+
(TERM unset/"dumb"), so without this the status bar and spinner
|
|
312
|
+
would never appear there. Same event-driven wakeup as `_run_live`.
|
|
313
|
+
"""
|
|
314
|
+
self.console.print(self._render_frame())
|
|
315
|
+
self._flush()
|
|
316
|
+
while worker.is_alive():
|
|
317
|
+
if self.question is not None:
|
|
318
|
+
self._ask_question_blocking()
|
|
319
|
+
continue
|
|
320
|
+
self._data_event.wait(timeout=0.1)
|
|
321
|
+
self._data_event.clear()
|
|
322
|
+
self.console.print(self._render_frame())
|
|
323
|
+
self._flush()
|
|
324
|
+
self.console.print(self._render_frame())
|
|
325
|
+
self._flush()
|
|
326
|
+
self._dump_conversation()
|
|
327
|
+
return False
|
|
328
|
+
|
|
329
|
+
def _flush(self) -> None:
|
|
330
|
+
"""Force stdout through so Live frames render in real time.
|
|
331
|
+
|
|
332
|
+
rich's Live does not flush after each refresh, and tty stdout is
|
|
333
|
+
line-buffered — without this the status bar/spinner would sit in
|
|
334
|
+
the stdio buffer and only appear once the run ends (or the 8KB
|
|
335
|
+
buffer fills).
|
|
336
|
+
"""
|
|
337
|
+
with contextlib.suppress(AttributeError, OSError):
|
|
338
|
+
self.console.file.flush()
|
|
339
|
+
|
|
340
|
+
def _run_agent(
|
|
341
|
+
self,
|
|
342
|
+
text: str,
|
|
343
|
+
seq: int,
|
|
344
|
+
system: str | None = None,
|
|
345
|
+
restore: Callable[[], None] | None = None,
|
|
346
|
+
) -> None:
|
|
347
|
+
try:
|
|
348
|
+
self.conversation_history.append(Message(role="user", content=text))
|
|
349
|
+
run_agent_loop(
|
|
350
|
+
self.session,
|
|
351
|
+
messages=list(self.conversation_history),
|
|
352
|
+
top_level=True,
|
|
353
|
+
system=system or self.session.system_prompt,
|
|
354
|
+
)
|
|
355
|
+
# Only the current run may update shared state: a stale
|
|
356
|
+
# worker (a newer run started — `run_seq` advanced) must not
|
|
357
|
+
# clobber the next run. A cancelled run with no successor
|
|
358
|
+
# is still current, so it adopts its salvaged partial
|
|
359
|
+
# history and the interrupted turn is not lost (the seq
|
|
360
|
+
# check is the staleness guard; the cancel event no longer
|
|
361
|
+
# blocks the adoption).
|
|
362
|
+
if seq == self.run_seq and self.session.last_messages:
|
|
363
|
+
self.conversation_history = list(self.session.last_messages)
|
|
364
|
+
except Exception as e: # noqa: BLE001
|
|
365
|
+
if seq == self.run_seq:
|
|
366
|
+
self._on_log(f"agent error: {e}")
|
|
367
|
+
finally:
|
|
368
|
+
# Only the current run may touch shared UI state: a stale
|
|
369
|
+
# worker from a cancelled run that finishes late must not
|
|
370
|
+
# wipe the next run's live stream or fire its restore
|
|
371
|
+
# callback (which could reset e.g. a borrowed project dir
|
|
372
|
+
# while the new run is mid-execution). The restore for a
|
|
373
|
+
# cancelled run is released by the Ctrl-C handler instead.
|
|
374
|
+
if seq == self.run_seq:
|
|
375
|
+
# the run is done: the final assistant message is now
|
|
376
|
+
# part of the conversation history, so drop the live
|
|
377
|
+
# stream buffer — otherwise the same text renders twice
|
|
378
|
+
# (stream row + history row) and eats the visible-row
|
|
379
|
+
# budget
|
|
380
|
+
with self.lock:
|
|
381
|
+
self.stream_text = ""
|
|
382
|
+
self._history_dirty = True
|
|
383
|
+
self._data_event.set()
|
|
384
|
+
if restore is not None:
|
|
385
|
+
restore()
|