openhands 0.0.0__py3-none-any.whl → 1.0.1__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.
Potentially problematic release.
This version of openhands might be problematic. Click here for more details.
- openhands-1.0.1.dist-info/METADATA +52 -0
- openhands-1.0.1.dist-info/RECORD +31 -0
- {openhands-0.0.0.dist-info → openhands-1.0.1.dist-info}/WHEEL +1 -2
- openhands-1.0.1.dist-info/entry_points.txt +2 -0
- openhands_cli/__init__.py +8 -0
- openhands_cli/agent_chat.py +186 -0
- openhands_cli/argparsers/main_parser.py +56 -0
- openhands_cli/argparsers/serve_parser.py +31 -0
- openhands_cli/gui_launcher.py +220 -0
- openhands_cli/listeners/__init__.py +4 -0
- openhands_cli/listeners/loading_listener.py +63 -0
- openhands_cli/listeners/pause_listener.py +83 -0
- openhands_cli/llm_utils.py +57 -0
- openhands_cli/locations.py +13 -0
- openhands_cli/pt_style.py +30 -0
- openhands_cli/runner.py +178 -0
- openhands_cli/setup.py +116 -0
- openhands_cli/simple_main.py +59 -0
- openhands_cli/tui/__init__.py +5 -0
- openhands_cli/tui/settings/mcp_screen.py +217 -0
- openhands_cli/tui/settings/settings_screen.py +202 -0
- openhands_cli/tui/settings/store.py +93 -0
- openhands_cli/tui/status.py +109 -0
- openhands_cli/tui/tui.py +100 -0
- openhands_cli/tui/utils.py +14 -0
- openhands_cli/user_actions/__init__.py +17 -0
- openhands_cli/user_actions/agent_action.py +95 -0
- openhands_cli/user_actions/exit_session.py +18 -0
- openhands_cli/user_actions/settings_action.py +171 -0
- openhands_cli/user_actions/types.py +18 -0
- openhands_cli/user_actions/utils.py +199 -0
- openhands/__init__.py +0 -1
- openhands/sdk/__init__.py +0 -45
- openhands/sdk/agent/__init__.py +0 -8
- openhands/sdk/agent/agent/__init__.py +0 -6
- openhands/sdk/agent/agent/agent.py +0 -349
- openhands/sdk/agent/base.py +0 -103
- openhands/sdk/context/__init__.py +0 -28
- openhands/sdk/context/agent_context.py +0 -153
- openhands/sdk/context/condenser/__init__.py +0 -5
- openhands/sdk/context/condenser/condenser.py +0 -73
- openhands/sdk/context/condenser/no_op_condenser.py +0 -13
- openhands/sdk/context/manager.py +0 -5
- openhands/sdk/context/microagents/__init__.py +0 -26
- openhands/sdk/context/microagents/exceptions.py +0 -11
- openhands/sdk/context/microagents/microagent.py +0 -345
- openhands/sdk/context/microagents/types.py +0 -70
- openhands/sdk/context/utils/__init__.py +0 -8
- openhands/sdk/context/utils/prompt.py +0 -52
- openhands/sdk/context/view.py +0 -116
- openhands/sdk/conversation/__init__.py +0 -12
- openhands/sdk/conversation/conversation.py +0 -207
- openhands/sdk/conversation/state.py +0 -50
- openhands/sdk/conversation/types.py +0 -6
- openhands/sdk/conversation/visualizer.py +0 -300
- openhands/sdk/event/__init__.py +0 -27
- openhands/sdk/event/base.py +0 -148
- openhands/sdk/event/condenser.py +0 -49
- openhands/sdk/event/llm_convertible.py +0 -265
- openhands/sdk/event/types.py +0 -5
- openhands/sdk/event/user_action.py +0 -12
- openhands/sdk/event/utils.py +0 -30
- openhands/sdk/llm/__init__.py +0 -19
- openhands/sdk/llm/exceptions.py +0 -108
- openhands/sdk/llm/llm.py +0 -867
- openhands/sdk/llm/llm_registry.py +0 -116
- openhands/sdk/llm/message.py +0 -216
- openhands/sdk/llm/metadata.py +0 -34
- openhands/sdk/llm/utils/fn_call_converter.py +0 -1049
- openhands/sdk/llm/utils/metrics.py +0 -311
- openhands/sdk/llm/utils/model_features.py +0 -153
- openhands/sdk/llm/utils/retry_mixin.py +0 -122
- openhands/sdk/llm/utils/telemetry.py +0 -252
- openhands/sdk/logger.py +0 -167
- openhands/sdk/mcp/__init__.py +0 -20
- openhands/sdk/mcp/client.py +0 -113
- openhands/sdk/mcp/definition.py +0 -69
- openhands/sdk/mcp/tool.py +0 -104
- openhands/sdk/mcp/utils.py +0 -59
- openhands/sdk/tests/llm/test_llm.py +0 -447
- openhands/sdk/tests/llm/test_llm_fncall_converter.py +0 -691
- openhands/sdk/tests/llm/test_model_features.py +0 -221
- openhands/sdk/tool/__init__.py +0 -30
- openhands/sdk/tool/builtins/__init__.py +0 -34
- openhands/sdk/tool/builtins/finish.py +0 -57
- openhands/sdk/tool/builtins/think.py +0 -60
- openhands/sdk/tool/schema.py +0 -236
- openhands/sdk/tool/security_prompt.py +0 -5
- openhands/sdk/tool/tool.py +0 -142
- openhands/sdk/utils/__init__.py +0 -14
- openhands/sdk/utils/discriminated_union.py +0 -210
- openhands/sdk/utils/json.py +0 -48
- openhands/sdk/utils/truncate.py +0 -44
- openhands/tools/__init__.py +0 -44
- openhands/tools/execute_bash/__init__.py +0 -30
- openhands/tools/execute_bash/constants.py +0 -31
- openhands/tools/execute_bash/definition.py +0 -166
- openhands/tools/execute_bash/impl.py +0 -38
- openhands/tools/execute_bash/metadata.py +0 -101
- openhands/tools/execute_bash/terminal/__init__.py +0 -22
- openhands/tools/execute_bash/terminal/factory.py +0 -113
- openhands/tools/execute_bash/terminal/interface.py +0 -189
- openhands/tools/execute_bash/terminal/subprocess_terminal.py +0 -412
- openhands/tools/execute_bash/terminal/terminal_session.py +0 -492
- openhands/tools/execute_bash/terminal/tmux_terminal.py +0 -160
- openhands/tools/execute_bash/utils/command.py +0 -150
- openhands/tools/str_replace_editor/__init__.py +0 -17
- openhands/tools/str_replace_editor/definition.py +0 -158
- openhands/tools/str_replace_editor/editor.py +0 -683
- openhands/tools/str_replace_editor/exceptions.py +0 -41
- openhands/tools/str_replace_editor/impl.py +0 -66
- openhands/tools/str_replace_editor/utils/__init__.py +0 -0
- openhands/tools/str_replace_editor/utils/config.py +0 -2
- openhands/tools/str_replace_editor/utils/constants.py +0 -9
- openhands/tools/str_replace_editor/utils/encoding.py +0 -135
- openhands/tools/str_replace_editor/utils/file_cache.py +0 -154
- openhands/tools/str_replace_editor/utils/history.py +0 -122
- openhands/tools/str_replace_editor/utils/shell.py +0 -72
- openhands/tools/task_tracker/__init__.py +0 -16
- openhands/tools/task_tracker/definition.py +0 -336
- openhands/tools/utils/__init__.py +0 -1
- openhands-0.0.0.dist-info/METADATA +0 -3
- openhands-0.0.0.dist-info/RECORD +0 -94
- openhands-0.0.0.dist-info/top_level.txt +0 -1
|
@@ -1,412 +0,0 @@
|
|
|
1
|
-
"""PTY-based terminal backend implementation (replaces pipe-based subprocess)."""
|
|
2
|
-
|
|
3
|
-
import fcntl
|
|
4
|
-
import os
|
|
5
|
-
import pty
|
|
6
|
-
import re
|
|
7
|
-
import select
|
|
8
|
-
import signal
|
|
9
|
-
import subprocess
|
|
10
|
-
import threading
|
|
11
|
-
import time
|
|
12
|
-
import uuid
|
|
13
|
-
from collections import deque
|
|
14
|
-
from typing import Deque
|
|
15
|
-
|
|
16
|
-
from openhands.sdk.logger import get_logger
|
|
17
|
-
from openhands.tools.execute_bash.constants import (
|
|
18
|
-
CMD_OUTPUT_PS1_BEGIN,
|
|
19
|
-
CMD_OUTPUT_PS1_END,
|
|
20
|
-
HISTORY_LIMIT,
|
|
21
|
-
)
|
|
22
|
-
from openhands.tools.execute_bash.metadata import CmdOutputMetadata
|
|
23
|
-
from openhands.tools.execute_bash.terminal import TerminalInterface
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
logger = get_logger(__name__)
|
|
27
|
-
|
|
28
|
-
ENTER = b"\n"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _normalize_eols(raw: bytes) -> bytes:
|
|
32
|
-
# CRLF/LF/CR -> CR, so each logical line is terminated with \r for the TTY
|
|
33
|
-
raw = raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
|
|
34
|
-
return ENTER.join(raw.split(b"\n"))
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
class SubprocessTerminal(TerminalInterface):
|
|
38
|
-
"""PTY-backed terminal backend.
|
|
39
|
-
|
|
40
|
-
Creates an interactive bash in a pseudoterminal (PTY) so programs behave as if
|
|
41
|
-
attached to a real terminal. Initialization uses a sentinel-based handshake
|
|
42
|
-
and prompt detection instead of blind sleeps.
|
|
43
|
-
"""
|
|
44
|
-
|
|
45
|
-
def __init__(
|
|
46
|
-
self,
|
|
47
|
-
work_dir: str,
|
|
48
|
-
username: str | None = None,
|
|
49
|
-
):
|
|
50
|
-
super().__init__(work_dir, username)
|
|
51
|
-
self.PS1 = CmdOutputMetadata.to_ps1_prompt()
|
|
52
|
-
self.process: subprocess.Popen | None = None
|
|
53
|
-
self._pty_master_fd: int | None = None
|
|
54
|
-
# Use a slightly larger buffer to match tmux behavior which seems to keep
|
|
55
|
-
# ~10,001 lines instead of exactly 10,000
|
|
56
|
-
self.output_buffer: Deque[str] = deque(
|
|
57
|
-
maxlen=HISTORY_LIMIT + 50
|
|
58
|
-
) # Circular buffer
|
|
59
|
-
self.output_lock = threading.Lock()
|
|
60
|
-
self.reader_thread: threading.Thread | None = None
|
|
61
|
-
self._current_command_running = False
|
|
62
|
-
|
|
63
|
-
# ------------------------- Lifecycle -------------------------
|
|
64
|
-
|
|
65
|
-
def initialize(self) -> None:
|
|
66
|
-
"""Initialize the PTY terminal session."""
|
|
67
|
-
if self._initialized:
|
|
68
|
-
return
|
|
69
|
-
|
|
70
|
-
env = os.environ.copy()
|
|
71
|
-
env["PS1"] = self.PS1
|
|
72
|
-
env["PS2"] = ""
|
|
73
|
-
env["TERM"] = "xterm-256color"
|
|
74
|
-
|
|
75
|
-
bash_cmd = ["/bin/bash", "-i"]
|
|
76
|
-
|
|
77
|
-
# Create a PTY; give the slave to the child, keep the master
|
|
78
|
-
master_fd, slave_fd = pty.openpty()
|
|
79
|
-
|
|
80
|
-
logger.debug("Initializing PTY terminal with: %s", " ".join(bash_cmd))
|
|
81
|
-
try:
|
|
82
|
-
self.process = subprocess.Popen(
|
|
83
|
-
bash_cmd,
|
|
84
|
-
stdin=slave_fd,
|
|
85
|
-
stdout=slave_fd,
|
|
86
|
-
stderr=slave_fd,
|
|
87
|
-
cwd=self.work_dir,
|
|
88
|
-
env=env,
|
|
89
|
-
text=False, # bytes I/O
|
|
90
|
-
bufsize=0,
|
|
91
|
-
preexec_fn=os.setsid, # new process group for signal handling
|
|
92
|
-
close_fds=True,
|
|
93
|
-
)
|
|
94
|
-
finally:
|
|
95
|
-
# Parent must close its copy of the slave FD
|
|
96
|
-
try:
|
|
97
|
-
os.close(slave_fd)
|
|
98
|
-
except Exception:
|
|
99
|
-
pass
|
|
100
|
-
|
|
101
|
-
self._pty_master_fd = master_fd
|
|
102
|
-
|
|
103
|
-
# Set master FD non-blocking
|
|
104
|
-
flags = fcntl.fcntl(self._pty_master_fd, fcntl.F_GETFL)
|
|
105
|
-
fcntl.fcntl(self._pty_master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
106
|
-
|
|
107
|
-
# Start output reader thread
|
|
108
|
-
self.reader_thread = threading.Thread(
|
|
109
|
-
target=self._read_output_continuously_pty, daemon=True
|
|
110
|
-
)
|
|
111
|
-
self.reader_thread.start()
|
|
112
|
-
self._initialized = True
|
|
113
|
-
|
|
114
|
-
# ===== Deterministic readiness (no blind sleeps) =====
|
|
115
|
-
# 1) Single atomic init line: clear PROMPT_COMMAND, set PS2/PS1, print sentinel
|
|
116
|
-
sentinel = f"__OH_READY_{uuid.uuid4().hex}__"
|
|
117
|
-
init_cmd = (
|
|
118
|
-
f"export PROMPT_COMMAND='export PS1=\"{self.PS1}\"'; "
|
|
119
|
-
f'export PS2=""; '
|
|
120
|
-
f'printf "{sentinel}"'
|
|
121
|
-
).encode("utf-8", "ignore")
|
|
122
|
-
|
|
123
|
-
self._write_pty(init_cmd + ENTER)
|
|
124
|
-
if not self._wait_for_output(sentinel, timeout=8.0):
|
|
125
|
-
raise RuntimeError("Shell did not become ready (sentinel not seen)")
|
|
126
|
-
|
|
127
|
-
self.clear_screen()
|
|
128
|
-
|
|
129
|
-
# 3) Wait for prompt to actually be visible
|
|
130
|
-
if not self._wait_for_prompt(timeout=5.0):
|
|
131
|
-
raise RuntimeError("Prompt not visible after init")
|
|
132
|
-
|
|
133
|
-
logger.debug("PTY terminal initialized with work dir: %s", self.work_dir)
|
|
134
|
-
|
|
135
|
-
def close(self) -> None:
|
|
136
|
-
"""Clean up the PTY terminal."""
|
|
137
|
-
if self._closed:
|
|
138
|
-
return
|
|
139
|
-
|
|
140
|
-
try:
|
|
141
|
-
if self.process:
|
|
142
|
-
# Try a graceful exit
|
|
143
|
-
try:
|
|
144
|
-
self._write_pty(b"exit\n")
|
|
145
|
-
except Exception:
|
|
146
|
-
pass
|
|
147
|
-
try:
|
|
148
|
-
self.process.wait(timeout=2)
|
|
149
|
-
except subprocess.TimeoutExpired:
|
|
150
|
-
# Escalate
|
|
151
|
-
try:
|
|
152
|
-
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
|
|
153
|
-
self.process.wait(timeout=1)
|
|
154
|
-
except subprocess.TimeoutExpired:
|
|
155
|
-
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
|
|
156
|
-
except Exception as e:
|
|
157
|
-
logger.error(f"Error closing PTY terminal: {e}", exc_info=True)
|
|
158
|
-
finally:
|
|
159
|
-
# Reader thread stop: close master FD; thread exits on read error/EOF
|
|
160
|
-
try:
|
|
161
|
-
if self._pty_master_fd is not None:
|
|
162
|
-
os.close(self._pty_master_fd)
|
|
163
|
-
except Exception:
|
|
164
|
-
pass
|
|
165
|
-
self._pty_master_fd = None
|
|
166
|
-
|
|
167
|
-
if self.reader_thread and self.reader_thread.is_alive():
|
|
168
|
-
self.reader_thread.join(timeout=1)
|
|
169
|
-
|
|
170
|
-
self.process = None
|
|
171
|
-
self._closed = True
|
|
172
|
-
|
|
173
|
-
# ------------------------- I/O Core -------------------------
|
|
174
|
-
|
|
175
|
-
def _write_pty(self, data: bytes) -> None:
|
|
176
|
-
if not self._initialized and self._pty_master_fd is None:
|
|
177
|
-
# allow init path to call before _initialized flips
|
|
178
|
-
raise RuntimeError("PTY master FD not ready")
|
|
179
|
-
if self._pty_master_fd is None:
|
|
180
|
-
raise RuntimeError("PTY terminal is not initialized")
|
|
181
|
-
try:
|
|
182
|
-
logger.debug(f"Wrote to subprocess PTY: {data!r}")
|
|
183
|
-
os.write(self._pty_master_fd, data)
|
|
184
|
-
except Exception as e:
|
|
185
|
-
logger.error(f"Failed to write to PTY: {e}", exc_info=True)
|
|
186
|
-
raise
|
|
187
|
-
|
|
188
|
-
def _read_output_continuously_pty(self) -> None:
|
|
189
|
-
"""Continuously read output from the PTY master in a separate thread."""
|
|
190
|
-
fd = self._pty_master_fd
|
|
191
|
-
if fd is None:
|
|
192
|
-
return
|
|
193
|
-
|
|
194
|
-
try:
|
|
195
|
-
while True:
|
|
196
|
-
# Exit early if process died
|
|
197
|
-
if self.process and self.process.poll() is not None:
|
|
198
|
-
break
|
|
199
|
-
|
|
200
|
-
# Use select to avoid busy spin
|
|
201
|
-
r, _, _ = select.select([fd], [], [], 0.1)
|
|
202
|
-
if not r:
|
|
203
|
-
continue
|
|
204
|
-
|
|
205
|
-
try:
|
|
206
|
-
chunk = os.read(fd, 4096)
|
|
207
|
-
if not chunk:
|
|
208
|
-
break # EOF
|
|
209
|
-
# Normalize newlines; PTY typically uses \n already
|
|
210
|
-
text = chunk.decode("utf-8", errors="replace")
|
|
211
|
-
with self.output_lock:
|
|
212
|
-
# Store one line per buffer item to make deque truncation work
|
|
213
|
-
self._add_text_to_buffer(text)
|
|
214
|
-
except OSError:
|
|
215
|
-
# Would-block or FD closed
|
|
216
|
-
continue
|
|
217
|
-
except Exception as e:
|
|
218
|
-
logger.debug(f"Error reading PTY output: {e}")
|
|
219
|
-
break
|
|
220
|
-
except Exception as e:
|
|
221
|
-
logger.error(f"PTY reader thread error: {e}", exc_info=True)
|
|
222
|
-
|
|
223
|
-
def _add_text_to_buffer(self, text: str) -> None:
|
|
224
|
-
"""Add text to buffer, ensuring one line per buffer item."""
|
|
225
|
-
# If there's a partial line in the last buffer item, combine with new text
|
|
226
|
-
if self.output_buffer and not self.output_buffer[-1].endswith("\n"):
|
|
227
|
-
combined_text = self.output_buffer[-1] + text
|
|
228
|
-
self.output_buffer.pop() # Remove the partial line
|
|
229
|
-
else:
|
|
230
|
-
combined_text = text
|
|
231
|
-
|
|
232
|
-
# Split into lines and add each line as a separate buffer item
|
|
233
|
-
lines = combined_text.split("\n")
|
|
234
|
-
|
|
235
|
-
# Add all complete lines (all but the last, which might be partial)
|
|
236
|
-
for line in lines[:-1]:
|
|
237
|
-
self.output_buffer.append(line + "\n")
|
|
238
|
-
|
|
239
|
-
# Add the last part (might be partial line)
|
|
240
|
-
if lines[-1]: # Only add if not empty
|
|
241
|
-
self.output_buffer.append(lines[-1])
|
|
242
|
-
|
|
243
|
-
# ------------------------- Readiness Helpers -------------------------
|
|
244
|
-
|
|
245
|
-
def _wait_for_output(self, pattern: str | re.Pattern, timeout: float = 5.0) -> bool:
|
|
246
|
-
"""Wait until the output buffer contains pattern (regex or literal)."""
|
|
247
|
-
deadline = time.time() + timeout
|
|
248
|
-
is_regex = hasattr(pattern, "search")
|
|
249
|
-
while time.time() < deadline:
|
|
250
|
-
# quick yield to reader thread
|
|
251
|
-
if self._pty_master_fd is not None:
|
|
252
|
-
select.select([], [], [], 0.02)
|
|
253
|
-
with self.output_lock:
|
|
254
|
-
data = "".join(self.output_buffer)
|
|
255
|
-
if is_regex:
|
|
256
|
-
assert isinstance(pattern, re.Pattern)
|
|
257
|
-
if pattern.search(data):
|
|
258
|
-
return True
|
|
259
|
-
else:
|
|
260
|
-
assert isinstance(pattern, str)
|
|
261
|
-
if pattern in data:
|
|
262
|
-
return True
|
|
263
|
-
return False
|
|
264
|
-
|
|
265
|
-
def _wait_for_prompt(self, timeout: float = 5.0) -> bool:
|
|
266
|
-
"""Wait until the screen ends with our PS1 end marker (prompt visible)."""
|
|
267
|
-
pat = re.compile(re.escape(CMD_OUTPUT_PS1_END.rstrip()) + r"\s*$")
|
|
268
|
-
deadline = time.time() + timeout
|
|
269
|
-
while time.time() < deadline:
|
|
270
|
-
with self.output_lock:
|
|
271
|
-
tail = "".join(self.output_buffer)[-4096:]
|
|
272
|
-
if pat.search(tail):
|
|
273
|
-
return True
|
|
274
|
-
time.sleep(0.05)
|
|
275
|
-
return False
|
|
276
|
-
|
|
277
|
-
# ------------------------- Public API -------------------------
|
|
278
|
-
|
|
279
|
-
def send_keys(self, text: str, enter: bool = True) -> None:
|
|
280
|
-
"""Send keystrokes to the PTY.
|
|
281
|
-
|
|
282
|
-
Supports:
|
|
283
|
-
- Plain text
|
|
284
|
-
- Ctrl sequences: 'C-a'..'C-z' (Ctrl+C sends ^C byte)
|
|
285
|
-
- Special names: 'ENTER','TAB','BS','ESC','UP','DOWN','LEFT','RIGHT',
|
|
286
|
-
'HOME','END','PGUP','PGDN','C-L','C-D'
|
|
287
|
-
"""
|
|
288
|
-
if not self._initialized:
|
|
289
|
-
raise RuntimeError("PTY terminal is not initialized")
|
|
290
|
-
|
|
291
|
-
specials = {
|
|
292
|
-
"ENTER": ENTER,
|
|
293
|
-
"TAB": b"\t",
|
|
294
|
-
"BS": b"\x7f", # Backspace (DEL)
|
|
295
|
-
"ESC": b"\x1b",
|
|
296
|
-
"UP": b"\x1b[A",
|
|
297
|
-
"DOWN": b"\x1b[B",
|
|
298
|
-
"RIGHT": b"\x1b[C",
|
|
299
|
-
"LEFT": b"\x1b[D",
|
|
300
|
-
"HOME": b"\x1b[H",
|
|
301
|
-
"END": b"\x1b[F",
|
|
302
|
-
"PGUP": b"\x1b[5~",
|
|
303
|
-
"PGDN": b"\x1b[6~",
|
|
304
|
-
"C-L": b"\x0c", # Ctrl+L
|
|
305
|
-
"C-D": b"\x04", # Ctrl+D (EOF)
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
upper = text.upper().strip()
|
|
309
|
-
payload: bytes | None = None
|
|
310
|
-
|
|
311
|
-
# Named specials
|
|
312
|
-
if upper in specials:
|
|
313
|
-
payload = specials[upper]
|
|
314
|
-
# Do NOT auto-append another EOL; special already includes it when needed.
|
|
315
|
-
append_eol = False
|
|
316
|
-
# Generic Ctrl-<letter>, including C-C (preferred over sending SIGINT directly)
|
|
317
|
-
elif upper.startswith(("C-", "CTRL-", "CTRL+")):
|
|
318
|
-
# last char after dash/plus is the key
|
|
319
|
-
key = upper.split("-", 1)[-1].split("+", 1)[-1]
|
|
320
|
-
if len(key) == 1 and "A" <= key <= "Z":
|
|
321
|
-
payload = bytes([ord(key) & 0x1F])
|
|
322
|
-
else:
|
|
323
|
-
# Unknown form; fall back to raw text
|
|
324
|
-
payload = text.encode("utf-8", "ignore")
|
|
325
|
-
append_eol = False # ctrl combos are “instant”
|
|
326
|
-
else:
|
|
327
|
-
raw = text.encode("utf-8", "ignore")
|
|
328
|
-
payload = _normalize_eols(raw) if enter else raw
|
|
329
|
-
append_eol = enter and not payload.endswith(ENTER)
|
|
330
|
-
|
|
331
|
-
if append_eol:
|
|
332
|
-
payload += ENTER
|
|
333
|
-
|
|
334
|
-
self._write_pty(payload)
|
|
335
|
-
self._current_command_running = self._current_command_running or (
|
|
336
|
-
append_eol or payload.endswith(ENTER)
|
|
337
|
-
)
|
|
338
|
-
|
|
339
|
-
def read_screen(self) -> str:
|
|
340
|
-
"""Read the current terminal screen content.
|
|
341
|
-
|
|
342
|
-
The content we return should NOT contains carriage returns (CR, \r).
|
|
343
|
-
"""
|
|
344
|
-
if not self._initialized:
|
|
345
|
-
raise RuntimeError("PTY terminal is not initialized")
|
|
346
|
-
|
|
347
|
-
# Give the reader thread a moment to capture any pending output
|
|
348
|
-
# This is especially important after sending a command
|
|
349
|
-
time.sleep(0.01)
|
|
350
|
-
|
|
351
|
-
with self.output_lock:
|
|
352
|
-
content = "".join(self.output_buffer)
|
|
353
|
-
lines = content.split("\n")
|
|
354
|
-
content = "\n".join(lines).replace("\r", "")
|
|
355
|
-
logger.debug(f"Read from subprocess PTY: {content!r}")
|
|
356
|
-
return content
|
|
357
|
-
|
|
358
|
-
def clear_screen(self) -> None:
|
|
359
|
-
"""Drop buffered output up to the most recent PS1 block; do not emit ^L."""
|
|
360
|
-
if not self._initialized:
|
|
361
|
-
return
|
|
362
|
-
|
|
363
|
-
need_prompt_nudge = False
|
|
364
|
-
with self.output_lock:
|
|
365
|
-
if not self.output_buffer:
|
|
366
|
-
need_prompt_nudge = True
|
|
367
|
-
else:
|
|
368
|
-
data = "".join(self.output_buffer)
|
|
369
|
-
start_idx = data.rfind(CMD_OUTPUT_PS1_BEGIN)
|
|
370
|
-
end_idx = data.rfind(CMD_OUTPUT_PS1_END)
|
|
371
|
-
if start_idx != -1 and end_idx != -1 and end_idx >= start_idx:
|
|
372
|
-
tail = data[start_idx:]
|
|
373
|
-
self.output_buffer.clear()
|
|
374
|
-
self.output_buffer.append(tail)
|
|
375
|
-
else:
|
|
376
|
-
self.output_buffer.clear()
|
|
377
|
-
need_prompt_nudge = True
|
|
378
|
-
|
|
379
|
-
if need_prompt_nudge:
|
|
380
|
-
try:
|
|
381
|
-
self._write_pty(ENTER) # ask bash to render a prompt, no screen clear
|
|
382
|
-
except Exception:
|
|
383
|
-
pass
|
|
384
|
-
|
|
385
|
-
def interrupt(self) -> bool:
|
|
386
|
-
"""Send SIGINT to the PTY process group (fallback to signal-based interrupt)."""
|
|
387
|
-
if not self._initialized or not self.process:
|
|
388
|
-
return False
|
|
389
|
-
|
|
390
|
-
try:
|
|
391
|
-
os.killpg(os.getpgid(self.process.pid), signal.SIGINT)
|
|
392
|
-
self._current_command_running = False
|
|
393
|
-
return True
|
|
394
|
-
except Exception as e:
|
|
395
|
-
logger.error(f"Failed to interrupt subprocess: {e}", exc_info=True)
|
|
396
|
-
return False
|
|
397
|
-
|
|
398
|
-
def is_running(self) -> bool:
|
|
399
|
-
"""Heuristic: command running if not at PS1 prompt and process alive."""
|
|
400
|
-
if not self._initialized or not self.process:
|
|
401
|
-
return False
|
|
402
|
-
|
|
403
|
-
# Check if process is still alive
|
|
404
|
-
if self.process.poll() is not None:
|
|
405
|
-
return False
|
|
406
|
-
|
|
407
|
-
try:
|
|
408
|
-
content = self.read_screen()
|
|
409
|
-
# If screen ends with prompt, no command is running
|
|
410
|
-
return not content.rstrip().endswith(CMD_OUTPUT_PS1_END.rstrip())
|
|
411
|
-
except Exception:
|
|
412
|
-
return self._current_command_running
|