scootcli 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.
- scootcli/__init__.py +4 -0
- scootcli/__main__.py +9 -0
- scootcli/activity.py +26 -0
- scootcli/agent.py +350 -0
- scootcli/approvals.py +167 -0
- scootcli/auth.py +59 -0
- scootcli/cli.py +276 -0
- scootcli/clipboard.py +89 -0
- scootcli/commands/__init__.py +56 -0
- scootcli/commands/approve.py +38 -0
- scootcli/commands/auth.py +107 -0
- scootcli/commands/base.py +31 -0
- scootcli/commands/compact.py +40 -0
- scootcli/commands/copy.py +23 -0
- scootcli/commands/exit.py +14 -0
- scootcli/commands/forget.py +28 -0
- scootcli/commands/help.py +29 -0
- scootcli/commands/init.py +50 -0
- scootcli/commands/logo.py +51 -0
- scootcli/commands/model.py +61 -0
- scootcli/commands/panel.py +28 -0
- scootcli/commands/reset.py +20 -0
- scootcli/commands/resume.py +31 -0
- scootcli/commands/save.py +29 -0
- scootcli/commands/sessions.py +42 -0
- scootcli/commands/status.py +59 -0
- scootcli/commands/verbosity.py +57 -0
- scootcli/commands/worktree.py +64 -0
- scootcli/commands/yolo.py +20 -0
- scootcli/config.py +241 -0
- scootcli/context.py +82 -0
- scootcli/credentials.py +79 -0
- scootcli/errors.py +87 -0
- scootcli/images.py +169 -0
- scootcli/keys.py +119 -0
- scootcli/lineeditor.py +577 -0
- scootcli/logo.py +116 -0
- scootcli/models.py +120 -0
- scootcli/panel.py +263 -0
- scootcli/preferences.py +87 -0
- scootcli/presets.py +38 -0
- scootcli/project.py +94 -0
- scootcli/prompts.py +100 -0
- scootcli/providers/__init__.py +20 -0
- scootcli/providers/base.py +370 -0
- scootcli/providers/openai_chat.py +142 -0
- scootcli/providers/openai_responses.py +248 -0
- scootcli/providers/registry.py +173 -0
- scootcli/rendering.py +86 -0
- scootcli/repl.py +801 -0
- scootcli/sessions.py +186 -0
- scootcli/status.py +71 -0
- scootcli/tools/__init__.py +68 -0
- scootcli/tools/base.py +152 -0
- scootcli/tools/edit_file.py +72 -0
- scootcli/tools/list_dir.py +47 -0
- scootcli/tools/read_file.py +56 -0
- scootcli/tools/run_shell.py +73 -0
- scootcli/tools/search.py +170 -0
- scootcli/tools/update_plan.py +104 -0
- scootcli/tools/write_file.py +61 -0
- scootcli/transport.py +312 -0
- scootcli/vision.py +167 -0
- scootcli/workspace.py +105 -0
- scootcli/worktree.py +114 -0
- scootcli-0.1.0.dist-info/METADATA +238 -0
- scootcli-0.1.0.dist-info/RECORD +71 -0
- scootcli-0.1.0.dist-info/WHEEL +5 -0
- scootcli-0.1.0.dist-info/entry_points.txt +2 -0
- scootcli-0.1.0.dist-info/licenses/LICENSE +21 -0
- scootcli-0.1.0.dist-info/top_level.txt +1 -0
scootcli/repl.py
ADDED
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
"""Persistent REPL with live status and ESC-to-interrupt (PLAN §6).
|
|
2
|
+
|
|
3
|
+
Lifecycle: banner → wait for a prompt → run the turn (spinner + ESC-interrupt) → print result →
|
|
4
|
+
repeat. Slash-commands (``/help``, ``/status``, …) are handled locally by the ``commands`` registry.
|
|
5
|
+
|
|
6
|
+
M2 runs a single chat turn per prompt (no tools yet — the agentic loop arrives in M4), but the
|
|
7
|
+
worker-thread + cancel-event machinery is exactly what the tool loop will reuse.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from typing import List
|
|
19
|
+
|
|
20
|
+
from . import commands
|
|
21
|
+
from .agent import Agent, AgentOutcome
|
|
22
|
+
from .approvals import MODES, request_approval
|
|
23
|
+
from .providers import ProviderPool
|
|
24
|
+
from .config import Config
|
|
25
|
+
from .keys import InterruptibleSection
|
|
26
|
+
from .lineeditor import LineEditor
|
|
27
|
+
from . import logo
|
|
28
|
+
from .panel import StatusBar, build_status_text
|
|
29
|
+
from .prompts import CHAT_SYSTEM_PROMPT
|
|
30
|
+
from .rendering import color, eprint, redact
|
|
31
|
+
from .status import Status
|
|
32
|
+
from .tools.base import ToolResult
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_CODES_RESET = "\033[0m" if sys.stdout.isatty() else ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _assistant_label(emoji: bool = True) -> str:
|
|
39
|
+
"""Lead-in gutter marking the start of the assistant's final answer (🛴 scoot, or ⏺ scoot)."""
|
|
40
|
+
return color(logo.label(emoji), "cyan", "bold")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# A slash-command is "/" + a command-ish name (letters/digits/-/_), then a space or end-of-line.
|
|
44
|
+
# This deliberately does NOT match a dropped file path like "/Users/me/shot.png" (which has more "/").
|
|
45
|
+
_COMMAND_RE = re.compile(r"^/[A-Za-z][A-Za-z0-9_-]*(?:\s|$)")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_slash_command(line: str) -> bool:
|
|
49
|
+
return bool(_COMMAND_RE.match(line))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _user_echo(text: str) -> str:
|
|
54
|
+
"""Bolded, colour-guttered echo of a submitted user prompt (dock mode only)."""
|
|
55
|
+
return color("❯ ", "green", "bold") + color(text, "bold")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ReplSession:
|
|
60
|
+
"""Holds conversation + token accounting for one interactive session."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, config: Config, provider: Optional[ProviderPool]):
|
|
63
|
+
self.config = config
|
|
64
|
+
self.provider = provider # the ProviderPool (or None in tests)
|
|
65
|
+
self.model = config.model # user preference: "auto" or a concrete id
|
|
66
|
+
self.active_model = self.resolved_model() # last concrete model actually used
|
|
67
|
+
self.approval_mode = config.approval if config.approval in MODES else "always"
|
|
68
|
+
self.messages: List[dict] = []
|
|
69
|
+
self.last_usage: dict = {}
|
|
70
|
+
self.total_prompt = 0
|
|
71
|
+
self.total_completion = 0
|
|
72
|
+
self._available: List[str] = None
|
|
73
|
+
self.bad_models: set = set() # models that returned "unavailable" this session
|
|
74
|
+
self.worktree = None # active git worktree (M5.1b), if any
|
|
75
|
+
self.trusted_tools: set = set() # tools the user auto-approved for this session ([t])
|
|
76
|
+
self.plan: List[dict] = [] # latest update_plan steps ({step, status}); shown in the UI
|
|
77
|
+
self.last_error = "" # short label of the most recent turn failure (shown in the bar until next turn)
|
|
78
|
+
self.last_output = "" # plain-text of the last assistant answer (for /c + Ctrl-S copy)
|
|
79
|
+
self.mascot_state = "idle" # drives the mascot's eyes in the status bar: idle | thinking | stopped
|
|
80
|
+
# Persistence (auto-save each turn; resume via --continue/--resume).
|
|
81
|
+
from . import sessions
|
|
82
|
+
|
|
83
|
+
self.id = sessions.new_session_id()
|
|
84
|
+
self.created = time.time()
|
|
85
|
+
self.resumed = False
|
|
86
|
+
|
|
87
|
+
def resolved_model(self) -> str:
|
|
88
|
+
"""Qualified ``provider/model`` for the current preference (``auto`` → the default fallback)."""
|
|
89
|
+
from .providers.base import qualify
|
|
90
|
+
from .providers.registry import default_provider_name, fallback_model
|
|
91
|
+
|
|
92
|
+
from .config import is_model_alias
|
|
93
|
+
|
|
94
|
+
if is_model_alias(self.model):
|
|
95
|
+
return fallback_model(self.config)
|
|
96
|
+
return qualify(default_provider_name(self.config), self.model)
|
|
97
|
+
|
|
98
|
+
def to_record(self):
|
|
99
|
+
"""Snapshot the session as a persistable record."""
|
|
100
|
+
from .sessions import SessionRecord
|
|
101
|
+
|
|
102
|
+
return SessionRecord(
|
|
103
|
+
id=self.id,
|
|
104
|
+
root=str(self.config.root),
|
|
105
|
+
created=self.created,
|
|
106
|
+
updated=time.time(),
|
|
107
|
+
model=self.model,
|
|
108
|
+
active_model=self.active_model,
|
|
109
|
+
approval_mode=self.approval_mode,
|
|
110
|
+
total_prompt=self.total_prompt,
|
|
111
|
+
total_completion=self.total_completion,
|
|
112
|
+
messages=self.messages,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def apply_record(self, record) -> None:
|
|
116
|
+
"""Load a saved conversation + settings into this live session.
|
|
117
|
+
|
|
118
|
+
The saved model is adopted only when this run set nothing explicit (the preference is still
|
|
119
|
+
the ``default`` alias) and the saved model's provider is configured; an explicit ``--model`` /
|
|
120
|
+
``SCOOT_MODEL`` / saved preference for this run always wins over what the session recorded.
|
|
121
|
+
"""
|
|
122
|
+
self.id = record.id
|
|
123
|
+
self.created = record.created or self.created
|
|
124
|
+
if self._may_adopt_saved_model(record.model, record.active_model):
|
|
125
|
+
self.model = record.model or self.model
|
|
126
|
+
self.active_model = record.active_model or self.active_model
|
|
127
|
+
if record.approval_mode in MODES:
|
|
128
|
+
self.approval_mode = record.approval_mode
|
|
129
|
+
self.total_prompt = record.total_prompt
|
|
130
|
+
self.total_completion = record.total_completion
|
|
131
|
+
self.messages = list(record.messages)
|
|
132
|
+
self.resumed = True
|
|
133
|
+
|
|
134
|
+
def _may_adopt_saved_model(self, model: str, active_model: str) -> bool:
|
|
135
|
+
from .config import DEFAULT_MODEL_ALIAS, is_model_alias
|
|
136
|
+
from .providers.base import split_model_id
|
|
137
|
+
|
|
138
|
+
if (self.model or "").strip().lower() != DEFAULT_MODEL_ALIAS:
|
|
139
|
+
return False # this run chose a model (flag, env, or saved preference): keep it
|
|
140
|
+
candidate = active_model if is_model_alias(model) else model
|
|
141
|
+
head, _ = split_model_id(candidate)
|
|
142
|
+
if not head:
|
|
143
|
+
return True
|
|
144
|
+
try:
|
|
145
|
+
from .auth import is_configured
|
|
146
|
+
from .providers import registry
|
|
147
|
+
|
|
148
|
+
spec = registry.get(head)
|
|
149
|
+
return spec is not None and is_configured(spec)
|
|
150
|
+
except Exception:
|
|
151
|
+
return True
|
|
152
|
+
|
|
153
|
+
def autosave(self) -> None:
|
|
154
|
+
"""Persist the session after a turn (best-effort; never raises)."""
|
|
155
|
+
if not self.messages:
|
|
156
|
+
return
|
|
157
|
+
try:
|
|
158
|
+
from . import sessions
|
|
159
|
+
|
|
160
|
+
sessions.save(self.to_record())
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
def available_models(self) -> List[str]:
|
|
165
|
+
"""Chat-capable, qualified model ids from the live lists (cached; safe fallback on failure)."""
|
|
166
|
+
if self._available is None:
|
|
167
|
+
from .models import is_chat_model
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
data = self.provider.list_models()
|
|
171
|
+
self._available = [m.id for m in data if is_chat_model(m.raw)] or [self.resolved_model()]
|
|
172
|
+
except Exception:
|
|
173
|
+
self._available = [self.resolved_model()]
|
|
174
|
+
return self._available
|
|
175
|
+
|
|
176
|
+
def reset(self) -> None:
|
|
177
|
+
from . import sessions
|
|
178
|
+
|
|
179
|
+
self.messages.clear()
|
|
180
|
+
self.last_usage = {}
|
|
181
|
+
self.plan = [] # drop the progress checklist so the bar's ◇ segment clears too
|
|
182
|
+
# Start a brand-new session id so a fresh conversation gets its own saved file.
|
|
183
|
+
self.id = sessions.new_session_id()
|
|
184
|
+
self.created = time.time()
|
|
185
|
+
self.resumed = False
|
|
186
|
+
|
|
187
|
+
def account(self, usage: dict) -> None:
|
|
188
|
+
self.last_usage = usage or {}
|
|
189
|
+
self.total_prompt += int(usage.get("prompt_tokens", 0) or 0)
|
|
190
|
+
self.total_completion += int(usage.get("completion_tokens", 0) or 0)
|
|
191
|
+
|
|
192
|
+
def full_messages(self) -> List[dict]:
|
|
193
|
+
return [{"role": "system", "content": CHAT_SYSTEM_PROMPT}, *self.messages]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class _StreamPrinter:
|
|
197
|
+
"""Prints streamed tokens live, holding back a small tail to suppress a trailing ``DONE``.
|
|
198
|
+
|
|
199
|
+
Content streams through a short buffer so a final ``DONE`` sentinel (emitted by the model per the
|
|
200
|
+
agent system prompt) never flashes on screen. The holdback is a few characters, so latency is
|
|
201
|
+
imperceptible.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
_HOLDBACK = 8
|
|
205
|
+
|
|
206
|
+
def __init__(self, status: Status, labels: bool = True, on_label=None):
|
|
207
|
+
self._status = status
|
|
208
|
+
self._labels = labels
|
|
209
|
+
self._on_label = on_label # called to emit the (once-per-turn) ⏺ scoot lead-in
|
|
210
|
+
self.started = False
|
|
211
|
+
self._buf = ""
|
|
212
|
+
|
|
213
|
+
def delta(self, text: str) -> None:
|
|
214
|
+
if not text:
|
|
215
|
+
return
|
|
216
|
+
if not self.started:
|
|
217
|
+
self._status.stop() # first token: clear the spinner line
|
|
218
|
+
if self._labels and sys.stdout.isatty():
|
|
219
|
+
if self._on_label is not None:
|
|
220
|
+
self._on_label() # print ⏺ scoot once per turn (no-op if already shown)
|
|
221
|
+
else:
|
|
222
|
+
print(_assistant_label()) # mark the start of the answer (kept out of piped output)
|
|
223
|
+
self.started = True
|
|
224
|
+
self._buf += text
|
|
225
|
+
if len(self._buf) > self._HOLDBACK:
|
|
226
|
+
emit, self._buf = self._buf[: -self._HOLDBACK], self._buf[-self._HOLDBACK:]
|
|
227
|
+
self._write(emit)
|
|
228
|
+
|
|
229
|
+
def close(self) -> None:
|
|
230
|
+
if not self.started:
|
|
231
|
+
return
|
|
232
|
+
tail = self._buf.rstrip()
|
|
233
|
+
if tail.endswith("DONE"):
|
|
234
|
+
tail = tail[:-4].rstrip()
|
|
235
|
+
self._write(tail)
|
|
236
|
+
self._write("\n")
|
|
237
|
+
|
|
238
|
+
@staticmethod
|
|
239
|
+
def _write(text: str) -> None:
|
|
240
|
+
if text:
|
|
241
|
+
sys.stdout.write(text)
|
|
242
|
+
sys.stdout.flush()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class ReplUI:
|
|
246
|
+
"""Supplies terminal affordances (spinner, ESC, approvals) to the agent loop.
|
|
247
|
+
|
|
248
|
+
Step-by-step narration, tool calls, and tool results are rendered as **transient** single-line
|
|
249
|
+
status (each overwriting the previous, like the spinner) so the scrollback stays clean; only
|
|
250
|
+
permanent output — the progress plan and the final answer — is committed with a newline. On a
|
|
251
|
+
non-TTY (piped/`--json`) these ephemeral lines are dropped entirely, keeping stdout for the
|
|
252
|
+
answer alone.
|
|
253
|
+
|
|
254
|
+
``verbosity`` regulates how much of a turn's "thinking" shows in the feed (set by ``/verbosity``):
|
|
255
|
+
|
|
256
|
+
* ``"full"`` (default) — reasoning narration is shown and each tool-in-use line is kept in the
|
|
257
|
+
feed as a log of what ran.
|
|
258
|
+
* ``"compact"`` — reasoning narration is shown; the tool-in-use line is a single transient row
|
|
259
|
+
above the spinner that is removed once the tool finishes (nothing accumulates).
|
|
260
|
+
* ``"quiet"`` — reasoning narration is suppressed entirely; tools render like ``compact``.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
LEVELS = ("full", "compact", "quiet")
|
|
264
|
+
|
|
265
|
+
def __init__(self, labels: bool = True, verbosity: str = "full", emoji: bool = True):
|
|
266
|
+
self.labels = labels
|
|
267
|
+
self.emoji = emoji # 🛴 label, or ⏺ when the terminal has no emoji font
|
|
268
|
+
self.verbosity = verbosity if verbosity in self.LEVELS else "full"
|
|
269
|
+
self._tty = sys.stdout.isatty()
|
|
270
|
+
self._transient = False # a transient line is currently on screen (no trailing newline)
|
|
271
|
+
self._labelled = False # the ⏺ scoot lead-in has been printed for the current turn
|
|
272
|
+
self._tool_line = None # pending tool-in-use line, pinned above the spinner by activity()
|
|
273
|
+
|
|
274
|
+
def begin_turn(self) -> None:
|
|
275
|
+
"""Reset per-turn state so the ⏺ scoot label prints once at the next assistant output."""
|
|
276
|
+
self._labelled = False
|
|
277
|
+
|
|
278
|
+
def label_once(self) -> None:
|
|
279
|
+
"""Print the ⏺ scoot lead-in the first time the assistant speaks in a turn (TTY only).
|
|
280
|
+
|
|
281
|
+
Subsequent streamed messages / narration in the same turn print underneath without repeating
|
|
282
|
+
the gutter, so a multi-step turn reads as one labelled block.
|
|
283
|
+
"""
|
|
284
|
+
if self._labelled or not self.labels or not self._tty:
|
|
285
|
+
return
|
|
286
|
+
print(_assistant_label(self.emoji))
|
|
287
|
+
self._labelled = True
|
|
288
|
+
|
|
289
|
+
# ── transient single-line region ─────────────────────────────────────────────
|
|
290
|
+
def _ephemeral(self, text: str) -> None:
|
|
291
|
+
"""Draw ``text`` on the current line, overwriting whatever was there (no newline)."""
|
|
292
|
+
if not self._tty:
|
|
293
|
+
return
|
|
294
|
+
width = max(1, shutil.get_terminal_size((80, 24)).columns - 1)
|
|
295
|
+
clipped = self._clip(text, width)
|
|
296
|
+
sys.stdout.write(f"\r\033[K{clipped}")
|
|
297
|
+
sys.stdout.flush()
|
|
298
|
+
self._transient = True
|
|
299
|
+
|
|
300
|
+
def _commit_line(self) -> None:
|
|
301
|
+
"""Clear any transient line so the next permanent print starts on a clean row."""
|
|
302
|
+
if self._transient and self._tty:
|
|
303
|
+
sys.stdout.write("\r\033[K")
|
|
304
|
+
sys.stdout.flush()
|
|
305
|
+
self._transient = False
|
|
306
|
+
|
|
307
|
+
@staticmethod
|
|
308
|
+
def _clip(text: str, width: int) -> str:
|
|
309
|
+
"""Truncate to ``width`` *visible* columns, ignoring ANSI escapes, keeping styling intact."""
|
|
310
|
+
visible = 0
|
|
311
|
+
out = []
|
|
312
|
+
i = 0
|
|
313
|
+
while i < len(text):
|
|
314
|
+
ch = text[i]
|
|
315
|
+
if ch == "\033": # copy an escape sequence verbatim (doesn't consume width)
|
|
316
|
+
j = text.find("m", i)
|
|
317
|
+
if j == -1:
|
|
318
|
+
break
|
|
319
|
+
out.append(text[i:j + 1])
|
|
320
|
+
i = j + 1
|
|
321
|
+
continue
|
|
322
|
+
if visible >= width:
|
|
323
|
+
out.append("…")
|
|
324
|
+
break
|
|
325
|
+
out.append(ch)
|
|
326
|
+
visible += 1
|
|
327
|
+
i += 1
|
|
328
|
+
out.append(_CODES_RESET)
|
|
329
|
+
return "".join(out)
|
|
330
|
+
|
|
331
|
+
@contextmanager
|
|
332
|
+
def activity(self, message: str, cancel_event: threading.Event):
|
|
333
|
+
# If a tool line is pending (from auto_approved / approval), pin it on its own row and run the
|
|
334
|
+
# spinner underneath, so the tool-in-use stays visible during the call. We keep the tool row on
|
|
335
|
+
# screen after the call (it scrolls into the feed as a log of what ran) — only the spinner row
|
|
336
|
+
# is cleared — so fast tools no longer flash past before you can read them.
|
|
337
|
+
tool_line = self._tool_line
|
|
338
|
+
self._tool_line = None
|
|
339
|
+
if tool_line and self._tty:
|
|
340
|
+
self._commit_line() # ensure a clean row for the pinned tool line
|
|
341
|
+
width = max(1, shutil.get_terminal_size((80, 24)).columns - 1)
|
|
342
|
+
sys.stdout.write("\r\033[K" + self._clip(tool_line, width) + "\n")
|
|
343
|
+
sys.stdout.flush()
|
|
344
|
+
else:
|
|
345
|
+
self._commit_line() # spinner owns the line next; drop any leftover transient
|
|
346
|
+
status = Status()
|
|
347
|
+
status.start(message)
|
|
348
|
+
try:
|
|
349
|
+
with InterruptibleSection(cancel_event):
|
|
350
|
+
yield
|
|
351
|
+
finally:
|
|
352
|
+
status.stop() # clears the spinner row; the tool row above stays on screen
|
|
353
|
+
# In compact/quiet the tool row is a single transient line above the spinner: erase it once
|
|
354
|
+
# the tool finishes so nothing accumulates. In full we keep it in the feed as a run log.
|
|
355
|
+
if tool_line and self._tty and self.verbosity != "full":
|
|
356
|
+
sys.stdout.write("\033[1A\r\033[K") # step up onto the tool row and erase it
|
|
357
|
+
sys.stdout.flush()
|
|
358
|
+
|
|
359
|
+
@contextmanager
|
|
360
|
+
def stream(self, cancel_event: threading.Event):
|
|
361
|
+
"""Yield a printer that renders streamed tokens live (spinner until the first token)."""
|
|
362
|
+
self._commit_line()
|
|
363
|
+
status = Status()
|
|
364
|
+
status.start("thinking…")
|
|
365
|
+
printer = _StreamPrinter(status, labels=self.labels, on_label=self.label_once)
|
|
366
|
+
try:
|
|
367
|
+
with InterruptibleSection(cancel_event):
|
|
368
|
+
yield printer
|
|
369
|
+
finally:
|
|
370
|
+
if not printer.started:
|
|
371
|
+
status.stop()
|
|
372
|
+
printer.close()
|
|
373
|
+
|
|
374
|
+
def assistant(self, text: str) -> None:
|
|
375
|
+
# Intermediate narration ("I'll read X, then edit Y") — transient on a TTY (overwritten by
|
|
376
|
+
# the next step); on a non-TTY it's diagnostic, so drop it (keep stdout clean for --json).
|
|
377
|
+
if self.verbosity == "quiet":
|
|
378
|
+
return # reasoning narration suppressed at this level
|
|
379
|
+
if self._tty:
|
|
380
|
+
self.label_once() # print the ⏺ scoot lead-in once, then narrate underneath it
|
|
381
|
+
self._ephemeral(color(text, "gray"))
|
|
382
|
+
else:
|
|
383
|
+
eprint(color(text, "gray"))
|
|
384
|
+
|
|
385
|
+
def approve(self, tool, args, ctx):
|
|
386
|
+
self._commit_line() # an approval prompt is interactive; don't let it clobber a transient
|
|
387
|
+
return request_approval(tool, args, ctx)
|
|
388
|
+
|
|
389
|
+
def auto_approved(self, tool, args) -> None:
|
|
390
|
+
import json as _json
|
|
391
|
+
|
|
392
|
+
dot = color("●", {"read": "cyan", "write": "yellow", "shell": "red"}.get(tool.risk, "cyan"))
|
|
393
|
+
# Stash the tool line; ``activity`` pins it above the spinner for the duration of the call and
|
|
394
|
+
# removes it afterwards, so the tool-in-use is visible without cluttering the transcript.
|
|
395
|
+
self._tool_line = (
|
|
396
|
+
f"{dot} {color(tool.name, 'bold')} "
|
|
397
|
+
f"{color(_json.dumps(args, ensure_ascii=False), 'gray')} {color('(auto)', 'gray')}")
|
|
398
|
+
|
|
399
|
+
def plan(self, plan) -> None:
|
|
400
|
+
"""Render the current plan as a checkbox list (✔ done · ▶ in progress · ○ pending)."""
|
|
401
|
+
self._commit_line() # the plan is permanent output
|
|
402
|
+
marks = {"completed": color("✔", "green"), "in_progress": color("▶", "cyan")}
|
|
403
|
+
done = sum(1 for s in plan if s.get("status") == "completed")
|
|
404
|
+
print(color(f"◇ plan · {done}/{len(plan)}", "bold"))
|
|
405
|
+
for step in plan:
|
|
406
|
+
status = step.get("status", "pending")
|
|
407
|
+
mark = marks.get(status, color("○", "gray"))
|
|
408
|
+
text = step.get("step", "")
|
|
409
|
+
body = color(text, "gray") if status == "completed" else text
|
|
410
|
+
print(f" {mark} {body}")
|
|
411
|
+
|
|
412
|
+
def tool_result(self, name: str, result: ToolResult) -> None:
|
|
413
|
+
icon = color("✔", "green") if result.ok else color("✗", "yellow")
|
|
414
|
+
detail = result.summary or (result.error if not result.ok else "")
|
|
415
|
+
self._ephemeral(f" {icon} {color(name, 'bold')} {color(detail, 'gray')}")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class Repl:
|
|
419
|
+
"""The interactive loop."""
|
|
420
|
+
|
|
421
|
+
def __init__(self, config: Config, provider: Optional[ProviderPool], resume=None):
|
|
422
|
+
self.session = ReplSession(config, provider)
|
|
423
|
+
if resume is not None:
|
|
424
|
+
self.session.apply_record(resume)
|
|
425
|
+
self.agent = Agent(config, provider)
|
|
426
|
+
self.labels = getattr(config, "labels", True)
|
|
427
|
+
self.ui = ReplUI(labels=self.labels, verbosity=getattr(config, "verbosity", "full"),
|
|
428
|
+
emoji=getattr(config, "emoji", True))
|
|
429
|
+
# The "dock" pins the prompt to a fixed bottom row; it needs the bar's reserved region and a
|
|
430
|
+
# real TTY. When off (flag/env/non-TTY), we fall back to a plain inline input().
|
|
431
|
+
panel_on = getattr(config, "panel", True)
|
|
432
|
+
self.dock = (getattr(config, "dock", True) and panel_on
|
|
433
|
+
and sys.stdout.isatty() and sys.stdin.isatty())
|
|
434
|
+
self.bar = StatusBar(panel_on, reserve_input=self.dock)
|
|
435
|
+
# The multi-row input dock shares geometry with the bar via a DockLayout so a wrapping line can
|
|
436
|
+
# grow the input area (and shrink it back) without the two disagreeing on row positions.
|
|
437
|
+
self._dock_layout = None
|
|
438
|
+
if self.dock:
|
|
439
|
+
from .panel import DockLayout
|
|
440
|
+
|
|
441
|
+
self._dock_layout = DockLayout()
|
|
442
|
+
self.bar.layout = self._dock_layout
|
|
443
|
+
self.editor = LineEditor(
|
|
444
|
+
enabled=self.dock, on_copy=self._copy_last,
|
|
445
|
+
layout=self._dock_layout, on_reflow=self._reflow_dock,
|
|
446
|
+
completer=self._command_names,
|
|
447
|
+
)
|
|
448
|
+
self.session.status_bar = self.bar # let the /panel command reach it
|
|
449
|
+
self.session.ui = self.ui # let the /verbosity command reach the feed renderer
|
|
450
|
+
self.session.dock = self.dock # surfaced in /status
|
|
451
|
+
self.session.redraw_home = self._redraw_home # let commands clear+reprint the header (e.g. /reset)
|
|
452
|
+
self._user = None # cached signed-in user label for the status bar
|
|
453
|
+
self._seed_history() # recover ↑-key recall from a resumed conversation
|
|
454
|
+
|
|
455
|
+
def _command_names(self):
|
|
456
|
+
"""Slash-command names (no leading '/') for Tab completion, alphabetically."""
|
|
457
|
+
commands.load_builtins()
|
|
458
|
+
return list(commands.all_commands().keys())
|
|
459
|
+
|
|
460
|
+
def _seed_history(self) -> None:
|
|
461
|
+
"""Prime the line editor's ↑/↓ recall with the user prompts from a resumed session."""
|
|
462
|
+
try:
|
|
463
|
+
for m in self.session.messages:
|
|
464
|
+
if m.get("role") != "user":
|
|
465
|
+
continue
|
|
466
|
+
text = m.get("content")
|
|
467
|
+
if not isinstance(text, str):
|
|
468
|
+
continue
|
|
469
|
+
line = text.strip()
|
|
470
|
+
if line and (not self.editor.history or self.editor.history[-1] != line):
|
|
471
|
+
self.editor.history.append(line)
|
|
472
|
+
except Exception:
|
|
473
|
+
pass # history recall is a nicety; never block startup on it
|
|
474
|
+
|
|
475
|
+
def _replay_transcript(self) -> None:
|
|
476
|
+
"""Re-print the restored conversation so a resumed session shows its full history on screen."""
|
|
477
|
+
try:
|
|
478
|
+
printed = False
|
|
479
|
+
for m in self.session.messages:
|
|
480
|
+
role = m.get("role")
|
|
481
|
+
content = m.get("content")
|
|
482
|
+
if not isinstance(content, str) or not content.strip():
|
|
483
|
+
continue
|
|
484
|
+
text = content.strip()
|
|
485
|
+
if role == "user":
|
|
486
|
+
print(_user_echo(text) if self.labels else (color("› ", "green") + text))
|
|
487
|
+
printed = True
|
|
488
|
+
elif role == "assistant":
|
|
489
|
+
if self.labels and sys.stdout.isatty():
|
|
490
|
+
print(_assistant_label(self.ui.emoji))
|
|
491
|
+
print(text)
|
|
492
|
+
printed = True
|
|
493
|
+
if printed:
|
|
494
|
+
print(color("─" * 8 + " end of resumed history " + "─" * 8, "gray"))
|
|
495
|
+
except Exception:
|
|
496
|
+
pass # transcript replay is cosmetic; never block startup on it
|
|
497
|
+
|
|
498
|
+
def run(self) -> int:
|
|
499
|
+
commands.load_builtins()
|
|
500
|
+
self._user = self._auth_user()
|
|
501
|
+
if sys.stdout.isatty():
|
|
502
|
+
# Start on a clean screen so the banner/bar don't overlap prior terminal content.
|
|
503
|
+
sys.stdout.write("\033[2J\033[3J\033[H") # clear screen + scrollback, cursor home
|
|
504
|
+
sys.stdout.flush()
|
|
505
|
+
self.bar.install()
|
|
506
|
+
self._banner()
|
|
507
|
+
if self.session.resumed:
|
|
508
|
+
self._replay_transcript()
|
|
509
|
+
try:
|
|
510
|
+
return self._loop()
|
|
511
|
+
finally:
|
|
512
|
+
self.bar.remove()
|
|
513
|
+
|
|
514
|
+
def _loop(self) -> int:
|
|
515
|
+
while True:
|
|
516
|
+
self._refresh_bar()
|
|
517
|
+
try:
|
|
518
|
+
raw = self.editor.readline("› ")
|
|
519
|
+
except EOFError:
|
|
520
|
+
print()
|
|
521
|
+
return 0
|
|
522
|
+
except KeyboardInterrupt: # Ctrl-C at the prompt quits the app
|
|
523
|
+
print()
|
|
524
|
+
return 0
|
|
525
|
+
|
|
526
|
+
line = raw.strip()
|
|
527
|
+
if not line:
|
|
528
|
+
continue
|
|
529
|
+
# In dock mode the input row is transient, so echo the submitted prompt into the scrolling
|
|
530
|
+
# region to keep it in history (inline input() already echoes what the user typed). Long
|
|
531
|
+
# image paths are shown as compact [Image N] badges; the real path is still processed.
|
|
532
|
+
if self.dock:
|
|
533
|
+
echo = self._badge_paths(raw)
|
|
534
|
+
print(_user_echo(echo) if self.labels else (color("› ", "green") + echo))
|
|
535
|
+
if _is_slash_command(line):
|
|
536
|
+
if self._handle_command(line) == commands.QUIT:
|
|
537
|
+
return 0
|
|
538
|
+
continue
|
|
539
|
+
try:
|
|
540
|
+
self._run_turn(line)
|
|
541
|
+
except KeyboardInterrupt: # Ctrl-C during work quits the app
|
|
542
|
+
print()
|
|
543
|
+
return 130
|
|
544
|
+
except Exception as exc: # never let one bad turn kill the REPL — surface it and continue
|
|
545
|
+
self._report_turn_error(exc)
|
|
546
|
+
|
|
547
|
+
def _report_turn_error(self, exc: BaseException) -> None:
|
|
548
|
+
"""Surface an unexpected turn failure to the user without crashing the REPL."""
|
|
549
|
+
from .errors import ScootError
|
|
550
|
+
|
|
551
|
+
self.ui._commit_line() # clear any leftover transient step/tool line
|
|
552
|
+
kind = exc.__class__.__name__
|
|
553
|
+
msg = redact(str(exc)) or kind
|
|
554
|
+
self.session.last_error = kind if isinstance(exc, ScootError) else f"{kind}"
|
|
555
|
+
self._refresh_bar() # surface the failure in the status bar (cleared when the next turn starts)
|
|
556
|
+
if isinstance(exc, ScootError):
|
|
557
|
+
print(color(f"⚠ {msg}", "red"))
|
|
558
|
+
else:
|
|
559
|
+
print(color(f"⚠ unexpected error ({kind}): {msg}", "red"))
|
|
560
|
+
if self.session.config.verbose:
|
|
561
|
+
import traceback
|
|
562
|
+
|
|
563
|
+
eprint(color(traceback.format_exc(), "gray"))
|
|
564
|
+
else:
|
|
565
|
+
print(color(" (run with --verbose or set SCOOT_VERBOSE=1 for a traceback)", "gray"))
|
|
566
|
+
|
|
567
|
+
def _auth_user(self):
|
|
568
|
+
"""Status-bar identity: the default provider, flagged when it still lacks a key."""
|
|
569
|
+
try:
|
|
570
|
+
from .auth import is_configured
|
|
571
|
+
|
|
572
|
+
pool = self.session.provider
|
|
573
|
+
name = pool.default_name
|
|
574
|
+
return name if is_configured(pool.spec) else f"{name} (no key)"
|
|
575
|
+
except Exception:
|
|
576
|
+
return None
|
|
577
|
+
|
|
578
|
+
def _copy_last(self) -> None:
|
|
579
|
+
"""Ctrl-S handler: copy the last answer to the clipboard and print a one-line result."""
|
|
580
|
+
from .clipboard import copy_session_output
|
|
581
|
+
|
|
582
|
+
ok, message = copy_session_output(self.session)
|
|
583
|
+
print(color(message, "gray" if ok else "yellow"))
|
|
584
|
+
|
|
585
|
+
def _badge_paths(self, raw: str) -> str:
|
|
586
|
+
"""Compact image paths in the echoed prompt to ``[Image N]`` badges (best-effort)."""
|
|
587
|
+
cfg = self.session.config
|
|
588
|
+
if not getattr(cfg, "images", True):
|
|
589
|
+
return raw
|
|
590
|
+
try:
|
|
591
|
+
from .images import badge_text
|
|
592
|
+
|
|
593
|
+
return badge_text(raw, root=getattr(cfg, "root", None))
|
|
594
|
+
except Exception:
|
|
595
|
+
return raw
|
|
596
|
+
|
|
597
|
+
def _maybe_fold_images(self, user_text: str) -> str:
|
|
598
|
+
"""If the prompt references dropped image files, describe them and fold that into the text.
|
|
599
|
+
|
|
600
|
+
Propagates ``Interrupted`` (so a real ESC aborts the turn); any other problem returns the
|
|
601
|
+
original prompt unchanged (never breaks a turn).
|
|
602
|
+
"""
|
|
603
|
+
from .errors import Interrupted
|
|
604
|
+
|
|
605
|
+
try:
|
|
606
|
+
from .vision import fold_images_into_text
|
|
607
|
+
|
|
608
|
+
return fold_images_into_text(
|
|
609
|
+
user_text, self.session.config, self.session.provider, ui=self.ui,
|
|
610
|
+
)
|
|
611
|
+
except Interrupted:
|
|
612
|
+
raise
|
|
613
|
+
except Exception:
|
|
614
|
+
return user_text
|
|
615
|
+
|
|
616
|
+
def _refresh_bar(self) -> None:
|
|
617
|
+
try:
|
|
618
|
+
self.bar.render(build_status_text(self.session, self._user))
|
|
619
|
+
except Exception:
|
|
620
|
+
pass # the bar must never break the REPL
|
|
621
|
+
|
|
622
|
+
def _reflow_dock(self) -> None:
|
|
623
|
+
"""Called by the editor when the wrapping input grows/shrinks: re-establish the region and
|
|
624
|
+
redraw the frame for the new height (without touching the editor's cursor save slot)."""
|
|
625
|
+
try:
|
|
626
|
+
self.bar.set_text(build_status_text(self.session, self._user)) # refresh text (no output)
|
|
627
|
+
self.bar.reflow() # re-establish region + redraw frame; no DECSC/DECRC (editor owns it)
|
|
628
|
+
except Exception:
|
|
629
|
+
pass # never let a redraw break the input loop
|
|
630
|
+
|
|
631
|
+
# ── UI ─────────────────────────────────────────────────────────────────────
|
|
632
|
+
def _redraw_home(self, note: str = None) -> None:
|
|
633
|
+
"""Clear the screen + scrollback and reprint the launch header (used by /reset, /compact).
|
|
634
|
+
|
|
635
|
+
Re-establishes the status bar afterwards so the fresh screen looks like a new launch. No-op
|
|
636
|
+
off a TTY. An optional ``note`` line replaces the resume/hint line in the banner.
|
|
637
|
+
"""
|
|
638
|
+
if not sys.stdout.isatty():
|
|
639
|
+
return
|
|
640
|
+
sys.stdout.write("\033[2J\033[3J\033[H")
|
|
641
|
+
sys.stdout.flush()
|
|
642
|
+
self._banner(note=note)
|
|
643
|
+
try:
|
|
644
|
+
self.bar.render(build_status_text(self.session, self._user))
|
|
645
|
+
except Exception:
|
|
646
|
+
pass
|
|
647
|
+
|
|
648
|
+
def _banner_lines(self, note: str = None) -> "list[str]":
|
|
649
|
+
"""The banner's information lines (uncoloured): what/where, resume state, key hints."""
|
|
650
|
+
cfg = self.session.config
|
|
651
|
+
lines = [f"{self.session.active_model} · root: {logo.tilde(cfg.root)}"]
|
|
652
|
+
if note:
|
|
653
|
+
lines.append(note)
|
|
654
|
+
elif self.session.resumed:
|
|
655
|
+
lines.append(f"resumed session {self.session.id} · {len(self.session.messages)} messages "
|
|
656
|
+
f"· /reset to start fresh")
|
|
657
|
+
else:
|
|
658
|
+
hint = self._resume_hint()
|
|
659
|
+
if hint:
|
|
660
|
+
lines.append(hint)
|
|
661
|
+
lines.append("type a prompt · /help · esc to interrupt · ctrl-c to quit")
|
|
662
|
+
lines.append("⌃S or /c copies the last reply to your clipboard")
|
|
663
|
+
return lines
|
|
664
|
+
|
|
665
|
+
def _banner(self, note: str = None) -> None:
|
|
666
|
+
"""Launch banner: the mascot with the info lines beside it, or a plain box with ``--no-logo``."""
|
|
667
|
+
lines = self._banner_lines(note)
|
|
668
|
+
if getattr(self.session.config, "logo", True):
|
|
669
|
+
from . import __version__
|
|
670
|
+
|
|
671
|
+
art_rows = logo.mascot("idle", "banner")
|
|
672
|
+
cols = shutil.get_terminal_size((100, 24)).columns
|
|
673
|
+
avail = max(20, cols - len(art_rows[0]) - 3)
|
|
674
|
+
head = f"{logo.NAME} {__version__} · "
|
|
675
|
+
lines = [logo.fit(lines[0], avail - len(head))] + [logo.fit(l, avail) for l in lines[1:]]
|
|
676
|
+
art = [color(row, "cyan") for row in art_rows]
|
|
677
|
+
text = [color(logo.NAME, "cyan", "bold") + color(f" {__version__} · " + lines[0], "gray")]
|
|
678
|
+
text += [color(line, "gray") for line in lines[1:]]
|
|
679
|
+
for row in logo.compose(art, text):
|
|
680
|
+
print(row)
|
|
681
|
+
return
|
|
682
|
+
from . import __version__
|
|
683
|
+
|
|
684
|
+
print(color(f"╭─ scoot {__version__} ", "cyan"))
|
|
685
|
+
for line in lines:
|
|
686
|
+
print(color("│ ", "cyan") + color(line, "gray"))
|
|
687
|
+
print(color("╰─", "cyan"))
|
|
688
|
+
|
|
689
|
+
def _resume_hint(self) -> "str | None":
|
|
690
|
+
"""The most recent saved session for this directory, so resume is discoverable (or ``None``)."""
|
|
691
|
+
if getattr(self.session.config, "resume", "hint") == "off":
|
|
692
|
+
return None
|
|
693
|
+
try:
|
|
694
|
+
from . import sessions
|
|
695
|
+
|
|
696
|
+
last = sessions.latest_for_root(str(self.session.config.root))
|
|
697
|
+
except Exception:
|
|
698
|
+
last = None
|
|
699
|
+
if not last or not last.messages:
|
|
700
|
+
return None
|
|
701
|
+
prompt = logo.fit(last.first_prompt(), 30)
|
|
702
|
+
turns = f"{last.turns} turn" + ("" if last.turns == 1 else "s")
|
|
703
|
+
return f"↩ {last.id} · {turns} · \"{prompt}\" · /resume or -c to continue"
|
|
704
|
+
|
|
705
|
+
def _handle_command(self, line: str):
|
|
706
|
+
name, _, args = line[1:].partition(" ")
|
|
707
|
+
cmd = commands.get(name)
|
|
708
|
+
if cmd is None:
|
|
709
|
+
eprint(color(f"unknown command: /{name} (try /help)", "yellow"))
|
|
710
|
+
return None
|
|
711
|
+
return cmd.handler(self.session, args.strip())
|
|
712
|
+
|
|
713
|
+
# ── One agentic turn ─────────────────────────────────────────────────────────
|
|
714
|
+
def _run_turn(self, user_text: str) -> None:
|
|
715
|
+
session = self.session
|
|
716
|
+
from .errors import Interrupted
|
|
717
|
+
|
|
718
|
+
session.last_error = "" # a fresh turn clears any prior failure shown in the bar
|
|
719
|
+
self.ui.begin_turn() # reset the once-per-turn ⏺ scoot label
|
|
720
|
+
session.mascot_state = "thinking" # eyes: > >
|
|
721
|
+
self._refresh_bar()
|
|
722
|
+
try:
|
|
723
|
+
try:
|
|
724
|
+
user_text = self._maybe_fold_images(user_text)
|
|
725
|
+
except Interrupted:
|
|
726
|
+
session.mascot_state = "stopped"
|
|
727
|
+
print(color("⏹ interrupted", "yellow"))
|
|
728
|
+
return # don't append a half-processed prompt to the conversation
|
|
729
|
+
session.messages.append({"role": "user", "content": user_text})
|
|
730
|
+
while True:
|
|
731
|
+
cancel = threading.Event()
|
|
732
|
+
outcome = self.agent.run_turn(session, self.ui, cancel)
|
|
733
|
+
self._render_outcome(outcome)
|
|
734
|
+
if outcome.status == "max_steps" and self._ask_continue():
|
|
735
|
+
continue # keep going on the same conversation for another batch of steps
|
|
736
|
+
break
|
|
737
|
+
session.mascot_state = "idle" if outcome.status in ("done", "max_steps") else "stopped"
|
|
738
|
+
self._maybe_autocompact()
|
|
739
|
+
session.autosave()
|
|
740
|
+
except BaseException:
|
|
741
|
+
session.mascot_state = "stopped" # eyes: - - (error / ctrl-c); the loop refreshes the bar
|
|
742
|
+
raise
|
|
743
|
+
|
|
744
|
+
def _ask_continue(self) -> bool:
|
|
745
|
+
"""After hitting the step limit, ask whether to keep going (interactive only)."""
|
|
746
|
+
limit = self.session.config.max_steps
|
|
747
|
+
try:
|
|
748
|
+
ans = input(color(
|
|
749
|
+
f"reached step limit ({limit}). continue for another {limit} steps? [y/N] ",
|
|
750
|
+
"yellow"))
|
|
751
|
+
except (EOFError, KeyboardInterrupt):
|
|
752
|
+
print()
|
|
753
|
+
return False
|
|
754
|
+
if ans.strip().lower() in ("y", "yes"):
|
|
755
|
+
return True
|
|
756
|
+
print(color("stopped. type a new prompt to redirect, or ask it to continue.", "gray"))
|
|
757
|
+
return False
|
|
758
|
+
|
|
759
|
+
def _maybe_autocompact(self) -> None:
|
|
760
|
+
"""Summarize automatically when the context grows past the configured threshold."""
|
|
761
|
+
from .activity import activity
|
|
762
|
+
from .context import compact, estimate_context_tokens
|
|
763
|
+
from .errors import ScootError, Interrupted
|
|
764
|
+
|
|
765
|
+
session = self.session
|
|
766
|
+
tokens = estimate_context_tokens(session)
|
|
767
|
+
if not tokens or tokens <= session.config.compact_at:
|
|
768
|
+
return
|
|
769
|
+
print(color(f"context ~{tokens} tokens > {session.config.compact_at}; auto-compacting…", "gray"))
|
|
770
|
+
try:
|
|
771
|
+
with activity("auto-compacting…") as cancel:
|
|
772
|
+
compact(session, cancel)
|
|
773
|
+
except (Interrupted, ScootError):
|
|
774
|
+
pass # best-effort; keep going even if summarization fails
|
|
775
|
+
|
|
776
|
+
def _render_outcome(self, outcome: AgentOutcome) -> None:
|
|
777
|
+
session = self.session
|
|
778
|
+
self.ui._commit_line() # clear any leftover transient step/tool line before permanent output
|
|
779
|
+
if outcome.status == "done":
|
|
780
|
+
answer = outcome.content.strip()
|
|
781
|
+
session.last_output = answer # remember for /c + Ctrl-S copy
|
|
782
|
+
if answer and not outcome.streamed:
|
|
783
|
+
if self.labels and sys.stdout.isatty():
|
|
784
|
+
print(_assistant_label(self.ui.emoji))
|
|
785
|
+
print(answer)
|
|
786
|
+
if session.config.verbose:
|
|
787
|
+
u = session.last_usage
|
|
788
|
+
eprint(color(
|
|
789
|
+
f"[{session.resolved_model()}] steps={outcome.steps} "
|
|
790
|
+
f"prompt={u.get('prompt_tokens', '?')} "
|
|
791
|
+
f"completion={u.get('completion_tokens', '?')}", "gray"))
|
|
792
|
+
elif outcome.status == "interrupted":
|
|
793
|
+
print(color("⏹ interrupted", "yellow"))
|
|
794
|
+
elif outcome.status == "aborted":
|
|
795
|
+
print(color("✋ aborted", "yellow"))
|
|
796
|
+
elif outcome.status == "max_steps":
|
|
797
|
+
# The continue-prompt is handled by _run_turn / _ask_continue.
|
|
798
|
+
pass
|
|
799
|
+
elif outcome.status == "error":
|
|
800
|
+
eprint(color(f"⚠ {redact(outcome.error)}", "red"))
|
|
801
|
+
|