scootcli 0.7.0__tar.gz → 0.8.0__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.0}/PKG-INFO +3 -1
- {scootcli-0.7.0 → scootcli-0.8.0}/README.md +2 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/__init__.py +1 -1
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/cli.py +13 -3
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/keys.py +50 -1
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/lineeditor.py +181 -11
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/panel.py +9 -7
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/repl.py +56 -1
- scootcli-0.8.0/src/scootcli/resize.py +164 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/status.py +17 -7
- {scootcli-0.7.0 → scootcli-0.8.0/src/scootcli.egg-info}/PKG-INFO +3 -1
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli.egg-info/SOURCES.txt +2 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_preferences.py +51 -0
- scootcli-0.8.0/tests/test_resize.py +141 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/LICENSE +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/pyproject.toml +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/setup.cfg +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/__main__.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/activity.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/agent.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/approvals.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/auth.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/clipboard.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/__init__.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/approve.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/auth.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/base.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/compact.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/copy.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/exit.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/forget.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/help.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/hooks.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/init.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/logo.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/model.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/panel.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/reset.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/resume.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/route.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/save.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/scope.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/sessions.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/status.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/verbosity.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/worktree.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/yolo.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/config.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/context.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/credentials.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/errors.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/headless.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/hooks.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/images.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/logo.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/models.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/preferences.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/presets.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/pricing.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/project.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/prompts.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/providers/__init__.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/providers/anthropic.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/providers/base.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/providers/openai_chat.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/providers/openai_responses.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/providers/registry.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/providers/router.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/rendering.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/sessions.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/tools/__init__.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/tools/base.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/tools/edit_file.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/tools/list_dir.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/tools/open_editor.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/tools/read_file.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/tools/run_shell.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/tools/search.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/tools/update_plan.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/tools/write_file.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/transport.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/updates.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/vision.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/workspace.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/worktree.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli.egg-info/entry_points.txt +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli.egg-info/top_level.txt +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_agent.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_anthropic.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_approvals.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_auth.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_clipboard.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_config.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_headless.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_hooks.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_images.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_lineeditor.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_logo.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_models.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_panel.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_providers.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_readiness.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_repl.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_resilience.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_router.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_scope.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_sessions.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_small_060.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_smoke.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_streaming.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_tools.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_transport_native.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_vision.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_workspace.py +0 -0
- {scootcli-0.7.0 → scootcli-0.8.0}/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.0
|
|
4
4
|
Summary: A tiny coding agent that goes where you point it
|
|
5
5
|
Author: Sergey Nes
|
|
6
6
|
License-Expression: MIT
|
|
@@ -116,6 +116,7 @@ scoot --model auto "..." # pick a model per prompt from the live lis
|
|
|
116
116
|
```
|
|
117
117
|
|
|
118
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.
|
|
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; a one-shot `scoot --model X "prompt"` does not change the saved choice.
|
|
119
120
|
|
|
120
121
|
### Routing
|
|
121
122
|
|
|
@@ -219,6 +220,7 @@ Press **Ctrl-N** while a turn is running to add a note: scoot asks for one line
|
|
|
219
220
|
Type `/` and press **Tab** to complete slash commands.
|
|
220
221
|
|
|
221
222
|
**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.
|
|
223
|
+
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
224
|
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
225
|
For clipboard copy through tmux, `set -g set-clipboard on` (or `external`) lets the OSC-52 escape reach the outer terminal.
|
|
224
226
|
Without a TTY, scoot falls back to a plain prompt with no bar and no dock.
|
|
@@ -91,6 +91,7 @@ scoot --model auto "..." # pick a model per prompt from the live lis
|
|
|
91
91
|
```
|
|
92
92
|
|
|
93
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.
|
|
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; a one-shot `scoot --model X "prompt"` does not change the saved choice.
|
|
94
95
|
|
|
95
96
|
### Routing
|
|
96
97
|
|
|
@@ -194,6 +195,7 @@ Press **Ctrl-N** while a turn is running to add a note: scoot asks for one line
|
|
|
194
195
|
Type `/` and press **Tab** to complete slash commands.
|
|
195
196
|
|
|
196
197
|
**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.
|
|
198
|
+
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
199
|
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
200
|
For clipboard copy through tmux, `set -g set-clipboard on` (or `external`) lets the OSC-52 escape reach the outer terminal.
|
|
199
201
|
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, exactly like ``/model`` inside the REPL, so it is saved as the
|
|
233
|
+
preference and a plain ``scoot`` next time starts on the same model. One-shot prompts and headless
|
|
234
|
+
runs do not save 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())
|
|
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
|
|
@@ -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)
|
|
@@ -17,8 +17,16 @@ scrolls horizontally (legacy). With a layout, a long line **wraps onto extra row
|
|
|
17
17
|
(and shrinks) with it: :func:`wrap_layout` computes the visual geometry purely (unit-tested), and the
|
|
18
18
|
renderer updates the shared layout + asks the status bar to re-establish the scroll region and redraw
|
|
19
19
|
the frame (``on_reflow``) so growing input never overwrites the bar. The input is framed by blank pad
|
|
20
|
-
rows (not drawn rules), which are resize-proof and leave no artifacts.
|
|
21
|
-
|
|
20
|
+
rows (not drawn rules), which are resize-proof and leave no artifacts.
|
|
21
|
+
|
|
22
|
+
**Resize.** A terminal resize (a window drag, a font zoom) resets the scroll region and moves the
|
|
23
|
+
content: the bar is gone, the input is on the wrong row. With a :class:`~scootcli.resize.ResizeWatcher`
|
|
24
|
+
the read loop wakes up on ``SIGWINCH`` and repaints at once. To put things back where they belong it
|
|
25
|
+
needs one fact the terminal keeps to itself: where the transcript ended. So ``readline`` asks for the
|
|
26
|
+
cursor position (``ESC[6n``) right after saving that spot (the "anchor" the answer resumes at), and
|
|
27
|
+
again for the caret after a resize; the difference says how far the content moved, and
|
|
28
|
+
:func:`~scootcli.resize.plan_reanchor` turns that into the scroll + clear + redraw that keeps the
|
|
29
|
+
transcript, the input, and the bar consistent at the new size.
|
|
22
30
|
"""
|
|
23
31
|
|
|
24
32
|
from __future__ import annotations
|
|
@@ -26,6 +34,8 @@ from __future__ import annotations
|
|
|
26
34
|
import os
|
|
27
35
|
import shutil
|
|
28
36
|
import sys
|
|
37
|
+
import time
|
|
38
|
+
from collections import deque
|
|
29
39
|
from dataclasses import dataclass, replace
|
|
30
40
|
|
|
31
41
|
from .rendering import color
|
|
@@ -60,6 +70,9 @@ _KILL_TO_START = "KILL_BOL" # Ctrl-U
|
|
|
60
70
|
_KILL_TO_END = "KILL_EOL" # Ctrl-K
|
|
61
71
|
_KILL_WORD = "KILL_WORD" # Ctrl-W
|
|
62
72
|
_PASTE_START = "PASTE_START" # ESC[200~ — begin a bracketed-paste payload
|
|
73
|
+
_RESIZE = "RESIZE" # the terminal changed size (ResizeWatcher wake-up)
|
|
74
|
+
_CURSOR = "cursor" # ("cursor", row, col): the terminal's answer to ESC[6n
|
|
75
|
+
_PASTE = "paste" # ("paste", text): a bracketed-paste payload
|
|
63
76
|
|
|
64
77
|
# Terminal control: enable/disable bracketed-paste mode so a multi-line paste arrives as one payload
|
|
65
78
|
# (wrapped in ESC[200~ … ESC[201~) instead of a stream of Enter keys that would each submit.
|
|
@@ -308,17 +321,40 @@ def wrap_layout(prompt_len: int, buffer: str, cursor: int, cols: int, max_rows:
|
|
|
308
321
|
|
|
309
322
|
|
|
310
323
|
|
|
324
|
+
def parse_cursor_report(body: str):
|
|
325
|
+
"""``"row;col"`` (the inside of ``ESC[row;colR``) → ``("cursor", row, col)``, or ``None`` if malformed."""
|
|
326
|
+
row, sep, col = body.partition(";")
|
|
327
|
+
if not sep or not row.isdigit() or not col.isdigit():
|
|
328
|
+
return None
|
|
329
|
+
return (_CURSOR, int(row), int(col))
|
|
330
|
+
|
|
331
|
+
|
|
311
332
|
class LineEditor:
|
|
312
333
|
"""Reads a line on a fixed bottom row; falls back to builtin ``input()`` when disabled/non-TTY."""
|
|
313
334
|
|
|
314
|
-
|
|
335
|
+
# How long to wait for the terminal's cursor report before carrying on without it. Local
|
|
336
|
+
# terminals answer within a millisecond; a slow SSH hop is the only thing that gets near this.
|
|
337
|
+
CURSOR_REPORT_TIMEOUT = 0.15
|
|
338
|
+
|
|
339
|
+
def __init__(self, enabled: bool = True, on_copy=None, layout=None, on_reflow=None, completer=None,
|
|
340
|
+
resize=None):
|
|
315
341
|
self.enabled = bool(enabled) and _HAVE_TERMIOS and sys.stdin.isatty() and sys.stdout.isatty()
|
|
316
342
|
self.history: list = [] # submitted lines, oldest→newest (↑/↓ recall)
|
|
317
343
|
self.on_copy = on_copy # zero-arg callback for Ctrl-S (copies + prints its own feedback)
|
|
318
344
|
self.layout = layout # shared DockLayout for multi-row growth (None = legacy single row)
|
|
319
345
|
self.on_reflow = on_reflow # called after layout.input_rows changes (re-establish region+frame)
|
|
320
346
|
self.completer = completer # zero-arg callable → iterable of slash-command names (no leading /)
|
|
347
|
+
self.resize = resize # ResizeWatcher: wakes the read loop on SIGWINCH (None = poll on keys)
|
|
348
|
+
self.active = False # True while readline owns the terminal (the Repl's resize hook checks)
|
|
321
349
|
self._drawn_rows = 1 # visual input-height currently on screen (drives clear-on-shrink)
|
|
350
|
+
self._caret_row = 0 # terminal row the caret was last drawn on (1-based)
|
|
351
|
+
self._caret_col = 0 # 0-based column of the caret as last drawn
|
|
352
|
+
self._caret_offset = 0 # input rows drawn above the caret's row
|
|
353
|
+
self._drawn_cols = 0 # terminal width at the last draw (re-wrap math on a width change)
|
|
354
|
+
self._input_top = 0 # terminal row of the first input row as last drawn (1-based)
|
|
355
|
+
self._anchor = None # (row, col) where output resumes after the prompt; row may be < 1
|
|
356
|
+
self._pending = deque() # keys read ahead while waiting for a cursor report
|
|
357
|
+
self._reports = deque() # what each outstanding ESC[6n was for, oldest first: "anchor"/"caret"
|
|
322
358
|
|
|
323
359
|
|
|
324
360
|
def readline(self, prompt: str = "› ") -> str:
|
|
@@ -337,16 +373,34 @@ class LineEditor:
|
|
|
337
373
|
if self.layout is not None:
|
|
338
374
|
self.layout.input_rows = 1 # start each line at a single row; it grows as the line wraps
|
|
339
375
|
self._drawn_rows = 1
|
|
376
|
+
self._anchor = None
|
|
377
|
+
self._pending.clear()
|
|
378
|
+
self._reports.clear()
|
|
340
379
|
try:
|
|
341
380
|
tty.setraw(fd) # raw: deliver Ctrl-C/Ctrl-D as bytes (we translate them ourselves)
|
|
381
|
+
self.active = True
|
|
342
382
|
sys.stdout.write(_PASTE_ON) # ask the terminal to bracket pasted text
|
|
343
383
|
sys.stdout.write("\0337") # DECSC: save the in-region cursor to restore afterwards
|
|
344
|
-
|
|
384
|
+
# Ask where that anchor is, and wait briefly for the answer before the first paint: if the
|
|
385
|
+
# last turn ended with the cursor inside the dock (a resize while it ran), the transcript's
|
|
386
|
+
# tail must be scrolled up before the input rows are drawn over it.
|
|
387
|
+
self._request_cursor("anchor")
|
|
388
|
+
if self._await_cursor("anchor") and self._anchor_below_region():
|
|
389
|
+
self._repaint(prompt, state)
|
|
390
|
+
else:
|
|
391
|
+
self._render(prompt, state)
|
|
345
392
|
while not state.done:
|
|
346
|
-
key = self.
|
|
393
|
+
key = self._next_key()
|
|
347
394
|
if key is None: # stream closed → treat as EOF
|
|
348
395
|
state = replace(state, eof=True)
|
|
349
396
|
break
|
|
397
|
+
if key == _RESIZE: # the terminal changed size: repaint everything at the new geometry
|
|
398
|
+
self._repaint(prompt, state)
|
|
399
|
+
continue
|
|
400
|
+
if isinstance(key, tuple) and key[0] == _CURSOR: # a late cursor report
|
|
401
|
+
if self._note_cursor(key) == "anchor" and self._anchor_below_region():
|
|
402
|
+
self._repaint(prompt, state)
|
|
403
|
+
continue
|
|
350
404
|
if isinstance(key, tuple): # ("paste", text) → bulk insert, never submits
|
|
351
405
|
state = insert_text(state, key[1])
|
|
352
406
|
elif key == "\x1b": # lone ESC (no recognised sequence) → ignore
|
|
@@ -382,6 +436,7 @@ class LineEditor:
|
|
|
382
436
|
sys.stdout.write(_PASTE_OFF) # stop bracketing paste once we hand control back
|
|
383
437
|
sys.stdout.flush()
|
|
384
438
|
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
439
|
+
self.active = False
|
|
385
440
|
|
|
386
441
|
|
|
387
442
|
if state.interrupt:
|
|
@@ -403,21 +458,35 @@ class LineEditor:
|
|
|
403
458
|
except Exception:
|
|
404
459
|
return ()
|
|
405
460
|
|
|
461
|
+
def _next_key(self):
|
|
462
|
+
"""The next key: one read ahead while waiting for a cursor report, else a fresh read."""
|
|
463
|
+
if self._pending:
|
|
464
|
+
return self._pending.popleft()
|
|
465
|
+
return self._read_key()
|
|
466
|
+
|
|
406
467
|
def _read_key(self):
|
|
407
|
-
"""Read one logical key: a decoded char, an ESC-sequence token, a ``("paste", text)``
|
|
408
|
-
or ``None`` on EOF.
|
|
468
|
+
"""Read one logical key: a decoded char, an ESC-sequence token, a ``("paste", text)`` or
|
|
469
|
+
``("cursor", row, col)`` event, ``RESIZE`` when the terminal changed size, or ``None`` on EOF.
|
|
409
470
|
|
|
410
471
|
Reads straight from the raw fd via ``os.read`` (not ``sys.stdin``) so escape sequences aren't
|
|
411
|
-
stranded in Python's stream buffer where ``select`` can't see them.
|
|
472
|
+
stranded in Python's stream buffer where ``select`` can't see them. With a resize watcher the
|
|
473
|
+
read waits on its wake-up pipe too, so a resize repaints at once rather than at the next key.
|
|
412
474
|
"""
|
|
413
475
|
fd = sys.stdin.fileno()
|
|
476
|
+
wake = self.resize.fd if self.resize is not None else -1
|
|
477
|
+
while wake >= 0:
|
|
478
|
+
ready, _, _ = select.select([fd, wake], [], [])
|
|
479
|
+
if wake in ready and self.resize.take():
|
|
480
|
+
return _RESIZE
|
|
481
|
+
if fd in ready:
|
|
482
|
+
break
|
|
414
483
|
b = os.read(fd, 1)
|
|
415
484
|
if not b:
|
|
416
485
|
return None
|
|
417
486
|
if b == b"\x1b":
|
|
418
487
|
token = self._read_escape(fd)
|
|
419
488
|
if token == _PASTE_START:
|
|
420
|
-
return (
|
|
489
|
+
return (_PASTE, self._read_paste(fd))
|
|
421
490
|
return token or "\x1b"
|
|
422
491
|
return self._decode(fd, b)
|
|
423
492
|
|
|
@@ -462,14 +531,67 @@ class LineEditor:
|
|
|
462
531
|
num = code
|
|
463
532
|
while True:
|
|
464
533
|
nxt = more()
|
|
465
|
-
if nxt in ("", "~"):
|
|
534
|
+
if nxt in ("", "~", "R"):
|
|
466
535
|
break
|
|
467
536
|
num += nxt
|
|
537
|
+
if nxt == "R": # cursor position report: ESC[row;colR (the answer to ESC[6n)
|
|
538
|
+
return parse_cursor_report(num)
|
|
468
539
|
if num == "200":
|
|
469
540
|
return _PASTE_START
|
|
470
541
|
return {"1": _HOME, "7": _HOME, "4": _END, "8": _END, "3": _DELETE}.get(num)
|
|
471
542
|
return None
|
|
472
543
|
|
|
544
|
+
# ── cursor reports (where the transcript ends, where the caret went after a resize) ──────────
|
|
545
|
+
def _request_cursor(self, kind: str) -> None:
|
|
546
|
+
"""Ask the terminal where the cursor is; the reply is matched to ``kind`` in arrival order."""
|
|
547
|
+
from .resize import CURSOR_REPORT
|
|
548
|
+
|
|
549
|
+
self._reports.append(kind)
|
|
550
|
+
sys.stdout.write(CURSOR_REPORT)
|
|
551
|
+
sys.stdout.flush()
|
|
552
|
+
|
|
553
|
+
def _note_cursor(self, report) -> str:
|
|
554
|
+
"""Record a ``("cursor", row, col)`` reply against the oldest outstanding request; return its kind."""
|
|
555
|
+
kind = self._reports.popleft() if self._reports else "anchor"
|
|
556
|
+
if kind == "anchor":
|
|
557
|
+
self._anchor = (report[1], report[2])
|
|
558
|
+
return kind
|
|
559
|
+
|
|
560
|
+
def _await_cursor(self, kind: str, timeout: float = None):
|
|
561
|
+
"""Wait (briefly) for the reply to the outstanding ``kind`` request; keys typed meanwhile are
|
|
562
|
+
queued for the main loop. Returns ``(row, col)`` or ``None`` when the terminal did not answer
|
|
563
|
+
in time, in which case all outstanding requests are forgotten, so a late reply can never be
|
|
564
|
+
matched to the wrong question."""
|
|
565
|
+
if timeout is None:
|
|
566
|
+
timeout = self.CURSOR_REPORT_TIMEOUT
|
|
567
|
+
fd = sys.stdin.fileno()
|
|
568
|
+
deadline = time.monotonic() + timeout
|
|
569
|
+
while kind in self._reports:
|
|
570
|
+
remaining = deadline - time.monotonic()
|
|
571
|
+
if remaining <= 0:
|
|
572
|
+
break
|
|
573
|
+
ready, _, _ = select.select([fd], [], [], remaining)
|
|
574
|
+
if not ready:
|
|
575
|
+
break
|
|
576
|
+
key = self._read_key()
|
|
577
|
+
if key is None:
|
|
578
|
+
self._pending.append(None) # EOF: let the main loop see it after the repaint
|
|
579
|
+
break
|
|
580
|
+
if isinstance(key, tuple) and key[0] == _CURSOR:
|
|
581
|
+
if self._note_cursor(key) == kind:
|
|
582
|
+
return key[1], key[2]
|
|
583
|
+
continue
|
|
584
|
+
self._pending.append(key)
|
|
585
|
+
self._reports.clear()
|
|
586
|
+
return None
|
|
587
|
+
|
|
588
|
+
def _anchor_below_region(self) -> bool:
|
|
589
|
+
"""Whether the output anchor sits inside the dock's band (the last turn ended after a resize)."""
|
|
590
|
+
if self._anchor is None or self.layout is None:
|
|
591
|
+
return False
|
|
592
|
+
rows = shutil.get_terminal_size((80, 24)).lines
|
|
593
|
+
return self._anchor[0] > self.layout.region_bottom(rows)
|
|
594
|
+
|
|
473
595
|
@staticmethod
|
|
474
596
|
def _read_paste(fd: int) -> str:
|
|
475
597
|
"""Collect a bracketed-paste payload up to the ESC[201~ terminator (bounded by a stall)."""
|
|
@@ -504,7 +626,7 @@ class LineEditor:
|
|
|
504
626
|
self.on_copy()
|
|
505
627
|
finally:
|
|
506
628
|
sys.stdout.write("\0337") # DECSC: re-save for the next scroll-region write
|
|
507
|
-
|
|
629
|
+
self._request_cursor("anchor") # the feedback moved the anchor: learn its new row
|
|
508
630
|
|
|
509
631
|
def _render(self, prompt: str, state: EditorState) -> None:
|
|
510
632
|
"""Draw the prompt + buffer on the fixed input row(s) and place the caret at the edit point."""
|
|
@@ -513,6 +635,50 @@ class LineEditor:
|
|
|
513
635
|
else:
|
|
514
636
|
self._render_single(prompt, state)
|
|
515
637
|
|
|
638
|
+
def _repaint(self, prompt: str, state: EditorState) -> None:
|
|
639
|
+
"""Rebuild the dock after a resize (or when the anchor turned up inside the dock's band).
|
|
640
|
+
|
|
641
|
+
The terminal moved the content and the cursor together, so the caret's new row (asked for
|
|
642
|
+
with ``ESC[6n``) says how far the transcript's end moved. From that, :func:`plan_reanchor`
|
|
643
|
+
gives the row where output resumes; if it spilled into the band the whole screen is scrolled
|
|
644
|
+
up first (the region is reset to full height for that), everything below it is blanked, the
|
|
645
|
+
region and the frame are re-established, the anchor is re-saved (DECSC) where the answer will
|
|
646
|
+
print, and the input is drawn at its new place. Emitted as one write so nothing interleaves.
|
|
647
|
+
"""
|
|
648
|
+
if self.layout is None:
|
|
649
|
+
self._render(prompt, state)
|
|
650
|
+
return
|
|
651
|
+
from .resize import input_rewrap_rows, plan_reanchor
|
|
652
|
+
|
|
653
|
+
size = shutil.get_terminal_size((80, 24))
|
|
654
|
+
rows, cols = size.lines, size.columns
|
|
655
|
+
self._request_cursor("caret")
|
|
656
|
+
caret_now = self._await_cursor("caret")
|
|
657
|
+
caret_row = caret_now[0] if caret_now else min(self._caret_row or rows, rows)
|
|
658
|
+
# The caret's row overstates the transcript's shift by the re-wrap of our own input rows.
|
|
659
|
+
caret_row -= input_rewrap_rows(self._caret_offset, self._caret_col, self._drawn_cols or cols, cols,
|
|
660
|
+
caret_now[1] if caret_now else None)
|
|
661
|
+
if self._anchor is not None:
|
|
662
|
+
anchor_row, anchor_col = self._anchor
|
|
663
|
+
else:
|
|
664
|
+
# Unknown anchor: assume the transcript ran right up to the dock (never overwrites output).
|
|
665
|
+
anchor_row, anchor_col = max(1, (self._input_top or rows) - 2), 1
|
|
666
|
+
_, height, _, _ = wrap_layout(len(prompt), state.buffer, state.cursor, cols, self.layout.MAX_INPUT_ROWS)
|
|
667
|
+
self.layout.input_rows = height
|
|
668
|
+
plan = plan_reanchor(anchor_row, self._caret_row or caret_row, caret_row, self.layout.region_bottom(rows))
|
|
669
|
+
out = ["\033[r"] # full-height region so the scroll below moves the whole screen
|
|
670
|
+
if plan.scroll:
|
|
671
|
+
out.append(f"\033[{rows};1H" + "\n" * plan.scroll)
|
|
672
|
+
out.extend(f"\033[{r};1H\033[2K" for r in range(plan.clear_from, rows + 1))
|
|
673
|
+
sys.stdout.write("".join(out))
|
|
674
|
+
self._drawn_rows = height
|
|
675
|
+
if self.on_reflow is not None:
|
|
676
|
+
self.on_reflow() # re-establish the region + redraw pad/spacer/bar for the new geometry
|
|
677
|
+
col = anchor_col if plan.anchor_row >= 1 else 1
|
|
678
|
+
sys.stdout.write(f"\033[{plan.cursor_row};{col}H\0337") # re-save where output resumes
|
|
679
|
+
self._anchor = (plan.anchor_row, col) # keep the virtual row: a later grow may bring it back
|
|
680
|
+
self._render(prompt, state)
|
|
681
|
+
|
|
516
682
|
def _render_single(self, prompt: str, state: EditorState) -> None:
|
|
517
683
|
"""Legacy one-row renderer: long lines scroll horizontally to keep the caret visible."""
|
|
518
684
|
size = shutil.get_terminal_size((80, 24))
|
|
@@ -529,6 +695,8 @@ class LineEditor:
|
|
|
529
695
|
+ color(prompt, "green") + visible
|
|
530
696
|
+ f"\033[{row};{curcol}H" # position the caret at the edit column
|
|
531
697
|
)
|
|
698
|
+
self._caret_row = self._input_top = row
|
|
699
|
+
self._caret_col, self._caret_offset, self._drawn_cols = curcol - 1, 0, cols
|
|
532
700
|
sys.stdout.flush()
|
|
533
701
|
|
|
534
702
|
def _render_multi(self, prompt: str, state: EditorState) -> None:
|
|
@@ -569,6 +737,8 @@ class LineEditor:
|
|
|
569
737
|
sys.stdout.write(f"\033[{top + i};1H\033[2K" + seg)
|
|
570
738
|
caret_row = top + (caret_line - window_top)
|
|
571
739
|
sys.stdout.write(f"\033[{caret_row};{caret_col + 1}H") # position the caret at the edit point
|
|
740
|
+
self._caret_row, self._input_top = caret_row, top
|
|
741
|
+
self._caret_col, self._caret_offset, self._drawn_cols = caret_col, caret_line - window_top, cols
|
|
572
742
|
sys.stdout.flush()
|
|
573
743
|
|
|
574
744
|
|
|
@@ -153,19 +153,21 @@ class StatusBar:
|
|
|
153
153
|
self._installed = True
|
|
154
154
|
|
|
155
155
|
def render(self, text: Optional[str] = None) -> None:
|
|
156
|
-
"""Redraw the bar
|
|
156
|
+
"""Redraw the bar and re-establish the scroll region (so it self-heals after a resize).
|
|
157
|
+
|
|
158
|
+
The region is always re-sent: terminals reset it on a resize (tmux on any height change,
|
|
159
|
+
xterm on any change), and the few bytes cost nothing. DECSC/DECRC keep the cursor where it was.
|
|
160
|
+
"""
|
|
157
161
|
if not self.enabled:
|
|
158
162
|
return
|
|
159
163
|
if text is not None:
|
|
160
164
|
self._text = text
|
|
161
165
|
rows = self._term_rows()
|
|
162
|
-
|
|
166
|
+
self._rows = rows
|
|
167
|
+
self._reserved_cache = self._reserved()
|
|
163
168
|
sys.stdout.write("\0337") # save cursor + attrs (DECSC)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
self._reserved_cache = reserved
|
|
167
|
-
sys.stdout.write(f"\033[1;{self._region_bottom(rows)}r") # (re)establish scroll region
|
|
168
|
-
self._installed = True
|
|
169
|
+
sys.stdout.write(f"\033[1;{self._region_bottom(rows)}r") # (re)establish scroll region
|
|
170
|
+
self._installed = True
|
|
169
171
|
self._draw_frame(rows)
|
|
170
172
|
sys.stdout.write("\0338") # restore cursor (DECRC)
|
|
171
173
|
sys.stdout.flush()
|
|
@@ -516,10 +516,15 @@ class Repl:
|
|
|
516
516
|
|
|
517
517
|
self._dock_layout = DockLayout()
|
|
518
518
|
self.bar.layout = self._dock_layout
|
|
519
|
+
# Resize (SIGWINCH): wakes the editor's read loop so it repaints at once; between prompts the
|
|
520
|
+
# handler calls _repaint_after_resize (see run()). Only meaningful with the dock on a TTY.
|
|
521
|
+
from .resize import ResizeWatcher
|
|
522
|
+
|
|
523
|
+
self.resize = ResizeWatcher() if self.dock else None
|
|
519
524
|
self.editor = LineEditor(
|
|
520
525
|
enabled=self.dock, on_copy=self._copy_last,
|
|
521
526
|
layout=self._dock_layout, on_reflow=self._reflow_dock,
|
|
522
|
-
completer=self._command_names,
|
|
527
|
+
completer=self._command_names, resize=self.resize,
|
|
523
528
|
)
|
|
524
529
|
self.session.status_bar = self.bar # let the /panel command reach it
|
|
525
530
|
from .hooks import Hooks
|
|
@@ -593,11 +598,15 @@ class Repl:
|
|
|
593
598
|
|
|
594
599
|
session_event(self.session, "SessionStart", source="resume" if self.session.resumed else "startup")
|
|
595
600
|
self._start_update_check()
|
|
601
|
+
if self.resize is not None:
|
|
602
|
+
self.resize.install(self._on_resize_signal)
|
|
596
603
|
code = 1
|
|
597
604
|
try:
|
|
598
605
|
code = self._loop()
|
|
599
606
|
return code
|
|
600
607
|
finally:
|
|
608
|
+
if self.resize is not None:
|
|
609
|
+
self.resize.uninstall()
|
|
601
610
|
self.bar.remove()
|
|
602
611
|
session_event(self.session, "SessionEnd", reason="quit" if code == 0 else f"exit {code}")
|
|
603
612
|
|
|
@@ -737,6 +746,52 @@ class Repl:
|
|
|
737
746
|
except Exception:
|
|
738
747
|
pass # never let a redraw break the input loop
|
|
739
748
|
|
|
749
|
+
def _on_resize_signal(self) -> None:
|
|
750
|
+
"""SIGWINCH outside the editor (a turn is running, or a command is printing)."""
|
|
751
|
+
if self.editor.active:
|
|
752
|
+
return # the editor's read loop wakes up and repaints on its own
|
|
753
|
+
try:
|
|
754
|
+
self._repaint_after_resize()
|
|
755
|
+
except Exception:
|
|
756
|
+
pass # a repaint must never break the REPL
|
|
757
|
+
|
|
758
|
+
def _repaint_after_resize(self) -> None:
|
|
759
|
+
"""Put the dock back after a mid-turn resize: the terminal reset the scroll region and moved
|
|
760
|
+
the content, so output would run into the band and the bar would scroll away.
|
|
761
|
+
|
|
762
|
+
While the ESC listener owns stdin the terminal can be asked where the cursor is; if it sits
|
|
763
|
+
inside the new band the whole screen is scrolled up so the cursor lands on the region's last
|
|
764
|
+
row (the transcript keeps flowing from there), the band is blanked, and the region and bar are
|
|
765
|
+
re-established around the cursor. With no listener (an approval prompt is up) the cursor's
|
|
766
|
+
row is unknown, so only the region and the bar are restored around it.
|
|
767
|
+
"""
|
|
768
|
+
from . import keys
|
|
769
|
+
from .status import paint_lock
|
|
770
|
+
|
|
771
|
+
if not self.bar.enabled:
|
|
772
|
+
return
|
|
773
|
+
with paint_lock:
|
|
774
|
+
text = build_status_text(self.session, self._user)
|
|
775
|
+
pos = keys.query_cursor()
|
|
776
|
+
if pos is None or self._dock_layout is None:
|
|
777
|
+
self.bar.render(text)
|
|
778
|
+
return
|
|
779
|
+
rows = shutil.get_terminal_size((80, 24)).lines
|
|
780
|
+
self._dock_layout.input_rows = 1 # the dock is collapsed between prompts
|
|
781
|
+
bottom = self._dock_layout.region_bottom(rows)
|
|
782
|
+
row, col = pos
|
|
783
|
+
scroll = max(0, row - bottom)
|
|
784
|
+
out = ["\033[r"] # full-height region so the scroll moves the whole screen
|
|
785
|
+
if scroll:
|
|
786
|
+
out.append(f"\033[{rows};1H" + "\n" * scroll)
|
|
787
|
+
row -= scroll
|
|
788
|
+
out.extend(f"\033[{r};1H\033[2K" for r in range(bottom + 1, rows + 1))
|
|
789
|
+
sys.stdout.write("".join(out))
|
|
790
|
+
self.bar.set_text(text)
|
|
791
|
+
self.bar.reflow() # DECSTBM + pad/spacer/bar; leaves the cursor anywhere
|
|
792
|
+
sys.stdout.write(f"\033[{row};{col}H")
|
|
793
|
+
sys.stdout.flush()
|
|
794
|
+
|
|
740
795
|
# ── UI ─────────────────────────────────────────────────────────────────────
|
|
741
796
|
def _redraw_home(self, note: str = None) -> None:
|
|
742
797
|
"""Clear the screen + scrollback and reprint the launch header (used by /reset, /compact).
|