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.
- scootcli/__init__.py +4 -0
- scootcli/__main__.py +9 -0
- scootcli/activity.py +26 -0
- scootcli/agent.py +350 -0
- scootcli/approvals.py +167 -0
- scootcli/auth.py +59 -0
- scootcli/cli.py +276 -0
- scootcli/clipboard.py +89 -0
- scootcli/commands/__init__.py +56 -0
- scootcli/commands/approve.py +38 -0
- scootcli/commands/auth.py +107 -0
- scootcli/commands/base.py +31 -0
- scootcli/commands/compact.py +40 -0
- scootcli/commands/copy.py +23 -0
- scootcli/commands/exit.py +14 -0
- scootcli/commands/forget.py +28 -0
- scootcli/commands/help.py +29 -0
- scootcli/commands/init.py +50 -0
- scootcli/commands/logo.py +51 -0
- scootcli/commands/model.py +61 -0
- scootcli/commands/panel.py +28 -0
- scootcli/commands/reset.py +20 -0
- scootcli/commands/resume.py +31 -0
- scootcli/commands/save.py +29 -0
- scootcli/commands/sessions.py +42 -0
- scootcli/commands/status.py +59 -0
- scootcli/commands/verbosity.py +57 -0
- scootcli/commands/worktree.py +64 -0
- scootcli/commands/yolo.py +20 -0
- scootcli/config.py +241 -0
- scootcli/context.py +82 -0
- scootcli/credentials.py +79 -0
- scootcli/errors.py +87 -0
- scootcli/images.py +169 -0
- scootcli/keys.py +119 -0
- scootcli/lineeditor.py +577 -0
- scootcli/logo.py +116 -0
- scootcli/models.py +120 -0
- scootcli/panel.py +263 -0
- scootcli/preferences.py +87 -0
- scootcli/presets.py +38 -0
- scootcli/project.py +94 -0
- scootcli/prompts.py +100 -0
- scootcli/providers/__init__.py +20 -0
- scootcli/providers/base.py +370 -0
- scootcli/providers/openai_chat.py +142 -0
- scootcli/providers/openai_responses.py +248 -0
- scootcli/providers/registry.py +173 -0
- scootcli/rendering.py +86 -0
- scootcli/repl.py +801 -0
- scootcli/sessions.py +186 -0
- scootcli/status.py +71 -0
- scootcli/tools/__init__.py +68 -0
- scootcli/tools/base.py +152 -0
- scootcli/tools/edit_file.py +72 -0
- scootcli/tools/list_dir.py +47 -0
- scootcli/tools/read_file.py +56 -0
- scootcli/tools/run_shell.py +73 -0
- scootcli/tools/search.py +170 -0
- scootcli/tools/update_plan.py +104 -0
- scootcli/tools/write_file.py +61 -0
- scootcli/transport.py +312 -0
- scootcli/vision.py +167 -0
- scootcli/workspace.py +105 -0
- scootcli/worktree.py +114 -0
- scootcli-0.1.0.dist-info/METADATA +238 -0
- scootcli-0.1.0.dist-info/RECORD +71 -0
- scootcli-0.1.0.dist-info/WHEEL +5 -0
- scootcli-0.1.0.dist-info/entry_points.txt +2 -0
- scootcli-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
|