scootcli 0.7.0__tar.gz → 0.8.1__tar.gz
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-0.7.0/src/scootcli.egg-info → scootcli-0.8.1}/PKG-INFO +6 -3
- {scootcli-0.7.0 → scootcli-0.8.1}/README.md +5 -2
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/__init__.py +1 -1
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/cli.py +13 -3
- scootcli-0.8.1/src/scootcli/commands/model.py +115 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/status.py +12 -1
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/config.py +31 -9
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/keys.py +50 -1
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/lineeditor.py +181 -11
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/panel.py +9 -7
- scootcli-0.8.1/src/scootcli/preferences.py +144 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/repl.py +56 -1
- scootcli-0.8.1/src/scootcli/resize.py +164 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/status.py +17 -7
- {scootcli-0.7.0 → scootcli-0.8.1/src/scootcli.egg-info}/PKG-INFO +6 -3
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli.egg-info/SOURCES.txt +2 -0
- scootcli-0.8.1/tests/test_preferences.py +266 -0
- scootcli-0.8.1/tests/test_resize.py +141 -0
- scootcli-0.7.0/src/scootcli/commands/model.py +0 -65
- scootcli-0.7.0/src/scootcli/preferences.py +0 -87
- scootcli-0.7.0/tests/test_preferences.py +0 -81
- {scootcli-0.7.0 → scootcli-0.8.1}/LICENSE +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/pyproject.toml +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/setup.cfg +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/__main__.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/activity.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/agent.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/approvals.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/auth.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/clipboard.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/__init__.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/approve.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/auth.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/base.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/compact.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/copy.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/exit.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/forget.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/help.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/hooks.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/init.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/logo.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/panel.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/reset.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/resume.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/route.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/save.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/scope.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/sessions.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/verbosity.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/worktree.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/yolo.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/context.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/credentials.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/errors.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/headless.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/hooks.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/images.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/logo.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/models.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/presets.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/pricing.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/project.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/prompts.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/providers/__init__.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/providers/anthropic.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/providers/base.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/providers/openai_chat.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/providers/openai_responses.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/providers/registry.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/providers/router.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/rendering.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/sessions.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/tools/__init__.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/tools/base.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/tools/edit_file.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/tools/list_dir.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/tools/open_editor.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/tools/read_file.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/tools/run_shell.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/tools/search.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/tools/update_plan.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/tools/write_file.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/transport.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/updates.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/vision.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/workspace.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/worktree.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli.egg-info/dependency_links.txt +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli.egg-info/entry_points.txt +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli.egg-info/top_level.txt +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_agent.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_anthropic.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_approvals.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_auth.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_clipboard.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_config.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_headless.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_hooks.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_images.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_lineeditor.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_logo.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_models.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_panel.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_providers.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_readiness.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_repl.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_resilience.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_router.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_scope.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_sessions.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_small_060.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_smoke.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_streaming.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_tools.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_transport_native.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_vision.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_workspace.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_worktree.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scootcli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: A tiny coding agent that goes where you point it
|
|
5
5
|
Author: Sergey Nes
|
|
6
6
|
License-Expression: MIT
|
|
@@ -115,7 +115,9 @@ scoot --model openai/gpt-5.3-codex "..."
|
|
|
115
115
|
scoot --model auto "..." # pick a model per prompt from the live list (cheap for trivial, strong for edits)
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
-
Inside the REPL, `/model <provider/model>` switches and is remembered for the
|
|
118
|
+
Inside the REPL, `/model <provider/model>` switches and is remembered for the folder you are in, so each project can have its own model; `/model default` goes back to the provider's preferred model.
|
|
119
|
+
Starting the REPL with `--model` is remembered the same way, so `scoot --model ollama/llama3.2` once and plain `scoot` afterwards keep using it there; a one-shot `scoot --model X "prompt"` does not change the saved choice.
|
|
120
|
+
`/model X everywhere` makes `X` the model for every folder that has no choice of its own, `/model forget` drops the current folder's choice so it follows that, and `/status` says where the active model came from.
|
|
119
121
|
|
|
120
122
|
### Routing
|
|
121
123
|
|
|
@@ -170,7 +172,7 @@ The most useful settings (see [`.env.example`](./.env.example) for all of them):
|
|
|
170
172
|
|---|---|---|
|
|
171
173
|
| `OPENAI_API_KEY` | OpenAI key (or `scoot auth set openai`) | |
|
|
172
174
|
| `SCOOT_PROVIDER` | default provider for bare model names | first with a key, else `ollama` |
|
|
173
|
-
| `SCOOT_MODEL` | `default`, `auto`, or `provider/model
|
|
175
|
+
| `SCOOT_MODEL` | `default`, `auto`, or `provider/model`; overrides the saved per-folder and global choices | `default` |
|
|
174
176
|
| `SCOOT_EFFORT` | reasoning effort | `medium` |
|
|
175
177
|
| `SCOOT_APPROVAL` | `always` · `auto-read` · `auto-edits` · `yolo` | `yolo` |
|
|
176
178
|
| `SCOOT_MAX_STEPS` | tool-call steps per turn before asking to continue | `50` |
|
|
@@ -219,6 +221,7 @@ Press **Ctrl-N** while a turn is running to add a note: scoot asks for one line
|
|
|
219
221
|
Type `/` and press **Tab** to complete slash commands.
|
|
220
222
|
|
|
221
223
|
**Terminals.** scoot works in macOS Terminal, iTerm2, and inside tmux; the status bar uses a scroll region, the input dock uses raw mode, and clipboard copy uses the system tool or an OSC-52 escape.
|
|
224
|
+
Resizing the window or zooming the font (also under tmux) repaints the dock at once: the transcript stays where it was, the input keeps its place and the bar is redrawn, at the prompt and while a turn runs.
|
|
222
225
|
Under tmux, ESC reaches scoot only after tmux's `escape-time` has passed, so with the default 500 ms the interrupt feels delayed; `set -sg escape-time 10` in `~/.tmux.conf` makes it immediate.
|
|
223
226
|
For clipboard copy through tmux, `set -g set-clipboard on` (or `external`) lets the OSC-52 escape reach the outer terminal.
|
|
224
227
|
Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
|
|
@@ -90,7 +90,9 @@ scoot --model openai/gpt-5.3-codex "..."
|
|
|
90
90
|
scoot --model auto "..." # pick a model per prompt from the live list (cheap for trivial, strong for edits)
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
-
Inside the REPL, `/model <provider/model>` switches and is remembered for the
|
|
93
|
+
Inside the REPL, `/model <provider/model>` switches and is remembered for the folder you are in, so each project can have its own model; `/model default` goes back to the provider's preferred model.
|
|
94
|
+
Starting the REPL with `--model` is remembered the same way, so `scoot --model ollama/llama3.2` once and plain `scoot` afterwards keep using it there; a one-shot `scoot --model X "prompt"` does not change the saved choice.
|
|
95
|
+
`/model X everywhere` makes `X` the model for every folder that has no choice of its own, `/model forget` drops the current folder's choice so it follows that, and `/status` says where the active model came from.
|
|
94
96
|
|
|
95
97
|
### Routing
|
|
96
98
|
|
|
@@ -145,7 +147,7 @@ The most useful settings (see [`.env.example`](./.env.example) for all of them):
|
|
|
145
147
|
|---|---|---|
|
|
146
148
|
| `OPENAI_API_KEY` | OpenAI key (or `scoot auth set openai`) | |
|
|
147
149
|
| `SCOOT_PROVIDER` | default provider for bare model names | first with a key, else `ollama` |
|
|
148
|
-
| `SCOOT_MODEL` | `default`, `auto`, or `provider/model
|
|
150
|
+
| `SCOOT_MODEL` | `default`, `auto`, or `provider/model`; overrides the saved per-folder and global choices | `default` |
|
|
149
151
|
| `SCOOT_EFFORT` | reasoning effort | `medium` |
|
|
150
152
|
| `SCOOT_APPROVAL` | `always` · `auto-read` · `auto-edits` · `yolo` | `yolo` |
|
|
151
153
|
| `SCOOT_MAX_STEPS` | tool-call steps per turn before asking to continue | `50` |
|
|
@@ -194,6 +196,7 @@ Press **Ctrl-N** while a turn is running to add a note: scoot asks for one line
|
|
|
194
196
|
Type `/` and press **Tab** to complete slash commands.
|
|
195
197
|
|
|
196
198
|
**Terminals.** scoot works in macOS Terminal, iTerm2, and inside tmux; the status bar uses a scroll region, the input dock uses raw mode, and clipboard copy uses the system tool or an OSC-52 escape.
|
|
199
|
+
Resizing the window or zooming the font (also under tmux) repaints the dock at once: the transcript stays where it was, the input keeps its place and the bar is redrawn, at the prompt and while a turn runs.
|
|
197
200
|
Under tmux, ESC reaches scoot only after tmux's `escape-time` has passed, so with the default 500 ms the interrupt feels delayed; `set -sg escape-time 10` in `~/.tmux.conf` makes it immediate.
|
|
198
201
|
For clipboard copy through tmux, `set -g set-clipboard on` (or `external`) lets the OSC-52 escape reach the outer terminal.
|
|
199
202
|
Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
|
|
@@ -225,11 +225,21 @@ def _cmd_auth(config: Config, pool: ProviderPool, words: List[str]) -> int:
|
|
|
225
225
|
return 0
|
|
226
226
|
|
|
227
227
|
|
|
228
|
-
def _interactive(pool: ProviderPool, resume=None) -> int:
|
|
229
|
-
"""Launch the persistent REPL (banner, live status, ESC-interrupt, slash-commands).
|
|
228
|
+
def _interactive(pool: ProviderPool, resume=None, remember_model: Optional[str] = None) -> int:
|
|
229
|
+
"""Launch the persistent REPL (banner, live status, ESC-interrupt, slash-commands).
|
|
230
|
+
|
|
231
|
+
``remember_model`` is the model named on the command line (``--model``), if any. Launching the REPL
|
|
232
|
+
with it counts as choosing it for this folder, exactly like ``/model`` inside the REPL, so a plain
|
|
233
|
+
``scoot`` here next time starts on the same model. One-shot prompts and headless runs do not save
|
|
234
|
+
it: a flag on a single command is not a choice for the next session.
|
|
235
|
+
"""
|
|
230
236
|
from .repl import Repl
|
|
231
237
|
|
|
232
238
|
config = pool.config
|
|
239
|
+
if remember_model and remember_model.strip():
|
|
240
|
+
from .preferences import set_model
|
|
241
|
+
|
|
242
|
+
set_model(remember_model.strip(), root=config.root)
|
|
233
243
|
# resume=auto (SCOOT_RESUME / --resume-last): reload the latest session for this directory
|
|
234
244
|
# unless the user already picked one explicitly via --resume/--continue.
|
|
235
245
|
if resume is None and getattr(config, "resume", "hint") == "auto":
|
|
@@ -309,7 +319,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
309
319
|
return run_headless(config.override(panel=False, dock=False, logo=False), pool, resume=resume)
|
|
310
320
|
if prompt is not None:
|
|
311
321
|
return _run_once(config, pool, prompt, args.json, resume=resume)
|
|
312
|
-
return _interactive(pool, resume=resume)
|
|
322
|
+
return _interactive(pool, resume=resume, remember_model=args.model)
|
|
313
323
|
except ScootError as exc:
|
|
314
324
|
eprint(color(f"⚠ {redact(str(exc))}", "red"))
|
|
315
325
|
return 1
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""/model: list available models or switch the active one.
|
|
2
|
+
|
|
3
|
+
Models are ``provider/model`` ids (``openai/gpt-5.3-codex``, ``ollama/llama3.2``); a bare name means
|
|
4
|
+
the default provider. A switch made here is **persisted** (``~/.config/scoot/preferences.json``) for
|
|
5
|
+
this folder, so the next launch here reuses it; ``/model X everywhere`` saves it for every folder
|
|
6
|
+
without a choice of its own, and ``/model forget`` drops this folder's choice so it follows that. An
|
|
7
|
+
explicit ``--model`` flag or ``SCOOT_MODEL`` still overrides it for that run.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .. import preferences
|
|
13
|
+
from ..providers.base import qualify, split_model_id
|
|
14
|
+
from ..rendering import color
|
|
15
|
+
from . import register
|
|
16
|
+
from .base import SlashCommand
|
|
17
|
+
|
|
18
|
+
_USAGE = "/model <provider/model> · /model default · /model auto (pick per task) · add 'everywhere' for all folders · /model forget"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _root(session):
|
|
22
|
+
return getattr(getattr(session, "config", None), "root", None)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _saved_line(session) -> str:
|
|
26
|
+
folder, everywhere = preferences.saved_models(_root(session))
|
|
27
|
+
return f"saved: this folder {folder or '(none)'} · everywhere {everywhere or '(none)'}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _remember(session, name: str, everywhere: bool) -> str:
|
|
31
|
+
"""Persist ``name`` for this folder (or every folder); return how to say so."""
|
|
32
|
+
root = _root(session)
|
|
33
|
+
if everywhere:
|
|
34
|
+
preferences.set_model_everywhere(name, root=root)
|
|
35
|
+
_note_origin(session, name, preferences.EVERYWHERE)
|
|
36
|
+
return "saved for every folder"
|
|
37
|
+
preferences.set_model(name, root=root)
|
|
38
|
+
_note_origin(session, name, preferences.FOLDER)
|
|
39
|
+
return "saved for this folder"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _note_origin(session, name: str, source: str) -> None:
|
|
43
|
+
"""Keep ``config.model_source`` truthful after a switch, so ``/status`` names the right layer."""
|
|
44
|
+
cfg = getattr(session, "config", None)
|
|
45
|
+
if cfg is not None and hasattr(cfg, "override"):
|
|
46
|
+
session.config = cfg.override(model=name, model_source=source)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _run(session, args: str):
|
|
50
|
+
words = args.split()
|
|
51
|
+
everywhere = False
|
|
52
|
+
if words and words[-1].lower() == "everywhere":
|
|
53
|
+
everywhere = True
|
|
54
|
+
words = words[:-1]
|
|
55
|
+
arg = " ".join(words)
|
|
56
|
+
available = session.available_models()
|
|
57
|
+
|
|
58
|
+
if not arg:
|
|
59
|
+
print(color(f"models (preference: {session.model} → active: {session.active_model})", "bold"))
|
|
60
|
+
print(color(f" {_saved_line(session)}", "gray"))
|
|
61
|
+
current = ""
|
|
62
|
+
for mid in available:
|
|
63
|
+
head, _ = split_model_id(mid)
|
|
64
|
+
if head != current:
|
|
65
|
+
current = head
|
|
66
|
+
print(color(f" {head}", "bold"))
|
|
67
|
+
mark = color(" ← active", "green") if mid == session.active_model else ""
|
|
68
|
+
print(" " + color(mid, "cyan") + mark)
|
|
69
|
+
errors = getattr(getattr(session, "provider", None), "list_errors", None) or {}
|
|
70
|
+
for name, err in sorted(errors.items()):
|
|
71
|
+
print(color(f" {name}: unavailable ({err})", "yellow"))
|
|
72
|
+
print(color(f" use: {_USAGE}", "gray"))
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
if arg == "forget":
|
|
76
|
+
preferences.clear_model(root=_root(session))
|
|
77
|
+
session.model = preferences.get_model(_root(session)) or "default"
|
|
78
|
+
session.active_model = session.resolved_model()
|
|
79
|
+
_note_origin(session, session.model, preferences.model_source(_root(session)) or "default")
|
|
80
|
+
print(color(f"this folder's model choice forgotten; now {session.model} → {session.active_model}. "
|
|
81
|
+
f"{_saved_line(session)}", "gray"))
|
|
82
|
+
if hasattr(session, "refresh_readiness") and not session.refresh_readiness():
|
|
83
|
+
print(color(session.setup_message, "yellow"))
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
if arg in ("auto", "default"):
|
|
87
|
+
session.model = arg
|
|
88
|
+
session.active_model = session.resolved_model()
|
|
89
|
+
how = _remember(session, arg, everywhere)
|
|
90
|
+
what = "picked per task from the live list" if arg == "auto" else f"the provider's preferred model, now {session.active_model}"
|
|
91
|
+
print(color(f"model preference set to {arg} ({what}); {how}.", "gray"))
|
|
92
|
+
if hasattr(session, "refresh_readiness") and not session.refresh_readiness():
|
|
93
|
+
print(color(session.setup_message, "yellow"))
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
if arg not in available:
|
|
97
|
+
head, _ = split_model_id(arg)
|
|
98
|
+
if head is None:
|
|
99
|
+
try:
|
|
100
|
+
arg = qualify(session.provider.default_name, arg)
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
if arg not in available:
|
|
104
|
+
print(color(f"warning: '{arg}' is not in the models list; setting it anyway.", "yellow"))
|
|
105
|
+
session.model = arg
|
|
106
|
+
session.active_model = arg
|
|
107
|
+
how = _remember(session, arg, everywhere)
|
|
108
|
+
print(color(f"model set to {arg}; {how}.", "gray"))
|
|
109
|
+
if hasattr(session, "refresh_readiness") and not session.refresh_readiness():
|
|
110
|
+
print(color(session.setup_message, "yellow"))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
register(SlashCommand("model", "list or switch model (default|auto|provider/model), per folder or everywhere",
|
|
114
|
+
_run, usage="[provider/model|default|auto] [everywhere] | forget"))
|
|
115
|
+
|
|
@@ -7,6 +7,16 @@ from . import register
|
|
|
7
7
|
from .base import SlashCommand
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
_ORIGINS = {"flag": "--model", "env": "SCOOT_MODEL", "folder": "saved for this folder",
|
|
11
|
+
"everywhere": "saved for every folder", "default": "the default"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _model_origin(session) -> str:
|
|
15
|
+
"""``" (from --model)"`` and the like: which layer chose the model this run started with."""
|
|
16
|
+
src = getattr(getattr(session, "config", None), "model_source", "")
|
|
17
|
+
return f" (from {_ORIGINS[src]})" if src in _ORIGINS else ""
|
|
18
|
+
|
|
19
|
+
|
|
10
20
|
def _run(session, args: str):
|
|
11
21
|
cfg = session.config
|
|
12
22
|
try:
|
|
@@ -20,7 +30,8 @@ def _run(session, args: str):
|
|
|
20
30
|
provider_row = getattr(cfg, "provider", "") or "?"
|
|
21
31
|
rows = [
|
|
22
32
|
("provider", provider_row),
|
|
23
|
-
("model", session.active_model + (" (auto)" if session.model.lower() == "auto" else "")
|
|
33
|
+
("model", session.active_model + (" (auto)" if session.model.lower() == "auto" else "")
|
|
34
|
+
+ _model_origin(session)),
|
|
24
35
|
("effort", getattr(cfg, "effort", "medium")),
|
|
25
36
|
("env files", ", ".join(getattr(cfg, "env_files", ()) or ()) or "none"),
|
|
26
37
|
("update", (f"{session.update_available} available: pipx upgrade scootcli"
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Precedence (highest wins):
|
|
4
4
|
CLI flag → environment variable → project ``.env`` → global ``~/.config/scoot/.env`` → default.
|
|
5
|
+
The model has two more layers between the ``.env`` files and the default: the model saved for this
|
|
6
|
+
workspace root, then the one saved for every folder (see ``preferences.py``); ``model_source`` says
|
|
7
|
+
which layer answered.
|
|
5
8
|
|
|
6
9
|
Two optional ``.env`` files are read: the global one in scoot's config home, and the nearest ``.env``
|
|
7
10
|
found walking up from the current directory (so a repo-root file applies from any subdirectory).
|
|
@@ -129,16 +132,23 @@ def is_model_alias(model: Optional[str]) -> bool:
|
|
|
129
132
|
return (model or "").strip().lower() in (DEFAULT_MODEL_ALIAS, AUTO_MODEL_ALIAS, "")
|
|
130
133
|
|
|
131
134
|
|
|
132
|
-
|
|
133
|
-
|
|
135
|
+
MODEL_SOURCES = ("flag", "env", "folder", "everywhere", "default")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _resolve_model_setting(model_env: Optional[str], root=None) -> Tuple[str, str]:
|
|
139
|
+
"""``(model, source)`` below the CLI flag (applied later): SCOOT_MODEL → the model saved for
|
|
140
|
+
``root`` → the model saved for every folder → default."""
|
|
134
141
|
if model_env:
|
|
135
|
-
return model_env
|
|
142
|
+
return model_env, "env"
|
|
136
143
|
try:
|
|
137
|
-
from .preferences import get_model
|
|
144
|
+
from .preferences import get_model, model_source
|
|
138
145
|
|
|
139
|
-
|
|
146
|
+
saved = get_model(root)
|
|
147
|
+
if saved:
|
|
148
|
+
return saved, model_source(root) or "everywhere"
|
|
140
149
|
except Exception:
|
|
141
|
-
|
|
150
|
+
pass
|
|
151
|
+
return DEFAULT_MODEL_ALIAS, "default"
|
|
142
152
|
|
|
143
153
|
|
|
144
154
|
def _resolve_logo_setting(logo_env: Optional[str]) -> bool:
|
|
@@ -161,6 +171,7 @@ class Config:
|
|
|
161
171
|
proxy: str = DEFAULT_PROXY
|
|
162
172
|
provider: str = "" # default provider name; empty → first provider with a key, else ollama
|
|
163
173
|
model: str = DEFAULT_MODEL_ALIAS # "default" | "auto" | a model id, ideally provider/model
|
|
174
|
+
model_source: str = "default" # where ``model`` came from: flag | env | folder | everywhere | default
|
|
164
175
|
effort: str = "medium" # reasoning effort for models that take it: low | medium | high | xhigh
|
|
165
176
|
timeout: int = 120
|
|
166
177
|
max_steps: int = 50
|
|
@@ -194,17 +205,20 @@ class Config:
|
|
|
194
205
|
load_dotenv(env_file)
|
|
195
206
|
|
|
196
207
|
get = os.environ.get
|
|
208
|
+
root = Path(get("SCOOT_ROOT", str(cwd))).resolve()
|
|
209
|
+
model, model_source = _resolve_model_setting(get("SCOOT_MODEL"), root)
|
|
197
210
|
return cls(
|
|
198
211
|
proxy=get("HTTPS_PROXY") or get("https_proxy") or DEFAULT_PROXY,
|
|
199
212
|
env_files=tuple(str(f) for f in files),
|
|
200
213
|
provider=get("SCOOT_PROVIDER", "").strip().lower(),
|
|
201
|
-
model=
|
|
214
|
+
model=model,
|
|
215
|
+
model_source=model_source,
|
|
202
216
|
effort=get("SCOOT_EFFORT", "medium").strip().lower(),
|
|
203
217
|
timeout=int(get("SCOOT_TIMEOUT", "120")),
|
|
204
218
|
max_steps=int(get("SCOOT_MAX_STEPS", "50")),
|
|
205
219
|
compact_at=int(get("SCOOT_COMPACT_AT", "100000")),
|
|
206
220
|
approval=get("SCOOT_APPROVAL", "yolo"),
|
|
207
|
-
root=
|
|
221
|
+
root=root,
|
|
208
222
|
verbose=_as_bool(get("SCOOT_VERBOSE", "false")),
|
|
209
223
|
stream=_as_bool(get("SCOOT_STREAM", "true")),
|
|
210
224
|
panel=_as_bool(get("SCOOT_PANEL", "true")),
|
|
@@ -223,10 +237,18 @@ class Config:
|
|
|
223
237
|
)
|
|
224
238
|
|
|
225
239
|
def override(self, **kwargs) -> "Config":
|
|
226
|
-
"""Return a copy with the given (non-None) fields overridden
|
|
240
|
+
"""Return a copy with the given (non-None) fields overridden: the CLI-flag layer.
|
|
241
|
+
|
|
242
|
+
A ``model`` here is the flag. A new ``root`` (``--root``) without one re-resolves the saved
|
|
243
|
+
model for that folder, unless the flag or ``SCOOT_MODEL`` already decided it.
|
|
244
|
+
"""
|
|
227
245
|
clean = {k: v for k, v in kwargs.items() if v is not None}
|
|
228
246
|
if "root" in clean:
|
|
229
247
|
clean["root"] = Path(clean["root"]).resolve()
|
|
248
|
+
if "model" in clean:
|
|
249
|
+
clean.setdefault("model_source", "flag")
|
|
250
|
+
elif "root" in clean and self.model_source not in ("flag", "env") and clean["root"] != self.root:
|
|
251
|
+
clean["model"], clean["model_source"] = _resolve_model_setting(None, clean["root"])
|
|
230
252
|
return replace(self, **clean)
|
|
231
253
|
|
|
232
254
|
def resolve_model(self, task_hint: str = "") -> str:
|
|
@@ -6,13 +6,19 @@ the in-flight request (and, later, tool) immediately.
|
|
|
6
6
|
|
|
7
7
|
Line editing for typing prompts happens in normal cooked mode (plain ``input()``); raw mode is only
|
|
8
8
|
active *while the agent works*, so we never interfere with the user typing.
|
|
9
|
+
|
|
10
|
+
While a section is active it is the only reader of stdin, so it also relays the terminal's answer to
|
|
11
|
+
a cursor-position request (``ESC[6n`` → ``ESC[row;colR``); :func:`query_cursor` is how the REPL finds
|
|
12
|
+
out where output is after a resize that happened mid-turn.
|
|
9
13
|
"""
|
|
10
14
|
|
|
11
15
|
from __future__ import annotations
|
|
12
16
|
|
|
13
17
|
import os
|
|
18
|
+
import re
|
|
14
19
|
import sys
|
|
15
20
|
import threading
|
|
21
|
+
from typing import Optional, Tuple
|
|
16
22
|
|
|
17
23
|
try:
|
|
18
24
|
import termios
|
|
@@ -25,6 +31,35 @@ except ImportError: # pragma: no cover - non-POSIX
|
|
|
25
31
|
|
|
26
32
|
ESC = "\x1b"
|
|
27
33
|
NOTE_KEY = b"\x0e" # Ctrl-N: ask for a note at the next model call
|
|
34
|
+
_CURSOR_REPORT = re.compile(rb"\x1b\[(\d+);(\d+)R") # the terminal's answer to ESC[6n
|
|
35
|
+
|
|
36
|
+
_active_section: "Optional[InterruptibleSection]" = None # the section currently reading stdin
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_cursor_report(data: bytes) -> Optional[Tuple[int, int]]:
|
|
40
|
+
"""``(row, col)`` from a byte stream holding ``ESC[row;colR``, or ``None``."""
|
|
41
|
+
m = _CURSOR_REPORT.search(data)
|
|
42
|
+
return (int(m.group(1)), int(m.group(2))) if m else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def query_cursor(timeout: float = 0.2) -> Optional[Tuple[int, int]]:
|
|
46
|
+
"""Ask the terminal where the cursor is, while a section's listener can read the answer.
|
|
47
|
+
|
|
48
|
+
Returns ``(row, col)`` (1-based) or ``None`` when no section is active (so nothing would read the
|
|
49
|
+
reply and it would leak into the next prompt) or the terminal did not answer in time.
|
|
50
|
+
"""
|
|
51
|
+
section = _active_section
|
|
52
|
+
if section is None or not section.enabled:
|
|
53
|
+
return None
|
|
54
|
+
from .resize import CURSOR_REPORT
|
|
55
|
+
|
|
56
|
+
section.cursor_event.clear()
|
|
57
|
+
section.cursor_report = None
|
|
58
|
+
sys.stdout.write(CURSOR_REPORT)
|
|
59
|
+
sys.stdout.flush()
|
|
60
|
+
if section.cursor_event.wait(timeout):
|
|
61
|
+
return section.cursor_report
|
|
62
|
+
return None
|
|
28
63
|
|
|
29
64
|
|
|
30
65
|
def read_key() -> str:
|
|
@@ -63,8 +98,11 @@ class InterruptibleSection:
|
|
|
63
98
|
self._old_attrs = None
|
|
64
99
|
self._thread = None
|
|
65
100
|
self._stop = threading.Event()
|
|
101
|
+
self.cursor_report: Optional[Tuple[int, int]] = None # last ESC[row;colR seen (see query_cursor)
|
|
102
|
+
self.cursor_event = threading.Event()
|
|
66
103
|
|
|
67
104
|
def __enter__(self) -> "InterruptibleSection":
|
|
105
|
+
global _active_section
|
|
68
106
|
if not self.enabled:
|
|
69
107
|
return self
|
|
70
108
|
try:
|
|
@@ -76,6 +114,7 @@ class InterruptibleSection:
|
|
|
76
114
|
return self
|
|
77
115
|
self._thread = threading.Thread(target=self._listen, daemon=True)
|
|
78
116
|
self._thread.start()
|
|
117
|
+
_active_section = self
|
|
79
118
|
return self
|
|
80
119
|
|
|
81
120
|
def _listen(self) -> None:
|
|
@@ -102,17 +141,27 @@ class InterruptibleSection:
|
|
|
102
141
|
except (OSError, ValueError):
|
|
103
142
|
more = None
|
|
104
143
|
if more:
|
|
144
|
+
seq = bytearray(ch)
|
|
105
145
|
try:
|
|
106
146
|
while select.select([sys.stdin], [], [], 0)[0]:
|
|
107
|
-
|
|
147
|
+
byte = os.read(self._fd, 1)
|
|
148
|
+
if not byte:
|
|
108
149
|
break
|
|
150
|
+
seq += byte
|
|
109
151
|
except (OSError, ValueError):
|
|
110
152
|
pass
|
|
153
|
+
report = parse_cursor_report(bytes(seq))
|
|
154
|
+
if report is not None: # the terminal answering query_cursor
|
|
155
|
+
self.cursor_report = report
|
|
156
|
+
self.cursor_event.set()
|
|
111
157
|
continue
|
|
112
158
|
self.cancel_event.set()
|
|
113
159
|
break
|
|
114
160
|
|
|
115
161
|
def __exit__(self, *exc) -> None:
|
|
162
|
+
global _active_section
|
|
163
|
+
if _active_section is self:
|
|
164
|
+
_active_section = None
|
|
116
165
|
self._stop.set()
|
|
117
166
|
if self._thread is not None:
|
|
118
167
|
self._thread.join(timeout=0.3)
|