scootcli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. scootcli/__init__.py +4 -0
  2. scootcli/__main__.py +9 -0
  3. scootcli/activity.py +26 -0
  4. scootcli/agent.py +350 -0
  5. scootcli/approvals.py +167 -0
  6. scootcli/auth.py +59 -0
  7. scootcli/cli.py +276 -0
  8. scootcli/clipboard.py +89 -0
  9. scootcli/commands/__init__.py +56 -0
  10. scootcli/commands/approve.py +38 -0
  11. scootcli/commands/auth.py +107 -0
  12. scootcli/commands/base.py +31 -0
  13. scootcli/commands/compact.py +40 -0
  14. scootcli/commands/copy.py +23 -0
  15. scootcli/commands/exit.py +14 -0
  16. scootcli/commands/forget.py +28 -0
  17. scootcli/commands/help.py +29 -0
  18. scootcli/commands/init.py +50 -0
  19. scootcli/commands/logo.py +51 -0
  20. scootcli/commands/model.py +61 -0
  21. scootcli/commands/panel.py +28 -0
  22. scootcli/commands/reset.py +20 -0
  23. scootcli/commands/resume.py +31 -0
  24. scootcli/commands/save.py +29 -0
  25. scootcli/commands/sessions.py +42 -0
  26. scootcli/commands/status.py +59 -0
  27. scootcli/commands/verbosity.py +57 -0
  28. scootcli/commands/worktree.py +64 -0
  29. scootcli/commands/yolo.py +20 -0
  30. scootcli/config.py +241 -0
  31. scootcli/context.py +82 -0
  32. scootcli/credentials.py +79 -0
  33. scootcli/errors.py +87 -0
  34. scootcli/images.py +169 -0
  35. scootcli/keys.py +119 -0
  36. scootcli/lineeditor.py +577 -0
  37. scootcli/logo.py +116 -0
  38. scootcli/models.py +120 -0
  39. scootcli/panel.py +263 -0
  40. scootcli/preferences.py +87 -0
  41. scootcli/presets.py +38 -0
  42. scootcli/project.py +94 -0
  43. scootcli/prompts.py +100 -0
  44. scootcli/providers/__init__.py +20 -0
  45. scootcli/providers/base.py +370 -0
  46. scootcli/providers/openai_chat.py +142 -0
  47. scootcli/providers/openai_responses.py +248 -0
  48. scootcli/providers/registry.py +173 -0
  49. scootcli/rendering.py +86 -0
  50. scootcli/repl.py +801 -0
  51. scootcli/sessions.py +186 -0
  52. scootcli/status.py +71 -0
  53. scootcli/tools/__init__.py +68 -0
  54. scootcli/tools/base.py +152 -0
  55. scootcli/tools/edit_file.py +72 -0
  56. scootcli/tools/list_dir.py +47 -0
  57. scootcli/tools/read_file.py +56 -0
  58. scootcli/tools/run_shell.py +73 -0
  59. scootcli/tools/search.py +170 -0
  60. scootcli/tools/update_plan.py +104 -0
  61. scootcli/tools/write_file.py +61 -0
  62. scootcli/transport.py +312 -0
  63. scootcli/vision.py +167 -0
  64. scootcli/workspace.py +105 -0
  65. scootcli/worktree.py +114 -0
  66. scootcli-0.1.0.dist-info/METADATA +238 -0
  67. scootcli-0.1.0.dist-info/RECORD +71 -0
  68. scootcli-0.1.0.dist-info/WHEEL +5 -0
  69. scootcli-0.1.0.dist-info/entry_points.txt +2 -0
  70. scootcli-0.1.0.dist-info/licenses/LICENSE +21 -0
  71. scootcli-0.1.0.dist-info/top_level.txt +1 -0
