Skip to content

Commit 2369c9f

Browse files
Merge pull request #8753 from sagemathinc/terminal-cpr
frontend/terminal: fix CPR (Cursor Position Request) support
2 parents 1b6be59 + f31aeae commit 2369c9f

File tree

2 files changed

+180
-10
lines changed

2 files changed

+180
-10
lines changed

src/packages/frontend/frame-editors/terminal-editor/connected-terminal.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,34 +11,36 @@ extra support for being connected to:
1111
- frame-editor (via actions)
1212
*/
1313

14-
import { callback, delay } from "awaiting";
15-
import { Map } from "immutable";
16-
import { debounce } from "lodash";
17-
import { Terminal as XTerminal } from "@xterm/xterm";
14+
import "@xterm/xterm/css/xterm.css";
15+
1816
import { FitAddon } from "@xterm/addon-fit";
1917
import { WebLinksAddon } from "@xterm/addon-web-links";
2018
import { WebglAddon } from "@xterm/addon-webgl";
21-
import "@xterm/xterm/css/xterm.css";
22-
import { webapp_client } from "@cocalc/frontend/webapp-client";
19+
import { Terminal as XTerminal } from "@xterm/xterm";
20+
import { callback, delay } from "awaiting";
21+
import { Map } from "immutable";
22+
import { debounce } from "lodash";
23+
2324
import { ProjectActions, redux } from "@cocalc/frontend/app-framework";
25+
import { modalParams } from "@cocalc/frontend/compute/select-server-for-file";
2426
import { get_buffer, set_buffer } from "@cocalc/frontend/copy-paste-buffer";
2527
import { file_associations } from "@cocalc/frontend/file-associations";
2628
import { isCoCalcURL } from "@cocalc/frontend/lib/cocalc-urls";
29+
import { webapp_client } from "@cocalc/frontend/webapp-client";
2730
import {
2831
close,
2932
endswith,
3033
filename_extension,
3134
replace_all,
3235
} from "@cocalc/util/misc";
36+
import { termPath } from "@cocalc/util/terminal/names";
3337
import { Actions, CodeEditorState } from "../code-editor/actions";
3438
import { ConnectionStatus } from "../frame-tree/types";
3539
import { touch, touch_project } from "../generic/client";
40+
import { ConatTerminal } from "./conat-terminal";
3641
import { ConnectedTerminalInterface } from "./connected-terminal-interface";
3742
import { open_init_file } from "./init-file";
3843
import { setTheme } from "./themes";
39-
import { modalParams } from "@cocalc/frontend/compute/select-server-for-file";
40-
import { ConatTerminal } from "./conat-terminal";
41-
import { termPath } from "@cocalc/util/terminal/names";
4244

4345
declare const $: any;
4446

