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.
Files changed (119) hide show
  1. {scootcli-0.7.0/src/scootcli.egg-info → scootcli-0.8.1}/PKG-INFO +6 -3
  2. {scootcli-0.7.0 → scootcli-0.8.1}/README.md +5 -2
  3. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/__init__.py +1 -1
  4. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/cli.py +13 -3
  5. scootcli-0.8.1/src/scootcli/commands/model.py +115 -0
  6. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/status.py +12 -1
  7. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/config.py +31 -9
  8. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/keys.py +50 -1
  9. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/lineeditor.py +181 -11
  10. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/panel.py +9 -7
  11. scootcli-0.8.1/src/scootcli/preferences.py +144 -0
  12. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/repl.py +56 -1
  13. scootcli-0.8.1/src/scootcli/resize.py +164 -0
  14. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/status.py +17 -7
  15. {scootcli-0.7.0 → scootcli-0.8.1/src/scootcli.egg-info}/PKG-INFO +6 -3
  16. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli.egg-info/SOURCES.txt +2 -0
  17. scootcli-0.8.1/tests/test_preferences.py +266 -0
  18. scootcli-0.8.1/tests/test_resize.py +141 -0
  19. scootcli-0.7.0/src/scootcli/commands/model.py +0 -65
  20. scootcli-0.7.0/src/scootcli/preferences.py +0 -87
  21. scootcli-0.7.0/tests/test_preferences.py +0 -81
  22. {scootcli-0.7.0 → scootcli-0.8.1}/LICENSE +0 -0
  23. {scootcli-0.7.0 → scootcli-0.8.1}/pyproject.toml +0 -0
  24. {scootcli-0.7.0 → scootcli-0.8.1}/setup.cfg +0 -0
  25. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/__main__.py +0 -0
  26. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/activity.py +0 -0
  27. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/agent.py +0 -0
  28. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/approvals.py +0 -0
  29. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/auth.py +0 -0
  30. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/clipboard.py +0 -0
  31. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/__init__.py +0 -0
  32. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/approve.py +0 -0
  33. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/auth.py +0 -0
  34. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/base.py +0 -0
  35. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/compact.py +0 -0
  36. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/copy.py +0 -0
  37. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/exit.py +0 -0
  38. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/forget.py +0 -0
  39. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/help.py +0 -0
  40. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/hooks.py +0 -0
  41. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/init.py +0 -0
  42. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/logo.py +0 -0
  43. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/panel.py +0 -0
  44. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/reset.py +0 -0
  45. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/resume.py +0 -0
  46. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/route.py +0 -0
  47. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/save.py +0 -0
  48. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/scope.py +0 -0
  49. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/sessions.py +0 -0
  50. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/verbosity.py +0 -0
  51. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/worktree.py +0 -0
  52. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/commands/yolo.py +0 -0
  53. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/context.py +0 -0
  54. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/credentials.py +0 -0
  55. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/errors.py +0 -0
  56. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/headless.py +0 -0
  57. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/hooks.py +0 -0
  58. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/images.py +0 -0
  59. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/logo.py +0 -0
  60. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/models.py +0 -0
  61. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/presets.py +0 -0
  62. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/pricing.py +0 -0
  63. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/project.py +0 -0
  64. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/prompts.py +0 -0
  65. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/providers/__init__.py +0 -0
  66. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/providers/anthropic.py +0 -0
  67. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/providers/base.py +0 -0
  68. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/providers/openai_chat.py +0 -0
  69. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/providers/openai_responses.py +0 -0
  70. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/providers/registry.py +0 -0
  71. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/providers/router.py +0 -0
  72. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/rendering.py +0 -0
  73. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/sessions.py +0 -0
  74. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/tools/__init__.py +0 -0
  75. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/tools/base.py +0 -0
  76. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/tools/edit_file.py +0 -0
  77. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/tools/list_dir.py +0 -0
  78. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/tools/open_editor.py +0 -0
  79. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/tools/read_file.py +0 -0
  80. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/tools/run_shell.py +0 -0
  81. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/tools/search.py +0 -0
  82. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/tools/update_plan.py +0 -0
  83. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/tools/write_file.py +0 -0
  84. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/transport.py +0 -0
  85. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/updates.py +0 -0
  86. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/vision.py +0 -0
  87. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/workspace.py +0 -0
  88. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli/worktree.py +0 -0
  89. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli.egg-info/dependency_links.txt +0 -0
  90. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli.egg-info/entry_points.txt +0 -0
  91. {scootcli-0.7.0 → scootcli-0.8.1}/src/scootcli.egg-info/top_level.txt +0 -0
  92. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_agent.py +0 -0
  93. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_anthropic.py +0 -0
  94. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_approvals.py +0 -0
  95. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_auth.py +0 -0
  96. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_clipboard.py +0 -0
  97. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_config.py +0 -0
  98. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_headless.py +0 -0
  99. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_hooks.py +0 -0
  100. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_images.py +0 -0
  101. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_lineeditor.py +0 -0
  102. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_logo.py +0 -0
  103. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_models.py +0 -0
  104. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_panel.py +0 -0
  105. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_providers.py +0 -0
  106. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_readiness.py +0 -0
  107. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_repl.py +0 -0
  108. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_resilience.py +0 -0
  109. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_router.py +0 -0
  110. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_scope.py +0 -0
  111. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_sessions.py +0 -0
  112. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_small_060.py +0 -0
  113. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_smoke.py +0 -0
  114. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_streaming.py +0 -0
  115. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_tools.py +0 -0
  116. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_transport_native.py +0 -0
  117. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_vision.py +0 -0
  118. {scootcli-0.7.0 → scootcli-0.8.1}/tests/test_workspace.py +0 -0
  119. {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.7.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 next launch; `/model default` goes back to the provider's preferred model.
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` | `default` |
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 next launch; `/model default` goes back to the provider's preferred model.
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` | `default` |
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.
@@ -1,4 +1,4 @@
1
1
  """scoot: a tiny coding agent that goes where you point it."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.8.1"
4
4
 
@@ -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
- def _resolve_model_setting(model_env: Optional[str]) -> str:
133
- """Model precedence (below the CLI flag, applied later): SCOOT_MODEL → saved pref → default."""
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
- return get_model() or DEFAULT_MODEL_ALIAS
146
+ saved = get_model(root)
147
+ if saved:
148
+ return saved, model_source(root) or "everywhere"
140
149
  except Exception:
141
- return DEFAULT_MODEL_ALIAS
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=_resolve_model_setting(get("SCOOT_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=Path(get("SCOOT_ROOT", str(cwd))).resolve(),
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 — the CLI-flag layer."""
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
- if not os.read(self._fd, 1):
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)