Rewritten semantic prompt state management (OSC 133) (#10455)

Fixes #5932 

This updates our core terminal internals to track [semantic prompts
(OSC133)](https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md)
in a way that can enable us to fully implement the specification. This
PR makes our terminal state fully handle all specified sequences
properly, but we still ignore some noted options (`aid`, `cl`, etc.)
that are not critical to functionality.

I took a look at our shell integrations and they should still be
accurate and correct with regards to their OSC133 usage.

This doesn't introduce any new functionality, but should result in
existing functionality being more correct, more robust, and gives us the
groundwork to do more with semantic prompts in the future.

> [!NOTE]
>
> This PR description is **fully human written.** I realize its layout
and use of headers may trigger AI smells. But it's all me, I promise. :)

## Extensions

I kept support for the Kitty `redraw=0` extension: if an OSC133A command
sets `redraw=0` then we will NOT clear the prompt on resize (assuming
the shell can NOT redraw the prompt). I don't actually know any scripts
that utilize this, so we may want to just throw it away, but we had
support before and it's cheap to keep around and it's tested.

## Limitations, Missing Features

* We don't currently respect `aid` so nested semantic zones can break
things. This would require significant rework since aids are arbitrary
strings. It'd require managed memory on pages to store the aids. Fwiw,
in my brief survey of popular mainstream terminals, I wasn't able to
find any that respect this. Maybe Warp does since semantic prompts are
so core to their product but its not OSS for me to verify.

## Internal Changes

### `terminal.Page`

The internal state now tracks the current _semantic content type_ via a
2-bit flag on each `Cell`:

* `output` (zero value) - Command output
* `input` - Prompt input, typed by the user
* `prompt` - Prompt itself, e.g. `user@host $`

In addition to this, each `Row` also has a 2-bit flag. This flag _only
tracks if the row might contain a prompt_:

* `no_prompt` (zero value) - No prompt cells
* `prompt` - Prompt cells exist and this started the prompt (OSC133 A)
* `prompt_continuation` - Prompt cells exist and this continued a prior
prompt (OSC 133 A `k=s` or `k=c`)

The row flags server two functions:

1. An optimization to make prompt scanning faster
2. The only place the existence of continuation vs primary prompt is
stored since cells themselves only store `prompt`

### `terminal.PageList`

The PageList has two new functions:

* `promptIterator` - An iterator that yields `Pin` values that map to
the _first line_ of a prompt.
* `highlightSemanticContent` - Returns a highlight (pin range) to map to
specific semantic content for a given prompt pin. This lets you quickly
grab stuff like the prompt lines, content lines, etc.

### `terminal.Screen`

* The Screen now has a flag that tracks whether we've seen any row
trigger a semantic prompt. This can be used as a fast path to avoid
expensive semantic prompt operations if we know our screen can't
possibly contain any. We don't currently use this but I plan to.

* **Big one: clearing the prompt lines is now built-in to resize.** This
makes our resize aware of semantic prompts which makes it work _so much
better_ already.

### `terminal.Terminal`

* A new function `semanticPrompt` for handling all the semantic prompt
operations.

**AI Disclosure:** AI was used in various ways throughout this. It is by
no means 100% AI-written, it's mostly human written. I reviewed
everything. AI was used mostly for some assistance on rote tasks.
This commit is contained in:
Mitchell Hashimoto
2026-02-01 13:04:43 -08:00
committed by GitHub
17 changed files with 3826 additions and 970 deletions

View File

@@ -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.*;

View File

@@ -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");
}
};

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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| {

View File

@@ -16,7 +16,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# 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)

View File

@@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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" {

View File

@@ -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);

View File

@@ -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);

View File

@@ -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" {

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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 {