diff --git a/src/Surface.zig b/src/Surface.zig index fa9b04685..44385fdae 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4295,7 +4295,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { // This flag is only set if we've seen at least one semantic prompt // OSC sequence. If we've never seen that sequence, we can't possibly // move the cursor so we can fast path out of here. - if (!t.flags.shell_redraws_prompt) return; + if (!t.screens.active.flags.semantic_content) return; // Get our path const from = t.screens.active.cursor.page_pin.*; diff --git a/src/inspector/widgets/renderer.zig b/src/inspector/widgets/renderer.zig index 3c6492dfe..1003b02ce 100644 --- a/src/inspector/widgets/renderer.zig +++ b/src/inspector/widgets/renderer.zig @@ -48,24 +48,76 @@ pub const Info = struct { ) void { if (!open) return; - cimgui.c.ImGui_SeparatorText("Overlays"); + cimgui.c.ImGui_SetNextItemOpen(true, cimgui.c.ImGuiCond_Once); + if (!cimgui.c.ImGui_CollapsingHeader("Overlays", cimgui.c.ImGuiTreeNodeFlags_None)) return; - // Hyperlinks - { - var hyperlinks: bool = self.features.contains(.highlight_hyperlinks); - _ = cimgui.c.ImGui_Checkbox("Overlay Hyperlinks", &hyperlinks); - cimgui.c.ImGui_SameLine(); - widgets.helpMarker("When enabled, highlights OSC8 hyperlinks."); + cimgui.c.ImGui_SeparatorText("Hyperlinks"); + self.overlayHyperlinks(alloc); + cimgui.c.ImGui_SeparatorText("Semantic Prompts"); + self.overlaySemanticPrompts(alloc); + } - if (!hyperlinks) { - _ = self.features.swapRemove(.highlight_hyperlinks); - } else { - self.features.put( - alloc, - .highlight_hyperlinks, - .highlight_hyperlinks, - ) catch log.warn("error enabling hyperlink overlay feature", .{}); - } + fn overlayHyperlinks(self: *Info, alloc: Allocator) void { + var hyperlinks: bool = self.features.contains(.highlight_hyperlinks); + _ = cimgui.c.ImGui_Checkbox("Overlay Hyperlinks", &hyperlinks); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("When enabled, highlights OSC8 hyperlinks."); + + if (!hyperlinks) { + _ = self.features.swapRemove(.highlight_hyperlinks); + } else { + self.features.put( + alloc, + .highlight_hyperlinks, + .highlight_hyperlinks, + ) catch log.warn("error enabling hyperlink overlay feature", .{}); } } + + fn overlaySemanticPrompts(self: *Info, alloc: Allocator) void { + var semantic_prompts: bool = self.features.contains(.semantic_prompts); + _ = cimgui.c.ImGui_Checkbox("Overlay Semantic Prompts", &semantic_prompts); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("When enabled, highlights OSC 133 semantic prompts."); + + // Handle the checkbox results + if (!semantic_prompts) { + _ = self.features.swapRemove(.semantic_prompts); + } else { + self.features.put( + alloc, + .semantic_prompts, + .semantic_prompts, + ) catch log.warn("error enabling semantic prompt overlay feature", .{}); + } + + // Help + cimgui.c.ImGui_Indent(); + defer cimgui.c.ImGui_Unindent(); + + cimgui.c.ImGui_TextDisabled("Colors:"); + + const prompt_rgb = renderer.Overlay.Color.semantic_prompt.rgb(); + const input_rgb = renderer.Overlay.Color.semantic_input.rgb(); + const prompt_col: cimgui.c.ImVec4 = .{ + .x = @as(f32, @floatFromInt(prompt_rgb.r)) / 255.0, + .y = @as(f32, @floatFromInt(prompt_rgb.g)) / 255.0, + .z = @as(f32, @floatFromInt(prompt_rgb.b)) / 255.0, + .w = 1.0, + }; + const input_col: cimgui.c.ImVec4 = .{ + .x = @as(f32, @floatFromInt(input_rgb.r)) / 255.0, + .y = @as(f32, @floatFromInt(input_rgb.g)) / 255.0, + .z = @as(f32, @floatFromInt(input_rgb.b)) / 255.0, + .w = 1.0, + }; + + _ = cimgui.c.ImGui_ColorButton("##prompt_color", prompt_col, cimgui.c.ImGuiColorEditFlags_NoTooltip); + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("Prompt"); + + _ = cimgui.c.ImGui_ColorButton("##input_color", input_col, cimgui.c.ImGuiColorEditFlags_NoTooltip); + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("Input"); + } }; diff --git a/src/inspector/widgets/screen.zig b/src/inspector/widgets/screen.zig index 9365158a1..481413b4a 100644 --- a/src/inspector/widgets/screen.zig +++ b/src/inspector/widgets/screen.zig @@ -57,7 +57,7 @@ pub const Info = struct { if (cimgui.c.ImGui_CollapsingHeader( "Cursor", - cimgui.c.ImGuiTreeNodeFlags_None, + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, )) { cursorTable(&screen.cursor); cimgui.c.ImGui_Separator(); @@ -69,7 +69,7 @@ pub const Info = struct { if (cimgui.c.ImGui_CollapsingHeader( "Keyboard", - cimgui.c.ImGuiTreeNodeFlags_None, + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, )) keyboardTable( screen, data.modify_other_keys_2, @@ -77,13 +77,13 @@ pub const Info = struct { if (cimgui.c.ImGui_CollapsingHeader( "Kitty Graphics", - cimgui.c.ImGuiTreeNodeFlags_None, + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, )) kittyGraphicsTable(&screen.kitty_images); if (cimgui.c.ImGui_CollapsingHeader( - "Internal Terminal State", - cimgui.c.ImGuiTreeNodeFlags_None, - )) internalStateTable(&screen.pages); + "Other Screen State", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) internalStateTable(screen); } // Cell window @@ -327,8 +327,10 @@ pub fn kittyGraphicsTable( /// Render internal terminal state table. pub fn internalStateTable( - pages: *const terminal.PageList, + screen: *const terminal.Screen, ) void { + const pages = &screen.pages; + if (!cimgui.c.ImGui_BeginTable( "##terminal_state", 2, @@ -347,9 +349,21 @@ pub fn internalStateTable( cimgui.c.ImGui_Text("Memory Limit"); _ = cimgui.c.ImGui_TableSetColumnIndex(1); cimgui.c.ImGui_Text("%d bytes (%d KiB)", pages.maxSize(), units.toKibiBytes(pages.maxSize())); + cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); cimgui.c.ImGui_Text("Viewport Location"); _ = cimgui.c.ImGui_TableSetColumnIndex(1); cimgui.c.ImGui_Text("%s", @tagName(pages.viewport).ptr); + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Semantic Content"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("Whether semantic prompt markers (OSC 133) have been seen."); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + var value: bool = screen.flags.semantic_content; + _ = cimgui.c.ImGui_Checkbox("##semantic_content", &value); + } } diff --git a/src/inspector/widgets/surface.zig b/src/inspector/widgets/surface.zig index 3b69f214c..d73e784ce 100644 --- a/src/inspector/widgets/surface.zig +++ b/src/inspector/widgets/surface.zig @@ -25,6 +25,7 @@ pub const Inspector = struct { terminal_info: widgets.terminal.Info, vt_stream: widgets.termio.Stream, renderer_info: widgets.renderer.Info, + show_demo_window: bool, pub fn init(alloc: Allocator) !Inspector { return .{ @@ -33,6 +34,7 @@ pub const Inspector = struct { .terminal_info = .empty, .vt_stream = try .init(alloc), .renderer_info = .empty, + .show_demo_window = true, }; } @@ -52,13 +54,6 @@ pub const Inspector = struct { const dockspace_id = cimgui.c.ImGui_GetID("Main Dockspace"); const first_render = createDockSpace(dockspace_id); - // In debug we show the ImGui demo window so we can easily view - // available widgets and such. - if (comptime builtin.mode == .Debug) { - var show: bool = true; // Always show it - cimgui.c.ImGui_ShowDemoWindow(&show); - } - // Draw everything that requires the terminal state mutex. { surface.renderer_state.mutex.lock(); @@ -136,6 +131,14 @@ pub const Inspector = struct { } } + // In debug we show the ImGui demo window so we can easily view + // available widgets and such. + if (comptime builtin.mode == .Debug) { + if (self.show_demo_window) { + cimgui.c.ImGui_ShowDemoWindow(&self.show_demo_window); + } + } + if (first_render) { // On first render, setup our initial focus state. We only // do this on first render so that we can let the user change @@ -171,12 +174,12 @@ pub const Inspector = struct { // this is the point we'd pre-split and so on for the initial // layout. const dock_id_main: cimgui.c.ImGuiID = dockspace_id; + cimgui.ImGui_DockBuilderDockWindow(window_imgui_demo, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(window_terminal, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(window_surface, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(window_keyboard, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(window_termio, dock_id_main); cimgui.ImGui_DockBuilderDockWindow(window_renderer, dock_id_main); - cimgui.ImGui_DockBuilderDockWindow(window_imgui_demo, dock_id_main); cimgui.ImGui_DockBuilderFinish(dockspace_id); } diff --git a/src/renderer/Overlay.zig b/src/renderer/Overlay.zig index 7eb94acb5..62eb004ba 100644 --- a/src/renderer/Overlay.zig +++ b/src/renderer/Overlay.zig @@ -21,6 +21,44 @@ const Size = size.Size; const CellSize = size.CellSize; const Image = @import("image.zig").Image; +const log = std.log.scoped(.renderer_overlay); + +/// The colors we use for overlays. +pub const Color = enum { + hyperlink, // light blue + semantic_prompt, // orange/gold + semantic_input, // cyan + + pub fn rgb(self: Color) z2d.pixel.RGB { + return switch (self) { + .hyperlink => .{ .r = 180, .g = 180, .b = 255 }, + .semantic_prompt => .{ .r = 255, .g = 200, .b = 64 }, + .semantic_input => .{ .r = 64, .g = 200, .b = 255 }, + }; + } + + /// The fill color for rectangles. + pub fn rectFill(self: Color) z2d.Pixel { + return self.alphaPixel(96); + } + + /// The border color for rectangles. + pub fn rectBorder(self: Color) z2d.Pixel { + return self.alphaPixel(200); + } + + /// The raw RGB as a pixel. + pub fn pixel(self: Color) z2d.Pixel { + return self.rgb().asPixel(); + } + + fn alphaPixel(self: Color, alpha: u8) z2d.Pixel { + var rgba: z2d.pixel.RGBA = .fromPixel(self.pixel()); + rgba.a = alpha; + return rgba.multiply().asPixel(); + } +}; + /// The surface we're drawing our overlay to. surface: z2d.Surface, @@ -30,6 +68,7 @@ cell_size: CellSize, /// The set of available features and their configuration. pub const Feature = union(enum) { highlight_hyperlinks, + semantic_prompts, }; pub const InitError = Allocator.Error || error{ @@ -100,6 +139,10 @@ pub fn applyFeatures( alloc, state, ), + .semantic_prompts => self.highlightSemanticPrompts( + alloc, + state, + ), }; } @@ -113,13 +156,8 @@ fn highlightHyperlinks( alloc: Allocator, state: *const terminal.RenderState, ) void { - const border_fill_rgb: z2d.pixel.RGB = .{ .r = 180, .g = 180, .b = 255 }; - const border_color = border_fill_rgb.asPixel(); - const fill_color: z2d.Pixel = px: { - var rgba: z2d.pixel.RGBA = .fromPixel(border_color); - rgba.a = 128; - break :px rgba.multiply().asPixel(); - }; + const border_color = Color.hyperlink.rectBorder(); + const fill_color = Color.hyperlink.rectFill(); const row_slice = state.row_data.slice(); const row_raw = row_slice.items(.raw); @@ -145,7 +183,7 @@ fn highlightHyperlinks( while (x < raw_cells.len and raw_cells[x].hyperlink) x += 1; const end_x = x; - self.highlightRect( + self.highlightGridRect( alloc, start_x, y, @@ -160,9 +198,105 @@ fn highlightHyperlinks( } } +fn highlightSemanticPrompts( + self: *Overlay, + alloc: Allocator, + state: *const terminal.RenderState, +) void { + const row_slice = state.row_data.slice(); + const row_raw = row_slice.items(.raw); + const row_cells = row_slice.items(.cells); + + // Highlight the row-level semantic prompt bars. The prompts are easy + // because they're part of the row metadata. + { + const prompt_border = Color.semantic_prompt.rectBorder(); + const prompt_fill = Color.semantic_prompt.rectFill(); + + var y: usize = 0; + while (y < row_raw.len) { + // If its not a semantic prompt row, skip it. + if (row_raw[y].semantic_prompt == .none) { + y += 1; + continue; + } + + // Find the full length of the semantic prompt row by connecting + // all continuations. + const start_y = y; + y += 1; + while (y < row_raw.len and + row_raw[y].semantic_prompt == .prompt_continuation) + { + y += 1; + } + const end_y = y; // Exclusive + + const bar_width = @min(@as(usize, 5), self.cell_size.width); + self.highlightPixelRect( + alloc, + 0, + start_y, + bar_width, + end_y - start_y, + prompt_border, + prompt_fill, + ) catch |err| { + log.warn("Error drawing semantic prompt bar: {}", .{err}); + }; + } + } + + // Highlight contiguous semantic cells within rows. + for (row_cells, 0..) |cells, y| { + const cells_slice = cells.slice(); + const raw_cells = cells_slice.items(.raw); + + var x: usize = 0; + while (x < raw_cells.len) { + const cell = raw_cells[x]; + const content = cell.semantic_content; + const start_x = x; + + // We skip output because its just the rest of the non-prompt + // parts and it makes the overlay too noisy. + if (cell.semantic_content == .output) { + x += 1; + continue; + } + + // Find the end of this content. + x += 1; + while (x < raw_cells.len) { + const next = raw_cells[x]; + if (next.semantic_content != content) break; + x += 1; + } + + const color: Color = switch (content) { + .prompt => .semantic_prompt, + .input => .semantic_input, + .output => unreachable, + }; + + self.highlightGridRect( + alloc, + start_x, + y, + x - start_x, + 1, + color.rectBorder(), + color.rectFill(), + ) catch |err| { + log.warn("Error drawing semantic content highlight: {}", .{err}); + }; + } + } +} + /// Creates a rectangle for highlighting a grid region. x/y/width/height /// are all in grid cells. -fn highlightRect( +fn highlightGridRect( self: *Overlay, alloc: Allocator, x: usize, @@ -227,3 +361,55 @@ fn highlightRect( ctx.setSourceToPixel(border_color); try ctx.stroke(); } + +/// Creates a rectangle for highlighting a region. x/y are grid cells and +/// width/height are pixels. +fn highlightPixelRect( + self: *Overlay, + alloc: Allocator, + x: usize, + y: usize, + width_px: usize, + height: usize, + border_color: z2d.Pixel, + fill_color: z2d.Pixel, +) !void { + const px_width = std.math.cast(i32, width_px) orelse return error.Overflow; + const px_height = std.math.cast(i32, try std.math.mul( + usize, + height, + self.cell_size.height, + )) orelse return error.Overflow; + + const start_x: f64 = @floatFromInt(std.math.cast(i32, try std.math.mul( + usize, + x, + self.cell_size.width, + )) orelse return error.Overflow); + const start_y: f64 = @floatFromInt(std.math.cast(i32, try std.math.mul( + usize, + y, + self.cell_size.height, + )) orelse return error.Overflow); + const end_x: f64 = start_x + @as(f64, @floatFromInt(px_width)); + const end_y: f64 = start_y + @as(f64, @floatFromInt(px_height)); + + var ctx: z2d.Context = .init(alloc, &self.surface); + defer ctx.deinit(); + + ctx.setAntiAliasingMode(.none); + ctx.setHairline(true); + + try ctx.moveTo(start_x, start_y); + try ctx.lineTo(end_x, start_y); + try ctx.lineTo(end_x, end_y); + try ctx.lineTo(start_x, end_y); + try ctx.closePath(); + + ctx.setSourceToPixel(fill_color); + try ctx.fill(); + + ctx.setLineWidth(1); + ctx.setSourceToPixel(border_color); + try ctx.stroke(); +} diff --git a/src/renderer/row.zig b/src/renderer/row.zig index 933bb338b..74a641012 100644 --- a/src/renderer/row.zig +++ b/src/renderer/row.zig @@ -16,8 +16,8 @@ pub fn neverExtendBg( // because prompts often contain special formatting (such as // powerline) that looks bad when extended. switch (row.semantic_prompt) { - .prompt, .prompt_continuation, .input => return true, - .unknown, .command => {}, + .prompt, .prompt_continuation => return true, + .none => {}, } for (0.., cells) |x, *cell| { diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 799d0cff6..40fd71b19 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -16,7 +16,7 @@ # along with this program. If not, see . # We need to be in interactive mode to proceed. -if [[ "$-" != *i* ]] ; then builtin return; fi +if [[ "$-" != *i* ]]; then builtin return; fi # When automatic shell integration is active, we were started in POSIX # mode and need to manually recreate the bash startup sequence. @@ -49,7 +49,10 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then if [[ $__ghostty_bash_flags != *"--noprofile"* ]]; then [ -r /etc/profile ] && builtin source "/etc/profile" for __ghostty_rcfile in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do - [ -r "$__ghostty_rcfile" ] && { builtin source "$__ghostty_rcfile"; break; } + [ -r "$__ghostty_rcfile" ] && { + builtin source "$__ghostty_rcfile" + break + } done fi else @@ -61,7 +64,10 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then # Void Linux uses /etc/bash/bashrc # Nixos uses /etc/bashrc for __ghostty_rcfile in /etc/bash.bashrc /etc/bash/bashrc /etc/bashrc; do - [ -r "$__ghostty_rcfile" ] && { builtin source "$__ghostty_rcfile"; break; } + [ -r "$__ghostty_rcfile" ] && { + builtin source "$__ghostty_rcfile" + break + } done if [[ -z "$GHOSTTY_BASH_RCFILE" ]]; then GHOSTTY_BASH_RCFILE="$HOME/.bashrc"; fi [ -r "$GHOSTTY_BASH_RCFILE" ] && builtin source "$GHOSTTY_BASH_RCFILE" @@ -101,9 +107,9 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then fi done if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then - builtin command sudo "$@"; + builtin command sudo "$@" else - builtin command sudo --preserve-env=TERMINFO "$@"; + builtin command sudo --preserve-env=TERMINFO "$@" fi } fi @@ -127,8 +133,8 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then while IFS=' ' read -r ssh_key ssh_value; do case "$ssh_key" in - user) ssh_user="$ssh_value" ;; - hostname) ssh_hostname="$ssh_value" ;; + user) ssh_user="$ssh_value" ;; + hostname) ssh_hostname="$ssh_value" ;; esac [[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break done < <(builtin command ssh -G "$@" 2>/dev/null) @@ -187,66 +193,71 @@ _ghostty_executing="" _ghostty_last_reported_cwd="" function __ghostty_precmd() { - local ret="$?" - if test "$_ghostty_executing" != "0"; then - _GHOSTTY_SAVE_PS1="$PS1" - _GHOSTTY_SAVE_PS2="$PS2" + local ret="$?" + if test "$_ghostty_executing" != "0"; then + _GHOSTTY_SAVE_PS1="$PS1" + _GHOSTTY_SAVE_PS2="$PS2" - # Marks - PS1=$PS1'\[\e]133;B\a\]' - PS2=$PS2'\[\e]133;B\a\]' + # Marks. We need to do fresh line (A) at the beginning of the prompt + # since if the cursor is not at the beginning of a line, the terminal + # will emit a newline. + PS1='\[\e]133;A;redraw=last\a\]'$PS1'\[\e]133;B\a\]' + PS2='\[\e]133;A;k=s\a\]'$PS2'\[\e]133;B\a\]' - # bash doesn't redraw the leading lines in a multiline prompt so - # mark the last line as a secondary prompt (k=s) to prevent the - # preceding lines from being erased by ghostty after a resize. - if [[ "${PS1}" == *"\n"* || "${PS1}" == *$'\n'* ]]; then - PS1=$PS1'\[\e]133;A;k=s\a\]' - fi - - # Cursor - if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then - [[ "$PS1" != *'\[\e[5 q\]'* ]] && PS1=$PS1'\[\e[5 q\]' # input - [[ "$PS0" != *'\[\e[0 q\]'* ]] && PS0=$PS0'\[\e[0 q\]' # reset - fi - - # Title (working directory) - if [[ "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then - PS1=$PS1'\[\e]2;\w\a\]' - fi + # Bash doesn't redraw the leading lines in a multiline prompt so + # we mark the start of each line (after each newline) as a secondary + # prompt. This correctly handles multiline prompts by setting the first + # to primary and the subsequent lines to secondary. + if [[ "${PS1}" == *"\n"* || "${PS1}" == *$'\n'* ]]; then + builtin local __ghostty_mark=$'\\[\\e]133;A;k=s\\a\\]' + PS1="${PS1//$'\n'/$'\n'$__ghostty_mark}" + PS1="${PS1//\\n/\\n$__ghostty_mark}" fi - if test "$_ghostty_executing" != ""; then - # End of current command. Report its status. - builtin printf "\e]133;D;%s;aid=%s\a" "$ret" "$BASHPID" + # Cursor + if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then + [[ "$PS1" != *'\[\e[5 q\]'* ]] && PS1=$PS1'\[\e[5 q\]' # input + [[ "$PS0" != *'\[\e[0 q\]'* ]] && PS0=$PS0'\[\e[0 q\]' # reset fi - # unfortunately bash provides no hooks to detect cwd changes - # in particular this means cwd reporting will not happen for a - # command like cd /test && cat. PS0 is evaluated before cd is run. - if [[ "$_ghostty_last_reported_cwd" != "$PWD" ]]; then - _ghostty_last_reported_cwd="$PWD" - builtin printf "\e]7;kitty-shell-cwd://%s%s\a" "$HOSTNAME" "$PWD" + # Title (working directory) + if [[ "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then + PS1=$PS1'\[\e]2;\w\a\]' fi + fi - # Fresh line and start of prompt. - builtin printf "\e]133;A;aid=%s\a" "$BASHPID" - _ghostty_executing=0 + if test "$_ghostty_executing" != ""; then + # End of current command. Report its status. + builtin printf "\e]133;D;%s;aid=%s\a" "$ret" "$BASHPID" + fi + + # unfortunately bash provides no hooks to detect cwd changes + # in particular this means cwd reporting will not happen for a + # command like cd /test && cat. PS0 is evaluated before cd is run. + if [[ "$_ghostty_last_reported_cwd" != "$PWD" ]]; then + _ghostty_last_reported_cwd="$PWD" + builtin printf "\e]7;kitty-shell-cwd://%s%s\a" "$HOSTNAME" "$PWD" + fi + + # Fresh line and start of prompt. + builtin printf "\e]133;A;redraw=last;aid=%s\a" "$BASHPID" + _ghostty_executing=0 } function __ghostty_preexec() { - builtin local cmd="$1" + builtin local cmd="$1" - PS1="$_GHOSTTY_SAVE_PS1" - PS2="$_GHOSTTY_SAVE_PS2" + PS1="$_GHOSTTY_SAVE_PS1" + PS2="$_GHOSTTY_SAVE_PS2" - # Title (current command) - if [[ -n $cmd && "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then - builtin printf "\e]2;%s\a" "${cmd//[[:cntrl:]]}" - fi + # Title (current command) + if [[ -n $cmd && "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then + builtin printf "\e]2;%s\a" "${cmd//[[:cntrl:]]/}" + fi - # End of input, start of output. - builtin printf "\e]133;C;\a" - _ghostty_executing=1 + # End of input, start of output. + builtin printf "\e]133;C;\a" + _ghostty_executing=1 } preexec_functions+=(__ghostty_preexec) diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 3fb3ec19b..ac609d6a0 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -132,6 +132,7 @@ _ghostty_deferred_init() { # asynchronously from a `zle -F` handler might still remove our # marks. Oh well. builtin local mark2=$'%{\e]133;A;k=s\a%}' + builtin local markB=$'%{\e]133;B\a%}' # Add marks conditionally to avoid a situation where we have # several marks in place. These conditions can have false # positives and false negatives though. @@ -139,8 +140,17 @@ _ghostty_deferred_init() { # - False positive (with prompt_percent): PS1="%(?.$mark1.)" # - False negative (with prompt_subst): PS1='$mark1' [[ $PS1 == *$mark1* ]] || PS1=${mark1}${PS1} + [[ $PS1 == *$markB* ]] || PS1=${PS1}${markB} + # Handle multiline prompts by marking continuation lines as + # secondary by replacing newlines with being prefixed + # with k=s + if [[ $PS1 == *$'\n'* ]]; then + PS1=${PS1//$'\n'/$'\n'${mark2}} + fi + # PS2 mark is needed when clearing the prompt on resize [[ $PS2 == *$mark2* ]] || PS2=${mark2}${PS2} + [[ $PS2 == *$markB* ]] || PS2=${PS2}${markB} (( _ghostty_state = 2 )) else # If our precmd hook is not the last, we cannot rely on prompt @@ -179,7 +189,10 @@ _ghostty_deferred_init() { # top. We cannot force prompt_subst on the user though, so we would # still need this code for the no_prompt_subst case. PS1=${PS1//$'%{\e]133;A\a%}'} + PS1=${PS1//$'%{\e]133;A;k=s\a%}'} + PS1=${PS1//$'%{\e]133;B\a%}'} PS2=${PS2//$'%{\e]133;A;k=s\a%}'} + PS2=${PS2//$'%{\e]133;B\a%}'} # This will work incorrectly in the presence of a preexec hook that # prints. For example, if MichaelAquilina/zsh-you-should-use installs diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 35826d97e..71534d0aa 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -12,6 +12,7 @@ const fastmem = @import("../fastmem.zig"); const tripwire = @import("../tripwire.zig"); const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinkedList; const color = @import("color.zig"); +const highlight = @import("highlight.zig"); const kitty = @import("kitty.zig"); const point = @import("point.zig"); const pagepkg = @import("page.zig"); @@ -1228,7 +1229,7 @@ const ReflowCursor = struct { // If the row has a semantic prompt then the blank row is meaningful // so we just consider pretend the first cell of the row isn't empty. - if (cols_len == 0 and src_row.semantic_prompt != .unknown) cols_len = 1; + if (cols_len == 0 and src_row.semantic_prompt != .none) cols_len = 1; } // Handle tracked pin adjustments. @@ -1972,7 +1973,7 @@ const ReflowCursor = struct { // If the row has a semantic prompt then the blank row is meaningful // so we always return all but one so that the row is drawn. - if (self.page_row.semantic_prompt != .unknown) return len - 1; + if (self.page_row.semantic_prompt != .none) return len - 1; return len; } @@ -2668,21 +2669,26 @@ fn scrollPrompt(self: *PageList, delta: isize) void { const delta_start: usize = @intCast(if (delta > 0) delta else -delta); var delta_rem: usize = delta_start; - // Iterate and count the number of prompts we see. - const viewport_pin = self.getTopLeft(.viewport); - var it = viewport_pin.rowIterator(if (delta > 0) .right_down else .left_up, null); - _ = it.next(); // skip our own row + // We start at the row before or after our viewport depending on the + // delta so that we don't land back on our current viewport. + const start_pin = start: { + const tl = self.getTopLeft(.viewport); + const adjusted: ?Pin = if (delta > 0) + tl.down(1) + else + tl.up(1); + break :start adjusted orelse return; + }; + + // Go through prompts delta times + var it = start_pin.promptIterator( + if (delta > 0) .right_down else .left_up, + null, + ); var prompt_pin: ?Pin = null; while (it.next()) |next| { - const row = next.rowAndCell().row; - switch (row.semantic_prompt) { - .command, .unknown => {}, - .prompt, .prompt_continuation, .input => { - delta_rem -= 1; - prompt_pin = next; - }, - } - + prompt_pin = next; + delta_rem -= 1; if (delta_rem == 0) break; } @@ -4210,9 +4216,321 @@ pub fn diagram( } } +/// Returns the boundaries of the given semantic content type for +/// the prompt at the given pin. The pin row MUST be the first row +/// of a prompt, otherwise the results may be nonsense. +/// +/// To get prompt pins, use promptIterator. Warning that if there are +/// no semantic prompts ever present, promptIterator will iterate the +/// entire PageList. Downstream callers should keep track of a flag if +/// they've ever seen semantic prompt operations to prevent this performance +/// case. +/// +/// Note that some semantic content type such as "input" is usually +/// nested within prompt boundaries, so the returned boundaries may include +/// prompt text. +pub fn highlightSemanticContent( + self: *const PageList, + at: Pin, + content: pagepkg.Cell.SemanticContent, +) ?highlight.Untracked { + // Performance note: we can do this more efficiently in a single + // forward-pass. Semantic content operations aren't usually fast path + // but if someone wants to optimize them someday that's great. + + const end: Pin = end: { + // Safety assertion, our starting point should be a prompt row. + // so the first returned prompt should be ourselves. + var it = at.promptIterator(.right_down, null); + assert(it.next().?.y == at.y); + + // Our end is the end of the line just before the next prompt + // line, which should exist since we verified we have at least + // two prompts here. + if (it.next()) |next| next: { + var prev = next.up(1) orelse break :next; + prev.x = prev.node.data.size.cols - 1; + break :end prev; + } + + // Didn't find any further prompt so the end of our zone is + // the end of the screen. + break :end self.getBottomRight(.screen).?; + }; + + switch (content) { + // For the prompt, we select all the way up to command output. + // We include all the input lines, too. + .prompt => { + var result: highlight.Untracked = .{ + .start = at.left(at.x), + .end = at, + }; + + var it = at.cellIterator(.right_down, end); + while (it.next()) |p| { + switch (p.rowAndCell().cell.semantic_content) { + .prompt, .input => result.end = p, + .output => break, + } + } + + return result; + }, + + // For input, we include the start of the input to the end of + // the input, which may include all the prompts in the middle, too. + .input => { + var result: highlight.Untracked = .{ + .start = undefined, + .end = undefined, + }; + + // Find the start + var it = at.cellIterator(.right_down, end); + while (it.next()) |p| { + switch (p.rowAndCell().cell.semantic_content) { + .prompt => {}, + .input => { + result.start = p; + result.end = p; + break; + }, + .output => return null, + } + } else { + // No input found + return null; + } + + // Find the end + while (it.next()) |p| { + switch (p.rowAndCell().cell.semantic_content) { + // Prompts can be nested in our input for continuation + .prompt => {}, + + // Output means we're done + .output => break, + + .input => result.end = p, + } + } + + return result; + }, + + .output => { + var result: highlight.Untracked = .{ + .start = undefined, + .end = undefined, + }; + + // Find the start + var it = at.cellIterator(.right_down, end); + while (it.next()) |p| { + const cell = p.rowAndCell().cell; + switch (cell.semantic_content) { + .prompt, .input => {}, + .output => { + // Skip empty cells - they default to .output but aren't real output + if (!cell.hasText()) continue; + result.start = p; + result.end = p; + break; + }, + } + } else { + // No output found + return null; + } + + // Find the end + while (it.next()) |p| { + const cell = p.rowAndCell().cell; + switch (cell.semantic_content) { + .prompt, .input => break, + .output => { + // Only extend to cells with actual text + if (cell.hasText()) result.end = p; + }, + } + } + + return result; + }, + } +} + /// Direction that iterators can move. pub const Direction = enum { left_up, right_down }; +pub const PromptIterator = struct { + /// The pin that we are currently at. Also the starting pin when + /// initializing. + current: ?Pin, + + /// The pin to end at or null if we end when we can't traverse + /// anymore. + limit: ?Pin, + + /// The direction to do the traversal. + direction: Direction, + + pub const empty: PromptIterator = .{ + .current = null, + .limit = null, + .direction = .left_up, + }; + + /// Return the next pin that represents the first row in a prompt. + /// From here, you can find the prompt input, command output, etc. + pub fn next(self: *PromptIterator) ?Pin { + switch (self.direction) { + .left_up => return self.nextLeftUp(), + .right_down => return self.nextRightDown(), + } + } + + pub fn nextRightDown(self: *PromptIterator) ?Pin { + // Start at our current pin. If we have no current it means + // we reached the end and we're done. + const start: Pin = self.current orelse return null; + + // We need to traverse downwards and look for prompts. + var current: ?Pin = start; + while (current) |p| : (current = p.down(1)) { + // Check our limit. + const at_limit = if (self.limit) |limit| limit.eql(p) else false; + + const rac = p.rowAndCell(); + switch (rac.row.semantic_prompt) { + // This row isn't a prompt. Keep looking. + .none => if (at_limit) break, + + // This is a prompt line or continuation line. In either + // case we consider the first line the prompt, and then + // skip over any remaining prompt lines. This handles the + // case where scrollback pruned the prompt. + .prompt, .prompt_continuation => { + // If we're at our limit just return this prompt. + if (at_limit) { + self.current = null; + return p.left(p.x); + } + + // Skip over any continuation lines that follow this prompt, + // up to our limit. + var end_pin = p; + while (end_pin.down(1)) |next_pin| : (end_pin = next_pin) { + switch (next_pin.rowAndCell().row.semantic_prompt) { + .prompt_continuation => if (self.limit) |limit| { + if (limit.eql(next_pin)) break; + }, + + .prompt, .none => { + self.current = next_pin; + return p.left(p.x); + }, + } + } + + self.current = null; + return p.left(p.x); + }, + } + } + + self.current = null; + return null; + } + + pub fn nextLeftUp(self: *PromptIterator) ?Pin { + // Start at our current pin. If we have no current it means + // we reached the end and we're done. + const start: Pin = self.current orelse return null; + + // We need to traverse upwards and look for prompts. + var current: ?Pin = start; + while (current) |p| : (current = p.up(1)) { + // Check our limit. + const at_limit = if (self.limit) |limit| limit.eql(p) else false; + + const rac = p.rowAndCell(); + switch (rac.row.semantic_prompt) { + // This row isn't a prompt. Keep looking. + .none => if (at_limit) break, + + // This is a prompt line. + .prompt => { + self.current = if (at_limit) null else p.up(1); + return p.left(p.x); + }, + + // If this is a prompt continuation, then we continue + // looking for the start of the prompt OR a non-prompt + // line, whichever is first. The non-prompt line is to handle + // poorly behaved programs or scrollback that's been cut-off. + .prompt_continuation => { + // If we're at our limit just return this continuation as prompt. + if (at_limit) { + self.current = null; + return p.left(p.x); + } + + var end_pin = p; + while (end_pin.up(1)) |prior| : (end_pin = prior) { + if (self.limit) |limit| { + if (limit.eql(prior)) break; + } + + switch (prior.rowAndCell().row.semantic_prompt) { + // No prompt. That means our last pin is good! + .none => { + self.current = prior; + return end_pin.left(end_pin.x); + }, + + // Prompt continuation, keep looking. + .prompt_continuation => {}, + + // Prompt! Found it! + .prompt => { + self.current = prior.up(1); + return prior.left(prior.x); + }, + } + } + + // No prior rows, trimmed scrollback probably. + self.current = null; + return p.left(p.x); + }, + } + } + + self.current = null; + return null; + } +}; + +pub fn promptIterator( + self: *const PageList, + direction: Direction, + tl_pt: point.Point, + bl_pt: ?point.Point, +) PromptIterator { + const tl_pin = self.pin(tl_pt).?; + const bl_pin = if (bl_pt) |pt| + self.pin(pt).? + else + self.getBottomRight(tl_pt) orelse return .empty; + + return switch (direction) { + .right_down => tl_pin.promptIterator(.right_down, bl_pin), + .left_up => bl_pin.promptIterator(.left_up, tl_pin), + }; +} + pub const CellIterator = struct { row_it: RowIterator, cell: ?Pin = null, @@ -4816,6 +5134,18 @@ pub const Pin = struct { return .{ .row_it = row_it, .cell = cell }; } + pub inline fn promptIterator( + self: Pin, + direction: Direction, + limit: ?Pin, + ) PromptIterator { + return .{ + .current = self, + .limit = limit, + .direction = direction, + }; + } + /// Returns true if this pin is between the top and bottom, inclusive. // // Note: this is primarily unit tested as part of the Kitty @@ -7565,6 +7895,1330 @@ test "PageList cellIterator reverse" { try testing.expect(it.next() == null); } +test "PageList promptIterator left_up" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + // Normal prompt + { + const rac = page.getRowAndCell(0, 3); + rac.row.semantic_prompt = .prompt; + } + // Continuation + { + const rac = page.getRowAndCell(0, 6); + rac.row.semantic_prompt = .prompt; + } + { + const rac = page.getRowAndCell(0, 7); + rac.row.semantic_prompt = .prompt_continuation; + } + { + const rac = page.getRowAndCell(0, 8); + rac.row.semantic_prompt = .prompt_continuation; + } + // Broken continuation that has non-prompts in between + { + const rac = page.getRowAndCell(0, 12); + rac.row.semantic_prompt = .prompt_continuation; + } + + var it = s.promptIterator(.left_up, .{ .screen = .{} }, null); + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 12, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 6, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 3, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + +test "PageList promptIterator right_down" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + // Normal prompt + { + const rac = page.getRowAndCell(0, 3); + rac.row.semantic_prompt = .prompt; + } + // Continuation (prompt on row 6, continuation on rows 7-8) + { + const rac = page.getRowAndCell(0, 6); + rac.row.semantic_prompt = .prompt; + } + { + const rac = page.getRowAndCell(0, 7); + rac.row.semantic_prompt = .prompt_continuation; + } + { + const rac = page.getRowAndCell(0, 8); + rac.row.semantic_prompt = .prompt_continuation; + } + // Broken continuation that has non-prompts in between (orphaned continuation at row 12) + { + const rac = page.getRowAndCell(0, 12); + rac.row.semantic_prompt = .prompt_continuation; + } + + var it = s.promptIterator(.right_down, .{ .screen = .{} }, null); + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 3, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 6, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 12, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + +test "PageList promptIterator right_down continuation at start" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt continuation at row 0 (no prior rows - simulates trimmed scrollback) + { + const rac = page.getRowAndCell(0, 0); + rac.row.semantic_prompt = .prompt_continuation; + } + { + const rac = page.getRowAndCell(0, 1); + rac.row.semantic_prompt = .prompt_continuation; + } + // Normal prompt later + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + } + + var it = s.promptIterator(.right_down, .{ .screen = .{} }, null); + { + // Should return the first continuation line since there's no prior prompt + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + +test "PageList promptIterator right_down with prompt before continuation" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 2, continuation on rows 3-4 + // Starting iteration from row 3 should still find the prompt at row 2 + { + const rac = page.getRowAndCell(0, 2); + rac.row.semantic_prompt = .prompt; + } + { + const rac = page.getRowAndCell(0, 3); + rac.row.semantic_prompt = .prompt_continuation; + } + { + const rac = page.getRowAndCell(0, 4); + rac.row.semantic_prompt = .prompt_continuation; + } + + // Start iteration from row 3 (middle of the continuation) + // Since we start on a continuation line, we treat it as the prompt start + // (handles case where scrollback pruned the actual prompt) + var it = s.promptIterator(.right_down, .{ .screen = .{ .y = 3 } }, null); + { + const p = it.next().?; + // Returns row 3 since that's the first prompt-related line we encounter + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 3, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + +test "PageList promptIterator right_down limit inclusive" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Iterate with limit at row 5 (the prompt row) - should include it + var it = s.promptIterator(.right_down, .{ .screen = .{} }, .{ .screen = .{ .y = 5 } }); + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + +test "PageList promptIterator left_up limit inclusive" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Iterate with limit at row 10 (the prompt row) - should include it + // tl_pt is the limit (upper bound), bl_pt is the start point for left_up + var it = s.promptIterator(.left_up, .{ .screen = .{ .y = 10 } }, .{ .screen = .{ .y = 15 } }); + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + +test "PageList highlightSemanticContent prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // Start the prompt for the first 5 cols + for (0..5) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + .semantic_content = .prompt, + }; + } + + // Next 3 let's make input + for (5..8) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'B' }, + .semantic_content = .input, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 2, .y = 5 } }).?, + .prompt, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 5, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent prompt with output" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First 3 cols are prompt + for (0..3) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Next 4 are input + for (3..7) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'l' }, + .semantic_content = .input, + }; + } + + // Rest is output (shouldn't be included in prompt highlight) + for (7..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting from prompt should include prompt and input, but stop at output + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .prompt, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 6, + .y = 5, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent prompt multiline" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt starts on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First row is all prompt + for (0..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + } + // Row 6 continues with input + { + for (0..5) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting should span both rows + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 2, .y = 5 } }).?, + .prompt, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 6, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent prompt only" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 with only prompt content (no input) + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + for (0..5) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting should only include the prompt cells + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .prompt, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 5, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent prompt to end of screen" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Single prompt on row 15, no following prompt + { + const rac = page.getRowAndCell(0, 15); + rac.row.semantic_prompt = .prompt; + + for (0..3) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + for (3..8) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + } + + // Highlighting should include prompt and input up to column 7 + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 15 } }).?, + .prompt, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 15, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 15, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent input basic" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First 3 cols are prompt + for (0..3) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Next 5 are input + for (3..8) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'l' }, + .semantic_content = .input, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting input should only include input cells + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .input, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 5, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent input with output" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First 2 cols are prompt + for (0..2) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Next 3 are input + for (2..5) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + + // Rest is output + for (5..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting input should stop at output + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .input, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 5, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent input multiline with continuation" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First 2 cols are prompt + for (0..2) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Rest is input + for (2..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + } + // Row 6 has continuation prompt then more input + { + // Continuation prompt + for (0..2) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '>' }, + .semantic_content = .prompt, + }; + } + + // More input + for (2..6) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'd' }, + .semantic_content = .input, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting input should span both rows, skipping continuation prompts + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .input, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 6, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent input no input returns null" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 with only prompt, then immediately output + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First 3 cols are prompt + for (0..3) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Rest is output (no input!) + for (3..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting input should return null when there's no input + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .input, + ); + try testing.expect(hl == null); +} + +test "PageList highlightSemanticContent input to end of screen" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Single prompt on row 15, no following prompt + { + const rac = page.getRowAndCell(0, 15); + rac.row.semantic_prompt = .prompt; + + for (0..2) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + for (2..7) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + } + + // Highlighting input with no following prompt + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 15 } }).?, + .input, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 15, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 6, + .y = 15, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent input prompt only returns null" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 with only prompt content, no input or output + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // All cells are prompt + for (0..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + } + // Mark rows 6-9 as prompt to ensure no input before next prompt + { + for (6..10) |y| { + for (0..10) |x| { + const cell = page.getRowAndCell(x, y).cell; + cell.semantic_content = .prompt; + } + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting input should return null when there's only prompts + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .input, + ); + try testing.expect(hl == null); +} + +test "PageList highlightSemanticContent output basic" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First 2 cols are prompt + for (0..2) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Next 3 are input + for (2..5) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'l' }, + .semantic_content = .input, + }; + } + + // Cols 5-7 are output + for (5..8) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + + // Mark remaining cells as prompt to bound the output + for (8..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.semantic_content = .prompt; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting output should only include output cells + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .output, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 5, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent output multiline" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First 2 cols are prompt + for (0..2) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Next 2 are input + for (2..4) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'l' }, + .semantic_content = .input, + }; + } + + // Rest of row 5 is output + for (4..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Row 6 is all output + { + for (0..10) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Row 7 has partial output then input to bound it + { + for (0..5) |x| { + const cell = page.getRowAndCell(x, 7).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + for (5..10) |x| { + const cell = page.getRowAndCell(x, 7).cell; + cell.semantic_content = .input; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting output should span multiple rows + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .output, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 7, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent output stops at next prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First 2 cols are prompt + for (0..2) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Next 2 are input + for (2..4) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'l' }, + .semantic_content = .input, + }; + } + + // Rest is output + for (4..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Row 6 has output then prompt starts + { + for (0..3) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + // Next prompt marker on same row + for (3..6) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting output should stop before prompt/input + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .output, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 6, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent output to end of screen" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Single prompt on row 15, no following prompt + { + const rac = page.getRowAndCell(0, 15); + rac.row.semantic_prompt = .prompt; + + for (0..2) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + for (2..4) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + + for (4..10) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Row 16 has output then prompt to bound it + { + for (0..8) |x| { + const cell = page.getRowAndCell(x, 16).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + for (8..10) |x| { + const cell = page.getRowAndCell(x, 16).cell; + cell.semantic_content = .prompt; + } + } + + // Highlighting output with no following prompt + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 15 } }).?, + .output, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 15, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 16, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent output no output returns null" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 with only prompt and input, no output + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First 3 cols are prompt + for (0..3) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Rest is input (must explicitly mark all cells to avoid default .output) + for (3..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + } + // Mark rows 6-9 as input to ensure no output between prompts + { + for (6..10) |y| { + for (0..10) |x| { + const cell = page.getRowAndCell(x, y).cell; + cell.semantic_content = .input; + } + } + } + // Prompt on row 10 (no output between prompts) + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting output should return null when there's no output + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .output, + ); + try testing.expect(hl == null); +} + +test "PageList highlightSemanticContent output skips empty cells" { + // Tests that empty cells with default .output semantic content are + // not selected as output. This can happen when a prompt/input line + // doesn't fill the entire row - trailing cells have default .output. + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 - only fills first 3 cells, rest are empty with default .output + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First 3 cols are prompt with text + for (0..3) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + // Cells 3-9 are empty (codepoint = 0) with default .output semantic content + // This simulates what happens when a short prompt is written + } + + // Row 6 has input (short, doesn't fill line) + { + for (0..4) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'l' }, + .semantic_content = .input, + }; + } + // Cells 4-9 are empty with default .output + } + + // Row 7-8 have actual output with text + { + for (7..9) |y| { + for (0..5) |x| { + const cell = page.getRowAndCell(x, y).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + } + + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting output should skip empty cells on rows 5-6 and find + // the actual output starting at row 7 + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .output, + ).?; + // Output should start at row 7, not row 5 (where empty cells have default .output) + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 7, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 8, + } }, s.pointFromPin(.screen, hl.end).?); +} + test "PageList erase" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 45fe9dfc6..2d27eb2d6 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -15,6 +15,7 @@ const Selection = @import("Selection.zig"); const PageList = @import("PageList.zig"); const StringMap = @import("StringMap.zig"); const ScreenFormatter = @import("formatter.zig").ScreenFormatter; +const osc = @import("osc.zig"); const pagepkg = @import("page.zig"); const point = @import("point.zig"); const size = @import("size.zig"); @@ -76,6 +77,9 @@ else /// Dirty flags for the renderer. dirty: Dirty = .{}, +/// Packed flags for the screen, internal state. +flags: Flags = .{}, + /// See Terminal.Dirty. This behaves the same way. pub const Dirty = packed struct { /// Set when the selection is set or unset, regardless of if the @@ -87,6 +91,17 @@ pub const Dirty = packed struct { hyperlink_hover: bool = false, }; +/// A set of internal state that we pack for memory size. +pub const Flags = packed struct { + /// This is flipped to true when any sort of semantic content is + /// seen. In particular, this is set to true only when a `prompt` type + /// is ever set on our cursor. + /// + /// This is used to optimize away semantic content operations if we know + /// we've never seen them. + semantic_content: bool = false, +}; + /// The cursor position and style. pub const Cursor = struct { // The x/y position within the active area. @@ -134,6 +149,11 @@ pub const Cursor = struct { /// because its most likely null. hyperlink: ?*hyperlink.Hyperlink = null, + /// The current semantic content type for the cursor that will be + /// applied to any newly written cells. + semantic_content: pagepkg.Cell.SemanticContent = .output, + semantic_content_clear_eol: bool = false, + /// The pointers into the page list where the cursor is currently /// located. This makes it faster to move the cursor. page_pin: *PageList.Pin, @@ -358,6 +378,7 @@ pub fn reset(self: *Screen) void { self.charset = .{}; self.kitty_keyboard = .{}; self.protected_mode = .off; + self.flags = .{}; self.clearSelection(); } @@ -1440,61 +1461,6 @@ pub fn clearUnprotectedCells( self.assertIntegrity(); } -/// Clears the prompt lines if the cursor is currently at a prompt. This -/// clears the entire line. This is used for resizing when the shell -/// handles reflow. -/// -/// The cleared cells are not colored with the current style background -/// color like other clear functions, because this is a special case used -/// for a specific purpose that does not want that behavior. -pub fn clearPrompt(self: *Screen) void { - var found: ?Pin = null; - - // From our cursor, move up and find all prompt lines. - var it = self.cursor.page_pin.rowIterator( - .left_up, - self.pages.pin(.{ .active = .{} }), - ); - while (it.next()) |p| { - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - // We are at a prompt but we're not at the start of the prompt. - // We mark our found value and continue because the prompt - // may be multi-line, unless this is the second time we've - // seen an .input marker, in which case we've run into an - // earlier prompt. - .input => { - if (found != null) break; - found = p; - }, - - // If we find the prompt then we're done. We are also done - // if we find any prompt continuation, because the shells - // that send this currently (zsh) cannot redraw every line. - .prompt, .prompt_continuation => { - found = p; - break; - }, - - // If we have command output, then we're most certainly not - // at a prompt. Break out of the loop. - .command => break, - - // If we don't know, we keep searching. - .unknown => {}, - } - } - - // If we found a prompt, we clear it. - if (found) |top| { - var clear_it = top.rowIterator(.right_down, null); - while (clear_it.next()) |p| { - const row = p.rowAndCell().row; - p.node.data.clearCells(row, 0, p.node.data.size.cols); - } - } -} - /// Clean up boundary conditions where a cell will become discontiguous with /// a neighboring cell because either one of them will be moved and/or cleared. /// @@ -1630,12 +1596,27 @@ pub inline fn blankCell(self: *const Screen) Cell { return self.cursor.style.bgCell() orelse .{}; } +pub const Resize = struct { + /// The new size to resize to + cols: size.CellCountInt, + rows: size.CellCountInt, + + /// Whether to reflow soft-wrapped text. + /// + /// This will reflow soft-wrapped text. If the screen size is getting + /// smaller and the maximum scrollback size is exceeded, data will be + /// lost from the top of the scrollback. + reflow: bool = true, + + /// Set this to enable prompt redraw on resize. This signals + /// that the running program can redraw the prompt if the cursor is + /// currently at a prompt. This detects OSC133 prompts lines and clears + /// them. If set to `.last`, only the most recent prompt line is cleared. + prompt_redraw: osc.semantic_prompt.Redraw = .false, +}; + /// Resize the screen. The rows or cols can be bigger or smaller. /// -/// This will reflow soft-wrapped text. If the screen size is getting -/// smaller and the maximum scrollback size is exceeded, data will be -/// lost from the top of the scrollback. -/// /// If this returns an error, the screen is left in a likely garbage state. /// It is very hard to undo this operation without blowing up our memory /// usage. The only way to recover is to reset the screen. The only way @@ -1645,29 +1626,7 @@ pub inline fn blankCell(self: *const Screen) Cell { /// (resize) is difficult. pub inline fn resize( self: *Screen, - cols: size.CellCountInt, - rows: size.CellCountInt, -) !void { - try self.resizeInternal(cols, rows, true); -} - -/// Resize the screen without any reflow. In this mode, columns/rows will -/// be truncated as they are shrunk. If they are grown, the new space is filled -/// with zeros. -pub inline fn resizeWithoutReflow( - self: *Screen, - cols: size.CellCountInt, - rows: size.CellCountInt, -) !void { - try self.resizeInternal(cols, rows, false); -} - -/// Resize the screen. -fn resizeInternal( - self: *Screen, - cols: size.CellCountInt, - rows: size.CellCountInt, - reflow: bool, + opts: Resize, ) !void { defer self.assertIntegrity(); @@ -1721,11 +1680,65 @@ fn resizeInternal( }; defer if (saved_cursor_pin) |p| self.pages.untrackPin(p); + // If our cursor is on a prompt or input line, clear it so the shell can + // redraw it. This works with OSC 133 semantic prompts. + // + // We check cursor.semantic_content rather than page_row.semantic_prompt + // because some shells (e.g., Nu) mark input areas with OSC 133 B but don't + // mark continuation lines with k=s. If the input spans multiple lines and + // continuation lines are unmarked, checking only page_row.semantic_prompt + // would miss them. By checking semantic_content, we assume that if the + // cursor is on anything other than command output, we're at a prompt/input + // line and should clear from there. + if (opts.prompt_redraw != .false and + self.cursor.semantic_content != .output) + prompt: { + switch (opts.prompt_redraw) { + .false => unreachable, + + // For `.last`, only clear the current line where the cursor is. + // For `.true`, clear all prompt lines starting from the beginning. + .last => { + const page = &self.cursor.page_pin.node.data; + const row = self.cursor.page_row; + const cells = page.getCells(row); + self.clearCells(page, row, cells); + }, + + .true => { + const start = start: { + var it = self.cursor.page_pin.promptIterator( + .left_up, + null, + ); + break :start it.next() orelse { + // This should never happen because promptIterator should always + // find a prompt if we already verified our row is some kind of + // prompt. + log.warn("cursor on prompt line but promptIterator found no prompt", .{}); + break :prompt; + }; + }; + + // Clear cells from our start down. We replace it with spaces, + // and do not physically erase the rows (eraseRows) because the + // shell is going to expect this space to be available. + var it = start.rowIterator(.right_down, null); + while (it.next()) |pin| { + const page = &pin.node.data; + const row = pin.rowAndCell().row; + const cells = page.getCells(row); + self.clearCells(page, row, cells); + } + }, + } + } + // Perform the resize operation. try self.pages.resize(.{ - .rows = rows, - .cols = cols, - .reflow = reflow, + .rows = opts.rows, + .cols = opts.cols, + .reflow = opts.reflow, .cursor = .{ .x = self.cursor.x, .y = self.cursor.y }, }); @@ -1752,7 +1765,7 @@ fn resizeInternal( // If we had pending wrap set and we're no longer at the end of // the line, we unset the pending wrap and move the cursor to // reflect the correct next position. - if (sc.pending_wrap and sc.x != cols - 1) { + if (sc.pending_wrap and sc.x != opts.cols - 1) { sc.pending_wrap = false; sc.x += 1; } @@ -2324,6 +2337,42 @@ pub fn cursorSetHyperlink(self: *Screen) PageList.IncreaseCapacityError!void { } } +/// Modify the semantic content type of the cursor. This should +/// be preferred over setting it manually since it handles all the +/// proper accounting. +pub fn cursorSetSemanticContent(self: *Screen, t: union(enum) { + prompt: osc.semantic_prompt.PromptKind, + output, + input: enum { clear_explicit, clear_eol }, +}) void { + const cursor = &self.cursor; + + switch (t) { + .output => { + cursor.semantic_content = .output; + cursor.semantic_content_clear_eol = false; + }, + + .input => |clear| { + cursor.semantic_content = .input; + cursor.semantic_content_clear_eol = switch (clear) { + .clear_explicit => false, + .clear_eol => true, + }; + }, + + .prompt => |kind| { + self.flags.semantic_content = true; + cursor.semantic_content = .prompt; + cursor.semantic_content_clear_eol = false; + cursor.page_row.semantic_prompt = switch (kind) { + .initial, .right => .prompt, + .continuation, .secondary => .prompt_continuation, + }; + }, + } +} + /// Set the selection to the given selection. If this is a tracked selection /// then the screen will take ownership of the selection. If this is untracked /// then the screen will convert it to tracked internally. This will automatically @@ -2456,16 +2505,36 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { // only happen within the same prompt state. For example, if you triple // click output, but the shell uses spaces to soft-wrap to the prompt // then the selection will stop prior to the prompt. See issue #1329. - const semantic_prompt_state: ?bool = state: { + const semantic_prompt_state: ?Cell.SemanticContent = state: { if (!opts.semantic_prompt_boundary) break :state null; const rac = opts.pin.rowAndCell(); - break :state rac.row.semantic_prompt.promptOrInput(); + break :state rac.cell.semantic_content; }; // The real start of the row is the first row in the soft-wrap. const start_pin: Pin = start_pin: { var it = opts.pin.rowIterator(.left_up, null); var it_prev: Pin = it.next().?; // skip self + + // First, check the current row for semantic boundaries before the clicked position. + if (semantic_prompt_state) |v| { + const row = it_prev.rowAndCell().row; + const cells = it_prev.node.data.getCells(row); + // Scan backwards from clicked position to find where our content starts + for (0..opts.pin.x + 1) |i| { + const x_rev = opts.pin.x - i; + if (cells[x_rev].semantic_content != v) { + var copy = it_prev; + copy.x = @intCast(x_rev + 1); + break :start_pin copy; + } + } + + // No boundary found before clicked position on current row. + // If row doesn't wrap from above, start is at column 0. + // Otherwise, continue checking previous rows. + } + while (it.next()) |p| { const row = p.rowAndCell().row; @@ -2476,13 +2545,18 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { } if (semantic_prompt_state) |v| { - // See semantic_prompt_state comment for why - const current_prompt = row.semantic_prompt.promptOrInput(); - if (current_prompt != v) { - var copy = it_prev; - copy.x = 0; - break :start_pin copy; + // We need to check every cell in this row in reverse + // order since we're going up and back. + const cells = p.node.data.getCells(row); + for (0..cells.len) |x| { + const x_rev = cells.len - 1 - x; + const cell = cells[x_rev]; + if (cell.semantic_content != v) break :start_pin it_prev; + it_prev = p; + it_prev.x = @intCast(x_rev); } + + continue; } it_prev = p; @@ -2500,13 +2574,32 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { const row = p.rowAndCell().row; if (semantic_prompt_state) |v| { - // See semantic_prompt_state comment for why - const current_prompt = row.semantic_prompt.promptOrInput(); - if (current_prompt != v) { + // We need to check every cell in this row + const cells = p.node.data.getCells(row); + + // If this is our pin row we can start from our x because + // the start_pin logic already found the real start. + const start_offset = if (p.node == opts.pin.node and + p.y == opts.pin.y) opts.pin.x else 0; + + // Handle the zero case specially because if the first + // col doesn't match then we end at the end of the prior + // row. But if this is the first row, we can't go back, + // so we scan forward to find where our content ends. + if (start_offset == 0 and cells[0].semantic_content != v) { var prev = p.up(1).?; prev.x = p.node.data.size.cols - 1; break :end_pin prev; } + + // For every other case, we end at the prior cell. + for (start_offset.., cells[start_offset..]) |x, cell| { + if (cell.semantic_content != v) { + var copy = p; + copy.x = @intCast(x - 1); + break :end_pin copy; + } + } } if (!row.wrap) { @@ -2749,181 +2842,60 @@ pub fn selectWord( /// are determined by semantic prompt information provided by shell integration. /// A selection can span multiple physical lines if they are soft-wrapped. /// -/// This will return null if a selection is impossible. The only scenarios -/// this happens is if: +/// This will return null if a selection is impossible: /// - the point pt is outside of the written screen space. /// - the point pt is on a prompt / input line. pub fn selectOutput(self: *Screen, pin: Pin) ?Selection { - _ = self; + // If our pin right now is not on output, then we return nothing. + if (pin.rowAndCell().cell.semantic_content != .output) return null; - switch (pin.rowAndCell().row.semantic_prompt) { - .input, .prompt_continuation, .prompt => { - // Cursor on a prompt line, selection impossible - return null; - }, + // Get the post prior prompt from this pin. This is the prompt whose + // output we'll be capturing. + const prompt_pin: Pin = prompt: { + // If we have a prompt above this point (including this point), + // then thats the prompt we want to capture output from. + var it = pin.promptIterator(.left_up, null); + if (it.next()) |p| break :prompt p; - else => {}, + // If we don't have a prompt, then we assume that we're + // capturing all the output up to the next prompt. + it = pin.promptIterator(.right_down, null); + const next = it.next() orelse return null; + + // We'll capture from the start of the screen to just above + // the prompt and will trim the trailing whitespace. + const start_pin = self.pages.getTopLeft(.screen); + var end_pin = next.up(1) orelse return null; + end_pin.x = end_pin.node.data.size.cols - 1; + var cell_it = end_pin.cellIterator(.left_up, start_pin); + while (cell_it.next()) |p| { + const cell = p.rowAndCell().cell; + end_pin = p; + if (cell.hasText()) break; + } + + return .init( + start_pin, + end_pin, + false, + ); + }; + + // Grab our content + var hl = self.pages.highlightSemanticContent( + prompt_pin, + .output, + ) orelse return null; + + // Trim our trailing whitespace + var cell_it = hl.end.cellIterator(.left_up, hl.start); + while (cell_it.next()) |p| { + const cell = p.rowAndCell().cell; + hl.end = p; + if (cell.hasText()) break; } - // Go forwards to find our end boundary - // We are looking for input start / prompt markers - const end: Pin = boundary: { - var it = pin.rowIterator(.right_down, null); - var it_prev = pin; - while (it.next()) |p| { - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - .input, .prompt_continuation, .prompt => { - var copy = it_prev; - copy.x = it_prev.node.data.size.cols - 1; - break :boundary copy; - }, - else => {}, - } - - it_prev = p; - } - - // Find the last non-blank row - it = it_prev.rowIterator(.left_up, null); - while (it.next()) |p| { - const row = p.rowAndCell().row; - const cells = p.node.data.getCells(row); - if (Cell.hasTextAny(cells)) { - var copy = p; - copy.x = p.node.data.size.cols - 1; - break :boundary copy; - } - } - - // In this case it means that all our rows are blank. Let's - // just return no selection, this is a weird case. - return null; - }; - - // Go backwards to find our start boundary - // We are looking for output start markers - const start: Pin = boundary: { - var it = pin.rowIterator(.left_up, null); - var it_prev = pin; - - // First, iterate until we find the first line of command output - while (it.next()) |p| { - it_prev = p; - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - .command => break, - - .unknown, - .prompt, - .prompt_continuation, - .input, - => {}, - } - } - - // Because the first line of command output may span multiple visual rows we must now - // iterate until we find the first row of anything other than command output and then - // yield the previous row. - while (it.next()) |p| { - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - .command => {}, - - .unknown, - .prompt, - .prompt_continuation, - .input, - => break :boundary it_prev, - } - it_prev = p; - } - - break :boundary it_prev; - }; - - return .init(start, end, false); -} - -/// Returns the selection bounds for the prompt at the given point. If the -/// point is not on a prompt line, this returns null. Note that due to -/// the underlying protocol, this will only return the y-coordinates of -/// the prompt. The x-coordinates of the start will always be zero and -/// the x-coordinates of the end will always be the last column. -/// -/// Note that this feature requires shell integration. If shell integration -/// is not enabled, this will always return null. -pub fn selectPrompt(self: *Screen, pin: Pin) ?Selection { - _ = self; - - // Ensure that the line the point is on is a prompt. - const is_known = switch (pin.rowAndCell().row.semantic_prompt) { - .prompt, .prompt_continuation, .input => true, - .command => return null, - - // We allow unknown to continue because not all shells output any - // semantic prompt information for continuation lines. This has the - // possibility of making this function VERY slow (we look at all - // scrollback) so we should try to avoid this in the future by - // setting a flag or something if we have EVER seen a semantic - // prompt sequence. - .unknown => false, - }; - - // Find the start of the prompt. - var saw_semantic_prompt = is_known; - const start: Pin = start: { - var it = pin.rowIterator(.left_up, null); - var it_prev = it.next().?; - while (it.next()) |p| { - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - // A prompt, we continue searching. - .prompt, .prompt_continuation, .input => saw_semantic_prompt = true, - - // See comment about "unknown" a few lines above. If we have - // previously seen a semantic prompt then if we see an unknown - // we treat it as a boundary. - .unknown => if (saw_semantic_prompt) break :start it_prev, - - // Command output or unknown, definitely not a prompt. - .command => break :start it_prev, - } - - it_prev = p; - } - - break :start it_prev; - }; - - // If we never saw a semantic prompt flag, then we can't trust our - // start value and we return null. This scenario usually means that - // semantic prompts aren't enabled via the shell. - if (!saw_semantic_prompt) return null; - - // Find the end of the prompt. - const end: Pin = end: { - var it = pin.rowIterator(.right_down, null); - var it_prev = it.next().?; - it_prev.x = it_prev.node.data.size.cols - 1; - while (it.next()) |p| { - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - // A prompt, we continue searching. - .prompt, .prompt_continuation, .input => {}, - - // Command output or unknown, definitely not a prompt. - .command, .unknown => break :end it_prev, - } - - it_prev = p; - it_prev.x = it_prev.node.data.size.cols - 1; - } - - break :end it_prev; - }; - - return .init(start, end, false); + return .init(hl.start, hl.end, false); } pub const LineIterator = struct { @@ -2969,8 +2941,16 @@ pub fn promptPath( x: isize, y: isize, } { + // Verify "from" is on a prompt row before calling highlightSemanticContent. + // highlightSemanticContent asserts the starting point is a prompt. + switch (from.rowAndCell().row.semantic_prompt) { + .prompt, .prompt_continuation => {}, + .none => return .{ .x = 0, .y = 0 }, + } + // Get our prompt bounds assuming "from" is at a prompt. - const bounds = self.selectPrompt(from) orelse return .{ .x = 0, .y = 0 }; + const hl = self.pages.highlightSemanticContent(from, .prompt) orelse return .{ .x = 0, .y = 0 }; + const bounds: Selection = .init(hl.start, hl.end, false); // Get our actual "to" point clamped to the bounds of the prompt. const to_clamped = if (bounds.contains(self, to)) @@ -3082,6 +3062,12 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { try self.cursorDownOrScroll(); self.cursorHorizontalAbsolute(0); self.cursor.pending_wrap = false; + if (self.cursor.semantic_content_clear_eol) { + self.cursorSetSemanticContent(.output); + } else switch (self.cursor.semantic_content) { + .input, .output => {}, + .prompt => self.cursor.page_row.semantic_prompt = .prompt_continuation, + } continue; } @@ -3114,6 +3100,10 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { try self.cursorDownOrScroll(); self.cursorHorizontalAbsolute(0); self.cursor.page_row.wrap_continuation = true; + switch (self.cursor.semantic_content) { + .input, .output => {}, + .prompt => self.cursor.page_row.semantic_prompt = .prompt_continuation, + } } assert(width == 1 or width == 2); @@ -3124,6 +3114,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { .content = .{ .codepoint = c }, .style_id = self.cursor.style_id, .protected = self.cursor.protected, + .semantic_content = self.cursor.semantic_content, }; // If we have a ref-counted style, increase. @@ -3145,6 +3136,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { .content = .{ .codepoint = 0 }, .wide = .spacer_head, .protected = self.cursor.protected, + .semantic_content = self.cursor.semantic_content, }; // If we have a hyperlink, add it to the cell. @@ -3163,6 +3155,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { .style_id = self.cursor.style_id, .wide = .wide, .protected = self.cursor.protected, + .semantic_content = self.cursor.semantic_content, }; // If we have a hyperlink, add it to the cell. @@ -3175,6 +3168,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { .content = .{ .codepoint = 0 }, .wide = .spacer_tail, .protected = self.cursor.protected, + .semantic_content = self.cursor.semantic_content, }; // If we have a hyperlink, add it to the cell. @@ -3200,29 +3194,6 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { } } -/// Write text that's marked as a semantic prompt. -fn testWriteSemanticString(self: *Screen, text: []const u8, semantic_prompt: Row.SemanticPrompt) !void { - // Determine the first row using the cursor position. If we know that our - // first write is going to start on the next line because of a pending - // wrap, we'll proactively start there. - const start_y = if (self.cursor.pending_wrap) self.cursor.y + 1 else self.cursor.y; - - try self.testWriteString(text); - - // Determine the last row that we actually wrote by inspecting the cursor's - // position. If we're in the first column, we haven't actually written any - // characters to it, so we end at the preceding row instead. - const end_y = if (self.cursor.x > 0) self.cursor.y else self.cursor.y - 1; - - // Mark the full range of written rows with our semantic prompt. - var y = start_y; - while (y <= end_y) { - const pin = self.pages.pin(.{ .active = .{ .y = y } }).?; - pin.rowAndCell().row.semantic_prompt = semantic_prompt; - y += 1; - } -} - test "Screen read and write" { const testing = std.testing; const alloc = testing.allocator; @@ -3835,88 +3806,6 @@ test "Screen eraseRows active partial" { } } -test "Screen: clearPrompt" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer s.deinit(); - - // Set one of the rows to be a prompt - try s.testWriteSemanticString("1ABCD\n", .unknown); - try s.testWriteSemanticString("2EFGH\n", .prompt); - try s.testWriteSemanticString("3IJKL", .input); - - s.clearPrompt(); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD", contents); - } -} - -test "Screen: clearPrompt continuation" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 5, .rows = 4, .max_scrollback = 0 }); - defer s.deinit(); - - // Set one of the rows to be a prompt followed by a continuation row - try s.testWriteSemanticString("1ABCD\n", .unknown); - try s.testWriteSemanticString("2EFGH\n", .prompt); - try s.testWriteSemanticString("3IJKL\n", .prompt_continuation); - try s.testWriteSemanticString("4MNOP", .input); - - s.clearPrompt(); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH", contents); - } -} - -test "Screen: clearPrompt consecutive inputs" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer s.deinit(); - - // Set both rows to be inputs - try s.testWriteSemanticString("1ABCD\n", .unknown); - try s.testWriteSemanticString("2EFGH\n", .input); - try s.testWriteSemanticString("3IJKL", .input); - - s.clearPrompt(); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH", contents); - } -} - -test "Screen: clearPrompt no prompt" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - - s.clearPrompt(); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - test "Screen: cursorDown across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; @@ -5730,7 +5619,7 @@ test "Screen: resize (no reflow) more rows" { try s.testWriteString(str); // Resize - try s.resizeWithoutReflow(10, 10); + try s.resize(.{ .cols = 10, .rows = 10, .reflow = false }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -5748,7 +5637,7 @@ test "Screen: resize (no reflow) less rows" { try s.testWriteString(str); try testing.expectEqual(5, s.cursor.x); try testing.expectEqual(2, s.cursor.y); - try s.resizeWithoutReflow(10, 2); + try s.resize(.{ .cols = 10, .rows = 2, .reflow = false }); // Since we shrunk, we should adjust our cursor try testing.expectEqual(5, s.cursor.x); @@ -5783,7 +5672,7 @@ test "Screen: resize (no reflow) less rows trims blank lines" { } const cursor = s.cursor; - try s.resizeWithoutReflow(6, 2); + try s.resize(.{ .cols = 6, .rows = 2, .reflow = false }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -5818,7 +5707,7 @@ test "Screen: resize (no reflow) more rows trims blank lines" { } const cursor = s.cursor; - try s.resizeWithoutReflow(10, 7); + try s.resize(.{ .cols = 10, .rows = 7, .reflow = false }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -5839,7 +5728,7 @@ test "Screen: resize (no reflow) more cols" { defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); - try s.resizeWithoutReflow(20, 3); + try s.resize(.{ .cols = 20, .rows = 3, .reflow = false }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -5856,7 +5745,7 @@ test "Screen: resize (no reflow) less cols" { defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); - try s.resizeWithoutReflow(4, 3); + try s.resize(.{ .cols = 4, .rows = 3, .reflow = false }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -5874,7 +5763,7 @@ test "Screen: resize (no reflow) more rows with scrollback cursor end" { defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); - try s.resizeWithoutReflow(7, 10); + try s.resize(.{ .cols = 7, .rows = 10, .reflow = false }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -5891,7 +5780,7 @@ test "Screen: resize (no reflow) less rows with scrollback" { defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); - try s.resizeWithoutReflow(7, 2); + try s.resize(.{ .cols = 7, .rows = 2, .reflow = false }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -5915,7 +5804,7 @@ test "Screen: resize (no reflow) less rows with empty trailing" { try s.testWriteString("A\nB"); const cursor = s.cursor; - try s.resizeWithoutReflow(5, 2); + try s.resize(.{ .cols = 5, .rows = 2, .reflow = false }); try testing.expectEqual(cursor.x, s.cursor.x); try testing.expectEqual(cursor.y, s.cursor.y); @@ -5947,7 +5836,7 @@ test "Screen: resize (no reflow) more rows with soft wrapping" { } // Resize - try s.resizeWithoutReflow(2, 10); + try s.resize(.{ .cols = 2, .rows = 10, .reflow = false }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -5976,7 +5865,7 @@ test "Screen: resize more rows no scrollback" { const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); const cursor = s.cursor; - try s.resize(5, 10); + try s.resize(.{ .cols = 5, .rows = 10 }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -6003,7 +5892,7 @@ test "Screen: resize more rows with empty scrollback" { const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); const cursor = s.cursor; - try s.resize(5, 10); + try s.resize(.{ .cols = 5, .rows = 10 }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -6047,7 +5936,7 @@ test "Screen: resize more rows with populated scrollback" { } // Resize - try s.resize(5, 10); + try s.resize(.{ .cols = 5, .rows = 10 }); // Cursor should still be on the "4" { @@ -6076,7 +5965,7 @@ test "Screen: resize more cols no reflow" { try s.testWriteString(str); const cursor = s.cursor; - try s.resize(10, 3); + try s.resize(.{ .cols = 10, .rows = 3 }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -6103,7 +5992,7 @@ test "Screen: resize more cols perfect split" { defer s.deinit(); const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str); - try s.resize(10, 3); + try s.resize(.{ .cols = 10, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); @@ -6133,7 +6022,7 @@ test "Screen: resize (no reflow) more cols with scrollback scrolled up" { try testing.expectEqualStrings("2\n3\n4", contents); } - try s.resize(8, 3); + try s.resize(.{ .cols = 8, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -6166,7 +6055,7 @@ test "Screen: resize (no reflow) less cols with scrollback scrolled up" { try testing.expectEqualStrings("2\n3\n4", contents); } - try s.resize(4, 3); + try s.resize(.{ .cols = 4, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -6198,11 +6087,14 @@ test "Screen: resize more cols no reflow preserves semantic prompt" { defer s.deinit(); // Set one of the rows to be a prompt - try s.testWriteSemanticString("1ABCD\n", .unknown); - try s.testWriteSemanticString("2EFGH\n", .prompt); - try s.testWriteSemanticString("3IJKL", .unknown); + s.cursorSetSemanticContent(.output); + try s.testWriteString("1ABCD\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("2EFGH"); + s.cursorSetSemanticContent(.output); + try s.testWriteString("\n3IJKL"); - try s.resize(10, 3); + try s.resize(.{ .cols = 10, .rows = 3, .reflow = false }); const expected = "1ABCD\n2EFGH\n3IJKL"; { @@ -6219,7 +6111,7 @@ test "Screen: resize more cols no reflow preserves semantic prompt" { // Our one row should still be a semantic prompt, the others should not. { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; - try testing.expect(list_cell.row.semantic_prompt == .unknown); + try testing.expect(list_cell.row.semantic_prompt == .none); } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; @@ -6227,7 +6119,7 @@ test "Screen: resize more cols no reflow preserves semantic prompt" { } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; - try testing.expect(list_cell.row.semantic_prompt == .unknown); + try testing.expect(list_cell.row.semantic_prompt == .none); } } @@ -6259,7 +6151,7 @@ test "Screen: resize more cols with reflow that fits full width" { } // Resize and verify we undid the soft wrap because we have space now - try s.resize(10, 3); + try s.resize(.{ .cols = 10, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -6299,7 +6191,7 @@ test "Screen: resize more cols with reflow that ends in newline" { } // Resize and verify we undid the soft wrap because we have space now - try s.resize(10, 3); + try s.resize(.{ .cols = 10, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -6344,7 +6236,7 @@ test "Screen: resize more cols with reflow that forces more wrapping" { } // Resize and verify we undid the soft wrap because we have space now - try s.resize(7, 3); + try s.resize(.{ .cols = 7, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -6385,7 +6277,7 @@ test "Screen: resize more cols with reflow that unwraps multiple times" { } // Resize and verify we undid the soft wrap because we have space now - try s.resize(15, 3); + try s.resize(.{ .cols = 15, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -6424,7 +6316,7 @@ test "Screen: resize more cols with populated scrollback" { } // Resize - try s.resize(10, 3); + try s.resize(.{ .cols = 10, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -6470,7 +6362,7 @@ test "Screen: resize more cols with reflow" { } // Resize and verify we undid the soft wrap because we have space now - try s.resize(7, 3); + try s.resize(.{ .cols = 7, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -6498,7 +6390,7 @@ test "Screen: resize more rows and cols with wrapping" { try testing.expectEqualStrings(expected, contents); } - try s.resize(5, 10); + try s.resize(.{ .cols = 5, .rows = 10 }); // Cursor should move due to wrapping try testing.expectEqual(@as(size.CellCountInt, 3), s.cursor.x); @@ -6527,7 +6419,7 @@ test "Screen: resize less rows no scrollback" { s.cursorAbsolute(0, 0); const cursor = s.cursor; - try s.resize(5, 1); + try s.resize(.{ .cols = 5, .rows = 1 }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -6567,7 +6459,7 @@ test "Screen: resize less rows moving cursor" { } // Resize - try s.resize(5, 1); + try s.resize(.{ .cols = 5, .rows = 1 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -6595,7 +6487,7 @@ test "Screen: resize less rows with empty scrollback" { defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); - try s.resize(5, 1); + try s.resize(.{ .cols = 5, .rows = 1 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); @@ -6626,7 +6518,7 @@ test "Screen: resize less rows with populated scrollback" { } // Resize - try s.resize(5, 1); + try s.resize(.{ .cols = 5, .rows = 1 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); @@ -6660,7 +6552,7 @@ test "Screen: resize less rows with full scrollback" { try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); // Resize - try s.resize(5, 2); + try s.resize(.{ .cols = 5, .rows = 2 }); // Cursor should stay in the same relative place (bottom of the // screen, same character). @@ -6692,7 +6584,7 @@ test "Screen: resize less cols no reflow" { s.cursorAbsolute(0, 0); const cursor = s.cursor; - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -6729,7 +6621,7 @@ test "Screen: resize less cols with reflow but row space" { try testing.expectEqual(@as(u32, 'D'), list_cell.cell.content.codepoint); } - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -6756,7 +6648,7 @@ test "Screen: resize less cols with reflow with trimmed rows" { defer s.deinit(); const str = "3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -6780,7 +6672,7 @@ test "Screen: resize less cols with reflow with trimmed rows and scrollback" { defer s.deinit(); const str = "3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -6813,7 +6705,7 @@ test "Screen: resize less cols with reflow previously wrapped" { try testing.expectEqualStrings(expected, contents); } - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); // { // const contents = try s.testString(alloc, .viewport); @@ -6848,7 +6740,7 @@ test "Screen: resize less cols with reflow and scrollback" { try testing.expectEqual(@as(u32, 'E'), list_cell.cell.content.codepoint); } - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -6889,7 +6781,7 @@ test "Screen: resize less cols with reflow previously wrapped and scrollback" { try testing.expectEqual(@as(u32, 'H'), list_cell.cell.content.codepoint); } - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -6931,7 +6823,7 @@ test "Screen: resize less cols with scrollback keeps cursor row" { // Move our cursor to the beginning s.cursorAbsolute(0, 0); - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -6967,7 +6859,7 @@ test "Screen: resize more rows, less cols with reflow with scrollback" { try testing.expectEqualStrings(expected, contents); } - try s.resize(2, 10); + try s.resize(.{ .cols = 2, .rows = 10 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -6996,7 +6888,7 @@ test "Screen: resize more rows then shrink again" { try s.testWriteString(str); // Grow - try s.resize(5, 10); + try s.resize(.{ .cols = 5, .rows = 10 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -7009,7 +6901,7 @@ test "Screen: resize more rows then shrink again" { } // Shrink - try s.resize(5, 3); + try s.resize(.{ .cols = 5, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7022,7 +6914,7 @@ test "Screen: resize more rows then shrink again" { } // Grow again - try s.resize(5, 10); + try s.resize(.{ .cols = 5, .rows = 10 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -7056,7 +6948,7 @@ test "Screen: resize less cols to eliminate wide char" { } // Resize to 1 column can't fit a wide char. So it should be deleted. - try s.resize(1, 1); + try s.resize(.{ .cols = 1, .rows = 1 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7095,7 +6987,7 @@ test "Screen: resize less cols to wrap wide char" { try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } - try s.resize(2, 3); + try s.resize(.{ .cols = 2, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7134,7 +7026,7 @@ test "Screen: resize less cols to eliminate wide char with row space" { try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } - try s.resize(1, 2); + try s.resize(.{ .cols = 1, .rows = 2 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7176,7 +7068,7 @@ test "Screen: resize more cols with wide spacer head" { try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } - try s.resize(4, 2); + try s.resize(.{ .cols = 4, .rows = 2 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7227,7 +7119,7 @@ test "Screen: resize more cols with wide spacer head multiple lines" { try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } - try s.resize(8, 2); + try s.resize(.{ .cols = 8, .rows = 2 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7273,7 +7165,7 @@ test "Screen: resize more cols requiring a wide spacer head" { // This resizes to 3 columns, which isn't enough space for our wide // char to enter row 1. But we need to mark the wide spacer head on the // end of the first row since we're wrapping to the next row. - try s.resize(3, 2); + try s.resize(.{ .cols = 3, .rows = 2 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7297,6 +7189,216 @@ test "Screen: resize more cols requiring a wide spacer head" { } } +test "Screen: resize more cols with cursor at prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); + defer s.deinit(); + + // zig fmt: off + try s.testWriteString("ABCDE\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("> "); + s.cursorSetSemanticContent(.{ .input = .clear_eol }); + try s.testWriteString("echo"); + // zig fmt: on + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE\n> echo"; + try testing.expectEqualStrings(expected, contents); + } + + try s.resize(.{ + .cols = 20, + .rows = 3, + .prompt_redraw = .true, + }); + + // Cursor should not move + try testing.expectEqual(6, s.cursor.x); + try testing.expectEqual(1, s.cursor.y); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE"; + try testing.expectEqualStrings(expected, contents); + } +} + +test "Screen: resize more cols with cursor not at prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); + defer s.deinit(); + + // zig fmt: off + try s.testWriteString("ABCDE\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("> "); + s.cursorSetSemanticContent(.{ .input = .clear_eol }); + try s.testWriteString("echo\n"); + try s.testWriteString("output"); + // zig fmt: on + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE\n> echo\noutput"; + try testing.expectEqualStrings(expected, contents); + } + + try s.resize(.{ + .cols = 20, + .rows = 3, + .prompt_redraw = .true, + }); + + // Cursor should not move + try testing.expectEqual(6, s.cursor.x); + try testing.expectEqual(2, s.cursor.y); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE\n> echo\noutput"; + try testing.expectEqualStrings(expected, contents); + } +} + +test "Screen: resize with prompt_redraw last clears only one line" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 4, .max_scrollback = 5 }); + defer s.deinit(); + + // zig fmt: off + try s.testWriteString("ABCDE\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("> "); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("hello\n"); + try s.testWriteString("world"); + // zig fmt: on + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE\n> hello\nworld"; + try testing.expectEqualStrings(expected, contents); + } + + // Cursor is at end of "world" line with semantic_content = .input + try s.resize(.{ + .cols = 20, + .rows = 4, + .prompt_redraw = .last, + }); + + // With .last, only the current line where cursor is should be cleared + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE\n> hello"; + try testing.expectEqualStrings(expected, contents); + } +} + +test "Screen: resize with prompt_redraw last multiline prompt clears only last line" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 5 }); + defer s.deinit(); + + // Create a 3-line prompt: 1 initial + 2 continuation lines + // zig fmt: off + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("line1\n"); + s.cursorSetSemanticContent(.{ .prompt = .continuation }); + try s.testWriteString("line2\n"); + s.cursorSetSemanticContent(.{ .prompt = .continuation }); + try s.testWriteString("line3"); + // zig fmt: on + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "line1\nline2\nline3"; + try testing.expectEqualStrings(expected, contents); + } + + // Cursor is at end of line3 (the last continuation line) + try s.resize(.{ + .cols = 30, + .rows = 5, + .prompt_redraw = .last, + }); + + // With .last, only line3 (where cursor is) should be cleared + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "line1\nline2"; + try testing.expectEqualStrings(expected, contents); + } +} + +test "Screen: resize with prompt_redraw clears input line without row semantic prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 5 }); + defer s.deinit(); + + // Simulate Nu shell behavior: marks input area with OSC 133 B but does not + // mark continuation lines with k=s sequence. This means: + // - cursor.semantic_content = .input + // - cursor.page_row.semantic_prompt = .none (not marked) + // The fix ensures we still clear based on semantic_content. + // zig fmt: off + try s.testWriteString("output\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("> "); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("hello\n"); + // Continue typing on next line - no prompt marking, but still in input mode + try s.testWriteString("world"); + // zig fmt: on + + // Verify the row has no semantic prompt marking (simulating Nu behavior) + try testing.expectEqual(.none, s.cursor.page_row.semantic_prompt); + // But the cursor's semantic content is input + try testing.expectEqual(.input, s.cursor.semantic_content); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "output\n> hello\nworld"; + try testing.expectEqualStrings(expected, contents); + } + + try s.resize(.{ + .cols = 30, + .rows = 5, + .prompt_redraw = .true, + }); + + // All prompt/input lines should be cleared even though the continuation + // row's semantic_prompt is .none + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "output"; + try testing.expectEqualStrings(expected, contents); + } +} + test "Screen: select untracked" { const testing = std.testing; const alloc = testing.allocator; @@ -7644,9 +7746,11 @@ test "Screen: selectLine semantic prompt boundary" { var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); - try s.testWriteSemanticString("ABCDE\n", .unknown); - try s.testWriteSemanticString("A ", .prompt); - try s.testWriteSemanticString("> ", .unknown); + try s.testWriteString("ABCDE\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("A "); + s.cursorSetSemanticContent(.output); + try s.testWriteString("> "); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); @@ -7661,14 +7765,13 @@ test "Screen: selectLine semantic prompt boundary" { .y = 1, } }).? }).?; defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 1, - } }, s.pages.pointFromPin(.active, sel.start()).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 1, - } }, s.pages.pointFromPin(.active, sel.end()).?); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + const expected = "A"; + try testing.expectEqualStrings(expected, contents); } { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ @@ -7687,6 +7790,359 @@ test "Screen: selectLine semantic prompt boundary" { } } +test "Screen: selectLine semantic prompt to input boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // Write prompt followed by user input on same row: "$>command" + // Using non-whitespace to avoid whitespace trimming affecting the test + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("$>"); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("command"); + + // Selecting from prompt should only select prompt + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 0, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + + // Selecting from input should only select input + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 5, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 2, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 8, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } +} + +test "Screen: selectLine semantic input to output boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // Row 0: user input + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("ls -la\n"); + // Row 1: command output + s.cursorSetSemanticContent(.output); + try s.testWriteString("file.txt"); + + // Selecting from input should only select input + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 2, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("ls -la", contents); + } + + // Selecting from output should only select output + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 2, + .y = 1, + } }).? }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("file.txt", contents); + } +} + +test "Screen: selectLine semantic mid-row boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // Single row with output then prompt then input: "out$>cmd" + // Using non-whitespace to avoid whitespace trimming affecting the test + s.cursorSetSemanticContent(.output); + try s.testWriteString("out"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("$>"); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("cmd"); + + // Selecting from output should stop at prompt + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 1, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 2, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + + // Selecting from prompt should only select prompt + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 3, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 4, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + + // Selecting from input should only select input + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 6, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 5, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } +} + +test "Screen: selectLine semantic boundary soft-wrap with mid-row transition" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // Row 0: prompt "$ " + input "cmd" (soft-wraps) + // Row 1: input continues "12" + output "out" + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("$ "); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("cmd12"); + s.cursorSetSemanticContent(.output); + try s.testWriteString("out"); + + // Verify layout + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("$ cmd\n12out", contents); + } + + // Selecting from input on row 0 should get all input across soft-wrap + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 3, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("cmd12", contents); + } + + // Selecting from input on row 1 should get all input across soft-wrap + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 0, + .y = 1, + } }).? }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("cmd12", contents); + } + + // Selecting from output should only get output + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 3, + .y = 1, + } }).? }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("out", contents); + } +} + +test "Screen: selectLine semantic boundary disabled" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // Write prompt followed by input + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("$ "); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("command"); + + // With semantic_prompt_boundary = false, should select entire line + { + var sel = s.selectLine(.{ + .pin = s.pages.pin(.{ .active = .{ + .x = 0, + .y = 0, + } }).?, + .semantic_prompt_boundary = false, + }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("$ command", contents); + } +} + +test "Screen: selectLine semantic boundary first cell of row" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // Row 0: input that soft-wraps + // Row 1: output starts at first cell + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("12345"); + s.cursorSetSemanticContent(.output); + try s.testWriteString("ABCDE"); + + // Verify soft-wrap happened + { + const pin = s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?; + const row = pin.rowAndCell().row; + try testing.expect(row.wrap); + } + + // Selecting from input should stop before output on row 1 + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 2, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 4, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + + // Selecting from output should only get output + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 2, + .y = 1, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 4, + .y = 1, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } +} + +test "Screen: selectLine semantic all same content" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // All prompt content that soft-wraps + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("prompt text"); + + // Verify soft-wrap + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("promp\nt tex\nt", contents); + } + + // Should select all prompt content across soft-wraps + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 2, + .y = 1, + } }).? }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("prompt text", contents); + } +} + test "Screen: selectWord" { const testing = std.testing; const alloc = testing.allocator; @@ -8060,59 +8516,64 @@ test "Screen: selectOutput" { var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); - // zig fmt: off - { - // line number: - try s.testWriteSemanticString("output1\n", .command); // 0 - try s.testWriteSemanticString("output1\n", .command); // 1 - try s.testWriteSemanticString("prompt2\n", .prompt); // 2 - try s.testWriteSemanticString("input2\n", .input); // 3 - try s.testWriteSemanticString( // - "output2output2output2output2\n", // 4, 5, 6 due to overflow - .command, // - ); // - try s.testWriteSemanticString("output2\n", .command); // 7 - try s.testWriteSemanticString("$ ", .prompt); // 8 prompt - try s.testWriteSemanticString("input3\n", .input); // 8 input - try s.testWriteSemanticString("output3\n", .command); // 9 - try s.testWriteSemanticString("output3\n", .command); // 10 - try s.testWriteSemanticString("output3", .command); // 11 - } - // zig fmt: on + // Build content with cell-level semantic content: + // Row 0-1: output1 (output) + // Row 2: prompt2 (prompt) + // Row 3: input2 (input) + // Row 4-7: output2 (output, with overflow causing wrap) + // Row 8: "$ " (prompt) + "input3" (input) + // Row 9-11: output3 (output) + s.cursorSetSemanticContent(.output); + try s.testWriteString("output1\n"); + try s.testWriteString("output1\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("prompt2\n"); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("input2\n"); + s.cursorSetSemanticContent(.output); + try s.testWriteString("output2output2output2output2\n"); + try s.testWriteString("output2\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("$ "); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("input3\n"); + s.cursorSetSemanticContent(.output); + try s.testWriteString("output3\n"); + try s.testWriteString("output3\n"); + try s.testWriteString("output3"); - // No start marker, should select from the beginning + // First output block (rows 0-1), should select those rows { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 1, .y = 1, } }).?).?; defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 9, - .y = 1, - } }, s.pages.pointFromPin(.active, sel.end()).?); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("output1\noutput1", contents); } - // Both start and end markers, should select between them + // Second output block (rows 4-7) { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 3, .y = 7, } }).?).?; defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 4, - } }, s.pages.pointFromPin(.active, sel.start()).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 9, - .y = 7, - } }, s.pages.pointFromPin(.active, sel.end()).?); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings( + "output2output2output2output2\noutput2", + contents, + ); } - // No end marker, should select till the end + // Third output block (rows 9-11) { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 2, @@ -8124,185 +8585,23 @@ test "Screen: selectOutput" { .y = 9, } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ - .x = 9, + .x = 6, .y = 11, } }, s.pages.pointFromPin(.active, sel.end()).?); } - // input / prompt at y = 0, pt.y = 0 + // Click on prompt should return null { - s.deinit(); - s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); - try s.testWriteSemanticString("$ ", .prompt); - try s.testWriteSemanticString("input1\n", .input); - try s.testWriteSemanticString("output1\n", .command); - try s.testWriteSemanticString("prompt2\n", .prompt); try testing.expect(s.selectOutput(s.pages.pin(.{ .active = .{ - .x = 2, - .y = 0, + .x = 1, + .y = 8, } }).?) == null); } -} - -test "Screen: selectPrompt basics" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); - defer s.deinit(); - - // zig fmt: off + // Click on input should return null { - // line number: - try s.testWriteSemanticString("output1\n", .command); // 0 - try s.testWriteSemanticString("output1\n", .command); // 1 - try s.testWriteSemanticString("prompt2\n", .prompt); // 2 - try s.testWriteSemanticString("input2\n", .input); // 3 - try s.testWriteSemanticString("output2\n", .command); // 4 - try s.testWriteSemanticString("output2\n", .command); // 5 - try s.testWriteSemanticString("$ ", .prompt); // 6 prompt - try s.testWriteSemanticString("input3\n", .input); // 6 input - try s.testWriteSemanticString("output3\n", .command); // 7 - try s.testWriteSemanticString("output3\n", .command); // 8 - try s.testWriteSemanticString("output3", .command); // 9 - } - // zig fmt: on - - // Not at a prompt - { - const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 0, - .y = 1, - } }).?); - try testing.expect(sel == null); - } - { - const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 0, + try testing.expect(s.selectOutput(s.pages.pin(.{ .active = .{ + .x = 5, .y = 8, - } }).?); - try testing.expect(sel == null); - } - - // Single line prompt - { - var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 6, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 6, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 9, - .y = 6, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Multi line prompt - { - var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 3, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 2, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 9, - .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Screen: selectPrompt prompt at start" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); - defer s.deinit(); - - // zig fmt: off - { - // line number: - try s.testWriteSemanticString("prompt1\n", .prompt); // 0 - try s.testWriteSemanticString("input1\n", .input); // 1 - try s.testWriteSemanticString("output2\n", .command); // 2 - try s.testWriteSemanticString("output2\n", .command); // 3 - } - // zig fmt: on - - // Not at a prompt - { - const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 0, - .y = 3, - } }).?); - try testing.expect(sel == null); - } - - // Multi line prompt - { - var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 1, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 9, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Screen: selectPrompt prompt at end" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); - defer s.deinit(); - - // zig fmt: off - { - // line number: - try s.testWriteSemanticString("output2\n", .command); // 0 - try s.testWriteSemanticString("output2\n", .command); // 1 - try s.testWriteSemanticString("prompt1\n", .prompt); // 2 - try s.testWriteSemanticString("input1\n", .input); // 3 - } - // zig fmt: on - - // Not at a prompt - { - const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 0, - .y = 1, - } }).?); - try testing.expect(sel == null); - } - - // Multi line prompt - { - var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 2, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 2, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 9, - .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end()).?); + } }).?) == null); } } @@ -8313,22 +8612,74 @@ test "Screen: promptPath" { var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); - // zig fmt: off + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const page = &s.pages.pages.first.?.data; + + // Set up: + // Row 0-1: output + // Row 2: prompt + // Row 3: input + // Row 4-5: output + // Row 6: prompt + input + // Row 7-9: output + + // Row 2: prompt (with prompt cells) and input { - // line number: - try s.testWriteSemanticString("output1\n", .command); // 0 - try s.testWriteSemanticString("output1\n", .command); // 1 - try s.testWriteSemanticString("prompt2\n", .prompt); // 2 - try s.testWriteSemanticString("input2\n", .input); // 3 - try s.testWriteSemanticString("output2\n", .command); // 4 - try s.testWriteSemanticString("output2\n", .command); // 5 - try s.testWriteSemanticString("$ ", .prompt); // 6 prompt - try s.testWriteSemanticString("input3\n", .input); // 6 input - try s.testWriteSemanticString("output3\n", .command); // 7 - try s.testWriteSemanticString("output3\n", .command); // 8 - try s.testWriteSemanticString("output3", .command); // 9 + const rac = page.getRowAndCell(0, 2); + rac.row.semantic_prompt = .prompt; + // First 3 cols are prompt + for (0..3) |x| { + const cell = page.getRowAndCell(x, 2).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'P' }, + .semantic_content = .prompt, + }; + } + // Next cols are input + for (3..10) |x| { + const cell = page.getRowAndCell(x, 2).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'I' }, + .semantic_content = .input, + }; + } + } + // Row 3: continuation line with input cells (same prompt block) + { + const rac = page.getRowAndCell(0, 3); + rac.row.semantic_prompt = .prompt_continuation; + for (0..6) |x| { + const cell = page.getRowAndCell(x, 3).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'I' }, + .semantic_content = .input, + }; + } + } + // Row 6: next prompt + input on same line + { + const rac = page.getRowAndCell(0, 6); + rac.row.semantic_prompt = .prompt; + for (0..2) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + for (2..8) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'i' }, + .semantic_content = .input, + }; + } } - // zig fmt: on // From is not in the prompt { @@ -8371,12 +8722,13 @@ test "Screen: promptPath" { } // To is out of bounds after + // Prompt ends at (5, 3) since that's the last input cell { const path = s.promptPath( s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?, s.pages.pin(.{ .active = .{ .x = 3, .y = 9 } }).?, ); - try testing.expectEqual(@as(isize, 3), path.x); + try testing.expectEqual(@as(isize, -1), path.x); try testing.expectEqual(@as(isize, 1), path.y); } } @@ -8990,7 +9342,7 @@ test "Screen: hyperlink cursor state on resize" { } // Resize. Any column growth will trigger a page to be reallocated. - try s.resize(10, 10); + try s.resize(.{ .cols = 10, .rows = 10 }); try testing.expect(s.cursor.hyperlink_id != 0); { const page = &s.cursor.page_pin.node.data; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index a955cbcae..31bc94d17 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -17,6 +17,7 @@ const charsets = @import("charsets.zig"); const csi = @import("csi.zig"); const hyperlink = @import("hyperlink.zig"); const kitty = @import("kitty.zig"); +const osc = @import("osc.zig"); const point = @import("point.zig"); const sgr = @import("sgr.zig"); const Tabstops = @import("Tabstops.zig"); @@ -79,11 +80,10 @@ mouse_shape: mouse_shape_pkg.MouseShape = .text, /// These are just a packed set of flags we may set on the terminal. flags: packed struct { - // This isn't a mode, this is set by OSC 133 using the "A" event. - // If this is true, it tells us that the shell supports redrawing - // the prompt and that when we resize, if the cursor is at a prompt, - // then we should clear the screen below and allow the shell to redraw. - shell_redraws_prompt: bool = false, + // This supports a Kitty extension where programs using semantic + // prompts (OSC133) can annotate their new prompts with `redraw=0` to + // disable clearing the prompt on resize. + shell_redraws_prompt: osc.semantic_prompt.Redraw = .true, // This is set via ESC[4;2m. Any other modify key mode just sets // this to false and we act in mode 1 by default. @@ -711,6 +711,7 @@ fn printCell( .style_id = self.screens.active.cursor.style_id, .wide = wide, .protected = self.screens.active.cursor.protected, + .semantic_content = self.screens.active.cursor.semantic_content, }; if (style_changed) { @@ -753,22 +754,35 @@ fn printWrap(self: *Terminal) !void { // We only mark that we soft-wrapped if we're at the edge of our // full screen. We don't mark the row as wrapped if we're in the // middle due to a right margin. - const mark_wrap = self.screens.active.cursor.x == self.cols - 1; - if (mark_wrap) self.screens.active.cursor.page_row.wrap = true; + const cursor: *Screen.Cursor = &self.screens.active.cursor; + const mark_wrap = cursor.x == self.cols - 1; + if (mark_wrap) cursor.page_row.wrap = true; // Get the old semantic prompt so we can extend it to the next // line. We need to do this before we index() because we may // modify memory. - const old_prompt = self.screens.active.cursor.page_row.semantic_prompt; + const old_semantic = cursor.semantic_content; + const old_semantic_clear = cursor.semantic_content_clear_eol; // Move to the next line try self.index(); self.screens.active.cursorHorizontalAbsolute(self.scrolling_region.left); + // Our pointer should never move + assert(cursor == &self.screens.active.cursor); + + // We always reset our semantic prompt state + cursor.semantic_content = old_semantic; + cursor.semantic_content_clear_eol = old_semantic_clear; + switch (old_semantic) { + .output, .input => {}, + .prompt => cursor.page_row.semantic_prompt = .prompt_continuation, + } + if (mark_wrap) { - // New line must inherit semantic prompt of the old line - self.screens.active.cursor.page_row.semantic_prompt = old_prompt; - self.screens.active.cursor.page_row.wrap_continuation = true; + const row = self.screens.active.cursor.page_row; + // Always mark the row as a continuation + row.wrap_continuation = true; } // Assure that our screen is consistent @@ -1057,6 +1071,127 @@ pub fn setProtectedMode(self: *Terminal, mode: ansi.ProtectedMode) void { } } +/// Perform a semantic prompt command. +/// +/// If there is an error, we do our best to get the terminal into +/// some coherent state, since callers typically can't handle errors +/// (since they're sending sequences via the pty). +pub fn semanticPrompt( + self: *Terminal, + cmd: osc.Command.SemanticPrompt, +) !void { + switch (cmd.action) { + .fresh_line => try self.semanticPromptFreshLine(), + + .fresh_line_new_prompt => { + // "First do a fresh-line." + try self.semanticPromptFreshLine(); + + // "Subsequent text (until a OSC "133;B" or OSC "133;I" command) + // is a prompt string (as if followed by OSC 133;P;k=i\007)." + self.screens.active.cursorSetSemanticContent(.{ + .prompt = cmd.readOption(.prompt_kind) orelse .initial, + }); + + // This is a kitty-specific flag that notes that the shell + // is NOT capable of redraw. Redraw defaults to true so this + // usually just disables it, but either is possible. + if (cmd.readOption(.redraw)) |v| { + self.flags.shell_redraws_prompt = v; + } + + // The "aid" and "cl" options are also valid for this + // command but we don't yet handle these in any meaningful way. + }, + + .new_command => { + // Same as OSC "133;A" but may first implicitly terminate a + // previous command: if the options specify an aid and there + // is an active (open) command with matching aid, finish the + // innermost such command (as well as any other commands + // nested more deeply). If no aid is specified, treat as an + // aid whose value is the empty string. + try self.semanticPrompt(.{ + .action = .fresh_line_new_prompt, + .options_unvalidated = cmd.options_unvalidated, + }); + }, + + .prompt_start => { + // Explicit start of prompt. Optional after an A or N command. + // The k (kind) option specifies the type of prompt: + // regular primary prompt (k=i or default), + // right-side prompts (k=r), or prompts for continuation lines (k=c or k=s). + self.screens.active.cursorSetSemanticContent(.{ + .prompt = cmd.readOption(.prompt_kind) orelse .initial, + }); + }, + + .end_prompt_start_input => { + // End of prompt and start of user input, terminated by a OSC + // "133;C" or another prompt (OSC "133;P"). + self.screens.active.cursorSetSemanticContent(.{ + .input = .clear_explicit, + }); + }, + + .end_prompt_start_input_terminate_eol => { + // End of prompt and start of user input, terminated by end-of-line. + self.screens.active.cursorSetSemanticContent(.{ + .input = .clear_eol, + }); + }, + + .end_input_start_output => { + // "End of input, and start of output." + self.screens.active.cursorSetSemanticContent(.output); + + // If our current row is marked as a prompt and we're + // at column zero then we assume we're un-prompting. This + // is a heuristic to deal with fish, mostly. The issue that + // fish brings up is that it has no PS2 equivalent and its + // builtin OSC133 marking doesn't output continuation lines + // as k=s. So, we assume when we get a newline with a prompt + // cursor that the new line is also a prompt. But fish changes + // to output on the newline. So if we're at col 0 we just assume + // we're overwriting the prompt. + if (self.screens.active.cursor.page_row.semantic_prompt != .none and + self.screens.active.cursor.x == 0) + { + self.screens.active.cursor.page_row.semantic_prompt = .none; + } + }, + + .end_command => { + // From a terminal state perspective, this doesn't really do + // anything. Other terminals appear to do nothing here. I think + // its reasonable at this point to reset our semantic content + // state but the spec doesn't really say what to do. + self.screens.active.cursorSetSemanticContent(.output); + }, + } +} + +// OSC 133;L +fn semanticPromptFreshLine(self: *Terminal) !void { + const left_margin = if (self.screens.active.cursor.x < self.scrolling_region.left) + 0 + else + self.scrolling_region.left; + + // Spec: "If the cursor is the initial column (left, assuming + // left-to-right writing), do nothing" This specification is very under + // specified. We are taking the liberty to assume that in a left/right + // margin context, if the cursor is outside of the left margin, we treat + // it as being at the left margin for the purposes of this command. + // This is arbitrary. If someone has a better reasonable idea we can + // apply it. + if (self.screens.active.cursor.x == left_margin) return; + + self.carriageReturn(); + try self.index(); +} + /// 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. @@ -1069,19 +1204,6 @@ pub const SemanticPrompt = enum { command, }; -/// 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 { - //log.debug("semantic_prompt y={} p={}", .{ self.screens.active.cursor.y, p }); - self.screens.active.cursor.page_row.semantic_prompt = switch (p) { - .prompt => .prompt, - .prompt_continuation => .prompt_continuation, - .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 outputting something. /// This requires shell integration (semantic prompt integration). @@ -1091,29 +1213,15 @@ pub fn cursorIsAtPrompt(self: *Terminal) bool { // If we're on the secondary screen, we're never at a prompt. if (self.screens.active_key == .alternate) return false; - // Reverse through the active - const start_x, const start_y = .{ self.screens.active.cursor.x, self.screens.active.cursor.y }; - defer self.screens.active.cursorAbsolute(start_x, start_y); + // If our page row is a prompt then we're always at a prompt + const cursor: *const Screen.Cursor = &self.screens.active.cursor; + if (cursor.page_row.semantic_prompt != .none) return true; - for (0..start_y + 1) |i| { - if (i > 0) self.screens.active.cursorUp(1); - switch (self.screens.active.cursor.page_row.semantic_prompt) { - // If we're at a prompt or input area, then we are at a prompt. - .prompt, - .prompt_continuation, - .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; + // Otherwise, determine our cursor state + return switch (cursor.semantic_content) { + .input, .prompt => true, + .output => false, + }; } /// Horizontal tab moves the cursor to the next tabstop, clearing @@ -1178,17 +1286,48 @@ pub fn tabReset(self: *Terminal) void { /// /// This unsets the pending wrap state without wrapping. pub fn index(self: *Terminal) !void { + const screen: *Screen = self.screens.active; + // Unset pending wrap state - self.screens.active.cursor.pending_wrap = false; + screen.cursor.pending_wrap = false; + + // We handle our cursor semantic prompt state AFTER doing the + // scrolling, because we may need to apply to new rows. + defer if (screen.cursor.semantic_content != .output) { + @branchHint(.unlikely); + + // Always reset any semantic content clear-eol state. + // + // The specification is not clear what "end-of-line" means. If we + // discover that there are more scenarios we should be unsetting + // this we should document and test it. + if (screen.cursor.semantic_content_clear_eol) { + screen.cursor.semantic_content = .output; + screen.cursor.semantic_content_clear_eol = false; + } else { + // If we aren't clearing our state at EOL and we're not output, + // then we mark the new row as a prompt continuation. This is + // to work around shells that don't send OSC 133 k=s sequences + // for continuations. + // + // This can be a false positive if the shell changes content + // type later and outputs something. We handle that in the + // semanticPrompt function. + screen.cursor.page_row.semantic_prompt = .prompt_continuation; + } + } else { + // This should never be set in the output mode. + assert(!screen.cursor.semantic_content_clear_eol); + }; // Outside of the scroll region we move the cursor one line down. - if (self.screens.active.cursor.y < self.scrolling_region.top or - self.screens.active.cursor.y > self.scrolling_region.bottom) + if (screen.cursor.y < self.scrolling_region.top or + screen.cursor.y > self.scrolling_region.bottom) { // We only move down if we're not already at the bottom of // the screen. - if (self.screens.active.cursor.y < self.rows - 1) { - self.screens.active.cursorDown(1); + if (screen.cursor.y < self.rows - 1) { + screen.cursorDown(1); } return; @@ -1197,13 +1336,13 @@ pub fn index(self: *Terminal) !void { // If the cursor is inside the scrolling region and on the bottom-most // line, then we scroll up. If our scrolling region is the full screen // we create scrollback. - if (self.screens.active.cursor.y == self.scrolling_region.bottom and - self.screens.active.cursor.x >= self.scrolling_region.left and - self.screens.active.cursor.x <= self.scrolling_region.right) + if (screen.cursor.y == self.scrolling_region.bottom and + screen.cursor.x >= self.scrolling_region.left and + screen.cursor.x <= self.scrolling_region.right) { if (comptime build_options.kitty_graphics) { // Scrolling dirties the images because it updates their placements pins. - self.screens.active.kitty_images.dirty = true; + screen.kitty_images.dirty = true; } // If our scrolling region is at the top, we create scrollback. @@ -1211,7 +1350,7 @@ pub fn index(self: *Terminal) !void { self.scrolling_region.left == 0 and self.scrolling_region.right == self.cols - 1) { - try self.screens.active.cursorScrollAbove(); + try screen.cursorScrollAbove(); return; } @@ -1225,7 +1364,7 @@ pub fn index(self: *Terminal) !void { // However, scrollUp is WAY slower. We should optimize this // case to work in the eraseRowBounded codepath and remove // this check. - !self.screens.active.blankCell().isZero()) + !screen.blankCell().isZero()) { try self.scrollUp(1); return; @@ -1235,9 +1374,9 @@ pub fn index(self: *Terminal) !void { // scroll the contents of the scrolling region. // Preserve old cursor just for assertions - const old_cursor = self.screens.active.cursor; + const old_cursor = screen.cursor; - try self.screens.active.pages.eraseRowBounded( + try screen.pages.eraseRowBounded( .{ .active = .{ .y = self.scrolling_region.top } }, self.scrolling_region.bottom - self.scrolling_region.top, ); @@ -1246,26 +1385,26 @@ pub fn index(self: *Terminal) !void { // up by 1, so we need to move it back down. A `cursorReload` // would be better option but this is more efficient and this is // a super hot path so we do this instead. - assert(self.screens.active.cursor.x == old_cursor.x); - assert(self.screens.active.cursor.y == old_cursor.y); - self.screens.active.cursor.y -= 1; - self.screens.active.cursorDown(1); + assert(screen.cursor.x == old_cursor.x); + assert(screen.cursor.y == old_cursor.y); + screen.cursor.y -= 1; + screen.cursorDown(1); // The operations above can prune our cursor style so we need to // update. This should never fail because the above can only FREE // memory. - self.screens.active.manualStyleUpdate() catch |err| { + screen.manualStyleUpdate() catch |err| { std.log.warn("deleteLines manualStyleUpdate err={}", .{err}); - self.screens.active.cursor.style = .{}; - self.screens.active.manualStyleUpdate() catch unreachable; + screen.cursor.style = .{}; + screen.manualStyleUpdate() catch unreachable; }; return; } // Increase cursor by 1, maximum to bottom of scroll region - if (self.screens.active.cursor.y < self.scrolling_region.bottom) { - self.screens.active.cursorDown(1); + if (screen.cursor.y < self.scrolling_region.bottom) { + screen.cursorDown(1); } } @@ -2247,15 +2386,11 @@ pub fn eraseDisplay( // If we're at a prompt or input area, then we are at a prompt. .prompt, .prompt_continuation, - .input, => break, // If we have command output, then we're most certainly not // at a prompt. - .command => break :at_prompt, - - // If we don't know, we keep searching. - .unknown => {}, + .none => break :at_prompt, } } else break :at_prompt; @@ -2562,21 +2697,19 @@ pub fn resize( // Resize primary screen, which supports reflow const primary = self.screens.get(.primary).?; - if (self.screens.active_key == .primary and - self.flags.shell_redraws_prompt) - { - primary.clearPrompt(); - } - if (self.modes.get(.wraparound)) { - try primary.resize(cols, rows); - } else { - try primary.resizeWithoutReflow(cols, rows); - } + try primary.resize(.{ + .cols = cols, + .rows = rows, + .reflow = self.modes.get(.wraparound), + .prompt_redraw = self.flags.shell_redraws_prompt, + }); // Alternate screen, if it exists, doesn't reflow - if (self.screens.get(.alternate)) |alt| { - try alt.resizeWithoutReflow(cols, rows); - } + if (self.screens.get(.alternate)) |alt| try alt.resize(.{ + .cols = cols, + .rows = rows, + .reflow = false, + }); // Whenever we resize we just mark it as a screen clear self.flags.dirty.clear = true; @@ -4221,19 +4354,20 @@ test "Terminal: soft wrap with semantic prompt" { var t = try init(testing.allocator, .{ .cols = 3, .rows = 80 }); defer t.deinit(testing.allocator); - // Mark our prompt. Should not make anything dirty on its own. - t.markSemanticPrompt(.prompt); + // Mark our prompt. + try t.semanticPrompt(.init(.prompt_start)); + // Should not make anything dirty on its own. try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + // Write and wrap for ("hello") |c| try t.print(c); - { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(Row.SemanticPrompt.prompt, list_cell.row.semantic_prompt); + try testing.expectEqual(.prompt, list_cell.row.semantic_prompt); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; - try testing.expectEqual(Row.SemanticPrompt.prompt, list_cell.row.semantic_prompt); + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); } } @@ -11184,33 +11318,343 @@ test "Terminal: eraseDisplay complete preserves cursor" { try testing.expect(t.screens.active.cursor.style_id != style.default_id); } +test "Terminal: semantic prompt" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Prompt + try t.semanticPrompt(.init(.fresh_line_new_prompt)); + for ("hello") |c| try t.print(c); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x - 1, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(.prompt, cell.semantic_content); + + const row = list_cell.row; + try testing.expectEqual(.prompt, row.semantic_prompt); + } + + // Start input but end it on EOL + try t.semanticPrompt(.init(.end_prompt_start_input_terminate_eol)); + t.carriageReturn(); + try t.linefeed(); + + // Write some output + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + for ("world") |c| try t.print(c); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x - 1, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(.output, cell.semantic_content); + + const row = list_cell.row; + try testing.expectEqual(.none, row.semantic_prompt); + } +} + +test "Terminal: semantic prompt continuations" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Prompt + try t.semanticPrompt(.init(.fresh_line_new_prompt)); + for ("hello") |c| try t.print(c); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x - 1, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(.prompt, cell.semantic_content); + + const row = list_cell.row; + try testing.expectEqual(.prompt, row.semantic_prompt); + } + + // Start input but end it on EOL + t.carriageReturn(); + try t.linefeed(); + try t.semanticPrompt(.{ + .action = .prompt_start, + .options_unvalidated = "k=c", + }); + + // Write some output + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + for ("world") |c| try t.print(c); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x - 1, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(.prompt, cell.semantic_content); + + const row = list_cell.row; + try testing.expectEqual(.prompt_continuation, row.semantic_prompt); + } +} + +test "Terminal: index in prompt mode marks new row as prompt continuation" { + // This tests the Fish shell workaround: when in prompt mode and we get + // a newline, assume the new row is a prompt continuation (since Fish + // doesn't emit OSC133 k=s markers for continuation lines). + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Start a prompt + try t.semanticPrompt(.init(.prompt_start)); + for ("hello") |c| try t.print(c); + + // Verify first row is marked as prompt + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 0, + } }).?; + try testing.expectEqual(.prompt, list_cell.row.semantic_prompt); + } + + // Now do a linefeed while still in prompt mode + t.carriageReturn(); + try t.linefeed(); + + // The new row should automatically be marked as prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } + + // The cursor semantic content should still be prompt + try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); +} + +test "Terminal: index in input mode does not mark new row as prompt" { + // Input mode should NOT trigger prompt continuation on newline + // (only prompt mode does, not input mode) + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Start a prompt then switch to input + try t.semanticPrompt(.init(.prompt_start)); + for ("$ ") |c| try t.print(c); + try t.semanticPrompt(.init(.end_prompt_start_input)); + for ("echo \\") |c| try t.print(c); + + // Linefeed while in input mode + t.carriageReturn(); + try t.linefeed(); + + // The new row should be marked as prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } + + // Our cursor should still be in input + try testing.expectEqual(.input, t.screens.active.cursor.semantic_content); +} + +test "Terminal: index in output mode does not mark new row as prompt" { + // Output mode should NOT trigger prompt continuation + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Complete prompt cycle: prompt -> input -> output + try t.semanticPrompt(.init(.prompt_start)); + for ("$ ") |c| try t.print(c); + try t.semanticPrompt(.init(.end_prompt_start_input)); + for ("ls") |c| try t.print(c); + try t.semanticPrompt(.init(.end_input_start_output)); + + // Linefeed while in output mode + t.carriageReturn(); + try t.linefeed(); + + // The new row should NOT be marked as a prompt + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.none, list_cell.row.semantic_prompt); + } +} + +test "Terminal: OSC133C at x=0 on prompt row clears prompt mark" { + // This tests the second Fish heuristic: when Fish emits a newline + // then immediately sends OSC133C (start output) at column 0, we + // should clear the prompt continuation mark we just set. + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Start a prompt + try t.semanticPrompt(.init(.prompt_start)); + for ("$ echo \\") |c| try t.print(c); + + // Simulate Fish behavior: newline first (which marks next row as prompt) + t.carriageReturn(); + try t.linefeed(); + + // Verify the new row is marked as prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } + + // Now Fish sends OSC133C at column 0 (cursor is still at x=0) + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try t.semanticPrompt(.init(.end_input_start_output)); + + // The prompt continuation should be cleared + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.none, list_cell.row.semantic_prompt); + } +} + +test "Terminal: OSC133C at x>0 on prompt row does not clear prompt mark" { + // If we're not at column 0, we shouldn't clear the prompt mark + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Start a prompt on a row + try t.semanticPrompt(.init(.prompt_start)); + for ("$ ") |c| try t.print(c); + + // Move to a new line and mark it as prompt continuation manually + t.carriageReturn(); + try t.linefeed(); + try t.semanticPrompt(.{ + .action = .prompt_start, + .options_unvalidated = "k=c", + }); + for ("> ") |c| try t.print(c); + + // Verify the row is marked as prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } + + // Now send OSC133C but cursor is NOT at column 0 + try testing.expect(t.screens.active.cursor.x > 0); + try t.semanticPrompt(.init(.end_input_start_output)); + + // The prompt continuation should NOT be cleared (we're not at x=0) + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } +} + +test "Terminal: multiple newlines in prompt mode marks all rows" { + // Multiple newlines should each mark their row as prompt continuation + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Start a prompt + try t.semanticPrompt(.init(.prompt_start)); + for ("line1") |c| try t.print(c); + + // Multiple newlines + t.carriageReturn(); + try t.linefeed(); + for ("line2") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("line3") |c| try t.print(c); + + // First row should be prompt + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 0, + } }).?; + try testing.expectEqual(.prompt, list_cell.row.semantic_prompt); + } + + // Second and third rows should be prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 2, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } +} + test "Terminal: cursorIsAtPrompt" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 3, .rows = 2 }); + var t = try init(alloc, .{ .cols = 10, .rows = 3 }); defer t.deinit(alloc); try testing.expect(!t.cursorIsAtPrompt()); - t.markSemanticPrompt(.prompt); + try t.semanticPrompt(.init(.prompt_start)); try testing.expect(t.cursorIsAtPrompt()); + for ("$ ") |c| try t.print(c); // 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 t.semanticPrompt(.init(.end_prompt_start_input)); try testing.expect(t.cursorIsAtPrompt()); + for ("ls") |c| try t.print(c); // But once we say we're starting output, we're not a prompt - t.markSemanticPrompt(.command); - try testing.expect(!t.cursorIsAtPrompt()); + // (cursor is not at x=0, so the Fish heuristic doesn't trigger) + try t.semanticPrompt(.init(.end_input_start_output)); + // Still a prompt because this line has a prompt + 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 t.semanticPrompt(.init(.prompt_start)); try testing.expect(t.cursorIsAtPrompt()); } @@ -11220,13 +11664,13 @@ test "Terminal: cursorIsAtPrompt alternate screen" { defer t.deinit(alloc); try testing.expect(!t.cursorIsAtPrompt()); - t.markSemanticPrompt(.prompt); + try t.semanticPrompt(.init(.prompt_start)); try testing.expect(t.cursorIsAtPrompt()); // Secondary screen is never a prompt try t.switchScreenMode(.@"1049", true); try testing.expect(!t.cursorIsAtPrompt()); - t.markSemanticPrompt(.prompt); + try t.semanticPrompt(.init(.prompt_start)); try testing.expect(!t.cursorIsAtPrompt()); } @@ -11236,6 +11680,7 @@ test "Terminal: fullReset with a non-empty pen" { try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); try t.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + t.screens.active.cursor.semantic_content = .input; t.fullReset(); { @@ -11248,6 +11693,7 @@ test "Terminal: fullReset with a non-empty pen" { } try testing.expectEqual(@as(style.Id, 0), t.screens.active.cursor.style_id); + try testing.expectEqual(.output, t.screens.active.cursor.semantic_content); } test "Terminal: fullReset hyperlink" { diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index b9061e2e9..a1386d14b 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -17,6 +17,7 @@ const parsers = @import("osc/parsers.zig"); const encoding = @import("osc/encoding.zig"); pub const color = parsers.color; +pub const semantic_prompt = parsers.semantic_prompt; const log = std.log.scoped(.osc); diff --git a/src/terminal/osc/parsers/semantic_prompt.zig b/src/terminal/osc/parsers/semantic_prompt.zig index f6a0cb593..9014312f4 100644 --- a/src/terminal/osc/parsers/semantic_prompt.zig +++ b/src/terminal/osc/parsers/semantic_prompt.zig @@ -47,11 +47,10 @@ pub const Option = enum { cl, prompt_kind, err, + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - // Kitty supports a "redraw" option for prompt_start. I can't find - // this documented anywhere but can see in the code that this is used - // by shell environments to tell the terminal that the shell will NOT - // redraw the prompt so we should attempt to resize it. + // Kitty supports a "redraw" option for prompt_start. This is extended + // by Ghostty with the "last" option. See Redraw the type for more details. redraw, // Use a special key instead of arrow keys to move the cursor on @@ -81,7 +80,7 @@ pub const Option = enum { .cl => Click, .prompt_kind => PromptKind, .err => []const u8, - .redraw => bool, + .redraw => Redraw, .special_key => bool, .click_events => bool, .exit_code => i32, @@ -169,7 +168,15 @@ pub const Option = enum { .cl => std.meta.stringToEnum(Click, value), .prompt_kind => if (value.len == 1) PromptKind.init(value[0]) else null, .err => value, - .redraw, .special_key, .click_events => if (value.len == 1) switch (value[0]) { + .redraw => if (std.mem.eql(u8, value, "0")) + .false + else if (std.mem.eql(u8, value, "1")) + .true + else if (std.mem.eql(u8, value, "last")) + .last + else + null, + .special_key, .click_events => if (value.len == 1) switch (value[0]) { '0' => false, '1' => true, else => null, @@ -208,6 +215,29 @@ pub const PromptKind = enum { } }; +/// The values for the `redraw` extension to OSC133. This was +/// started by Kitty[1] and extended by Ghostty (the "last" option). +/// +/// [1]: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers +pub const Redraw = enum(u2) { + /// The shell supports redrawing the full prompt and all continuations. + /// This is the default value, it does not need to be explicitly set + /// unless it is to reset a prior other value. + true, + + /// The shell does NOT support redrawing. In this case, Ghostty will NOT + /// clear any prompt lines on resize. + false, + + /// The shell supports redrawing only the LAST line of the prompt. + /// Ghostty will only clear the last line of the prompt on resize. + /// + /// This is specifically introduced because Bash only redraws the last + /// line. It is literally the only shell that does this and it does this + /// because its bad and they should feel bad. Don't be like Bash. + last, +}; + /// Parse OSC 133, semantic prompts pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { const writer = parser.writer orelse { @@ -513,7 +543,7 @@ test "OSC 133: fresh_line_new_prompt with redraw=0" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.readOption(.redraw).? == false); + try testing.expect(cmd.semantic_prompt.readOption(.redraw).? == .false); } test "OSC 133: fresh_line_new_prompt with redraw=1" { @@ -527,7 +557,7 @@ test "OSC 133: fresh_line_new_prompt with redraw=1" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.readOption(.redraw).? == true); + try testing.expect(cmd.semantic_prompt.readOption(.redraw).? == .true); } test "OSC 133: fresh_line_new_prompt with invalid redraw" { @@ -870,8 +900,9 @@ test "Option.read err" { test "Option.read redraw" { const testing = std.testing; - try testing.expect(Option.redraw.read("redraw=1").? == true); - try testing.expect(Option.redraw.read("redraw=0").? == false); + try testing.expect(Option.redraw.read("redraw=1").? == .true); + try testing.expect(Option.redraw.read("redraw=0").? == .false); + try testing.expect(Option.redraw.read("redraw=last").? == .last); try testing.expect(Option.redraw.read("redraw=2") == null); try testing.expect(Option.redraw.read("redraw=10") == null); try testing.expect(Option.redraw.read("redraw=") == null); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 6a5958681..61507dc75 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1898,9 +1898,16 @@ pub const Row = packed struct(u64) { /// false negatives. This is used to optimize hyperlink operations. hyperlink: bool = false, - /// The semantic prompt type for this row as specified by the - /// running program, or "unknown" if it was never set. - semantic_prompt: SemanticPrompt = .unknown, + /// The semantic prompt state for this row. + /// + /// This is ONLY meant to note if there are ANY cells in this + /// row that are part of a prompt. This is an optimization for more + /// efficiently implementing jump-to-prompt operations. + /// + /// This may contain false positives but never false negatives. If + /// this is set, you should still check individual cells to see if they + /// have prompt semantics. + semantic_prompt: SemanticPrompt = .none, /// True if this row contains a virtual placeholder for the Kitty /// graphics protocol. (U+10EEEE) @@ -1922,29 +1929,22 @@ pub const Row = packed struct(u64) { /// screen. dirty: bool = false, - _padding: u22 = 0, + _padding: u23 = 0, - /// 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. + /// The semantic prompt state of the row. See `semantic_prompt`. + pub const SemanticPrompt = enum(u2) { + /// No prompt cells in this row. + none = 0, + /// Prompt cells exist in this row and this is a primary prompt + /// line. A primary prompt line is one that is not a continuation + /// and is the beginning of a prompt. prompt = 1, + /// Prompt cells exist in this row that had k=c set (continuation) + /// line. This is used as a way to detect when a line should + /// be considered part of some prior prompt. If no prior prompt + /// is found, the last (most historical) prompt continuation line is + /// considered the prompt. prompt_continuation = 2, - - /// 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 = 3, - - /// This line is the start of command output. - command = 4, - - /// True if this is a prompt or input line. - pub fn promptOrInput(self: SemanticPrompt) bool { - return self == .prompt or self == .prompt_continuation or self == .input; - } }; /// Returns true if this row has any managed memory outside of the @@ -1994,7 +1994,12 @@ pub const Cell = packed struct(u64) { /// the hyperlink_set to get the actual hyperlink data. hyperlink: bool = false, - _padding: u18 = 0, + /// The semantic type of the content of this cell. This is used + /// by the semantic prompt (OSC 133) set of sequences to understand + /// boundary points for content. + semantic_content: SemanticContent = .output, + + _padding: u16 = 0, pub const ContentTag = enum(u2) { /// A single codepoint, could be zero to be empty cell. @@ -2033,6 +2038,19 @@ pub const Cell = packed struct(u64) { spacer_head = 3, }; + pub const SemanticContent = enum(u2) { + /// Regular output content, such as command output. + output = 0, + + /// Content that is part of user input, such as the command + /// to execute at a prompt. + input = 1, + + /// Content that is part of prompt emitted by the interactive + /// application, such as "user@host >" + prompt = 2, + }; + /// Helper to make a cell that just has a codepoint. pub fn init(cp: u21) Cell { // We have to use this bitCast here to ensure that our memory is @@ -2166,6 +2184,10 @@ test "Cell is zero by default" { const cell = Cell.init(0); const cell_int: u64 = @bitCast(cell); try std.testing.expectEqual(@as(u64, 0), cell_int); + + // The zero value should be output type for semantic content. + // This is very important for our assumptions elsewhere. + try std.testing.expectEqual(Cell.SemanticContent.output, cell.semantic_content); } test "Page capacity adjust cols down" { diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 18ed0dd42..eca13bf06 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -153,7 +153,7 @@ pub const Handler = struct { .full_reset => self.terminal.fullReset(), .start_hyperlink => try self.terminal.screens.active.startHyperlink(value.uri, value.id), .end_hyperlink => self.terminal.screens.active.endHyperlink(), - .semantic_prompt => self.semanticPrompt(value), + .semantic_prompt => try self.terminal.semanticPrompt(value), .mouse_shape => self.terminal.mouse_shape = value, .color_operation => try self.colorOperation(value.op, &value.requests), .kitty_color_report => try self.kittyColorOperation(value), @@ -209,42 +209,6 @@ pub const Handler = struct { } } - fn semanticPrompt( - self: *Handler, - cmd: Action.SemanticPrompt, - ) void { - switch (cmd.action) { - .fresh_line_new_prompt => { - const kind = cmd.readOption(.prompt_kind) orelse .initial; - switch (kind) { - .initial, .right => { - self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt; - if (cmd.readOption(.redraw)) |redraw| { - self.terminal.flags.shell_redraws_prompt = redraw; - } - }, - .continuation, .secondary => { - self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt_continuation; - }, - } - }, - - .end_prompt_start_input => self.terminal.markSemanticPrompt(.input), - .end_input_start_output => self.terminal.markSemanticPrompt(.command), - .end_command => self.terminal.screens.active.cursor.page_row.semantic_prompt = .input, - - // All of these commands weren't previously handled by our - // semantic prompt code. I am PR-ing the parser separate from the - // handling so we just ignore these like we did before, even - // though we should handle them eventually. - .end_prompt_start_input_terminate_eol, - .fresh_line, - .new_command, - .prompt_start, - => {}, - } - } - fn setMode(self: *Handler, mode: modes.Mode, enabled: bool) !void { // Set the mode on the terminal self.terminal.modes.set(mode, enabled); @@ -905,3 +869,122 @@ test "palette dirty flag set on color change" { try s.nextSlice("\x1b]21;1=rgb:00/ff/00\x1b\\"); try testing.expect(t.flags.dirty.palette); } + +test "semantic prompt fresh line" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + try s.nextSlice("Hello"); + try s.nextSlice("\x1b]133;L\x07"); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); +} + +test "semantic prompt fresh line new prompt" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Write some text and then send OSC 133;A (fresh_line_new_prompt) + try s.nextSlice("Hello"); + try s.nextSlice("\x1b]133;A\x07"); + + // Should do a fresh line (carriage return + index) + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + + // Should set cursor semantic_content to prompt + try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); + + // Test with redraw option + try s.nextSlice("prompt$ "); + try s.nextSlice("\x1b]133;A;redraw=1\x07"); + try testing.expect(t.flags.shell_redraws_prompt == .true); +} + +test "semantic prompt end of input, then start output" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Write some text and then send OSC 133;A (fresh_line_new_prompt) + try s.nextSlice("Hello"); + try s.nextSlice("\x1b]133;A\x07"); + try s.nextSlice("prompt$ "); + try s.nextSlice("\x1b]133;B\x07"); + try testing.expectEqual(.input, t.screens.active.cursor.semantic_content); + try s.nextSlice("\x1b]133;C\x07"); + try testing.expectEqual(.output, t.screens.active.cursor.semantic_content); +} + +test "semantic prompt prompt_start" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Write some text + try s.nextSlice("Hello"); + + // OSC 133;P marks the start of a prompt (without fresh line behavior) + try s.nextSlice("\x1b]133;P\x07"); + try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); +} + +test "semantic prompt new_command" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Write some text + try s.nextSlice("Hello"); + try s.nextSlice("\x1b]133;N\x07"); + + // Should behave like fresh_line_new_prompt - cursor moves to column 0 + // on next line since we had content + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); +} + +test "semantic prompt new_command at column zero" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // OSC 133;N when already at column 0 should stay on same line + try s.nextSlice("\x1b]133;N\x07"); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); +} + +test "semantic prompt end_prompt_start_input_terminate_eol clears on linefeed" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set input terminated by EOL + try s.nextSlice("\x1b]133;I\x07"); + try testing.expectEqual(.input, t.screens.active.cursor.semantic_content); + + // Linefeed should reset semantic content to output + try s.nextSlice("\n"); + try testing.expectEqual(.output, t.screens.active.cursor.semantic_content); +} diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index f46e2ec05..89ea7407b 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -610,8 +610,9 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void { // send a FF (0x0C) to the shell so that it can repaint the screen. // Mark the current row as a not a prompt so we can properly // clear the full screen in the next eraseDisplay call. - self.terminal.markSemanticPrompt(.command); - assert(!self.terminal.cursorIsAtPrompt()); + // TODO: fix this + // self.terminal.markSemanticPrompt(.command); + // assert(!self.terminal.cursorIsAtPrompt()); self.terminal.eraseDisplay(.complete, false); } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 63094b106..bc3edd185 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -320,7 +320,7 @@ pub const StreamHandler = struct { .progress_report => self.progressReport(value), .start_hyperlink => try self.startHyperlink(value.uri, value.id), .clipboard_contents => try self.clipboardContents(value.kind, value.data), - .semantic_prompt => self.semanticPrompt(value), + .semantic_prompt => try self.semanticPrompt(value), .mouse_shape => try self.setMouseShape(value), .configure_charset => self.configureCharset(value.slot, value.charset), .set_attribute => { @@ -1069,28 +1069,12 @@ pub const StreamHandler = struct { fn semanticPrompt( self: *StreamHandler, cmd: Stream.Action.SemanticPrompt, - ) void { + ) !void { switch (cmd.action) { - .fresh_line_new_prompt => { - const kind = cmd.readOption(.prompt_kind) orelse .initial; - switch (kind) { - .initial, .right => { - self.terminal.markSemanticPrompt(.prompt); - if (cmd.readOption(.redraw)) |redraw| { - self.terminal.flags.shell_redraws_prompt = redraw; - } - }, - .continuation, .secondary => { - self.terminal.markSemanticPrompt(.prompt_continuation); - }, - } - }, - - .end_prompt_start_input => self.terminal.markSemanticPrompt(.input), .end_input_start_output => { - self.terminal.markSemanticPrompt(.command); self.surfaceMessageWriter(.start_command); }, + .end_command => { // The specification seems to not specify the type but // other terminals accept 32-bits, but exit codes are really @@ -1103,16 +1087,19 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.{ .stop_command = code }); }, - // All of these commands weren't previously handled by our - // semantic prompt code. I am PR-ing the parser separate from the - // handling so we just ignore these like we did before, even - // though we should handle them eventually. + // Handled by Terminal, no special handling by us + .end_prompt_start_input, .end_prompt_start_input_terminate_eol, .fresh_line, + .fresh_line_new_prompt, .new_command, .prompt_start, => {}, } + + // We do this last so failures are still processed correctly + // above. + try self.terminal.semanticPrompt(cmd); } fn reportPwd(self: *StreamHandler, url: []const u8) !void {