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.
Files changed (116) hide show
  1. {scootcli-0.7.0/src/scootcli.egg-info → scootcli-0.8.0}/PKG-INFO +3 -1
  2. {scootcli-0.7.0 → scootcli-0.8.0}/README.md +2 -0
  3. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/__init__.py +1 -1
  4. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/cli.py +13 -3
  5. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/keys.py +50 -1
  6. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/lineeditor.py +181 -11
  7. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/panel.py +9 -7
  8. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/repl.py +56 -1
  9. scootcli-0.8.0/src/scootcli/resize.py +164 -0
  10. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/status.py +17 -7
  11. {scootcli-0.7.0 → scootcli-0.8.0/src/scootcli.egg-info}/PKG-INFO +3 -1
  12. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli.egg-info/SOURCES.txt +2 -0
  13. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_preferences.py +51 -0
  14. scootcli-0.8.0/tests/test_resize.py +141 -0
  15. {scootcli-0.7.0 → scootcli-0.8.0}/LICENSE +0 -0
  16. {scootcli-0.7.0 → scootcli-0.8.0}/pyproject.toml +0 -0
  17. {scootcli-0.7.0 → scootcli-0.8.0}/setup.cfg +0 -0
  18. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/__main__.py +0 -0
  19. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/activity.py +0 -0
  20. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/agent.py +0 -0
  21. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/approvals.py +0 -0
  22. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/auth.py +0 -0
  23. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/clipboard.py +0 -0
  24. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/__init__.py +0 -0
  25. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/approve.py +0 -0
  26. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/auth.py +0 -0
  27. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/base.py +0 -0
  28. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/compact.py +0 -0
  29. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/copy.py +0 -0
  30. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/exit.py +0 -0
  31. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/forget.py +0 -0
  32. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/help.py +0 -0
  33. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/hooks.py +0 -0
  34. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/init.py +0 -0
  35. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/logo.py +0 -0
  36. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/model.py +0 -0
  37. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/panel.py +0 -0
  38. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/reset.py +0 -0
  39. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/resume.py +0 -0
  40. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/route.py +0 -0
  41. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/save.py +0 -0
  42. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/scope.py +0 -0
  43. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/sessions.py +0 -0
  44. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/status.py +0 -0
  45. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/verbosity.py +0 -0
  46. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/worktree.py +0 -0
  47. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/commands/yolo.py +0 -0
  48. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/config.py +0 -0
  49. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/context.py +0 -0
  50. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/credentials.py +0 -0
  51. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/errors.py +0 -0
  52. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/headless.py +0 -0
  53. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/hooks.py +0 -0
  54. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/images.py +0 -0
  55. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/logo.py +0 -0
  56. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/models.py +0 -0
  57. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/preferences.py +0 -0
  58. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/presets.py +0 -0
  59. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/pricing.py +0 -0
  60. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/project.py +0 -0
  61. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/prompts.py +0 -0
  62. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/providers/__init__.py +0 -0
  63. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/providers/anthropic.py +0 -0
  64. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/providers/base.py +0 -0
  65. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/providers/openai_chat.py +0 -0
  66. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/providers/openai_responses.py +0 -0
  67. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/providers/registry.py +0 -0
  68. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/providers/router.py +0 -0
  69. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/rendering.py +0 -0
  70. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/sessions.py +0 -0
  71. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/tools/__init__.py +0 -0
  72. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/tools/base.py +0 -0
  73. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/tools/edit_file.py +0 -0
  74. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/tools/list_dir.py +0 -0
  75. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/tools/open_editor.py +0 -0
  76. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/tools/read_file.py +0 -0
  77. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/tools/run_shell.py +0 -0
  78. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/tools/search.py +0 -0
  79. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/tools/update_plan.py +0 -0
  80. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/tools/write_file.py +0 -0
  81. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/transport.py +0 -0
  82. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/updates.py +0 -0
  83. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/vision.py +0 -0
  84. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/workspace.py +0 -0
  85. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli/worktree.py +0 -0
  86. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
  87. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli.egg-info/entry_points.txt +0 -0
  88. {scootcli-0.7.0 → scootcli-0.8.0}/src/scootcli.egg-info/top_level.txt +0 -0
  89. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_agent.py +0 -0
  90. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_anthropic.py +0 -0
  91. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_approvals.py +0 -0
  92. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_auth.py +0 -0
  93. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_clipboard.py +0 -0
  94. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_config.py +0 -0
  95. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_headless.py +0 -0
  96. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_hooks.py +0 -0
  97. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_images.py +0 -0
  98. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_lineeditor.py +0 -0
  99. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_logo.py +0 -0
  100. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_models.py +0 -0
  101. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_panel.py +0 -0
  102. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_providers.py +0 -0
  103. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_readiness.py +0 -0
  104. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_repl.py +0 -0
  105. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_resilience.py +0 -0
  106. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_router.py +0 -0
  107. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_scope.py +0 -0
  108. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_sessions.py +0 -0
  109. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_small_060.py +0 -0
  110. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_smoke.py +0 -0
  111. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_streaming.py +0 -0
  112. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_tools.py +0 -0
  113. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_transport_native.py +0 -0
  114. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_vision.py +0 -0
  115. {scootcli-0.7.0 → scootcli-0.8.0}/tests/test_workspace.py +0 -0
  116. {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.7.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.
@@ -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.0"
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, 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
- 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)
@@ -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. Resize is handled implicitly —
21
- the renderer recomputes the terminal size on every keystroke.
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
- def __init__(self, enabled: bool = True, on_copy=None, layout=None, on_reflow=None, completer=None):
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
- self._render(prompt, state)
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._read_key()
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)`` event,
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 ("paste", self._read_paste(fd))
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
- sys.stdout.flush()
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 (recomputes size so it self-heals after a resize)."""
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
- reserved = self._reserved()
166
+ self._rows = rows
167
+ self._reserved_cache = self._reserved()
163
168
  sys.stdout.write("\0337") # save cursor + attrs (DECSC)
164
- if not self._installed or rows != self._rows or reserved != self._reserved_cache:
165
- self._rows = rows
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).