@@ -776,8 +778,17 @@ export class Terminal<T extends CodeEditorState = CodeEditorState> {
776778
this.conn_write(key);
777779
}
778780
});
781+
// CPR (Cursor Position Request): when the PTY sends ESC[6n, xterm.js responds
782+
// with ESC[row;colR via onData. We must forward this even during rendering
783+
// (ignoreData > 0), but only for live sessions — not during history replay
784+
// (ignore_terminal_data = true), which would send phantom replies.
785+
// Test: run scripts/cpr_test.py in a CoCalc terminal for a visual demo.
786+
const CPR_RESPONSE_RE = /^\x1b\[\d+;\d+R$/;
779787
this.terminal.onData((data) => {
780-
if (!this.ignoreData) {
788+
if (
789+
!this.ignoreData ||
790+
(!this.ignore_terminal_data && CPR_RESPONSE_RE.test(data))
791+
) {
781792
this.conn_write(data);
782793
}
783794
});

src/scripts/cpr_test.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import os
2+
import random
3+
import re
4+
import select
5+
import sys
6+
import termios
7+
import time
8+
import tty
9+
10+
11+
def read_cpr(fd: int, timeout=1.0):
12+
os.write(sys.stdout.fileno(), b"\x1b[6n")
13+
sys.stdout.flush()
14+
buf = b""
15+
end = time.time() + timeout
16+
while time.time() < end:
17+
r, _, _ = select.select([fd], [], [], 0.05)
18+
if not r:
19+
continue
20+
buf += os.read(fd, 64)
21+
m = re.search(rb"\x1b\[(\d+);(\d+)R", buf)
22+
if m:
23+
return int(m.group(1)), int(m.group(2))
24+
return None
25+
26+
27+
def mv(r, c):
28+
sys.stdout.write(f"\x1b[{r};{c}H")
29+
30+
31+
def put(r, c, s):
32+
mv(r, c)
33+
sys.stdout.write(s)
34+
35+
36+
def clamp(v, lo, hi):
37+
return max(lo, min(hi, v))
38+
39+
40+
def terminal_size():
41+
try:
42+
size = os.get_terminal_size(sys.stdin.fileno())
43+
return size.lines, size.columns
44+
except OSError:
45+
return 24, 80
46+
47+
48+
def main():
49+
if not sys.stdin.isatty() or not sys.stdout.isatty():
50+
print("ERROR: CPRs aren't supported here (stdin/stdout is not a TTY).")
51+
return 1
52+
53+
fd = sys.stdin.fileno()
54+
old = termios.tcgetattr(fd)
55+
56+
tty.setcbreak(fd)
57+
h, w = 20, 40
58+
59+
try:
60+
first_cpr = read_cpr(fd)
61+
if first_cpr is None:
62+
print("ERROR: CPRs aren't supported by this terminal.")
63+
return 1
64+
initial_row, initial_col = first_cpr
65+
66+
# Reserve room below existing output so animation stays "after" prior lines.
67+
reserve_rows = h + 4
68+
sys.stdout.write("\n" * reserve_rows)
69+
sys.stdout.flush()
70+
71+
# Anchor the drawing region inside the newly created area.
72+
second_cpr = read_cpr(fd)
73+
if second_cpr is None:
74+
print("ERROR: CPRs aren't supported by this terminal.")
75+
return 1
76+
draw_row, _ = second_cpr
77+
78+
rows, cols = terminal_size()
79+
max_row = max(1, rows - (h + 3))
80+
max_col = max(1, cols - (w + 2))
81+
r0 = clamp(draw_row - (h + 3), 1, max_row)
82+
c0 = clamp(1, 1, max_col)
83+
84+
sys.stdout.write("\x1b[?25l") # hide cursor
85+
86+
# Double-walled box border
87+
put(r0, c0, "╔" + "═" * w + "╗")
88+
put(r0 + h + 1, c0, "╚" + "═" * w + "╝")
89+
for i in range(1, h + 1):
90+
put(r0 + i, c0, "║")
91+
put(r0 + i, c0 + w + 1, "║")
92+
93+
# Random starting point and direction inside the grid.
94+
x = random.randint(1, w)
95+
y = random.randint(1, h)
96+
dx = random.choice([-1, 1])
97+
dy = random.choice([-1, 1])
98+
99+
# Trail grows from tiny dot -> rings -> filled circle -> ball.
100+
# All share the ball's cycling color so they're clearly visible.
101+
TRAIL_CHARS = ["·", "∙", "∘", "○", "◎", "◉"] # oldest -> newest
102+
BALL = "●"
103+
TRAIL_LEN = len(TRAIL_CHARS)
104+
trail = [] # list of (row, col), oldest first
105+
106+
for t in range(300):
107+
# Erase the position falling off the back of the trail
108+
if len(trail) == TRAIL_LEN:
109+
er, ec = trail[0]
110+
put(r0 + er, c0 + ec, " ")
111+
trail.pop(0)
112+
color = 31 + (t % 6)
113+
# Redraw trail: '.' far back, 'o' close to ball, all in ball's color
114+
for i, (tr, tc) in enumerate(trail):
115+
put(r0 + tr, c0 + tc, f"\x1b[{color}m{TRAIL_CHARS[i]}\x1b[0m")
116+
put(r0 + y, c0 + x, f"\x1b[{color}m{BALL}\x1b[0m")
117+
put(
118+
r0 + h + 3,
119+
c0,
120+
f"grid={h}x{w} initial=({initial_row},{initial_col}) anchor=({r0},{c0}) ball=({y},{x}) vel=({dy:+d},{dx:+d})",
121+
)
122+
sys.stdout.flush()
123+
time.sleep(0.03)
124+
125+
trail.append((y, x))
126+
bounced = False
127+
128+
if x + dx < 1 or x + dx > w:
129+
dx *= -1
130+
bounced = True
131+
# Jitter: occasionally perturb the orthogonal axis on wall hit.
132+
if random.random() < 0.35:
133+
dy *= -1
134+
135+
if y + dy < 1 or y + dy > h:
136+
dy *= -1
137+
bounced = True
138+
# Jitter: occasionally perturb the orthogonal axis on wall hit.
139+
if random.random() < 0.35:
140+
dx *= -1
141+
142+
x += dx
143+
y += dy
144+
145+
if bounced and random.random() < 0.4:
146+
# Small random post-bounce offset for less deterministic paths.
147+
x += random.choice([-1, 0, 1])
148+
y += random.choice([-1, 0, 1])
149+
x = clamp(x, 1, w)
150+
y = clamp(y, 1, h)
151+
return 0
152+
finally:
153+
sys.stdout.write("\x1b[0m\x1b[?25h\n") # reset + show cursor
154+
sys.stdout.flush()
155+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
156+
157+
158+
if __name__ == "__main__":
159+
raise SystemExit(main())

0 commit comments

Comments
 (0)