diff --git a/lib/ghostty.ts b/lib/ghostty.ts index 69d2426..6ee32bc 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -609,6 +609,40 @@ export class GhosttyTerminal { return null; // TODO: Add hyperlink support } + /** + * Check if there are pending responses from the terminal. + * Responses are generated by escape sequences like DSR (Device Status Report). + */ + hasResponse(): boolean { + return this.exports.ghostty_terminal_has_response(this.handle); + } + + /** + * Read pending responses from the terminal. + * Returns the response string, or null if no responses pending. + * + * Responses are generated by escape sequences that require replies: + * - DSR 6 (cursor position): Returns \x1b[row;colR + * - DSR 5 (operating status): Returns \x1b[0n + */ + readResponse(): string | null { + if (!this.hasResponse()) return null; + + const bufSize = 256; // Most responses are small + const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufSize); + + try { + const bytesRead = this.exports.ghostty_terminal_read_response(this.handle, bufPtr, bufSize); + + if (bytesRead <= 0) return null; + + const bytes = new Uint8Array(this.memory.buffer, bufPtr, bytesRead); + return new TextDecoder().decode(bytes.slice()); + } finally { + this.exports.ghostty_wasm_free_u8_array(bufPtr, bufSize); + } + } + /** * Query arbitrary terminal mode by number * @param mode Mode number (e.g., 25 for cursor visibility, 2004 for bracketed paste) diff --git a/lib/terminal.ts b/lib/terminal.ts index 0322840..2d365b6 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -502,6 +502,10 @@ export class Terminal implements ITerminalCore { // Write directly to WASM terminal (handles VT parsing internally) this.wasmTerm!.write(data); + // Process any responses generated by the terminal (e.g., DSR cursor position) + // These need to be sent back to the PTY via onData + this.processTerminalResponses(); + // Check for bell character (BEL, \x07) // WASM doesn't expose bell events, so we detect it in the data stream if (typeof data === 'string' && data.includes('\x07')) { @@ -1684,6 +1688,29 @@ export class Terminal implements ITerminalCore { animate(); } + /** + * Process any pending terminal responses and emit them via onData. + * + * This handles escape sequences that require the terminal to send a response + * back to the PTY, such as: + * - DSR 6 (cursor position): Shell sends \x1b[6n, terminal responds with \x1b[row;colR + * - DSR 5 (operating status): Shell sends \x1b[5n, terminal responds with \x1b[0n + * + * Without this, shells like nushell that rely on cursor position queries + * will hang waiting for a response that never comes. + */ + private processTerminalResponses(): void { + if (!this.wasmTerm) return; + + // Read any pending responses from the WASM terminal + const response = this.wasmTerm.readResponse(); + if (response) { + // Send response back to the PTY via onData + // This is the same path as user keyboard input + this.dataEmitter.fire(response); + } + } + /** * Check for title changes in written data (OSC sequences) * Simplified implementation - looks for OSC 0, 1, 2 diff --git a/lib/types.ts b/lib/types.ts index c505277..facced2 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -380,6 +380,10 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { bufLen: number ): number; // Returns cells written or -1 on error ghostty_terminal_is_row_wrapped(terminal: TerminalHandle, row: number): number; + + // Response API (for DSR and other terminal queries) + ghostty_terminal_has_response(terminal: TerminalHandle): boolean; + ghostty_terminal_read_response(terminal: TerminalHandle, bufPtr: number, bufLen: number): number; // Returns bytes written, 0 if no response, -1 on error } // ============================================================================ diff --git a/patches/ghostty-wasm-api.patch b/patches/ghostty-wasm-api.patch index 0c02270..f764358 100644 --- a/patches/ghostty-wasm-api.patch +++ b/patches/ghostty-wasm-api.patch @@ -32,7 +32,7 @@ new file mode 100644 index 000000000..e371164b6 --- /dev/null +++ b/include/ghostty/vt/terminal.h -@@ -0,0 +1,192 @@ +@@ -0,0 +1,214 @@ +/** + * @file terminal.h + * @@ -220,6 +220,28 @@ index 000000000..e371164b6 +/** Check if a row is a continuation from previous row (soft-wrapped) */ +bool ghostty_terminal_is_row_wrapped(GhosttyTerminal term, int y); + ++/* ============================================================================ ++ * Response API - for DSR and other terminal queries ++ * ========================================================================= */ ++ ++/** ++ * Check if there are pending responses from the terminal. ++ * Responses are generated by escape sequences like DSR (Device Status Report). ++ */ ++bool ghostty_terminal_has_response(GhosttyTerminal term); ++ ++/** ++ * Read pending responses from the terminal. ++ * @param out_buffer Buffer to write response bytes to ++ * @param buffer_size Size of buffer in bytes ++ * @return Number of bytes written, 0 if no responses pending, -1 on error ++ */ ++int ghostty_terminal_read_response( ++ GhosttyTerminal term, ++ uint8_t* out_buffer, ++ size_t buffer_size ++); ++ +#ifdef __cplusplus +} +#endif @@ -229,7 +251,7 @@ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 03a883e20..35f6b787f 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig -@@ -140,6 +140,35 @@ comptime { +@@ -140,6 +140,39 @@ comptime { @export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" }); @export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" }); @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); @@ -262,6 +284,10 @@ index 03a883e20..35f6b787f 100644 + @export(&c.terminal_get_scrollback_length, .{ .name = "ghostty_terminal_get_scrollback_length" }); + @export(&c.terminal_get_scrollback_line, .{ .name = "ghostty_terminal_get_scrollback_line" }); + @export(&c.terminal_is_row_wrapped, .{ .name = "ghostty_terminal_is_row_wrapped" }); ++ ++ // Response API (for DSR and other queries) ++ @export(&c.terminal_has_response, .{ .name = "ghostty_terminal_has_response" }); ++ @export(&c.terminal_read_response, .{ .name = "ghostty_terminal_read_response" }); // On Wasm we need to export our allocator convenience functions. if (builtin.target.cpu.arch.isWasm()) { @@ -277,7 +303,7 @@ index bc92597f5..6a97183fe 100644 // The full C API, unexported. pub const osc_new = osc.new; -@@ -52,6 +53,36 @@ pub const key_encoder_encode = key_encode.encode; +@@ -52,6 +53,40 @@ pub const key_encoder_encode = key_encode.encode; pub const paste_is_safe = paste.is_safe; @@ -310,11 +336,15 @@ index bc92597f5..6a97183fe 100644 +pub const terminal_get_scrollback_length = terminal.getScrollbackLength; +pub const terminal_get_scrollback_line = terminal.getScrollbackLine; +pub const terminal_is_row_wrapped = terminal.isRowWrapped; ++ ++// Response API (for DSR and other queries) ++pub const terminal_has_response = terminal.hasResponse; ++pub const terminal_read_response = terminal.readResponse; + test { _ = color; _ = osc; -@@ -59,6 +90,7 @@ test { +@@ -59,6 +93,7 @@ test { _ = key_encode; _ = paste; _ = sgr; @@ -327,7 +357,7 @@ new file mode 100644 index 000000000..3868eb17b --- /dev/null +++ b/src/terminal/c/terminal.zig -@@ -0,0 +1,567 @@ +@@ -0,0 +1,882 @@ +//! C API wrapper for Terminal +//! +//! This provides a minimal, high-performance interface to Ghostty's Terminal @@ -348,22 +378,289 @@ index 000000000..3868eb17b +const builtin = @import("builtin"); + +const Terminal = @import("../Terminal.zig"); -+const ReadonlyStream = @import("../stream_readonly.zig").Stream; ++const stream = @import("../stream.zig"); ++const Action = stream.Action; +const render = @import("../render.zig"); +const RenderState = render.RenderState; +const color = @import("../color.zig"); +const modespkg = @import("../modes.zig"); +const point = @import("../point.zig"); +const Style = @import("../style.zig").Style; ++const device_status = @import("../device_status.zig"); + +const log = std.log.scoped(.terminal_c); + ++/// Response handler that processes VT sequences and queues responses. ++/// This extends the readonly stream handler to also handle queries. ++const ResponseHandler = struct { ++ alloc: Allocator, ++ terminal: *Terminal, ++ response_buffer: *std.ArrayList(u8), ++ ++ pub fn init(alloc: Allocator, terminal: *Terminal, response_buffer: *std.ArrayList(u8)) ResponseHandler { ++ return .{ ++ .alloc = alloc, ++ .terminal = terminal, ++ .response_buffer = response_buffer, ++ }; ++ } ++ ++ pub fn deinit(self: *ResponseHandler) void { ++ _ = self; ++ } ++ ++ pub fn vt( ++ self: *ResponseHandler, ++ comptime action: Action.Tag, ++ value: Action.Value(action), ++ ) !void { ++ switch (action) { ++ // Device status reports - these need responses ++ .device_status => try self.handleDeviceStatus(value.request), ++ ++ // All the terminal state modifications (same as stream_readonly.zig) ++ .print => try self.terminal.print(value.cp), ++ .print_repeat => try self.terminal.printRepeat(value), ++ .backspace => self.terminal.backspace(), ++ .carriage_return => self.terminal.carriageReturn(), ++ .linefeed => try self.terminal.linefeed(), ++ .index => try self.terminal.index(), ++ .next_line => { ++ try self.terminal.index(); ++ self.terminal.carriageReturn(); ++ }, ++ .reverse_index => self.terminal.reverseIndex(), ++ .cursor_up => self.terminal.cursorUp(value.value), ++ .cursor_down => self.terminal.cursorDown(value.value), ++ .cursor_left => self.terminal.cursorLeft(value.value), ++ .cursor_right => self.terminal.cursorRight(value.value), ++ .cursor_pos => self.terminal.setCursorPos(value.row, value.col), ++ .cursor_col => self.terminal.setCursorPos(self.terminal.screens.active.cursor.y + 1, value.value), ++ .cursor_row => self.terminal.setCursorPos(value.value, self.terminal.screens.active.cursor.x + 1), ++ .cursor_col_relative => self.terminal.setCursorPos( ++ self.terminal.screens.active.cursor.y + 1, ++ self.terminal.screens.active.cursor.x + 1 +| value.value, ++ ), ++ .cursor_row_relative => self.terminal.setCursorPos( ++ self.terminal.screens.active.cursor.y + 1 +| value.value, ++ self.terminal.screens.active.cursor.x + 1, ++ ), ++ .cursor_style => { ++ const blink = switch (value) { ++ .default, .steady_block, .steady_bar, .steady_underline => false, ++ .blinking_block, .blinking_bar, .blinking_underline => true, ++ }; ++ const style: @import("../Screen.zig").CursorStyle = switch (value) { ++ .default, .blinking_block, .steady_block => .block, ++ .blinking_bar, .steady_bar => .bar, ++ .blinking_underline, .steady_underline => .underline, ++ }; ++ self.terminal.modes.set(.cursor_blinking, blink); ++ self.terminal.screens.active.cursor.cursor_style = style; ++ }, ++ .erase_display_below => self.terminal.eraseDisplay(.below, value), ++ .erase_display_above => self.terminal.eraseDisplay(.above, value), ++ .erase_display_complete => self.terminal.eraseDisplay(.complete, value), ++ .erase_display_scrollback => self.terminal.eraseDisplay(.scrollback, value), ++ .erase_display_scroll_complete => self.terminal.eraseDisplay(.scroll_complete, value), ++ .erase_line_right => self.terminal.eraseLine(.right, value), ++ .erase_line_left => self.terminal.eraseLine(.left, value), ++ .erase_line_complete => self.terminal.eraseLine(.complete, value), ++ .erase_line_right_unless_pending_wrap => self.terminal.eraseLine(.right_unless_pending_wrap, value), ++ .delete_chars => self.terminal.deleteChars(value), ++ .erase_chars => self.terminal.eraseChars(value), ++ .insert_lines => self.terminal.insertLines(value), ++ .insert_blanks => self.terminal.insertBlanks(value), ++ .delete_lines => self.terminal.deleteLines(value), ++ .scroll_up => self.terminal.scrollUp(value), ++ .scroll_down => self.terminal.scrollDown(value), ++ .horizontal_tab => try self.horizontalTab(value), ++ .horizontal_tab_back => try self.horizontalTabBack(value), ++ .tab_clear_current => self.terminal.tabClear(.current), ++ .tab_clear_all => self.terminal.tabClear(.all), ++ .tab_set => self.terminal.tabSet(), ++ .tab_reset => self.terminal.tabReset(), ++ .set_mode => try self.setMode(value.mode, true), ++ .reset_mode => try self.setMode(value.mode, false), ++ .save_mode => self.terminal.modes.save(value.mode), ++ .restore_mode => { ++ const v = self.terminal.modes.restore(value.mode); ++ try self.setMode(value.mode, v); ++ }, ++ .top_and_bottom_margin => self.terminal.setTopAndBottomMargin(value.top_left, value.bottom_right), ++ .left_and_right_margin => self.terminal.setLeftAndRightMargin(value.top_left, value.bottom_right), ++ .left_and_right_margin_ambiguous => { ++ if (self.terminal.modes.get(.enable_left_and_right_margin)) { ++ self.terminal.setLeftAndRightMargin(0, 0); ++ } else { ++ self.terminal.saveCursor(); ++ } ++ }, ++ .save_cursor => self.terminal.saveCursor(), ++ .restore_cursor => try self.terminal.restoreCursor(), ++ .invoke_charset => self.terminal.invokeCharset(value.bank, value.charset, value.locking), ++ .configure_charset => self.terminal.configureCharset(value.slot, value.charset), ++ .set_attribute => switch (value) { ++ .unknown => {}, ++ else => self.terminal.setAttribute(value) catch {}, ++ }, ++ .protected_mode_off => self.terminal.setProtectedMode(.off), ++ .protected_mode_iso => self.terminal.setProtectedMode(.iso), ++ .protected_mode_dec => self.terminal.setProtectedMode(.dec), ++ .mouse_shift_capture => self.terminal.flags.mouse_shift_capture = if (value) .true else .false, ++ .kitty_keyboard_push => self.terminal.screens.active.kitty_keyboard.push(value.flags), ++ .kitty_keyboard_pop => self.terminal.screens.active.kitty_keyboard.pop(@intCast(value)), ++ .kitty_keyboard_set => self.terminal.screens.active.kitty_keyboard.set(.set, value.flags), ++ .kitty_keyboard_set_or => self.terminal.screens.active.kitty_keyboard.set(.@"or", value.flags), ++ .kitty_keyboard_set_not => self.terminal.screens.active.kitty_keyboard.set(.not, value.flags), ++ .modify_key_format => { ++ self.terminal.flags.modify_other_keys_2 = false; ++ switch (value) { ++ .other_keys_numeric => self.terminal.flags.modify_other_keys_2 = true, ++ else => {}, ++ } ++ }, ++ .active_status_display => self.terminal.status_display = value, ++ .decaln => try self.terminal.decaln(), ++ .full_reset => self.terminal.fullReset(), ++ .start_hyperlink => try self.terminal.screens.active.startHyperlink(value.uri, value.id), ++ .end_hyperlink => self.terminal.screens.active.endHyperlink(), ++ .prompt_start => { ++ self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt; ++ self.terminal.flags.shell_redraws_prompt = value.redraw; ++ }, ++ .prompt_continuation => self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt_continuation, ++ .prompt_end => self.terminal.markSemanticPrompt(.input), ++ .end_of_input => self.terminal.markSemanticPrompt(.command), ++ .end_of_command => self.terminal.screens.active.cursor.page_row.semantic_prompt = .input, ++ .mouse_shape => self.terminal.mouse_shape = value, ++ .color_operation => try self.colorOperation(value.op, &value.requests), ++ .kitty_color_report => try self.kittyColorOperation(value), ++ ++ // Actions that require no response and have no terminal effect ++ .dcs_hook, ++ .dcs_put, ++ .dcs_unhook, ++ .apc_start, ++ .apc_end, ++ .apc_put, ++ .bell, ++ .enquiry, ++ .request_mode, ++ .request_mode_unknown, ++ .size_report, ++ .xtversion, ++ .device_attributes, ++ .kitty_keyboard_query, ++ .window_title, ++ .report_pwd, ++ .show_desktop_notification, ++ .progress_report, ++ .clipboard_contents, ++ .title_push, ++ .title_pop, ++ => {}, ++ } ++ } ++ ++ fn handleDeviceStatus(self: *ResponseHandler, req: device_status.Request) !void { ++ switch (req) { ++ .operating_status => { ++ // DSR 5 - Operating status report: always report "OK" ++ try self.response_buffer.appendSlice(self.alloc, "\x1B[0n"); ++ }, ++ .cursor_position => { ++ // DSR 6 - Cursor position report (CPR) ++ const cursor = self.terminal.screens.active.cursor; ++ const x = if (self.terminal.modes.get(.origin)) ++ cursor.x -| self.terminal.scrolling_region.left ++ else ++ cursor.x; ++ const y = if (self.terminal.modes.get(.origin)) ++ cursor.y -| self.terminal.scrolling_region.top ++ else ++ cursor.y; ++ var buf: [32]u8 = undefined; ++ const resp = std.fmt.bufPrint(&buf, "\x1B[{};{}R", .{ ++ y + 1, ++ x + 1, ++ }) catch return; ++ try self.response_buffer.appendSlice(self.alloc, resp); ++ }, ++ .color_scheme => { ++ // Not supported in WASM context ++ }, ++ } ++ } ++ ++ inline fn horizontalTab(self: *ResponseHandler, count: u16) !void { ++ for (0..count) |_| { ++ const x = self.terminal.screens.active.cursor.x; ++ try self.terminal.horizontalTab(); ++ if (x == self.terminal.screens.active.cursor.x) break; ++ } ++ } ++ ++ inline fn horizontalTabBack(self: *ResponseHandler, count: u16) !void { ++ for (0..count) |_| { ++ const x = self.terminal.screens.active.cursor.x; ++ try self.terminal.horizontalTabBack(); ++ if (x == self.terminal.screens.active.cursor.x) break; ++ } ++ } ++ ++ fn setMode(self: *ResponseHandler, mode: modespkg.Mode, enabled: bool) !void { ++ self.terminal.modes.set(mode, enabled); ++ switch (mode) { ++ .autorepeat, .reverse_colors => {}, ++ .origin => self.terminal.setCursorPos(1, 1), ++ .enable_left_and_right_margin => if (!enabled) { ++ self.terminal.scrolling_region.left = 0; ++ self.terminal.scrolling_region.right = self.terminal.cols - 1; ++ }, ++ .alt_screen_legacy => try self.terminal.switchScreenMode(.@"47", enabled), ++ .alt_screen => try self.terminal.switchScreenMode(.@"1047", enabled), ++ .alt_screen_save_cursor_clear_enter => try self.terminal.switchScreenMode(.@"1049", enabled), ++ .save_cursor => if (enabled) { ++ self.terminal.saveCursor(); ++ } else { ++ try self.terminal.restoreCursor(); ++ }, ++ .enable_mode_3 => {}, ++ .@"132_column" => try self.terminal.deccolm( ++ self.terminal.screens.active.alloc, ++ if (enabled) .@"132_cols" else .@"80_cols", ++ ), ++ else => {}, ++ } ++ } ++ ++ fn colorOperation(self: *ResponseHandler, op: anytype, requests: anytype) !void { ++ _ = self; ++ _ = op; ++ _ = requests; ++ // Color operations are not supported in WASM context ++ } ++ ++ fn kittyColorOperation(self: *ResponseHandler, value: anytype) !void { ++ _ = self; ++ _ = value; ++ // Kitty color operations are not supported in WASM context ++ } ++}; ++ ++/// The stream type using our response handler ++const ResponseStream = stream.Stream(ResponseHandler); ++ +/// Wrapper struct that owns the Terminal, stream, and RenderState. +const TerminalWrapper = struct { + alloc: Allocator, + terminal: Terminal, -+ stream: ReadonlyStream, ++ handler: ResponseHandler, ++ stream: ResponseStream, + render_state: RenderState, ++ /// Response buffer for DSR and other query responses ++ response_buffer: std.ArrayList(u8), + /// Track alternate screen state to detect screen switches + last_screen_is_alternate: bool = false, +}; @@ -475,11 +772,22 @@ index 000000000..3868eb17b + return null; + }; + ++ // Initialize response buffer ++ wrapper.response_buffer = .{}; ++ ++ // Initialize handler with references to terminal and response buffer ++ wrapper.handler = ResponseHandler.init(alloc, &wrapper.terminal, &wrapper.response_buffer); ++ ++ // Initialize stream with the handler ++ wrapper.stream = ResponseStream.init(wrapper.handler); ++ + wrapper.* = .{ + .alloc = alloc, + .terminal = wrapper.terminal, -+ .stream = wrapper.terminal.vtStream(), ++ .handler = wrapper.handler, ++ .stream = wrapper.stream, + .render_state = RenderState.empty, ++ .response_buffer = wrapper.response_buffer, + }; + + // NOTE: linefeed mode must be FALSE to match native terminal behavior @@ -492,6 +800,7 @@ index 000000000..3868eb17b + const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return)); + const alloc = wrapper.alloc; + wrapper.stream.deinit(); ++ wrapper.response_buffer.deinit(alloc); + wrapper.render_state.deinit(alloc); + wrapper.terminal.deinit(alloc); + alloc.destroy(wrapper); @@ -866,6 +1175,42 @@ index 000000000..3868eb17b +} + +// ============================================================================ ++// Response API - for DSR and other terminal queries ++// ============================================================================ ++ ++/// Check if there are pending responses from the terminal ++pub fn hasResponse(ptr: ?*anyopaque) callconv(.c) bool { ++ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); ++ return wrapper.response_buffer.items.len > 0; ++} ++ ++/// Read pending responses from the terminal. ++/// Returns number of bytes written to buffer, or 0 if no responses pending. ++/// Returns -1 on error (null pointer or buffer too small). ++pub fn readResponse(ptr: ?*anyopaque, out: [*]u8, buf_size: usize) callconv(.c) c_int { ++ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); ++ const len = @min(wrapper.response_buffer.items.len, buf_size); ++ if (len == 0) return 0; ++ ++ @memcpy(out[0..len], wrapper.response_buffer.items[0..len]); ++ ++ // Remove consumed bytes from buffer ++ if (len == wrapper.response_buffer.items.len) { ++ wrapper.response_buffer.clearRetainingCapacity(); ++ } else { ++ // Shift remaining bytes to front ++ std.mem.copyForwards( ++ u8, ++ wrapper.response_buffer.items[0..], ++ wrapper.response_buffer.items[len..], ++ ); ++ wrapper.response_buffer.shrinkRetainingCapacity(wrapper.response_buffer.items.len - len); ++ } ++ ++ return @intCast(len); ++} ++ ++// ============================================================================ +// Tests +// ============================================================================ +