wafer-core 0.1.38__py3-none-any.whl → 0.1.39__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.
- wafer_core/lib/trace_compare/fusion_analyzer.py +2 -0
- wafer_core/rollouts/_logging/__init__.py +5 -1
- wafer_core/rollouts/_logging/logging_config.py +95 -3
- wafer_core/rollouts/_logging/sample_handler.py +66 -0
- wafer_core/rollouts/_pytui/__init__.py +114 -0
- wafer_core/rollouts/_pytui/app.py +809 -0
- wafer_core/rollouts/_pytui/console.py +291 -0
- wafer_core/rollouts/_pytui/renderer.py +210 -0
- wafer_core/rollouts/_pytui/spinner.py +73 -0
- wafer_core/rollouts/_pytui/terminal.py +489 -0
- wafer_core/rollouts/_pytui/text.py +470 -0
- wafer_core/rollouts/_pytui/theme.py +241 -0
- wafer_core/rollouts/evaluation.py +142 -177
- wafer_core/rollouts/progress_app.py +395 -0
- wafer_core/rollouts/tui/DESIGN.md +251 -115
- wafer_core/rollouts/tui/monitor.py +64 -20
- wafer_core/tools/compile/__init__.py +30 -0
- wafer_core/tools/compile/compiler.py +314 -0
- wafer_core/tools/compile/modal_compile.py +359 -0
- wafer_core/tools/compile/tests/__init__.py +1 -0
- wafer_core/tools/compile/tests/test_compiler.py +675 -0
- wafer_core/tools/compile/tests/test_data/utils.cuh +10 -0
- wafer_core/tools/compile/tests/test_data/vector_add.cu +7 -0
- wafer_core/tools/compile/tests/test_data/with_header.cu +9 -0
- wafer_core/tools/compile/tests/test_modal_integration.py +326 -0
- wafer_core/tools/compile/types.py +117 -0
- {wafer_core-0.1.38.dist-info → wafer_core-0.1.39.dist-info}/METADATA +1 -1
- {wafer_core-0.1.38.dist-info → wafer_core-0.1.39.dist-info}/RECORD +29 -12
- wafer_core/rollouts/events.py +0 -240
- wafer_core/rollouts/progress_display.py +0 -476
- wafer_core/utils/event_streaming.py +0 -63
- {wafer_core-0.1.38.dist-info → wafer_core-0.1.39.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""Terminal abstraction - raw mode, cursor control, input handling.
|
|
2
|
+
|
|
3
|
+
Unified terminal layer for all TUI apps. Handles:
|
|
4
|
+
- Raw mode (termios) with /dev/tty fallback
|
|
5
|
+
- Non-blocking keyboard input with escape sequence buffering
|
|
6
|
+
- SIGWINCH resize handling
|
|
7
|
+
- Alternate screen buffer (optional)
|
|
8
|
+
- Bracketed paste mode (optional)
|
|
9
|
+
- Atexit cleanup for crash safety
|
|
10
|
+
- External editor support (temporarily restore cooked mode)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import atexit
|
|
16
|
+
import os
|
|
17
|
+
import select
|
|
18
|
+
import signal
|
|
19
|
+
import sys
|
|
20
|
+
import termios
|
|
21
|
+
import time
|
|
22
|
+
import tty
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from types import FrameType
|
|
25
|
+
from typing import Any, Protocol
|
|
26
|
+
|
|
27
|
+
# ANSI escape sequences — named for readability and grep-ability
|
|
28
|
+
ALT_SCREEN_ON = "\x1b[?1049h"
|
|
29
|
+
ALT_SCREEN_OFF = "\x1b[?1049l"
|
|
30
|
+
BRACKETED_PASTE_ON = "\x1b[?2004h"
|
|
31
|
+
BRACKETED_PASTE_OFF = "\x1b[?2004l"
|
|
32
|
+
SYNC_OUTPUT_ON = "\x1b[?2026h"
|
|
33
|
+
SYNC_OUTPUT_OFF = "\x1b[?2026l"
|
|
34
|
+
CURSOR_SHOW = "\x1b[?25h"
|
|
35
|
+
CURSOR_HIDE = "\x1b[?25l"
|
|
36
|
+
CLEAR_SCREEN_HOME = "\x1b[2J\x1b[H"
|
|
37
|
+
CLEAR_LINE = "\x1b[K"
|
|
38
|
+
CLEAR_LINE_FULL = "\x1b[2K"
|
|
39
|
+
CLEAR_TO_END = "\x1b[J"
|
|
40
|
+
RESET_ATTRS = "\x1b[0m"
|
|
41
|
+
|
|
42
|
+
# Mouse tracking (SGR extended mode - handles coordinates > 223)
|
|
43
|
+
MOUSE_ON = "\x1b[?1000h\x1b[?1006h" # Enable button events + SGR encoding
|
|
44
|
+
MOUSE_OFF = "\x1b[?1000l\x1b[?1006l"
|
|
45
|
+
|
|
46
|
+
# Focus reporting
|
|
47
|
+
FOCUS_ON = "\x1b[?1004h"
|
|
48
|
+
FOCUS_OFF = "\x1b[?1004l"
|
|
49
|
+
|
|
50
|
+
# Known escape sequences - ported from bubbletea/key.go
|
|
51
|
+
# Maps sequence -> key name for reliable detection
|
|
52
|
+
KNOWN_SEQUENCES: dict[str, str] = {
|
|
53
|
+
# Arrow keys
|
|
54
|
+
"\x1b[A": "up", "\x1b[B": "down", "\x1b[C": "right", "\x1b[D": "left",
|
|
55
|
+
"\x1b[1;2A": "shift+up", "\x1b[1;2B": "shift+down",
|
|
56
|
+
"\x1b[1;2C": "shift+right", "\x1b[1;2D": "shift+left",
|
|
57
|
+
"\x1b[OA": "shift+up", "\x1b[OB": "shift+down", # DECCKM
|
|
58
|
+
"\x1b[OC": "shift+right", "\x1b[OD": "shift+left",
|
|
59
|
+
"\x1b[a": "shift+up", "\x1b[b": "shift+down", # urxvt
|
|
60
|
+
"\x1b[c": "shift+right", "\x1b[d": "shift+left",
|
|
61
|
+
"\x1b[1;3A": "alt+up", "\x1b[1;3B": "alt+down",
|
|
62
|
+
"\x1b[1;3C": "alt+right", "\x1b[1;3D": "alt+left",
|
|
63
|
+
"\x1b[1;4A": "alt+shift+up", "\x1b[1;4B": "alt+shift+down",
|
|
64
|
+
"\x1b[1;4C": "alt+shift+right", "\x1b[1;4D": "alt+shift+left",
|
|
65
|
+
"\x1b[1;5A": "ctrl+up", "\x1b[1;5B": "ctrl+down",
|
|
66
|
+
"\x1b[1;5C": "ctrl+right", "\x1b[1;5D": "ctrl+left",
|
|
67
|
+
"\x1b[Oa": "alt+ctrl+up", "\x1b[Ob": "alt+ctrl+down", # urxvt
|
|
68
|
+
"\x1b[Oc": "alt+ctrl+right", "\x1b[Od": "alt+ctrl+left",
|
|
69
|
+
"\x1b[1;6A": "ctrl+shift+up", "\x1b[1;6B": "ctrl+shift+down",
|
|
70
|
+
"\x1b[1;6C": "ctrl+shift+right", "\x1b[1;6D": "ctrl+shift+left",
|
|
71
|
+
"\x1b[1;7A": "alt+ctrl+up", "\x1b[1;7B": "alt+ctrl+down",
|
|
72
|
+
"\x1b[1;7C": "alt+ctrl+right", "\x1b[1;7D": "alt+ctrl+left",
|
|
73
|
+
"\x1b[1;8A": "alt+ctrl+shift+up", "\x1b[1;8B": "alt+ctrl+shift+down",
|
|
74
|
+
"\x1b[1;8C": "alt+ctrl+shift+right", "\x1b[1;8D": "alt+ctrl+shift+left",
|
|
75
|
+
# Application mode arrows (powershell, etc)
|
|
76
|
+
"\x1bOA": "up", "\x1bOB": "down", "\x1bOC": "right", "\x1bOD": "left",
|
|
77
|
+
# Misc keys
|
|
78
|
+
"\x1b[Z": "shift+tab",
|
|
79
|
+
"\x1b[2~": "insert", "\x1b[3;2~": "alt+insert",
|
|
80
|
+
"\x1b[3~": "delete", "\x1b[3;3~": "alt+delete",
|
|
81
|
+
# Page Up/Down
|
|
82
|
+
"\x1b[5~": "pageup", "\x1b[5;3~": "alt+pageup",
|
|
83
|
+
"\x1b[5;5~": "ctrl+pageup", "\x1b[5^": "ctrl+pageup", # urxvt
|
|
84
|
+
"\x1b[5;7~": "alt+ctrl+pageup",
|
|
85
|
+
"\x1b[6~": "pagedown", "\x1b[6;3~": "alt+pagedown",
|
|
86
|
+
"\x1b[6;5~": "ctrl+pagedown", "\x1b[6^": "ctrl+pagedown", # urxvt
|
|
87
|
+
"\x1b[6;7~": "alt+ctrl+pagedown",
|
|
88
|
+
# Home/End - xterm style
|
|
89
|
+
"\x1b[1~": "home", "\x1b[H": "home",
|
|
90
|
+
"\x1b[1;3H": "alt+home", "\x1b[1;5H": "ctrl+home",
|
|
91
|
+
"\x1b[1;7H": "alt+ctrl+home", "\x1b[1;2H": "shift+home",
|
|
92
|
+
"\x1b[1;4H": "alt+shift+home", "\x1b[1;6H": "ctrl+shift+home",
|
|
93
|
+
"\x1b[1;8H": "alt+ctrl+shift+home",
|
|
94
|
+
"\x1b[4~": "end", "\x1b[F": "end",
|
|
95
|
+
"\x1b[1;3F": "alt+end", "\x1b[1;5F": "ctrl+end",
|
|
96
|
+
"\x1b[1;7F": "alt+ctrl+end", "\x1b[1;2F": "shift+end",
|
|
97
|
+
"\x1b[1;4F": "alt+shift+end", "\x1b[1;6F": "ctrl+shift+end",
|
|
98
|
+
"\x1b[1;8F": "alt+ctrl+shift+end",
|
|
99
|
+
# Home/End - urxvt style
|
|
100
|
+
"\x1b[7~": "home", "\x1b[7^": "ctrl+home",
|
|
101
|
+
"\x1b[7$": "shift+home", "\x1b[7@": "ctrl+shift+home",
|
|
102
|
+
"\x1b[8~": "end", "\x1b[8^": "ctrl+end",
|
|
103
|
+
"\x1b[8$": "shift+end", "\x1b[8@": "ctrl+shift+end",
|
|
104
|
+
# Function keys - linux console
|
|
105
|
+
"\x1b[[A": "f1", "\x1b[[B": "f2", "\x1b[[C": "f3",
|
|
106
|
+
"\x1b[[D": "f4", "\x1b[[E": "f5",
|
|
107
|
+
# Function keys - vt100/xterm
|
|
108
|
+
"\x1bOP": "f1", "\x1bOQ": "f2", "\x1bOR": "f3", "\x1bOS": "f4",
|
|
109
|
+
"\x1b[1;3P": "alt+f1", "\x1b[1;3Q": "alt+f2",
|
|
110
|
+
"\x1b[1;3R": "alt+f3", "\x1b[1;3S": "alt+f4",
|
|
111
|
+
# Function keys - urxvt style
|
|
112
|
+
"\x1b[11~": "f1", "\x1b[12~": "f2", "\x1b[13~": "f3", "\x1b[14~": "f4",
|
|
113
|
+
"\x1b[15~": "f5", "\x1b[15;3~": "alt+f5",
|
|
114
|
+
"\x1b[17~": "f6", "\x1b[18~": "f7", "\x1b[19~": "f8",
|
|
115
|
+
"\x1b[20~": "f9", "\x1b[21~": "f10",
|
|
116
|
+
"\x1b[17;3~": "alt+f6", "\x1b[18;3~": "alt+f7",
|
|
117
|
+
"\x1b[19;3~": "alt+f8", "\x1b[20;3~": "alt+f9", "\x1b[21;3~": "alt+f10",
|
|
118
|
+
"\x1b[23~": "f11", "\x1b[24~": "f12",
|
|
119
|
+
"\x1b[23;3~": "alt+f11", "\x1b[24;3~": "alt+f12",
|
|
120
|
+
# F13-F20
|
|
121
|
+
"\x1b[1;2P": "f13", "\x1b[1;2Q": "f14",
|
|
122
|
+
"\x1b[25~": "f13", "\x1b[26~": "f14",
|
|
123
|
+
"\x1b[25;3~": "alt+f13", "\x1b[26;3~": "alt+f14",
|
|
124
|
+
"\x1b[1;2R": "f15", "\x1b[1;2S": "f16",
|
|
125
|
+
"\x1b[28~": "f15", "\x1b[29~": "f16",
|
|
126
|
+
"\x1b[28;3~": "alt+f15", "\x1b[29;3~": "alt+f16",
|
|
127
|
+
"\x1b[15;2~": "f17", "\x1b[17;2~": "f18",
|
|
128
|
+
"\x1b[18;2~": "f19", "\x1b[19;2~": "f20",
|
|
129
|
+
"\x1b[31~": "f17", "\x1b[32~": "f18", "\x1b[33~": "f19", "\x1b[34~": "f20",
|
|
130
|
+
# Bracketed paste markers
|
|
131
|
+
"\x1b[200~": "paste_start", "\x1b[201~": "paste_end",
|
|
132
|
+
# Focus events
|
|
133
|
+
"\x1b[I": "focus", "\x1b[O": "blur",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
# Global reference for atexit cleanup
|
|
137
|
+
_active_terminal: Terminal | None = None
|
|
138
|
+
_cleanup_done: bool = False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _cleanup_terminal() -> None:
|
|
142
|
+
"""Atexit handler to restore terminal state."""
|
|
143
|
+
global _active_terminal, _cleanup_done
|
|
144
|
+
|
|
145
|
+
if _cleanup_done:
|
|
146
|
+
return
|
|
147
|
+
_cleanup_done = True
|
|
148
|
+
|
|
149
|
+
if _active_terminal is not None:
|
|
150
|
+
_active_terminal.stop()
|
|
151
|
+
|
|
152
|
+
# Fallback: run stty sane to ensure terminal is usable
|
|
153
|
+
import subprocess
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
subprocess.run(["stty", "sane"], stdin=open("/dev/tty"), check=False)
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TerminalProtocol(Protocol):
|
|
162
|
+
"""Protocol for terminal implementations."""
|
|
163
|
+
|
|
164
|
+
def start(self, on_input: Callable[[str], None], on_resize: Callable[[], None]) -> None: ...
|
|
165
|
+
def stop(self) -> None: ...
|
|
166
|
+
def write(self, data: str) -> None: ...
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def columns(self) -> int: ...
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def rows(self) -> int: ...
|
|
173
|
+
|
|
174
|
+
def hide_cursor(self) -> None: ...
|
|
175
|
+
def show_cursor(self) -> None: ...
|
|
176
|
+
def clear_line(self) -> None: ...
|
|
177
|
+
def clear_from_cursor(self) -> None: ...
|
|
178
|
+
def clear_screen(self) -> None: ...
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class Terminal:
|
|
182
|
+
"""Real terminal using /dev/tty with raw mode support.
|
|
183
|
+
|
|
184
|
+
Features:
|
|
185
|
+
- /dev/tty for input (works even when stdin is piped)
|
|
186
|
+
- Non-blocking reads with escape sequence buffering
|
|
187
|
+
- Optional alternate screen buffer
|
|
188
|
+
- Optional bracketed paste mode
|
|
189
|
+
- Atexit cleanup for crash recovery
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
*,
|
|
195
|
+
alternate_screen: bool = False,
|
|
196
|
+
use_alternate_screen: bool | None = None, # Compat alias
|
|
197
|
+
bracketed_paste: bool = False,
|
|
198
|
+
mouse: bool = False,
|
|
199
|
+
) -> None:
|
|
200
|
+
self._old_settings: list | None = None
|
|
201
|
+
self._input_handler: Callable[[str], None] | None = None
|
|
202
|
+
self._resize_handler: Callable[[], None] | None = None
|
|
203
|
+
self._old_sigwinch: Any = None
|
|
204
|
+
self._running = False
|
|
205
|
+
self._tty_fd: int | None = None
|
|
206
|
+
self._input_buffer: str = "" # Buffer for multi-byte reads
|
|
207
|
+
self._paste_buffer: str | None = None # Collecting paste content
|
|
208
|
+
# Accept both alternate_screen and use_alternate_screen (compat)
|
|
209
|
+
if use_alternate_screen is not None:
|
|
210
|
+
self._alternate_screen = use_alternate_screen
|
|
211
|
+
else:
|
|
212
|
+
self._alternate_screen = alternate_screen
|
|
213
|
+
self._bracketed_paste = bracketed_paste
|
|
214
|
+
self._mouse = mouse
|
|
215
|
+
|
|
216
|
+
def start(self, on_input: Callable[[str], None], on_resize: Callable[[], None]) -> None:
|
|
217
|
+
"""Start terminal in raw mode with input/resize handlers."""
|
|
218
|
+
global _active_terminal, _cleanup_done
|
|
219
|
+
|
|
220
|
+
self._input_handler = on_input
|
|
221
|
+
self._resize_handler = on_resize
|
|
222
|
+
|
|
223
|
+
_active_terminal = self
|
|
224
|
+
_cleanup_done = False
|
|
225
|
+
atexit.register(_cleanup_terminal)
|
|
226
|
+
|
|
227
|
+
# Open /dev/tty for keyboard input
|
|
228
|
+
try:
|
|
229
|
+
self._tty_fd = os.open("/dev/tty", os.O_RDONLY | os.O_NONBLOCK)
|
|
230
|
+
self._old_settings = termios.tcgetattr(self._tty_fd)
|
|
231
|
+
tty.setraw(self._tty_fd)
|
|
232
|
+
except (OSError, termios.error):
|
|
233
|
+
self._tty_fd = None
|
|
234
|
+
if sys.stdin.isatty():
|
|
235
|
+
self._old_settings = termios.tcgetattr(sys.stdin.fileno())
|
|
236
|
+
tty.setraw(sys.stdin.fileno())
|
|
237
|
+
|
|
238
|
+
# Enter alternate screen buffer
|
|
239
|
+
if self._alternate_screen:
|
|
240
|
+
sys.stdout.write(ALT_SCREEN_ON)
|
|
241
|
+
|
|
242
|
+
# Enable bracketed paste mode
|
|
243
|
+
if self._bracketed_paste:
|
|
244
|
+
sys.stdout.write(BRACKETED_PASTE_ON)
|
|
245
|
+
|
|
246
|
+
# Enable mouse tracking
|
|
247
|
+
if self._mouse:
|
|
248
|
+
sys.stdout.write(MOUSE_ON)
|
|
249
|
+
|
|
250
|
+
sys.stdout.flush()
|
|
251
|
+
|
|
252
|
+
# SIGWINCH for resize events
|
|
253
|
+
self._old_sigwinch = signal.signal(signal.SIGWINCH, self._handle_sigwinch)
|
|
254
|
+
self._running = True
|
|
255
|
+
|
|
256
|
+
def stop(self) -> None:
|
|
257
|
+
"""Stop terminal and restore previous settings."""
|
|
258
|
+
global _active_terminal, _cleanup_done
|
|
259
|
+
|
|
260
|
+
if not self._running and self._old_settings is None:
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
self._running = False
|
|
264
|
+
_cleanup_done = True
|
|
265
|
+
|
|
266
|
+
# Restore terminal to clean state
|
|
267
|
+
sys.stdout.write(CURSOR_SHOW)
|
|
268
|
+
|
|
269
|
+
if self._bracketed_paste:
|
|
270
|
+
sys.stdout.write(BRACKETED_PASTE_OFF)
|
|
271
|
+
|
|
272
|
+
if self._mouse:
|
|
273
|
+
sys.stdout.write(MOUSE_OFF)
|
|
274
|
+
|
|
275
|
+
# End synchronized output (in case we're mid-render)
|
|
276
|
+
sys.stdout.write(SYNC_OUTPUT_OFF)
|
|
277
|
+
sys.stdout.write(RESET_ATTRS)
|
|
278
|
+
|
|
279
|
+
if self._alternate_screen:
|
|
280
|
+
sys.stdout.write(ALT_SCREEN_OFF)
|
|
281
|
+
else:
|
|
282
|
+
sys.stdout.write("\n")
|
|
283
|
+
|
|
284
|
+
sys.stdout.flush()
|
|
285
|
+
|
|
286
|
+
# Restore terminal settings
|
|
287
|
+
if self._old_settings is not None:
|
|
288
|
+
if self._tty_fd is not None:
|
|
289
|
+
termios.tcsetattr(self._tty_fd, termios.TCSADRAIN, self._old_settings)
|
|
290
|
+
os.close(self._tty_fd)
|
|
291
|
+
self._tty_fd = None
|
|
292
|
+
elif sys.stdin.isatty():
|
|
293
|
+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._old_settings)
|
|
294
|
+
self._old_settings = None
|
|
295
|
+
|
|
296
|
+
# Restore SIGWINCH handler
|
|
297
|
+
if self._old_sigwinch is not None:
|
|
298
|
+
signal.signal(signal.SIGWINCH, self._old_sigwinch)
|
|
299
|
+
self._old_sigwinch = None
|
|
300
|
+
|
|
301
|
+
self._input_handler = None
|
|
302
|
+
self._resize_handler = None
|
|
303
|
+
|
|
304
|
+
_active_terminal = None
|
|
305
|
+
try:
|
|
306
|
+
atexit.unregister(_cleanup_terminal)
|
|
307
|
+
except Exception:
|
|
308
|
+
pass
|
|
309
|
+
|
|
310
|
+
def write(self, data: str) -> None:
|
|
311
|
+
"""Write data to stdout."""
|
|
312
|
+
sys.stdout.write(data)
|
|
313
|
+
sys.stdout.flush()
|
|
314
|
+
|
|
315
|
+
@property
|
|
316
|
+
def columns(self) -> int:
|
|
317
|
+
"""Terminal width in columns."""
|
|
318
|
+
return os.get_terminal_size().columns
|
|
319
|
+
|
|
320
|
+
@property
|
|
321
|
+
def rows(self) -> int:
|
|
322
|
+
"""Terminal height in rows."""
|
|
323
|
+
return os.get_terminal_size().lines
|
|
324
|
+
|
|
325
|
+
def hide_cursor(self) -> None:
|
|
326
|
+
self.write(CURSOR_HIDE)
|
|
327
|
+
|
|
328
|
+
def show_cursor(self) -> None:
|
|
329
|
+
self.write(CURSOR_SHOW)
|
|
330
|
+
|
|
331
|
+
def clear_line(self) -> None:
|
|
332
|
+
self.write(CLEAR_LINE)
|
|
333
|
+
|
|
334
|
+
def clear_from_cursor(self) -> None:
|
|
335
|
+
self.write(CLEAR_TO_END)
|
|
336
|
+
|
|
337
|
+
def clear_screen(self) -> None:
|
|
338
|
+
self.write(CLEAR_SCREEN_HOME)
|
|
339
|
+
|
|
340
|
+
def move_cursor(self, row: int, col: int) -> None:
|
|
341
|
+
"""Move cursor to position (1-indexed)."""
|
|
342
|
+
self.write(f"\x1b[{row};{col}H")
|
|
343
|
+
|
|
344
|
+
def read_input(self) -> str | None:
|
|
345
|
+
"""Read available input (non-blocking).
|
|
346
|
+
|
|
347
|
+
Returns None if no input available. Uses buffered reads like bubbletea
|
|
348
|
+
to reduce syscall overhead (read up to 256 bytes at once).
|
|
349
|
+
|
|
350
|
+
Escape sequences are detected by looking for terminators (letters, ~).
|
|
351
|
+
Returns one logical input at a time (single char or complete escape sequence).
|
|
352
|
+
"""
|
|
353
|
+
fd = self._tty_fd if self._tty_fd is not None else sys.stdin.fileno()
|
|
354
|
+
|
|
355
|
+
# Check buffer first
|
|
356
|
+
if not self._input_buffer:
|
|
357
|
+
if not select.select([fd], [], [], 0)[0]:
|
|
358
|
+
return None
|
|
359
|
+
# Read up to 256 bytes at once (like bubbletea) to reduce syscalls
|
|
360
|
+
data = os.read(fd, 256).decode("utf-8", errors="replace")
|
|
361
|
+
if not data:
|
|
362
|
+
return None
|
|
363
|
+
self._input_buffer = data
|
|
364
|
+
|
|
365
|
+
# Extract one logical input from buffer
|
|
366
|
+
buf = self._input_buffer
|
|
367
|
+
|
|
368
|
+
if buf[0] != "\x1b":
|
|
369
|
+
# Regular character
|
|
370
|
+
self._input_buffer = buf[1:]
|
|
371
|
+
return buf[0]
|
|
372
|
+
|
|
373
|
+
# Escape sequence - find the end
|
|
374
|
+
if len(buf) == 1:
|
|
375
|
+
# Just ESC, might be incomplete - try to read more
|
|
376
|
+
if select.select([fd], [], [], 0.005)[0]: # 5ms wait
|
|
377
|
+
more = os.read(fd, 256).decode("utf-8", errors="replace")
|
|
378
|
+
if more:
|
|
379
|
+
buf = buf + more
|
|
380
|
+
self._input_buffer = buf
|
|
381
|
+
|
|
382
|
+
# Check against known sequences first (O(1) lookup, longest match first)
|
|
383
|
+
for length in range(min(len(buf), 10), 1, -1): # Check longest first
|
|
384
|
+
candidate = buf[:length]
|
|
385
|
+
if candidate in KNOWN_SEQUENCES:
|
|
386
|
+
self._input_buffer = buf[length:]
|
|
387
|
+
return candidate
|
|
388
|
+
|
|
389
|
+
# Fall back to heuristic: look for sequence terminator
|
|
390
|
+
for i in range(1, len(buf)):
|
|
391
|
+
c = buf[i]
|
|
392
|
+
# CSI sequences end with letter, function keys with ~
|
|
393
|
+
if c.isalpha() or c == "~":
|
|
394
|
+
seq = buf[: i + 1]
|
|
395
|
+
self._input_buffer = buf[i + 1 :]
|
|
396
|
+
return seq
|
|
397
|
+
|
|
398
|
+
# No terminator found - if buffer is small, wait for more
|
|
399
|
+
if len(buf) < 12:
|
|
400
|
+
deadline = time.time() + 0.010 # 10ms max
|
|
401
|
+
while time.time() < deadline:
|
|
402
|
+
if select.select([fd], [], [], 0.001)[0]:
|
|
403
|
+
more = os.read(fd, 256).decode("utf-8", errors="replace")
|
|
404
|
+
if more:
|
|
405
|
+
buf = buf + more
|
|
406
|
+
self._input_buffer = buf
|
|
407
|
+
# Check known sequences
|
|
408
|
+
for length in range(min(len(buf), 10), 1, -1):
|
|
409
|
+
candidate = buf[:length]
|
|
410
|
+
if candidate in KNOWN_SEQUENCES:
|
|
411
|
+
self._input_buffer = buf[length:]
|
|
412
|
+
return candidate
|
|
413
|
+
# Check for terminator
|
|
414
|
+
for i in range(1, len(buf)):
|
|
415
|
+
c = buf[i]
|
|
416
|
+
if c.isalpha() or c == "~":
|
|
417
|
+
seq = buf[: i + 1]
|
|
418
|
+
self._input_buffer = buf[i + 1 :]
|
|
419
|
+
return seq
|
|
420
|
+
else:
|
|
421
|
+
break
|
|
422
|
+
|
|
423
|
+
# Still no terminator - return bare ESC, keep rest in buffer
|
|
424
|
+
self._input_buffer = buf[1:]
|
|
425
|
+
return buf[0]
|
|
426
|
+
|
|
427
|
+
def run_external_editor(self, initial_content: str = "") -> str | None:
|
|
428
|
+
"""Temporarily exit raw mode, run $EDITOR, return edited content."""
|
|
429
|
+
import subprocess
|
|
430
|
+
import tempfile
|
|
431
|
+
|
|
432
|
+
editor = os.environ.get("EDITOR", os.environ.get("VISUAL", "vim"))
|
|
433
|
+
|
|
434
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
|
|
435
|
+
f.write(initial_content)
|
|
436
|
+
temp_path = f.name
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
# Restore cooked mode
|
|
440
|
+
if self._old_settings is not None:
|
|
441
|
+
if self._tty_fd is not None:
|
|
442
|
+
termios.tcsetattr(self._tty_fd, termios.TCSADRAIN, self._old_settings)
|
|
443
|
+
elif sys.stdin.isatty():
|
|
444
|
+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._old_settings)
|
|
445
|
+
|
|
446
|
+
if self._bracketed_paste:
|
|
447
|
+
sys.stdout.write(BRACKETED_PASTE_OFF)
|
|
448
|
+
sys.stdout.write(CURSOR_SHOW)
|
|
449
|
+
sys.stdout.write(CLEAR_SCREEN_HOME)
|
|
450
|
+
sys.stdout.flush()
|
|
451
|
+
|
|
452
|
+
with open("/dev/tty") as tty_in, open("/dev/tty", "w") as tty_out:
|
|
453
|
+
result = subprocess.run(
|
|
454
|
+
[editor, temp_path],
|
|
455
|
+
stdin=tty_in,
|
|
456
|
+
stdout=tty_out,
|
|
457
|
+
stderr=tty_out,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
if result.returncode == 0:
|
|
461
|
+
with open(temp_path) as f:
|
|
462
|
+
content = f.read()
|
|
463
|
+
return content.strip() if content.strip() else None
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
finally:
|
|
467
|
+
try:
|
|
468
|
+
os.unlink(temp_path)
|
|
469
|
+
except OSError:
|
|
470
|
+
pass
|
|
471
|
+
|
|
472
|
+
# Restore raw mode
|
|
473
|
+
if self._tty_fd is not None:
|
|
474
|
+
tty.setraw(self._tty_fd)
|
|
475
|
+
elif sys.stdin.isatty():
|
|
476
|
+
tty.setraw(sys.stdin.fileno())
|
|
477
|
+
|
|
478
|
+
if self._bracketed_paste:
|
|
479
|
+
sys.stdout.write(BRACKETED_PASTE_ON)
|
|
480
|
+
sys.stdout.write(CURSOR_HIDE)
|
|
481
|
+
sys.stdout.write(CLEAR_SCREEN_HOME)
|
|
482
|
+
sys.stdout.flush()
|
|
483
|
+
|
|
484
|
+
if self._resize_handler:
|
|
485
|
+
self._resize_handler()
|
|
486
|
+
|
|
487
|
+
def _handle_sigwinch(self, signum: int, frame: FrameType | None) -> None:
|
|
488
|
+
if self._resize_handler:
|
|
489
|
+
self._resize_handler()
|