scootcli/keys.py ADDED
@@ -0,0 +1,119 @@
1
+ """Stdlib raw-mode keyboard handling for ESC-to-interrupt (PLAN §6).
2
+
3
+ During a working turn we put the terminal in cbreak mode and run a small listener thread that watches
4
+ stdin for the ESC key. On ESC we set a shared ``cancel_event`` which the transport polls to terminate
5
+ the in-flight request (and, later, tool) immediately.
6
+
7
+ Line editing for typing prompts happens in normal cooked mode (plain ``input()``); raw mode is only
8
+ active *while the agent works*, so we never interfere with the user typing.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import sys
15
+ import threading
16
+
17
+ try:
18
+ import termios
19
+ import tty
20
+ import select
21
+
22
+ _HAVE_TERMIOS = True
23
+ except ImportError: # pragma: no cover - non-POSIX
24
+ _HAVE_TERMIOS = False
25
+
26
+ ESC = "\x1b"
27
+
28
+
29
+ def read_key() -> str:
30
+ """Read a single keypress (lowercased). Falls back to a line read on non-TTY (piped) input."""
31
+ if not (_HAVE_TERMIOS and sys.stdin.isatty()):
32
+ line = sys.stdin.readline()
33
+ return (line.strip()[:1].lower() if line.strip() else "q")
34
+ fd = sys.stdin.fileno()
35
+ old = termios.tcgetattr(fd)
36
+ try:
37
+ tty.setcbreak(fd)
38
+ ch = sys.stdin.read(1)
39
+ finally:
40
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
41
+ return ch.lower()
42
+
43
+
44
+ class InterruptibleSection:
45
+ """Context manager: enable ESC-interrupt for the duration of a working turn.
46
+
47
+ Usage::
48
+
49
+ cancel = threading.Event()
50
+ with InterruptibleSection(cancel):
51
+ ...run work, poll cancel...
52
+
53
+ If stdin is not a TTY (piped input) or termios is unavailable, this is a no-op and ESC-interrupt
54
+ is simply unavailable — the work still runs normally.
55
+ """
56
+
57
+ def __init__(self, cancel_event: threading.Event):
58
+ self.cancel_event = cancel_event
59
+ self.enabled = _HAVE_TERMIOS and sys.stdin.isatty()
60
+ self._fd = sys.stdin.fileno() if self.enabled else -1
61
+ self._old_attrs = None
62
+ self._thread = None
63
+ self._stop = threading.Event()
64
+
65
+ def __enter__(self) -> "InterruptibleSection":
66
+ if not self.enabled:
67
+ return self
68
+ try:
69
+ self._old_attrs = termios.tcgetattr(self._fd)
70
+ tty.setcbreak(self._fd) # keeps ISIG so Ctrl-C still quits
71
+ except (termios.error, ValueError):
72
+ self.enabled = False
73
+ self._old_attrs = None
74
+ return self
75
+ self._thread = threading.Thread(target=self._listen, daemon=True)
76
+ self._thread.start()
77
+ return self
78
+
79
+ def _listen(self) -> None:
80
+ while not self._stop.is_set():
81
+ try:
82
+ ready, _, _ = select.select([sys.stdin], [], [], 0.1)
83
+ except (OSError, ValueError):
84
+ break
85
+ if not ready:
86
+ continue
87
+ try:
88
+ ch = os.read(self._fd, 1)
89
+ except (OSError, ValueError):
90
+ break
91
+ if ch == b"\x1b":
92
+ # Distinguish a bare ESC (a real interrupt) from an escape sequence — arrow keys,
93
+ # mouse reports, or bracketed-paste markers (\x1b[201~) left in the buffer after a
94
+ # drag-and-drop. If more bytes follow immediately, it's a sequence: drain + ignore.
95
+ try:
96
+ more, _, _ = select.select([sys.stdin], [], [], 0.05)
97
+ except (OSError, ValueError):
98
+ more = None
99
+ if more:
100
+ try:
101
+ while select.select([sys.stdin], [], [], 0)[0]:
102
+ if not os.read(self._fd, 1):
103
+ break
104
+ except (OSError, ValueError):
105
+ pass
106
+ continue
107
+ self.cancel_event.set()
108
+ break
109
+
110
+ def __exit__(self, *exc) -> None:
111
+ self._stop.set()
112
+ if self._thread is not None:
113
+ self._thread.join(timeout=0.3)
114
+ if self._old_attrs is not None:
115
+ try:
116
+ termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_attrs)
117
+ except (termios.error, ValueError):
118
+ pass
119
+
scootcli/lineeditor.py ADDED
@@ -0,0 +1,577 @@
1
+ """Fixed-bottom input editor (stdlib raw-mode) for the REPL dock (PLAN §19 / M14).
2
+
3
+ The editor renders the prompt near the bottom of the terminal (just above the status bar) and returns
4
+ the typed line, while all agent output scrolls in the region above it. The core is a **pure state
5
+ machine** (:func:`apply_key`) so it is unit-testable with scripted keys and no TTY; the rendering/
6
+ reading is a thin layer on top.
7
+
8
+ M14a shipped: printable insert, Backspace, Enter (submit), Ctrl-C (interrupt), Ctrl-D (EOF on empty).
9
+ M14b adds full line editing: ←/→ + Ctrl-B/F, Home/End + Ctrl-A/E, Ctrl-U/K/W kill ops, forward Delete,
10
+ ↑/↓ (and Ctrl-P/N) history recall. M14c adds bracketed paste: a multi-line paste arrives as one payload
11
+ (ESC[200~ … ESC[201~) and is inserted via :func:`insert_text` with newlines flattened to spaces, so it
12
+ never submits mid-paste. **Tab** completes/cycles a leading ``/command`` name (:func:`cycle_completion`,
13
+ fed by the command registry via a ``completer`` callback).
14
+
15
+ Rendering has two modes. Without a :class:`~scootcli.panel.DockLayout` the input is a single row that
16
+ scrolls horizontally (legacy). With a layout, a long line **wraps onto extra rows** and the dock grows
17
+ (and shrinks) with it: :func:`wrap_layout` computes the visual geometry purely (unit-tested), and the
18
+ renderer updates the shared layout + asks the status bar to re-establish the scroll region and redraw
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.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import shutil
28
+ import sys
29
+ from dataclasses import dataclass, replace
30
+
31
+ from .rendering import color
32
+
33
+ try:
34
+ import select
35
+ import termios
36
+ import tty
37
+
38
+ _HAVE_TERMIOS = True
39
+ except ImportError: # pragma: no cover - non-POSIX
40
+ _HAVE_TERMIOS = False
41
+
42
+ # Raw control keys.
43
+ _ENTER = ("\r", "\n")
44
+ _BACKSPACE = ("\x7f", "\b")
45
+ _CTRL_C = "\x03"
46
+ _CTRL_D = "\x04"
47
+ _CTRL_S = "\x13" # copy the last answer to the clipboard ("save"); deliverable because raw mode is on
48
+ _TAB = "\t" # slash-command completion (cycles through matches)
49
+
50
+ # Logical key tokens (multi-char so they never collide with a printable insert). The reader translates
51
+ # raw ESC sequences and editing control bytes into these before calling apply_key.
52
+ _LEFT = "LEFT"
53
+ _RIGHT = "RIGHT"
54
+ _HOME = "HOME"
55
+ _END = "END"
56
+ _UP = "UP"
57
+ _DOWN = "DOWN"
58
+ _DELETE = "DELETE" # forward delete (Delete key / ESC[3~)
59
+ _KILL_TO_START = "KILL_BOL" # Ctrl-U
60
+ _KILL_TO_END = "KILL_EOL" # Ctrl-K
61
+ _KILL_WORD = "KILL_WORD" # Ctrl-W
62
+ _PASTE_START = "PASTE_START" # ESC[200~ — begin a bracketed-paste payload
63
+
64
+ # Terminal control: enable/disable bracketed-paste mode so a multi-line paste arrives as one payload
65
+ # (wrapped in ESC[200~ … ESC[201~) instead of a stream of Enter keys that would each submit.
66
+ _PASTE_ON = "\033[?2004h"
67
+ _PASTE_OFF = "\033[?2004l"
68
+
69
+ # Editing control bytes → logical tokens (readline-style emacs bindings).
70
+ _CONTROL_KEYS = {
71
+ "\x01": _HOME, # Ctrl-A
72
+ "\x05": _END, # Ctrl-E
73
+ "\x02": _LEFT, # Ctrl-B
74
+ "\x06": _RIGHT, # Ctrl-F
75
+ "\x10": _UP, # Ctrl-P
76
+ "\x0e": _DOWN, # Ctrl-N
77
+ "\x15": _KILL_TO_START, # Ctrl-U
78
+ "\x0b": _KILL_TO_END, # Ctrl-K
79
+ "\x17": _KILL_WORD, # Ctrl-W
80
+ }
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class EditorState:
85
+ """Immutable editor state; :func:`apply_key` returns the next one.
86
+
87
+ History recall is modelled purely: ``history`` is the (immutable) list of prior lines and
88
+ ``hist_pos`` indexes into it, where ``hist_pos == len(history)`` means "the live line". ``stash``
89
+ remembers the in-progress line while browsing so ↓ past the newest entry restores it.
90
+ """
91
+
92
+ buffer: str = ""
93
+ cursor: int = 0
94
+ submit: bool = False # Enter pressed → return the buffer
95
+ eof: bool = False # Ctrl-D on an empty buffer → raise EOFError (like input())
96
+ interrupt: bool = False # Ctrl-C → raise KeyboardInterrupt (quit app)
97
+ copy: bool = False # Ctrl-S → copy last answer (non-terminating; readline resets it)
98
+ history: tuple = ()
99
+ hist_pos: int = 0
100
+ stash: str = ""
101
+ cleared: bool = False # ↓ on the live line stashed the draft into ``stash`` and blanked it; ↑ restores
102
+ comp_prefix: object = None # base prefix captured on the first Tab of a completion cycle (None = idle)
103
+ comp_index: int = 0 # which match the last Tab landed on (cycles with repeated Tab)
104
+
105
+ @property
106
+ def done(self) -> bool:
107
+ return self.submit or self.eof or self.interrupt
108
+
109
+
110
+ def _kill_word(state: EditorState) -> EditorState:
111
+ """Delete the whitespace-delimited word immediately before the cursor (Ctrl-W)."""
112
+ b, i = state.buffer, state.cursor
113
+ j = i
114
+ while j > 0 and b[j - 1] == " ":
115
+ j -= 1
116
+ while j > 0 and b[j - 1] != " ":
117
+ j -= 1
118
+ return replace(state, buffer=b[:j] + b[i:], cursor=j)
119
+
120
+
121
+ def _history_prev(state: EditorState) -> EditorState:
122
+ """↑ / Ctrl-P: move to an older history entry (stashing the live line on first step up).
123
+
124
+ If the live line was just cleared with ↓ (``cleared``), ↑ first restores that stashed draft
125
+ instead of stepping into history.
126
+ """
127
+ if state.cleared:
128
+ return replace(state, buffer=state.stash, cursor=len(state.stash), cleared=False)
129
+ if state.hist_pos <= 0:
130
+ return state
131
+ stash = state.buffer if state.hist_pos == len(state.history) else state.stash
132
+ pos = state.hist_pos - 1
133
+ buf = state.history[pos]
134
+ return replace(state, buffer=buf, cursor=len(buf), hist_pos=pos, stash=stash)
135
+
136
+
137
+ def _history_next(state: EditorState) -> EditorState:
138
+ """↓ / Ctrl-N: move to a newer entry, restoring the stashed live line past the newest.
139
+
140
+ At the live line (``hist_pos == len(history)``) with text present, ↓ instead stashes the current
141
+ draft and blanks the input (a quick "clear but keep it" — ↑ brings the draft back).
142
+ """
143
+ if state.hist_pos >= len(state.history):
144
+ if state.buffer and not state.cleared:
145
+ return replace(state, buffer="", cursor=0, stash=state.buffer, cleared=True)
146
+ return state
147
+ pos = state.hist_pos + 1
148
+ buf = state.history[pos] if pos < len(state.history) else state.stash
149
+ return replace(state, buffer=buf, cursor=len(buf), hist_pos=pos)
150
+
151
+
152
+ def apply_key(state: EditorState, key: str) -> EditorState:
153
+ """Pure transition: given the current state and a key (raw char or logical token), return next."""
154
+ # Any key other than Tab breaks an in-progress completion cycle (Tab is handled by the reader,
155
+ # which calls cycle_completion directly, so it never reaches here).
156
+ if state.comp_prefix is not None:
157
+ state = replace(state, comp_prefix=None)
158
+ # Terminating keys.
159
+ if key == _CTRL_C:
160
+ return replace(state, interrupt=True)
161
+ if key == _CTRL_D:
162
+ # EOF only on an empty buffer (mirrors builtin input()); otherwise ignored.
163
+ return replace(state, eof=True) if not state.buffer else state
164
+ if key in _ENTER:
165
+ return replace(state, submit=True)
166
+ if key == _CTRL_S:
167
+ # Copy the last answer to the clipboard. Non-terminating: readline handles it, resets the
168
+ # flag, and keeps the in-progress buffer intact.
169
+ return replace(state, copy=True)
170
+
171
+ # Deletion.
172
+ if key in _BACKSPACE:
173
+ if state.cursor > 0:
174
+ b = state.buffer
175
+ return replace(state, buffer=b[: state.cursor - 1] + b[state.cursor:], cursor=state.cursor - 1)
176
+ return state
177
+ if key == _DELETE:
178
+ if state.cursor < len(state.buffer):
179
+ b = state.buffer
180
+ return replace(state, buffer=b[: state.cursor] + b[state.cursor + 1:])
181
+ return state
182
+ if key == _KILL_TO_START:
183
+ return replace(state, buffer=state.buffer[state.cursor:], cursor=0)
184
+ if key == _KILL_TO_END:
185
+ return replace(state, buffer=state.buffer[: state.cursor])
186
+ if key == _KILL_WORD:
187
+ return _kill_word(state)
188
+
189
+ # Cursor movement.
190
+ if key == _LEFT:
191
+ return replace(state, cursor=max(0, state.cursor - 1))
192
+ if key == _RIGHT:
193
+ return replace(state, cursor=min(len(state.buffer), state.cursor + 1))
194
+ if key == _HOME:
195
+ return replace(state, cursor=0)
196
+ if key == _END:
197
+ return replace(state, cursor=len(state.buffer))
198
+
199
+ # History recall.
200
+ if key == _UP:
201
+ return _history_prev(state)
202
+ if key == _DOWN:
203
+ return _history_next(state)
204
+
205
+ # Editing control bytes mapped to the tokens above (so callers can feed raw Ctrl-* too).
206
+ token = _CONTROL_KEYS.get(key)
207
+ if token is not None:
208
+ return apply_key(state, token)
209
+
210
+ # Printable insert (ignore any other control chars).
211
+ if len(key) == 1 and key >= " " and key != "\x7f":
212
+ b = state.buffer
213
+ return replace(state, buffer=b[: state.cursor] + key + b[state.cursor:],
214
+ cursor=state.cursor + 1, cleared=False)
215
+ return state
216
+
217
+
218
+ def insert_text(state: EditorState, text: str) -> EditorState:
219
+ """Insert a chunk of text at the cursor (bracketed paste). Pure and unit-testable.
220
+
221
+ Single-line editor: newlines/tabs collapse to spaces and other control chars are dropped, so a
222
+ multi-line paste becomes one clean line instead of submitting on every embedded newline.
223
+ """
224
+ out = []
225
+ for c in text:
226
+ if c in "\r\n\t":
227
+ out.append(" ")
228
+ elif c >= " " and c != "\x7f":
229
+ out.append(c)
230
+ # else: drop stray control chars
231
+ cleaned = "".join(out)
232
+ if not cleaned:
233
+ return state
234
+ b = state.buffer
235
+ return replace(
236
+ state,
237
+ buffer=b[: state.cursor] + cleaned + b[state.cursor:],
238
+ cursor=state.cursor + len(cleaned),
239
+ cleared=False,
240
+ )
241
+
242
+
243
+ def cycle_completion(state: EditorState, candidates) -> EditorState:
244
+ """Tab: complete/cycle a leading slash-command name. Pure and unit-testable.
245
+
246
+ Only acts when the buffer is a bare ``/word`` token (a slash followed by zero or more
247
+ non-space chars, cursor at end). On the first Tab it captures that prefix, finds the matching
248
+ command names in alphabetical order, and shows the first match; each further Tab (with the cycle
249
+ still live) advances to the next match, wrapping around. ``candidates`` are command names
250
+ *without* the leading slash. Any non-Tab key clears the cycle (see :func:`apply_key`).
251
+ """
252
+ buf = state.buffer
253
+ # Must be a single leading slash token with the cursor at the end (no mid-line completion).
254
+ if not buf.startswith("/") or " " in buf or state.cursor != len(buf):
255
+ return replace(state, comp_prefix=None)
256
+
257
+ # The prefix to match against is captured on the first Tab of a cycle; on later Tabs we keep the
258
+ # original prefix so cycling stays anchored even though the buffer now holds a full command name.
259
+ prefix = state.comp_prefix if state.comp_prefix is not None else buf[1:]
260
+ matches = sorted(n for n in candidates if n.startswith(prefix))
261
+ if not matches:
262
+ return replace(state, comp_prefix=None)
263
+
264
+ if state.comp_prefix is None:
265
+ index = 0 # first Tab → first match
266
+ else:
267
+ index = (state.comp_index + 1) % len(matches) # subsequent Tab → next match (wraps)
268
+ completed = "/" + matches[index]
269
+ return replace(
270
+ state,
271
+ buffer=completed,
272
+ cursor=len(completed),
273
+ comp_prefix=prefix,
274
+ comp_index=index,
275
+ cleared=False,
276
+ )
277
+
278
+
279
+ # ── Pure wrapping geometry (unit-testable, no TTY) ─────────────────────────────
280
+ def wrap_layout(prompt_len: int, buffer: str, cursor: int, cols: int, max_rows: int):
281
+ """Compute how a ``prompt + buffer`` line wraps across rows of width ``cols``.
282
+
283
+ Returns ``(window_top, height, caret_line, caret_col)`` where:
284
+ * ``window_top`` — index of the first *visual* line shown (vertical scroll when the line is
285
+ taller than ``max_rows``);
286
+ * ``height`` — number of visual rows to draw (``1..max_rows``);
287
+ * ``caret_line`` — the visual line (absolute) the caret sits on;
288
+ * ``caret_col`` — the 0-based column of the caret within its line.
289
+
290
+ Pure: the renderer turns these into absolute cursor moves; kept separate so it is testable.
291
+ """
292
+ cols = max(1, cols)
293
+ max_rows = max(1, max_rows)
294
+ stream_len = prompt_len + len(buffer)
295
+ caret_pos = prompt_len + cursor
296
+ caret_line = caret_pos // cols
297
+ caret_col = caret_pos % cols
298
+ total_lines = max(1, -(-stream_len // cols)) if stream_len else 1
299
+ # If the caret sits exactly at a wrap boundary (end of line == start of the next), show the extra
300
+ # empty line so the caret has somewhere to land.
301
+ total_lines = max(total_lines, caret_line + 1)
302
+ height = min(total_lines, max_rows)
303
+ window_top = 0
304
+ if caret_line >= max_rows:
305
+ window_top = caret_line - max_rows + 1
306
+ window_top = min(window_top, max(0, total_lines - max_rows))
307
+ return window_top, height, caret_line, caret_col
308
+
309
+
310
+
311
+ class LineEditor:
312
+ """Reads a line on a fixed bottom row; falls back to builtin ``input()`` when disabled/non-TTY."""
313
+
314
+ def __init__(self, enabled: bool = True, on_copy=None, layout=None, on_reflow=None, completer=None):
315
+ self.enabled = bool(enabled) and _HAVE_TERMIOS and sys.stdin.isatty() and sys.stdout.isatty()
316
+ self.history: list = [] # submitted lines, oldest→newest (↑/↓ recall)
317
+ self.on_copy = on_copy # zero-arg callback for Ctrl-S (copies + prints its own feedback)
318
+ self.layout = layout # shared DockLayout for multi-row growth (None = legacy single row)
319
+ self.on_reflow = on_reflow # called after layout.input_rows changes (re-establish region+frame)
320
+ self.completer = completer # zero-arg callable → iterable of slash-command names (no leading /)
321
+ self._drawn_rows = 1 # visual input-height currently on screen (drives clear-on-shrink)
322
+
323
+
324
+ def readline(self, prompt: str = "› ") -> str:
325
+ """Read one line. Raises ``EOFError`` / ``KeyboardInterrupt`` exactly like ``input()``.
326
+
327
+ When the dock is disabled or stdin/stdout is not a TTY, defers to builtin ``input()`` so
328
+ piping and the test suite behave unchanged.
329
+ """
330
+ if not self.enabled:
331
+ return input(color(prompt, "green"))
332
+
333
+ fd = sys.stdin.fileno()
334
+ old = termios.tcgetattr(fd)
335
+ hist = tuple(self.history)
336
+ state = EditorState(history=hist, hist_pos=len(hist))
337
+ if self.layout is not None:
338
+ self.layout.input_rows = 1 # start each line at a single row; it grows as the line wraps
339
+ self._drawn_rows = 1
340
+ try:
341
+ tty.setraw(fd) # raw: deliver Ctrl-C/Ctrl-D as bytes (we translate them ourselves)
342
+ sys.stdout.write(_PASTE_ON) # ask the terminal to bracket pasted text
343
+ sys.stdout.write("\0337") # DECSC: save the in-region cursor to restore afterwards
344
+ self._render(prompt, state)
345
+ while not state.done:
346
+ key = self._read_key()
347
+ if key is None: # stream closed → treat as EOF
348
+ state = replace(state, eof=True)
349
+ break
350
+ if isinstance(key, tuple): # ("paste", text) → bulk insert, never submits
351
+ state = insert_text(state, key[1])
352
+ elif key == "\x1b": # lone ESC (no recognised sequence) → ignore
353
+ continue
354
+ elif key == _TAB: # slash-command completion (cycle through matches)
355
+ state = cycle_completion(state, self._candidates())
356
+ else:
357
+ state = apply_key(state, key)
358
+ if state.copy: # Ctrl-S: copy the last answer, then carry on editing
359
+ self._handle_copy()
360
+ state = replace(state, copy=False)
361
+ if not state.done:
362
+ self._render(prompt, state)
363
+ finally:
364
+ # Clear the transient input band so the submitted text doesn't linger on the fixed input
365
+ # line(s) while the turn runs (the prompt is echoed into the scroll region separately), and
366
+ # collapse the dock back to a single input row so the turn output regains that space. We
367
+ # clear the WHOLE grown band (pad + input rows + spacer + bar) — not just the input rows —
368
+ # so a grown dock never leaves a stale row behind that would scroll up into the transcript.
369
+ rows = shutil.get_terminal_size((80, 24)).lines
370
+ if self.layout is not None:
371
+ band_top = self.layout.top_pad_row(rows) # top of the current (possibly grown) band
372
+ for r in range(band_top, rows + 1):
373
+ sys.stdout.write(f"\033[{max(1, r)};1H\033[2K")
374
+ self.layout.input_rows = 1
375
+ self._drawn_rows = 1
376
+ if self.on_reflow is not None:
377
+ self.on_reflow() # re-establish the region + redraw the frame for the 1-row dock
378
+ else:
379
+ inrow = max(1, rows - 1)
380
+ sys.stdout.write(f"\033[{inrow};1H\033[2K")
381
+ sys.stdout.write("\0338") # DECRC: restore the cursor back into the output region
382
+ sys.stdout.write(_PASTE_OFF) # stop bracketing paste once we hand control back
383
+ sys.stdout.flush()
384
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
385
+
386
+
387
+ if state.interrupt:
388
+ raise KeyboardInterrupt
389
+ if state.eof:
390
+ raise EOFError
391
+ line = state.buffer
392
+ if line and (not self.history or self.history[-1] != line):
393
+ self.history.append(line) # de-duplicate consecutive repeats
394
+ return line
395
+
396
+ # ── input ────────────────────────────────────────────────────────────────────
397
+ def _candidates(self):
398
+ """Return the slash-command names offered for Tab completion (empty when no completer)."""
399
+ if self.completer is None:
400
+ return ()
401
+ try:
402
+ return tuple(self.completer())
403
+ except Exception:
404
+ return ()
405
+
406
+ 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.
409
+
410
+ 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.
412
+ """
413
+ fd = sys.stdin.fileno()
414
+ b = os.read(fd, 1)
415
+ if not b:
416
+ return None
417
+ if b == b"\x1b":
418
+ token = self._read_escape(fd)
419
+ if token == _PASTE_START:
420
+ return ("paste", self._read_paste(fd))
421
+ return token or "\x1b"
422
+ return self._decode(fd, b)
423
+
424
+ @staticmethod
425
+ def _decode(fd: int, first: bytes) -> str:
426
+ """Decode one (possibly multi-byte UTF-8) character starting with ``first``."""
427
+ lead = first[0]
428
+ if lead < 0x80 or lead < 0xC0:
429
+ extra = 0 # ASCII, or a stray continuation byte (decode leniently)
430
+ elif lead < 0xE0:
431
+ extra = 1
432
+ elif lead < 0xF0:
433
+ extra = 2
434
+ else:
435
+ extra = 3
436
+ buf = bytearray(first)
437
+ for _ in range(extra):
438
+ more = os.read(fd, 1)
439
+ if not more:
440
+ break
441
+ buf += more
442
+ return bytes(buf).decode("utf-8", "replace")
443
+
444
+ def _read_escape(self, fd: int):
445
+ """Translate a CSI/SS3 escape sequence (already past ESC) into a logical key token, or None."""
446
+
447
+ def more(timeout: float = 0.03) -> str:
448
+ r, _, _ = select.select([fd], [], [], timeout)
449
+ if not r:
450
+ return ""
451
+ b = os.read(fd, 1)
452
+ return b.decode("latin-1") if b else ""
453
+
454
+ intro = more()
455
+ if intro not in ("[", "O"): # not a CSI (ESC[) or SS3 (ESCO) sequence
456
+ return None
457
+ code = more()
458
+ simple = {"A": _UP, "B": _DOWN, "C": _RIGHT, "D": _LEFT, "H": _HOME, "F": _END}
459
+ if code in simple:
460
+ return simple[code]
461
+ if code.isdigit(): # extended form like ESC[3~ (Delete), ESC[1~ (Home), ESC[200~ (paste)
462
+ num = code
463
+ while True:
464
+ nxt = more()
465
+ if nxt in ("", "~"):
466
+ break
467
+ num += nxt
468
+ if num == "200":
469
+ return _PASTE_START
470
+ return {"1": _HOME, "7": _HOME, "4": _END, "8": _END, "3": _DELETE}.get(num)
471
+ return None
472
+
473
+ @staticmethod
474
+ def _read_paste(fd: int) -> str:
475
+ """Collect a bracketed-paste payload up to the ESC[201~ terminator (bounded by a stall)."""
476
+ term = b"\x1b[201~"
477
+ buf = bytearray()
478
+ while True:
479
+ r, _, _ = select.select([fd], [], [], 0.5)
480
+ if not r:
481
+ break # safety: stop if the terminator never arrives
482
+ chunk = os.read(fd, 4096)
483
+ if not chunk:
484
+ break
485
+ buf += chunk
486
+ idx = buf.find(term)
487
+ if idx != -1:
488
+ del buf[idx:]
489
+ break
490
+ return bytes(buf).decode("utf-8", "replace")
491
+
492
+ # ── rendering ────────────────────────────────────────────────────────────────
493
+ def _handle_copy(self) -> None:
494
+ """Run the Ctrl-S copy callback, printing its feedback into the scroll region.
495
+
496
+ The input row is transient, so drop back to the saved in-region cursor (DECRC), let the
497
+ callback print a normal line there, then re-save (DECSC) so the next output flows below it.
498
+ The caller re-renders the input row afterwards.
499
+ """
500
+ if self.on_copy is None:
501
+ return
502
+ sys.stdout.write("\0338") # DECRC: restore the in-region cursor
503
+ try:
504
+ self.on_copy()
505
+ finally:
506
+ sys.stdout.write("\0337") # DECSC: re-save for the next scroll-region write
507
+ sys.stdout.flush()
508
+
509
+ def _render(self, prompt: str, state: EditorState) -> None:
510
+ """Draw the prompt + buffer on the fixed input row(s) and place the caret at the edit point."""
511
+ if self.layout is not None:
512
+ self._render_multi(prompt, state)
513
+ else:
514
+ self._render_single(prompt, state)
515
+
516
+ def _render_single(self, prompt: str, state: EditorState) -> None:
517
+ """Legacy one-row renderer: long lines scroll horizontally to keep the caret visible."""
518
+ size = shutil.get_terminal_size((80, 24))
519
+ rows, cols = size.lines, size.columns
520
+ row = max(1, rows - 1) # input row sits just above the bottom status bar
521
+ pw = len(prompt)
522
+ avail = max(1, cols - pw - 1)
523
+ # Horizontal scroll window: keep the cursor inside [start, start+avail).
524
+ start = 0 if state.cursor < avail else state.cursor - avail + 1
525
+ visible = state.buffer[start:start + avail]
526
+ curcol = pw + (state.cursor - start) + 1
527
+ sys.stdout.write(
528
+ f"\033[{row};1H\033[2K" # move to the input row and clear it
529
+ + color(prompt, "green") + visible
530
+ + f"\033[{row};{curcol}H" # position the caret at the edit column
531
+ )
532
+ sys.stdout.flush()
533
+
534
+ def _render_multi(self, prompt: str, state: EditorState) -> None:
535
+ """Multi-row renderer: the input wraps onto new lines and the dock grows/shrinks with it.
536
+
537
+ When the visual height changes we update the shared ``layout`` and ask the status bar to
538
+ re-establish the scroll region + redraw the frame (``on_reflow``) before drawing the input
539
+ rows, so growing input never overwrites the bar and shrinking input frees the space again.
540
+ """
541
+ size = shutil.get_terminal_size((80, 24))
542
+ rows, cols = size.lines, size.columns
543
+ pw = len(prompt)
544
+ window_top, height, caret_line, caret_col = wrap_layout(
545
+ pw, state.buffer, state.cursor, cols, self.layout.MAX_INPUT_ROWS
546
+ )
547
+
548
+ # If the height changed, clear the old (possibly taller) band, resize the dock, and reflow the
549
+ # frame before we draw. Clearing the union band avoids leaving stale text behind on shrink.
550
+ if height != self._drawn_rows:
551
+ union = max(height, self._drawn_rows)
552
+ band_top = max(1, rows - (union + 3) + 1) # rule + union + spacer + bar
553
+ for r in range(band_top, rows + 1):
554
+ sys.stdout.write(f"\033[{r};1H\033[K")
555
+ self.layout.input_rows = height
556
+ if self.on_reflow is not None:
557
+ self.on_reflow() # re-establish region + redraw rule/spacer/bar for the new height
558
+ self._drawn_rows = height
559
+
560
+ # Build the wrapped stream (uncolored) and slice the visible window of lines.
561
+ stream = prompt + state.buffer
562
+ top = self.layout.input_top(rows)
563
+ for i in range(height):
564
+ line_idx = window_top + i
565
+ seg = stream[line_idx * cols:(line_idx + 1) * cols]
566
+ if line_idx == 0:
567
+ # First visual line begins with the prompt — colorize just that prefix.
568
+ seg = color(prompt, "green") + seg[pw:]
569
+ sys.stdout.write(f"\033[{top + i};1H\033[2K" + seg)
570
+ caret_row = top + (caret_line - window_top)
571
+ sys.stdout.write(f"\033[{caret_row};{caret_col + 1}H") # position the caret at the edit point
572
+ sys.stdout.flush()
573
+
574
+
575
+
576
+
577
+