PTY input handling gaps: cursor shape (DECSCUSR), Ctrl+V forwarding, and mouse scroll
Chưa có ai nhận issue này.
Đánh giá
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức phù hợp với người mới
- 58/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức độ hoạt động
- Ít trao đổi
- Công nghệ
- typescript, wasm
- Lĩnh vực
- frontend
Hướng nghiên cứu
Bắt đầu với ba điểm vào được nêu tên: lib/ghostty.ts, lib/input-handler.ts và src/Terminal.ts, sử dụng các ADRs 013, 014 và 017 để làm ngữ cảnh. Xác nhận từng hành vi tại ranh giới PTY: cập nhật hình dạng con trỏ, chuyển tiếp Ctrl+V và các sự kiện con lăn SGR trong khi theo dõi chuột; hoàn tất nghĩa là cả ba bản sửa đều hoạt động mà không làm hồi quy việc dán văn bản hoặc thao tác cuộn viewport thông thường.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
We hit three input handling gaps while building webtty — a browser terminal that uses ghostty-web as its renderer. All three are places where ghostty-web behaves differently from xterm.js, and all three have clean fixes in the JS layer.
Bug 1: DECSCUSR cursor shape sequences are silently dropped
Symptom: Applications like vim, neovim, and fish emit DECSCUSR (ESC [ Ps SP q) to switch cursor shape at runtime (bar in insert mode, block in normal mode). With ghostty-web, the cursor shape never changes — it stays fixed at whatever was set at construction time.
Root cause: GhosttyTerminal.getCursor() in lib/ghostty.ts hardcodes style: 'block' with a TODO comment instead of reading cursor_visual_style from the WASM render state. The WASM binary processes DECSCUSR correctly and updates RenderState.Cursor.visual_style — the JS wrapper just never reads it back.
Workaround we ship in webtty: We intercept DECSCUSR sequences client-side before term.write() and set term.options.cursorStyle directly. It works, but it is the wrong layer.
Proposed fix: In getCursor(), read cursor_visual_style (data key 10) via ghostty_render_state_get and map it to the renderer style strings. The render loop already calls getCursor() after each write() and passes the result to renderer.setCursorStyle() — so this one change is sufficient.
// lib/ghostty.ts — GhosttyTerminal.getCursor()
// data key 10 = cursor_visual_style (0=block, 1=underline, 2=bar)
const visualStyle = this.exports.ghostty_render_state_get(this.handle, 10);
const style = visualStyle === 2 ? 'bar' : visualStyle === 1 ? 'underline' : 'block';
return { x: ..., y: ..., visible: ..., blinking: ..., style };
Full analysis and context: ADR 013 — Fix to ghostty-web
Bug 2: Ctrl+V keydown is not forwarded to the PTY
Symptom: Pasting non-text content (e.g. an image) into a TUI application running under ghostty-web does nothing. The same setup works correctly under ttyd (xterm.js). Specifically, opencode's image paste flow — which relies on receiving \x16 in the PTY stream and then reading the clipboard natively via osascript / powershell / wl-paste — never triggers.
Root cause: InputHandler.handleKeyDown in lib/input-handler.ts returns early on Ctrl+V / Cmd+V without emitting \x16 to onDataCallback:
if ((A.ctrlKey || A.metaKey) && A.code === "KeyV")
return; // \x16 never reaches the PTY
xterm.js forwards the keydown unconditionally; ghostty-web does not. For text paste this is invisible (the paste event fires and handlePaste covers it), but when the clipboard has no text/plain, handlePaste drops the event silently and the PTY sees nothing.
Workaround we ship in webtty: We add a capture-phase paste listener that sends \x16 when clipboardData has no text/plain. It works, but intercepting the paste event to infer a missed keydown is a hack.
Proposed fix: Emit the encoded keydown before the early return, so \x16 reaches the PTY. The paste event still fires afterwards, so text paste via handlePaste is completely unaffected.
// lib/input-handler.ts — InputHandler.handleKeyDown
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyV') {
const encoded = this.encoder.encode({ key: Key.V, mods: Mods.CTRL, action: KeyAction.PRESS });
if (encoded.length > 0) {
this.onDataCallback(new TextDecoder().decode(encoded)); // \x16 → PTY
}
return; // browser paste event fires next → handlePaste covers text
}
Full analysis and context: ADR 014 — Fix to ghostty-web
Bug 3: Mouse wheel sends arrow keys instead of SGR scroll sequences when mouse tracking is active
Symptom: When a TUI app (e.g. vim with set mouse=a) enables mouse tracking, scrolling the mouse wheel moves the cursor up/down instead of scrolling the buffer. The same vim in xterm.js-based terminals (VSCode, iTerm2) scrolls correctly.
Root cause: Terminal.handleWheel in src/Terminal.ts is registered on the canvas with capture: true and calls stopPropagation() unconditionally — which prevents InputHandler.handleWheel (the handler that correctly checks hasMouseTracking() and sends SGR sequences) from ever running. It then sends arrow keys regardless of whether the app has requested mouse events:
// src/Terminal.ts — Terminal.handleWheel (current, broken)
this.handleWheel = (e: WheelEvent) => {
e.preventDefault();
e.stopPropagation(); // ← blocks InputHandler
if (this.customWheelEventHandler?.(e)) return;
if (this.wasmTerm?.isAlternateScreen()) {
const dir = e.deltaY > 0 ? 'down' : 'up';
const lines = Math.min(Math.abs(Math.round(e.deltaY / 33)), 5);
for (let i = 0; i < lines; i++)
this.dataEmitter.fire(dir === 'up' ? '\x1B[A' : '\x1B[B'); // ← always, ignores mouse tracking
} else {
// scroll viewport
}
};
xterm.js checks ctx.requestedEvents.wheel before falling back to arrow keys — ghostty-web skips this check entirely.
Workaround we ship in webtty: We use attachCustomWheelEventHandler to intercept wheel events when hasMouseTracking() is true and send SGR scroll sequences (\x1b[<64;col;rowM / \x1b[<65;col;rowM) directly to the PTY, returning true to prevent the arrow-key path from running.
Proposed fix: In the isAlternateScreen() branch, guard the arrow-key loop with hasMouseTracking(). When mouse tracking is active, emit the SGR scroll sequence via dataEmitter instead. The canvas and renderer are already available on this:
// src/Terminal.ts — Terminal.handleWheel (fixed)
if (this.wasmTerm?.isAlternateScreen()) {
if (this.wasmTerm.hasMouseTracking()) {
// App negotiated mouse tracking — send SGR scroll sequence, not arrow keys.
const metrics = this.renderer?.getMetrics();
if (metrics && this.canvas) {
const rect = this.canvas.getBoundingClientRect();
const col = Math.max(1, Math.floor((e.clientX - rect.left) / metrics.width) + 1);
const row = Math.max(1, Math.floor((e.clientY - rect.top) / metrics.height) + 1);
const btn = e.deltaY < 0 ? 64 : 65;
this.dataEmitter.fire(`\x1b[<${btn};${col};${row}M`);
}
return;
}
// No mouse tracking: arrow-key fallback for apps like `less`.
const dir = e.deltaY > 0 ? 'down' : 'up';
const lines = Math.min(Math.abs(Math.round(e.deltaY / 33)), 5);
for (let i = 0; i < lines; i++)
this.dataEmitter.fire(dir === 'up' ? '\x1B[A' : '\x1B[B');
} else {
// scroll viewport (unchanged)
}
Full analysis and context: ADR 017 — Fix to ghostty-web
Contribution
All three fixes are small and self-contained. We are happy to submit PRs for any or all of them if the approach looks right to you — just say the word.
- Ngôn ngữ chính
- TypeScript
- Star
- 2.9k
- Fork
- 179
- Chỉ số merge pull request
- Không có pull request nào được merge trong 30 ngày
Chuẩn bị môi trường
Dự án này không cung cấp dev container, Dockerfile hay hướng dẫn đóng góp, nên bạn cần tự thiết lập môi trường: hãy bắt đầu từ README và xem hướng dẫn đóng góp lần đầu của chúng tôi để biết các bước chung.
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của coder/ghostty-web
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
coder/ghostty-web#199 ·
-
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 72/100
coder/ghostty-web#198 ·
-
attachCustomKeyEventHandler inverts xterm.js's return-value contract (silently swallows all input)Đang mở
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 52/100
coder/ghostty-web#192 ·
-
A single throw inside render() permanently stops the render loop, and a consumer cannot restart itĐang mở
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 65/100
coder/ghostty-web#189 ·
-
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 70/100
coder/ghostty-web#188 ·
Tất cả issue của coder/ghostty-web
Issue tương tự
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
Maintainer thường phản hồi trong vòng 1 ngày
-
automated issue report
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 68/100
-
documentation
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 92/100
github/copilot-sdk#2804 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 62/100
drizzle-team/drizzle-orm#6418 ·
Maintainer thường phản hồi trong vòng 4 ngày
-
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 92/100
diegosouzapw/OmniRoute#15307 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 2 ngày