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/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
+
@@ -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
+