From 4047a9055560de76939025ac843619da92350d80 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 May 2023 15:45:51 -0700 Subject: [PATCH 1/3] terminal: track semantic prompt metadata per row --- src/terminal/Screen.zig | 35 +++++++++++++ src/terminal/Terminal.zig | 101 ++++++++++++++++++++++++++++++++++++++ src/terminal/osc.zig | 1 + src/terminal/stream.zig | 18 +++++++ 4 files changed, 155 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 149f7d879..ef362ce78 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -136,7 +136,32 @@ pub const RowHeader = struct { /// True if any cell in this row has a grapheme associated with it. grapheme: bool = false, + + /// True if this row is an active prompt (awaiting input). This is + /// set to false when the semantic prompt events (OSC 133) are received. + /// There are scenarios where the shell may never send this event, so + /// in order to reliably test prompt status, you need to iterate + /// backwards from the cursor to check the current line status going + /// back. + semantic_prompt: SemanticPrompt = .unknown, } = .{}, + + /// Semantic prompt type. + pub const SemanticPrompt = enum(u3) { + /// Unknown, the running application didn't tell us for this line. + unknown = 0, + + /// This is a prompt line, meaning it only contains the shell prompt. + /// For poorly behaving shells, this may also be the input. + prompt = 1, + + /// This line contains the input area. We don't currently track + /// where this actually is in the line, so we just assume it is somewhere. + input = 2, + + /// This line is the start of command output. + command = 3, + }; }; /// Cell is a single cell within the screen. @@ -276,6 +301,16 @@ pub const Row = struct { return self.storage[0].header.flags.dirty; } + /// Set the semantic prompt state for this row. + pub fn setSemanticPrompt(self: Row, p: RowHeader.SemanticPrompt) void { + self.storage[0].header.flags.semantic_prompt = p; + } + + /// Retrieve the semantic prompt state for this row. + pub fn getSemanticPrompt(self: Row) RowHeader.SemanticPrompt { + return self.storage[0].header.flags.semantic_prompt; + } + /// Retrieve the header for this row. pub fn header(self: Row) RowHeader { return self.storage[0].header; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 71e3a6bff..b29012563 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -31,6 +31,17 @@ pub const ScreenType = enum { alternate, }; +/// The semantic prompt type. This is used when tracking a line type and +/// requires integration with the shell. By default, we mark a line as "none" +/// meaning we don't know what type it is. +/// +/// See: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md +pub const SemanticPrompt = enum { + prompt, + input, + command, +}; + /// Screen is the current screen state. The "active_screen" field says what /// the current screen is. The backup screen is the opposite of the active /// screen. @@ -1386,6 +1397,50 @@ pub fn setScrollingRegion(self: *Terminal, top: usize, bottom: usize) void { self.setCursorPos(1, 1); } +/// Mark the current semantic prompt information. Current escape sequences +/// (OSC 133) only allow setting this for wherever the current active cursor +/// is located. +pub fn markSemanticPrompt(self: *Terminal, p: SemanticPrompt) void { + const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); + row.setSemanticPrompt(switch (p) { + .prompt => .prompt, + .input => .input, + .command => .command, + }); +} + +/// Returns true if the cursor is currently at a prompt. Another way to look +/// at this is it returns false if the shell is currently outputing something. +/// This requires shell integration (semantic prompt integration). +/// +/// If the shell integration doesn't exist, this will always return false. +pub fn cursorIsAtPrompt(self: *Terminal) bool { + // If we're on the secondary screen, we're never at a prompt. + if (self.active_screen == .alternate) return false; + + var y: usize = 0; + while (y <= self.screen.cursor.y) : (y += 1) { + // We want to go bottom up + const bottom_y = self.screen.cursor.y - y; + const row = self.screen.getRow(.{ .active = bottom_y }); + switch (row.getSemanticPrompt()) { + // If we're at a prompt or input area, then we are at a prompt. + .prompt, + .input, + => return true, + + // If we have command output, then we're most certainly not + // at a prompt. + .command => return false, + + // If we don't know, we keep searching. + .unknown => {}, + } + } + + return false; +} + /// Full reset pub fn fullReset(self: *Terminal) void { self.primaryScreen(.{ .clear_on_exit = true, .cursor_save = true }); @@ -2117,3 +2172,49 @@ test "Terminal: insertBlanks more than size" { try testing.expectEqualStrings("", str); } } + +test "Terminal: cursorIsAtPrompt" { + const alloc = testing.allocator; + var t = try init(alloc, 3, 2); + defer t.deinit(alloc); + + try testing.expect(!t.cursorIsAtPrompt()); + t.markSemanticPrompt(.prompt); + try testing.expect(t.cursorIsAtPrompt()); + + // Input is also a prompt + t.markSemanticPrompt(.input); + try testing.expect(t.cursorIsAtPrompt()); + + // Newline -- we expect we're still at a prompt if we received + // prompt stuff before. + try t.linefeed(); + try testing.expect(t.cursorIsAtPrompt()); + + // But once we say we're starting output, we're not a prompt + t.markSemanticPrompt(.command); + try testing.expect(!t.cursorIsAtPrompt()); + try t.linefeed(); + try testing.expect(!t.cursorIsAtPrompt()); + + // Until we know we're at a prompt again + try t.linefeed(); + t.markSemanticPrompt(.prompt); + try testing.expect(t.cursorIsAtPrompt()); +} + +test "Terminal: cursorIsAtPrompt alternate screen" { + const alloc = testing.allocator; + var t = try init(alloc, 3, 2); + defer t.deinit(alloc); + + try testing.expect(!t.cursorIsAtPrompt()); + t.markSemanticPrompt(.prompt); + try testing.expect(t.cursorIsAtPrompt()); + + // Secondary screen is never a prompt + t.alternateScreen(.{}); + try testing.expect(!t.cursorIsAtPrompt()); + t.markSemanticPrompt(.prompt); + try testing.expect(!t.cursorIsAtPrompt()); +} diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 8a0a1a1b6..1c6e796c3 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -276,6 +276,7 @@ pub const Parser = struct { self.command = .{ .end_of_command = .{} }; self.complete = true; }, + else => self.state = .invalid, }, diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 1b5467b68..16fa72893 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -471,6 +471,24 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, + .prompt_start => { + if (@hasDecl(T, "promptStart")) { + try self.handler.promptStart(); + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .prompt_end => { + if (@hasDecl(T, "promptEnd")) { + try self.handler.promptEnd(); + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + + .end_of_command => |end| { + if (@hasDecl(T, "endOfCommand")) { + try self.handler.endOfCommand(end.exit_code); + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + else => if (@hasDecl(T, "oscUnimplemented")) try self.handler.oscUnimplemented(cmd) else From de00892f8e11148c2c026ccdb4080202710d7f2f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 May 2023 15:48:31 -0700 Subject: [PATCH 2/3] termio/exec: handle semantic prompt events --- src/terminal/stream.zig | 6 ++++++ src/termio/Exec.zig | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 16fa72893..4a6840d58 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -483,6 +483,12 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, + .end_of_input => { + if (@hasDecl(T, "endOfInput")) { + try self.handler.endOfInput(); + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + .end_of_command => |end| { if (@hasDecl(T, "endOfCommand")) { try self.handler.endOfCommand(end.exit_code); diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 4e908f72a..115db9c14 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -1235,4 +1235,16 @@ const StreamHandler = struct { ), }, .{ .forever = {} }); } + + pub fn promptStart(self: *StreamHandler) !void { + self.terminal.markSemanticPrompt(.prompt); + } + + pub fn promptEnd(self: *StreamHandler) !void { + self.terminal.markSemanticPrompt(.input); + } + + pub fn endOfInput(self: *StreamHandler) !void { + self.terminal.markSemanticPrompt(.command); + } }; From 7b651627d53568a13fb284700765a1fbdadae2fb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 May 2023 15:52:56 -0700 Subject: [PATCH 3/3] core: surface confirm close logic updated to handle semantic prompts --- src/Surface.zig | 20 ++++++++++---------- src/terminal/Terminal.zig | 1 + 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 481847c20..2701919c4 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -529,18 +529,18 @@ pub fn deinit(self: *Surface) void { /// close process, which should ultimately deinitialize this surface. pub fn close(self: *Surface) void { const process_alive = process_alive: { - // Inform close() if it should hold open the surface or not. If the child - // exited, we don't want to - var process_alive = !self.child_exited; + // If the child has exited then our process is certainly not alive. + // We check this first to avoid the locking overhead below. + if (self.child_exited) break :process_alive false; - // However, if we are configured to not hold open surfaces explicitly, - // just tell close to not hold them open by saying there are no alive - // processes - if (!self.config.confirm_close_surface) { - process_alive = false; - } + // If we are configured to not hold open surfaces explicitly, just + // always say there is nothing alive. + if (!self.config.confirm_close_surface) break :process_alive false; - break :process_alive process_alive; + // We have to talk to the terminal. + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + break :process_alive !self.io.terminal.cursorIsAtPrompt(); }; self.rt_surface.close(process_alive); diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index b29012563..56430991a 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1401,6 +1401,7 @@ pub fn setScrollingRegion(self: *Terminal, top: usize, bottom: usize) void { /// (OSC 133) only allow setting this for wherever the current active cursor /// is located. pub fn markSemanticPrompt(self: *Terminal, p: SemanticPrompt) void { + log.warn("semantic_prompt: {}", .{p}); const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); row.setSemanticPrompt(switch (p) { .prompt => .prompt,