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/logo.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""scoot's mascot: one square-head robot on a kick scooter, drawn once and reused everywhere.
|
|
2
|
+
|
|
3
|
+
Every variant below is the same character in the same pose (one arm on the handlebar, standing on
|
|
4
|
+
the deck, two wheels) so the mascot stays recognisable across the README, the launch banner, the
|
|
5
|
+
status bar and ``/help``. The eyes are the only moving part: they track what the agent is doing.
|
|
6
|
+
|
|
7
|
+
Variants:
|
|
8
|
+
* ``banner`` - box-drawing, 5 rows: the REPL launch banner.
|
|
9
|
+
* ``lean`` - leaning forward with speed lines: ``/logo`` and the README on unicode terminals.
|
|
10
|
+
* ``micro`` - 3 rows: ``/help`` header and other compact spots.
|
|
11
|
+
* ``ascii`` - plain ASCII: renders in every font (README fallback).
|
|
12
|
+
|
|
13
|
+
Nothing here prints or colours; callers decide where the art goes and how it is styled.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Dict, Iterable, List, Tuple
|
|
19
|
+
|
|
20
|
+
NAME = "scoot"
|
|
21
|
+
LABEL_EMOJI = "๐ด" # U+1F6F4 kick scooter
|
|
22
|
+
LABEL_PLAIN = "โบ"
|
|
23
|
+
|
|
24
|
+
STATES = ("idle", "thinking", "stopped")
|
|
25
|
+
|
|
26
|
+
# Eye pairs per state, for the wide (3-cell) face used by banner / lean.
|
|
27
|
+
EYES: Dict[str, str] = {"idle": "o o", "thinking": "> >", "stopped": "- -"}
|
|
28
|
+
|
|
29
|
+
_ART: Dict[str, Tuple[str, ...]] = {
|
|
30
|
+
"banner": (
|
|
31
|
+
" โญโโโโฎ",
|
|
32
|
+
" โ{eyes}โ",
|
|
33
|
+
" Tโโโคโโโโค",
|
|
34
|
+
" โ โฐโฌโโฌโฏ",
|
|
35
|
+
" (o)โโโโงโโงโ(o)",
|
|
36
|
+
),
|
|
37
|
+
"lean": (
|
|
38
|
+
" โญโโโโฎ",
|
|
39
|
+
" โ โ{eyes}โ",
|
|
40
|
+
" โTโโคโโโโค",
|
|
41
|
+
" โ โฐโฌโโฌโฏ",
|
|
42
|
+
" (o)โโโงโโงโโ(o)",
|
|
43
|
+
),
|
|
44
|
+
"micro": (
|
|
45
|
+
" โญ{eyes}โฎ",
|
|
46
|
+
" Tโโค โ",
|
|
47
|
+
" (o)โโงโโโโโโโ(o)",
|
|
48
|
+
),
|
|
49
|
+
"ascii": (
|
|
50
|
+
" .---.",
|
|
51
|
+
" |{eyes}|",
|
|
52
|
+
" T__|---|",
|
|
53
|
+
" | |_|_|",
|
|
54
|
+
" (o)=======(o)",
|
|
55
|
+
),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
VARIANTS = tuple(_ART)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def eyes(state: str = "idle", variant: str = "banner") -> str:
|
|
62
|
+
"""The eye glyphs for ``state``; ``micro``/``ascii`` join them with ``_`` (``o_o``)."""
|
|
63
|
+
pair = EYES.get(state, EYES["idle"])
|
|
64
|
+
return pair.replace(" ", "_") if variant in ("micro", "ascii") else pair
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def mascot(state: str = "idle", variant: str = "banner") -> List[str]:
|
|
68
|
+
"""Rows of the mascot, right-padded to a common width so they compose side by side."""
|
|
69
|
+
rows = [row.format(eyes=eyes(state, variant)) for row in _ART[variant]]
|
|
70
|
+
width = max(len(r) for r in rows)
|
|
71
|
+
return [r.ljust(width) for r in rows]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def face(state: str = "idle") -> str:
|
|
75
|
+
"""One-cell-high face for the status bar: ``โญo oโฎ`` idle, ``โญ> >โฎ`` thinking, ``โญ- -โฎ`` stopped."""
|
|
76
|
+
return f"โญ{eyes(state)}โฎ"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def label(emoji: bool = True) -> str:
|
|
80
|
+
"""The assistant's transcript lead-in: ``๐ด scoot``, or ``โบ scoot`` where emoji are unwanted."""
|
|
81
|
+
return f"{LABEL_EMOJI if emoji else LABEL_PLAIN} {NAME}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def tilde(path) -> str:
|
|
85
|
+
"""Show the home directory as ``~`` (banner and status text stay short)."""
|
|
86
|
+
import os
|
|
87
|
+
|
|
88
|
+
p = str(path)
|
|
89
|
+
home = os.path.expanduser("~")
|
|
90
|
+
if home and (p == home or p.startswith(home + os.sep)):
|
|
91
|
+
return "~" + p[len(home):]
|
|
92
|
+
return p
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def fit(text: str, width: int) -> str:
|
|
96
|
+
"""Shorten ``text`` to ``width`` cells with a middle ellipsis so both ends stay readable."""
|
|
97
|
+
if width <= 0 or len(text) <= width:
|
|
98
|
+
return text
|
|
99
|
+
if width < 5:
|
|
100
|
+
return text[:width]
|
|
101
|
+
head = (width - 1) // 2
|
|
102
|
+
tail = width - 1 - head
|
|
103
|
+
return text[:head] + "โฆ" + text[-tail:]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def compose(art: Iterable[str], text: Iterable[str], gap: int = 3) -> List[str]:
|
|
107
|
+
"""Place ``text`` rows to the right of ``art`` rows (both top-aligned; the shorter side is padded)."""
|
|
108
|
+
art, text = list(art), list(text)
|
|
109
|
+
width = max((len(r) for r in art), default=0)
|
|
110
|
+
blank = " " * width
|
|
111
|
+
rows = []
|
|
112
|
+
for i in range(max(len(art), len(text))):
|
|
113
|
+
left = art[i] if i < len(art) else blank
|
|
114
|
+
right = text[i] if i < len(text) else ""
|
|
115
|
+
rows.append((left + " " * gap + right).rstrip())
|
|
116
|
+
return rows
|
scootcli/models.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Client-side model resolution for the ``auto`` alias.
|
|
2
|
+
|
|
3
|
+
Picks a concrete model id from the *live* model list (qualified ``provider/model`` ids) using a
|
|
4
|
+
lightweight, rule-based heuristic: a strong coding model for multi-step/edit tasks, a cheaper/faster
|
|
5
|
+
one for trivial Q&A. Tiers are substring-preference lists so they keep working as model names evolve.
|
|
6
|
+
This heuristic is the seed of the routing provider planned for 0.3.0.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from typing import List
|
|
13
|
+
|
|
14
|
+
# Preference-ordered substrings. First available match wins.
|
|
15
|
+
# NOTE: tokens are chosen to avoid accidental substring hits (e.g. "mini" โ "geMINI").
|
|
16
|
+
STRONG_TIER = [
|
|
17
|
+
"gpt-5.3-codex", "codex", "opus", "gpt-5.6", "gpt-5.5", "gpt-5.4", "gpt-5.3", "sonnet", "gpt-4.1",
|
|
18
|
+
"gpt-4o", "qwen3", "llama3.3", "llama3.1", "llama3.2", "mistral",
|
|
19
|
+
]
|
|
20
|
+
CHEAP_TIER = [
|
|
21
|
+
"-mini", "-nano", "flash", "haiku", "gpt-4o-mini", "llama3.2", "qwen3", "mistral",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
# Markers of small/fast models โ the STRONG tier must never pick one of these.
|
|
25
|
+
_SMALL_MARKERS = ("-mini", "flash", "haiku", "nano", "turbo")
|
|
26
|
+
|
|
27
|
+
# Words that suggest a multi-step / code-changing task (โ strong tier).
|
|
28
|
+
_COMPLEX_HINTS = (
|
|
29
|
+
"refactor", "implement", "fix", "add ", "create", "edit", "rewrite", "debug",
|
|
30
|
+
"test", "build", "migrate", "optimize", "generate", "write ", "change", "rename",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def looks_complex(text: str) -> bool:
|
|
35
|
+
t = (text or "").lower()
|
|
36
|
+
return len(t) > 240 or any(h in t for h in _COMPLEX_HINTS)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_small(model_id: str) -> bool:
|
|
40
|
+
return any(marker in model_id for marker in _SMALL_MARKERS)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_SNAPSHOT_RE = re.compile(r"-\d{4}-\d{2}-\d{2}$|-\d{4}$")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_snapshot(model_id: str) -> bool:
|
|
47
|
+
"""Dated snapshot ids (``gpt-4o-mini-2024-07-18``, ``gpt-4-0613``) lose to their plain alias."""
|
|
48
|
+
return bool(_SNAPSHOT_RE.search(model_id or ""))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _prefer_plain(available: List[str]) -> List[str]:
|
|
52
|
+
"""Drop snapshot ids when any plain id exists, so tiers pick ``gpt-5-mini`` over a dated variant."""
|
|
53
|
+
plain = [m for m in available if not is_snapshot(m)]
|
|
54
|
+
return plain or list(available)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_chat_model(descriptor: dict) -> bool:
|
|
58
|
+
caps = descriptor.get("capabilities")
|
|
59
|
+
ctype = caps.get("type") if isinstance(caps, dict) else None
|
|
60
|
+
mid = descriptor.get("id", "")
|
|
61
|
+
if "embedding" in mid:
|
|
62
|
+
return False
|
|
63
|
+
return ctype == "chat" or ctype is None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# โโ Vision (M22) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
67
|
+
# Preference-ordered substrings for picking a vision model (seeded from the proven candidate list in
|
|
68
|
+
# First available match wins.
|
|
69
|
+
VISION_TIER = [
|
|
70
|
+
"gpt-5.3-codex", "gpt-5", "gpt-4.1", "gpt-4o", "claude-sonnet", "claude-opus", "gemini",
|
|
71
|
+
"qwen2.5vl", "qwen3-vl", "llama3.2-vision", "gemma3", "minicpm-v", "llava", "claude",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
# Substring markers of models that are (very likely) vision-capable, used when the /models descriptor
|
|
75
|
+
# doesn't expose an explicit capability flag.
|
|
76
|
+
_VISION_MARKERS = ("gpt-4o", "gpt-4.1", "gpt-5", "claude", "gemini", "vision", "vl", "llava",
|
|
77
|
+
"gemma3", "minicpm-v")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_vision_model(descriptor: dict) -> bool:
|
|
81
|
+
"""True if a /models descriptor looks vision-capable (explicit flag, else a name heuristic)."""
|
|
82
|
+
caps = descriptor.get("capabilities")
|
|
83
|
+
if isinstance(caps, dict):
|
|
84
|
+
supports = caps.get("supports")
|
|
85
|
+
if isinstance(supports, dict) and supports.get("vision"):
|
|
86
|
+
return True
|
|
87
|
+
mid = (descriptor.get("id") or "").lower()
|
|
88
|
+
return any(marker in mid for marker in _VISION_MARKERS)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def resolve_vision(available: List[str], fallback: str = "") -> str:
|
|
92
|
+
"""Choose a vision model id from live ids (VISION_TIER order); ``fallback`` when nothing matches."""
|
|
93
|
+
ids = [m for m in available if m]
|
|
94
|
+
if not ids:
|
|
95
|
+
return fallback
|
|
96
|
+
for pref in VISION_TIER:
|
|
97
|
+
for mid in ids:
|
|
98
|
+
if pref in mid:
|
|
99
|
+
return mid
|
|
100
|
+
return fallback if fallback in ids else ids[0]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def resolve_auto(task_text: str, available: List[str], fallback: str = "") -> str:
|
|
104
|
+
"""Choose a model id for an ``auto`` request from ``available``; ``fallback`` when the list is empty."""
|
|
105
|
+
if not available:
|
|
106
|
+
return fallback
|
|
107
|
+
available = _prefer_plain(available)
|
|
108
|
+
if looks_complex(task_text):
|
|
109
|
+
for pref in STRONG_TIER:
|
|
110
|
+
for model_id in available:
|
|
111
|
+
if pref in model_id and not _is_small(model_id):
|
|
112
|
+
return model_id
|
|
113
|
+
else:
|
|
114
|
+
for pref in CHEAP_TIER:
|
|
115
|
+
for model_id in available:
|
|
116
|
+
if pref in model_id:
|
|
117
|
+
return model_id
|
|
118
|
+
# No tier match: fall back to a present model.
|
|
119
|
+
return fallback if fallback in available else available[0]
|
|
120
|
+
|
scootcli/panel.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Persistent bottom status bar for the REPL (stdlib-only, TTY-only).
|
|
2
|
+
|
|
3
|
+
Uses a DECSTBM scroll region so the last terminal row stays pinned while normal output scrolls above
|
|
4
|
+
it. The bar shows the signed-in user, session/folder, model, approval mode, and tokens spent. It is a
|
|
5
|
+
no-op when stdout is not a TTY or when disabled (``SCOOT_PANEL=false`` / ``/panel``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _fmt_tokens(n: int) -> str:
|
|
16
|
+
if n >= 1000:
|
|
17
|
+
return f"{n / 1000:.1f}k"
|
|
18
|
+
return str(n)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DockLayout:
|
|
22
|
+
"""Shared bottom-dock geometry between the :class:`StatusBar` and the ``LineEditor``.
|
|
23
|
+
|
|
24
|
+
The dock reserves a band of rows at the bottom of the terminal, from the top down:
|
|
25
|
+
|
|
26
|
+
top pad (1, blank) ยท input block (``input_rows``, grows as the line wraps) ยท spacer (1) ยท bar (1)
|
|
27
|
+
|
|
28
|
+
``input_rows`` is owned by the line editor (it grows/shrinks as the typed line wraps) and read by
|
|
29
|
+
the status bar when it (re)establishes the DECSTBM scroll region, so the two never disagree. The
|
|
30
|
+
separation above/below the input is provided by **blank pad rows** (not drawn rules): blanks are
|
|
31
|
+
immune to terminal-resize width changes and can never leave a smeared artifact in the transcript.
|
|
32
|
+
All row math lives here so both components compute identical positions.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
MAX_INPUT_ROWS = 6 # cap the growth; beyond this the input scrolls vertically within the band
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
self.input_rows = 1
|
|
39
|
+
|
|
40
|
+
def reserved(self) -> int:
|
|
41
|
+
# top pad + input block + spacer + bar
|
|
42
|
+
return 1 + self.input_rows + 1 + 1
|
|
43
|
+
|
|
44
|
+
def region_bottom(self, rows: int) -> int:
|
|
45
|
+
return max(1, rows - self.reserved())
|
|
46
|
+
|
|
47
|
+
def top_pad_row(self, rows: int) -> int:
|
|
48
|
+
return self.region_bottom(rows) + 1
|
|
49
|
+
|
|
50
|
+
def input_top(self, rows: int) -> int:
|
|
51
|
+
return self.top_pad_row(rows) + 1
|
|
52
|
+
|
|
53
|
+
def input_bottom(self, rows: int) -> int:
|
|
54
|
+
return self.input_top(rows) + self.input_rows - 1
|
|
55
|
+
|
|
56
|
+
def spacer_row(self, rows: int) -> int:
|
|
57
|
+
return self.input_bottom(rows) + 1
|
|
58
|
+
|
|
59
|
+
def bar_row(self, rows: int) -> int:
|
|
60
|
+
return rows
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build_status_text(session, user: Optional[str] = None) -> str:
|
|
64
|
+
"""Compose the status-bar text from the live session (pure; unit-testable)."""
|
|
65
|
+
import os
|
|
66
|
+
|
|
67
|
+
folder = os.path.basename(str(session.config.root).rstrip("/")) or str(session.config.root)
|
|
68
|
+
short_id = session.id.split("-")[-1] if getattr(session, "id", "") else "?"
|
|
69
|
+
model = session.active_model or session.resolved_model()
|
|
70
|
+
if session.model.lower() == "auto":
|
|
71
|
+
model += " (auto)"
|
|
72
|
+
spent = session.total_prompt + session.total_completion
|
|
73
|
+
ctx = int((getattr(session, "last_usage", None) or {}).get("prompt_tokens", 0) or 0)
|
|
74
|
+
if not ctx:
|
|
75
|
+
try:
|
|
76
|
+
from .context import estimate_messages_tokens
|
|
77
|
+
|
|
78
|
+
ctx = estimate_messages_tokens(getattr(session, "messages", None))
|
|
79
|
+
except Exception:
|
|
80
|
+
ctx = 0
|
|
81
|
+
# Context as a share of the auto-compact threshold โ a heads-up that a /compact is coming. Warn
|
|
82
|
+
# (โณ) once we cross 80% so the user can /compact on their own terms first.
|
|
83
|
+
limit = int(getattr(getattr(session, "config", None), "compact_at", 0) or 0)
|
|
84
|
+
ctx_seg = f"โค ctx {_fmt_tokens(ctx)}"
|
|
85
|
+
if limit > 0:
|
|
86
|
+
pct = min(999, int(round(ctx * 100 / limit)))
|
|
87
|
+
ctx_seg = f"{'โณ' if pct >= 80 else 'โค'} ctx {_fmt_tokens(ctx)} ({pct}%)"
|
|
88
|
+
ctx_seg += f" ยท ฮฃ {_fmt_tokens(spent)} tok"
|
|
89
|
+
segments = [
|
|
90
|
+
f"โป {user}" if user else "โป signed in",
|
|
91
|
+
f"โ {folder}ยท{short_id}",
|
|
92
|
+
f"โ {model}",
|
|
93
|
+
f"โ {session.approval_mode}",
|
|
94
|
+
ctx_seg,
|
|
95
|
+
]
|
|
96
|
+
msgs = getattr(session, "messages", None)
|
|
97
|
+
if msgs:
|
|
98
|
+
segments.append(f"โ {len(msgs)}")
|
|
99
|
+
if getattr(session, "worktree", None) is not None:
|
|
100
|
+
segments.append(f"โ {session.worktree.branch}")
|
|
101
|
+
plan = getattr(session, "plan", None)
|
|
102
|
+
if plan:
|
|
103
|
+
done = sum(1 for s in plan if s.get("status") == "completed")
|
|
104
|
+
segments.append(f"โ {done}/{len(plan)}")
|
|
105
|
+
err = getattr(session, "last_error", None)
|
|
106
|
+
if err:
|
|
107
|
+
segments.append(f"โ {err}")
|
|
108
|
+
# The mascot's face leads the bar; its eyes follow the turn (idle / thinking / stopped).
|
|
109
|
+
if getattr(getattr(session, "config", None), "logo", True):
|
|
110
|
+
from .logo import face
|
|
111
|
+
|
|
112
|
+
segments.insert(0, face(getattr(session, "mascot_state", "idle")))
|
|
113
|
+
return " โ ".join(segments)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class StatusBar:
|
|
117
|
+
"""A pinned bottom bar. Safe no-op when not a TTY or disabled.
|
|
118
|
+
|
|
119
|
+
When ``reserve_input`` is set (the REPL "dock", PLAN ยง19), rows are reserved just above the bar for
|
|
120
|
+
a fixed input line owned by :class:`~scootcli.lineeditor.LineEditor`. With a :class:`DockLayout` the
|
|
121
|
+
input can span **multiple rows** (a wrapping line) framed by blank pad rows above and below; the bar
|
|
122
|
+
reads ``layout.input_rows`` when it (re)establishes the scroll region, and blanks the pad/spacer and
|
|
123
|
+
draws the bar (the input rows themselves are owned by the editor). :meth:`reflow` lets the editor
|
|
124
|
+
grow/shrink that region mid-edit without disturbing its own saved cursor.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self, enabled: bool = True, reserve_input: bool = False, layout=None):
|
|
128
|
+
self.enabled = enabled and sys.stdout.isatty()
|
|
129
|
+
self.reserve_input = reserve_input
|
|
130
|
+
self.layout = layout # DockLayout when the multi-row dock is active; None = legacy single row
|
|
131
|
+
self._installed = False
|
|
132
|
+
self._rows = 0
|
|
133
|
+
self._reserved_cache = -1
|
|
134
|
+
self._text = ""
|
|
135
|
+
|
|
136
|
+
# โโ lifecycle โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
137
|
+
def install(self) -> None:
|
|
138
|
+
if not self.enabled or self._installed:
|
|
139
|
+
return
|
|
140
|
+
self._rows = self._term_rows()
|
|
141
|
+
# Reserve the bottom rows: a blank spacer + [optional input row] + the bar. The scroll region
|
|
142
|
+
# is 1..region_bottom, so output never butts up against (or overwrites) the reserved rows. Home
|
|
143
|
+
# the cursor so whatever prints next (the banner) starts at the top and flows down.
|
|
144
|
+
sys.stdout.write(f"\033[1;{self._region_bottom(self._rows)}r")
|
|
145
|
+
sys.stdout.write("\033[1;1H")
|
|
146
|
+
sys.stdout.flush()
|
|
147
|
+
self._installed = True
|
|
148
|
+
|
|
149
|
+
def render(self, text: Optional[str] = None) -> None:
|
|
150
|
+
"""Redraw the bar (recomputes size so it self-heals after a resize)."""
|
|
151
|
+
if not self.enabled:
|
|
152
|
+
return
|
|
153
|
+
if text is not None:
|
|
154
|
+
self._text = text
|
|
155
|
+
rows = self._term_rows()
|
|
156
|
+
reserved = self._reserved()
|
|
157
|
+
sys.stdout.write("\0337") # save cursor + attrs (DECSC)
|
|
158
|
+
if not self._installed or rows != self._rows or reserved != self._reserved_cache:
|
|
159
|
+
self._rows = rows
|
|
160
|
+
self._reserved_cache = reserved
|
|
161
|
+
sys.stdout.write(f"\033[1;{self._region_bottom(rows)}r") # (re)establish scroll region
|
|
162
|
+
self._installed = True
|
|
163
|
+
self._draw_frame(rows)
|
|
164
|
+
sys.stdout.write("\0338") # restore cursor (DECRC)
|
|
165
|
+
sys.stdout.flush()
|
|
166
|
+
|
|
167
|
+
def reflow(self) -> None:
|
|
168
|
+
"""Re-establish the scroll region + redraw the frame for the current ``layout.input_rows``.
|
|
169
|
+
|
|
170
|
+
Unlike :meth:`render`, this does **not** use the DECSC/DECRC save slot โ it is called by the
|
|
171
|
+
line editor *while editing*, which owns that slot for its own final cursor restore. The caller
|
|
172
|
+
repositions the caret afterwards, so we may leave the cursor anywhere.
|
|
173
|
+
"""
|
|
174
|
+
if not self.enabled:
|
|
175
|
+
return
|
|
176
|
+
rows = self._term_rows()
|
|
177
|
+
self._rows = rows
|
|
178
|
+
self._reserved_cache = self._reserved()
|
|
179
|
+
self._installed = True
|
|
180
|
+
sys.stdout.write(f"\033[1;{self._region_bottom(rows)}r") # (re)establish for the new height
|
|
181
|
+
self._draw_frame(rows)
|
|
182
|
+
# no flush โ the editor flushes after drawing the input rows + caret
|
|
183
|
+
|
|
184
|
+
def set_text(self, text: str) -> None:
|
|
185
|
+
"""Update the cached bar text without emitting anything (used before :meth:`reflow`)."""
|
|
186
|
+
self._text = text
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _draw_frame(self, rows: int) -> None:
|
|
190
|
+
"""Draw the non-input reserved rows: the top pad (if docked), the spacer, and the bar.
|
|
191
|
+
|
|
192
|
+
The pad + spacer are kept **blank** (just erased) rather than drawn with rule glyphs, so they
|
|
193
|
+
are immune to resize width changes and never leave an artifact when the region scrolls.
|
|
194
|
+
"""
|
|
195
|
+
cols = self._term_cols()
|
|
196
|
+
bar = self._text[: cols - 2]
|
|
197
|
+
if self.layout is not None:
|
|
198
|
+
sys.stdout.write(f"\033[{self.layout.top_pad_row(rows)};1H\033[K") # blank pad above input
|
|
199
|
+
# Keep the spacer row blank (the input row(s), if reserved, are owned by the editor โ never
|
|
200
|
+
# touched here), then draw the bar on the fixed bottom row in reverse video.
|
|
201
|
+
sys.stdout.write(f"\033[{self._spacer_row(rows)};1H\033[K")
|
|
202
|
+
sys.stdout.write(f"\033[{rows};1H\033[K\033[7m {bar} \033[0m")
|
|
203
|
+
|
|
204
|
+
def remove(self) -> None:
|
|
205
|
+
if not self.enabled or not self._installed:
|
|
206
|
+
return
|
|
207
|
+
rows = self._term_rows()
|
|
208
|
+
sys.stdout.write("\033[r") # reset scroll region to full screen
|
|
209
|
+
# Clear the whole reserved band (pad + input rows + spacer + bar) so nothing lingers.
|
|
210
|
+
if self.layout is not None:
|
|
211
|
+
top = self.layout.top_pad_row(rows)
|
|
212
|
+
elif self.reserve_input:
|
|
213
|
+
top = rows - 2 # legacy: spacer, input, bar
|
|
214
|
+
else:
|
|
215
|
+
top = rows - 1 # spacer, bar
|
|
216
|
+
for r in range(top, rows + 1):
|
|
217
|
+
sys.stdout.write(f"\033[{r};1H\033[K")
|
|
218
|
+
sys.stdout.flush()
|
|
219
|
+
self._installed = False
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def toggle(self) -> bool:
|
|
223
|
+
"""Flip enabled state (install/remove accordingly). Returns the new state."""
|
|
224
|
+
if self._installed:
|
|
225
|
+
self.remove()
|
|
226
|
+
self.enabled = False
|
|
227
|
+
else:
|
|
228
|
+
self.enabled = sys.stdout.isatty()
|
|
229
|
+
# render() re-establishes the scroll region inside a cursor save/restore, so re-enabling
|
|
230
|
+
# mid-session doesn't jump the cursor the way install() (which homes it) would.
|
|
231
|
+
self.render(self._text)
|
|
232
|
+
return self.enabled
|
|
233
|
+
|
|
234
|
+
# โโ internals โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
235
|
+
def _reserved(self) -> int:
|
|
236
|
+
# Rows kept out of the scroll region. With a DockLayout the input block grows, so defer to it
|
|
237
|
+
# (rule + input_rows + spacer + bar). Legacy paths: spacer + bar, plus one input row if docked.
|
|
238
|
+
if self.layout is not None:
|
|
239
|
+
return self.layout.reserved()
|
|
240
|
+
return 3 if self.reserve_input else 2
|
|
241
|
+
|
|
242
|
+
def _region_bottom(self, rows: int) -> int:
|
|
243
|
+
# Bottom row of the scroll region (guard tiny terminals).
|
|
244
|
+
if self.layout is not None:
|
|
245
|
+
return self.layout.region_bottom(rows)
|
|
246
|
+
return max(1, rows - self._reserved())
|
|
247
|
+
|
|
248
|
+
def _spacer_row(self, rows: int) -> int:
|
|
249
|
+
# The blank separator sits just below the input block (or the scroll region when not docked).
|
|
250
|
+
if self.layout is not None:
|
|
251
|
+
return self.layout.spacer_row(rows)
|
|
252
|
+
return self._region_bottom(rows) + 1
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def _term_rows() -> int:
|
|
256
|
+
return shutil.get_terminal_size((80, 24)).lines
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def _term_cols() -> int:
|
|
260
|
+
return shutil.get_terminal_size((80, 24)).columns
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
|
scootcli/preferences.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Persisted user preferences (the chosen model, the mascot toggle), surviving across launches.
|
|
2
|
+
|
|
3
|
+
Unlike sessions (per-directory conversations), this is a single *global* preference file at
|
|
4
|
+
``~/.config/scoot/preferences.json``. Writing is best-effort โ a failure never breaks the CLI.
|
|
5
|
+
``SCOOT_CONFIG_DIR`` overrides the location (used by tests).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from .config import SCOOT_CONFIG_DIR
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _config_dir() -> Path:
|
|
19
|
+
override = os.environ.get("SCOOT_CONFIG_DIR")
|
|
20
|
+
return Path(override).expanduser() if override else SCOOT_CONFIG_DIR
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _prefs_file() -> Path:
|
|
24
|
+
return _config_dir() / "preferences.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_preferences() -> dict:
|
|
28
|
+
try:
|
|
29
|
+
data = json.loads(_prefs_file().read_text())
|
|
30
|
+
return data if isinstance(data, dict) else {}
|
|
31
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
32
|
+
return {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _write(prefs: dict) -> None:
|
|
36
|
+
directory = _config_dir()
|
|
37
|
+
try:
|
|
38
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
try:
|
|
40
|
+
os.chmod(directory, 0o700)
|
|
41
|
+
except OSError:
|
|
42
|
+
pass
|
|
43
|
+
path = _prefs_file()
|
|
44
|
+
tmp = directory / ".preferences.tmp"
|
|
45
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
46
|
+
with os.fdopen(fd, "w") as fh:
|
|
47
|
+
json.dump(prefs, fh)
|
|
48
|
+
os.replace(tmp, path) # atomic
|
|
49
|
+
except OSError:
|
|
50
|
+
pass # preferences are best-effort
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def save_preference(key: str, value) -> None:
|
|
54
|
+
prefs = load_preferences()
|
|
55
|
+
if value is None:
|
|
56
|
+
prefs.pop(key, None)
|
|
57
|
+
else:
|
|
58
|
+
prefs[key] = value
|
|
59
|
+
_write(prefs)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# โโ model preference โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
63
|
+
def get_model() -> Optional[str]:
|
|
64
|
+
model = load_preferences().get("model")
|
|
65
|
+
return model or None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def set_model(name: str) -> None:
|
|
69
|
+
"""Persist the chosen model (including ``auto``) so the next launch reuses it."""
|
|
70
|
+
save_preference("model", name)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def clear_model() -> None:
|
|
74
|
+
save_preference("model", None)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# โโ mascot preference โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
78
|
+
def get_logo() -> Optional[bool]:
|
|
79
|
+
"""``True``/``False`` if the user chose via ``/logo on|off``; ``None`` when unset (default on)."""
|
|
80
|
+
value = load_preferences().get("logo")
|
|
81
|
+
return value if isinstance(value, bool) else None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def set_logo(on: Optional[bool]) -> None:
|
|
85
|
+
"""Persist the mascot toggle (``None`` forgets it โ back to the default)."""
|
|
86
|
+
save_preference("logo", on)
|
|
87
|
+
|
scootcli/presets.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""CLI preset shortcuts (PLAN ยง6). Optional sugar that seed the agent with a task string.
|
|
2
|
+
|
|
3
|
+
Each preset is ``fn(paths, message) -> str`` returning the seed prompt. Adding a preset = add an entry
|
|
4
|
+
to :data:`PRESETS`. These are pure convenience โ every preset is expressible as a plain prompt.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Callable, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _explain(paths: List[str], message: Optional[str]) -> str:
|
|
13
|
+
target = " ".join(paths) if paths else "the current directory"
|
|
14
|
+
extra = f" Focus: {message}." if message else ""
|
|
15
|
+
return (f"Explain {target}: summarize its purpose, key parts, and how to use it."
|
|
16
|
+
f" Read files as needed; do not modify anything.{extra}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _edit(paths: List[str], message: Optional[str]) -> str:
|
|
20
|
+
target = " ".join(paths) if paths else "the relevant file(s)"
|
|
21
|
+
instruction = message or "make the requested change"
|
|
22
|
+
return f"Edit {target}: {instruction}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# name -> (seed builder, needs_message)
|
|
26
|
+
PRESETS: Dict[str, "Callable[[List[str], Optional[str]], str]"] = {
|
|
27
|
+
"explain": _explain,
|
|
28
|
+
"edit": _edit,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_preset(word: str) -> bool:
|
|
33
|
+
return word in PRESETS
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_prompt(name: str, rest: List[str], message: Optional[str]) -> str:
|
|
37
|
+
return PRESETS[name](rest, message)
|
|
38
|
+
|