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.
Files changed (71) hide show
  1. scootcli/__init__.py +4 -0
  2. scootcli/__main__.py +9 -0
  3. scootcli/activity.py +26 -0
  4. scootcli/agent.py +350 -0
  5. scootcli/approvals.py +167 -0
  6. scootcli/auth.py +59 -0
  7. scootcli/cli.py +276 -0
  8. scootcli/clipboard.py +89 -0
  9. scootcli/commands/__init__.py +56 -0
  10. scootcli/commands/approve.py +38 -0
  11. scootcli/commands/auth.py +107 -0
  12. scootcli/commands/base.py +31 -0
  13. scootcli/commands/compact.py +40 -0
  14. scootcli/commands/copy.py +23 -0
  15. scootcli/commands/exit.py +14 -0
  16. scootcli/commands/forget.py +28 -0
  17. scootcli/commands/help.py +29 -0
  18. scootcli/commands/init.py +50 -0
  19. scootcli/commands/logo.py +51 -0
  20. scootcli/commands/model.py +61 -0
  21. scootcli/commands/panel.py +28 -0
  22. scootcli/commands/reset.py +20 -0
  23. scootcli/commands/resume.py +31 -0
  24. scootcli/commands/save.py +29 -0
  25. scootcli/commands/sessions.py +42 -0
  26. scootcli/commands/status.py +59 -0
  27. scootcli/commands/verbosity.py +57 -0
  28. scootcli/commands/worktree.py +64 -0
  29. scootcli/commands/yolo.py +20 -0
  30. scootcli/config.py +241 -0
  31. scootcli/context.py +82 -0
  32. scootcli/credentials.py +79 -0
  33. scootcli/errors.py +87 -0
  34. scootcli/images.py +169 -0
  35. scootcli/keys.py +119 -0
  36. scootcli/lineeditor.py +577 -0
  37. scootcli/logo.py +116 -0
  38. scootcli/models.py +120 -0
  39. scootcli/panel.py +263 -0
  40. scootcli/preferences.py +87 -0
  41. scootcli/presets.py +38 -0
  42. scootcli/project.py +94 -0
  43. scootcli/prompts.py +100 -0
  44. scootcli/providers/__init__.py +20 -0
  45. scootcli/providers/base.py +370 -0
  46. scootcli/providers/openai_chat.py +142 -0
  47. scootcli/providers/openai_responses.py +248 -0
  48. scootcli/providers/registry.py +173 -0
  49. scootcli/rendering.py +86 -0
  50. scootcli/repl.py +801 -0
  51. scootcli/sessions.py +186 -0
  52. scootcli/status.py +71 -0
  53. scootcli/tools/__init__.py +68 -0
  54. scootcli/tools/base.py +152 -0
  55. scootcli/tools/edit_file.py +72 -0
  56. scootcli/tools/list_dir.py +47 -0
  57. scootcli/tools/read_file.py +56 -0
  58. scootcli/tools/run_shell.py +73 -0
  59. scootcli/tools/search.py +170 -0
  60. scootcli/tools/update_plan.py +104 -0
  61. scootcli/tools/write_file.py +61 -0
  62. scootcli/transport.py +312 -0
  63. scootcli/vision.py +167 -0
  64. scootcli/workspace.py +105 -0
  65. scootcli/worktree.py +114 -0
  66. scootcli-0.1.0.dist-info/METADATA +238 -0
  67. scootcli-0.1.0.dist-info/RECORD +71 -0
  68. scootcli-0.1.0.dist-info/WHEEL +5 -0
  69. scootcli-0.1.0.dist-info/entry_points.txt +2 -0
  70. scootcli-0.1.0.dist-info/licenses/LICENSE +21 -0
  71. 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
+