Skip to content

Commit 5d94f14

Browse files
committed
fix: convert \n to \r\n in output and use stty to fix PTY dimensions on connect
1 parent ec8286b commit 5d94f14

1 file changed

Lines changed: 29 additions & 39 deletions

File tree

centml/cli/shell.py

Lines changed: 29 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -73,26 +73,49 @@ def _resolve_pod(cclient, deployment_id, pod_name=None):
7373
return running_pods[0]
7474

7575

76-
async def _forward_io(ws):
76+
async def _forward_io(ws, fix_dimensions=False):
7777
"""Bidirectional forwarding between local stdin/stdout and WebSocket.
7878
79+
Args:
80+
ws: WebSocket connection.
81+
fix_dimensions: When True, send ``stty rows R cols C`` through stdin
82+
on the first output message to fix the remote PTY dimensions,
83+
then clear the screen so the prompt redraws at the correct width.
84+
7985
Returns the remote exit code.
8086
"""
8187
loop = asyncio.get_running_loop()
8288
exit_code = 0
8389
stdin_fd = sys.stdin.fileno()
84-
8590
stdin_closed = asyncio.Event()
91+
dimensions_fixed = not fix_dimensions
8692

8793
async def _read_ws():
88-
nonlocal exit_code
94+
nonlocal exit_code, dimensions_fixed
8995
async for raw_msg in ws:
9096
msg = json.loads(raw_msg)
9197
if msg.get("data"):
92-
sys.stdout.buffer.write(msg["data"].encode("utf-8", errors="replace"))
98+
if not dimensions_fixed:
99+
dimensions_fixed = True
100+
r, c = shutil.get_terminal_size()
101+
# Set PTY size from inside the shell and clear screen.
102+
# Leading space keeps the command out of bash history.
103+
await ws.send(
104+
json.dumps(
105+
{
106+
"operation": "stdin",
107+
"data": f" stty rows {r} cols {c} 2>/dev/null; clear\n",
108+
}
109+
)
110+
)
111+
# Convert bare \n to \r\n (equivalent to xterm.js convertEol).
112+
# The remote PTY may send lone \n; without this the cursor
113+
# moves down without returning to column 0.
114+
text = msg["data"].replace("\n", "\r\n")
115+
sys.stdout.buffer.write(text.encode("utf-8", errors="replace"))
93116
sys.stdout.buffer.flush()
94117
elif msg.get("error"):
95-
sys.stderr.write(f"Error: {msg['error']}\n")
118+
sys.stderr.write(f"Error: {msg['error']}\r\n")
96119
sys.stderr.flush()
97120
if "Code" in msg:
98121
exit_code = msg["Code"]
@@ -154,14 +177,6 @@ async def _interactive_session(ws_url, token):
154177
old_settings = termios.tcgetattr(fd)
155178
try:
156179
tty.setraw(fd)
157-
# Re-enable output post-processing so the local terminal converts
158-
# bare \n to \r\n. The remote PTY may send lone \n (Kubernetes
159-
# exec PTY behavior). xterm.js handles this with convertEol;
160-
# for a raw CLI terminal we need OPOST. Extra \r before \n from
161-
# a remote that already sends \r\n is harmless.
162-
attrs = termios.tcgetattr(fd)
163-
attrs[1] |= termios.OPOST
164-
termios.tcsetattr(fd, termios.TCSANOW, attrs)
165180
rows, cols = shutil.get_terminal_size()
166181

167182
headers = {"Authorization": f"Bearer {token}"}
@@ -180,35 +195,10 @@ def _send_resize():
180195
ws.send(json.dumps({"operation": "resize", "rows": r, "cols": c}))
181196
)
182197

183-
async def _force_initial_redraw():
184-
"""Toggle PTY width to force SIGWINCH, then Ctrl+L to redraw.
185-
186-
The initial resize may arrive before the remote shell
187-
starts, so the shell never sees a SIGWINCH. Toggling
188-
cols by +1/-1 guarantees a size change and SIGWINCH.
189-
Ctrl+L then clears the screen so the prompt redraws at
190-
the correct width.
191-
"""
192-
try:
193-
await asyncio.sleep(0.5)
194-
r, c = shutil.get_terminal_size()
195-
await ws.send(
196-
json.dumps({"operation": "resize", "rows": r, "cols": c + 1})
197-
)
198-
await asyncio.sleep(0.05)
199-
await ws.send(
200-
json.dumps({"operation": "resize", "rows": r, "cols": c})
201-
)
202-
await asyncio.sleep(0.1)
203-
await ws.send(json.dumps({"operation": "stdin", "data": "\x0c"}))
204-
except Exception:
205-
pass
206-
207-
asyncio.create_task(_force_initial_redraw())
208198
loop.add_signal_handler(signal.SIGWINCH, _send_resize)
209199

210200
try:
211-
exit_code = await _forward_io(ws)
201+
exit_code = await _forward_io(ws, fix_dimensions=True)
212202
finally:
213203
loop.remove_signal_handler(signal.SIGWINCH)
214204

0 commit comments

Comments
 (0)