Rewrite semantic prompt parsing, parse the full spec (#10427)

Related to #5932 

This updates our OSC parser to parse the full OSC 133 specification:
https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md

The logic for handling these events was _unchanged_ from our prior
implementation. This is just a parser-only update. As such, we ignore a
bunch of semantic prompt command we should definitely handle, and
incorrectly handle others. This is the crux of #5932 that I want to head
towards fixing. This PR just contains the parser updates.

I also retained all the Kitty parser extensions.

**AI disclosure:** AI helped a lot of the rote tasks once I manually did
a few. I'm still reviewing this manually but will do so shortly.
This commit is contained in:
Mitchell Hashimoto
2026-01-24 07:04:04 -08:00
committed by GitHub
8 changed files with 726 additions and 736 deletions

View File

@@ -41,74 +41,8 @@ pub const Command = union(Key) {
/// in the log.
change_window_icon: [:0]const u8,
/// First do a fresh-line. Then start a new command, and enter prompt mode:
/// 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). Note: I've noticed
/// not all shells will send the prompt end code.
prompt_start: struct {
/// "aid" is an optional "application identifier" that helps disambiguate
/// nested shell sessions. It can be anything but is usually a process ID.
aid: ?[:0]const u8 = null,
/// "kind" tells us which kind of semantic prompt sequence this is:
/// - primary: normal, left-aligned first-line prompt (initial, default)
/// - continuation: an editable continuation line
/// - secondary: a non-editable continuation line
/// - right: a right-aligned prompt that may need adjustment during reflow
kind: enum { primary, continuation, secondary, right } = .primary,
/// If true, the shell will not redraw the prompt on resize so don't erase it.
/// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
redraw: bool = true,
/// Use a special key instead of arrow keys to move the cursor on
/// mouse click. Useful if arrow keys have side-effets like triggering
/// auto-complete. The shell integration script should bind the special
/// key as needed.
/// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
special_key: bool = false,
/// If true, the shell is capable of handling mouse click events.
/// Ghostty will then send a click event to the shell when the user
/// clicks somewhere in the prompt. The shell can then move the cursor
/// to that position or perform some other appropriate action. If false,
/// Ghostty may generate a number of fake key events to move the cursor
/// which is not very robust.
/// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
click_events: bool = false,
},
/// End of prompt and start of user input, terminated by a OSC "133;C"
/// or another prompt (OSC "133;P").
prompt_end: void,
/// The OSC "133;C" command can be used to explicitly end
/// the input area and begin the output area. However, some applications
/// don't provide a convenient way to emit that command.
/// That is why we also specify an implicit way to end the input area
/// at the end of the line. In the case of multiple input lines: If the
/// cursor is on a fresh (empty) line and we see either OSC "133;P" or
/// OSC "133;I" then this is the start of a continuation input line.
/// If we see anything else, it is the start of the output area (or end
/// of command).
end_of_input: struct {
/// The command line that the user entered.
/// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers
cmdline: ?[:0]const u8 = null,
},
/// End of current command.
///
/// The exit-code need not be specified if there are no options,
/// or if the command was cancelled (no OSC "133;C"), such as by typing
/// an interrupt/cancel character (typically ctrl-C) during line-editing.
/// Otherwise, it must be an integer code, where 0 means the command
/// succeeded, and other values indicate failure. In additing to the
/// exit-code there may be an err= option, which non-legacy terminals
/// should give precedence to. The err=_value_ option is more general:
/// an empty string is success, and any non-empty value (which need not
/// be an integer) is an error code. So to indicate success both ways you
/// could send OSC "133;D;0;err=\007", though `OSC "133;D;0\007" is shorter.
end_of_command: struct {
exit_code: ?u8 = null,
// TODO: err option
},
/// Semantic prompt command: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md
semantic_prompt: SemanticPrompt,
/// Set or get clipboard contents. If data is null, then the current
/// clipboard contents are sent to the pty. If data is set, this
@@ -218,6 +152,8 @@ pub const Command = union(Key) {
/// Kitty text sizing protocol (OSC 66)
kitty_text_sizing: parsers.kitty_text_sizing.OSC,
pub const SemanticPrompt = parsers.semantic_prompt.Command;
pub const Key = LibEnum(
if (build_options.c_abi) .c else .zig,
// NOTE: Order matters, see LibEnum documentation.
@@ -225,10 +161,7 @@ pub const Command = union(Key) {
"invalid",
"change_window_title",
"change_window_icon",
"prompt_start",
"prompt_end",
"end_of_input",
"end_of_command",
"semantic_prompt",
"clipboard_contents",
"report_pwd",
"mouse_shape",
@@ -460,15 +393,12 @@ pub const Parser = struct {
.conemu_sleep,
.conemu_wait_input,
.conemu_xterm_emulation,
.end_of_command,
.end_of_input,
.hyperlink_end,
.hyperlink_start,
.invalid,
.mouse_shape,
.prompt_end,
.prompt_start,
.report_pwd,
.semantic_prompt,
.show_desktop_notification,
.kitty_text_sizing,
=> {},

View File

@@ -15,17 +15,5 @@ pub const rxvt_extension = @import("parsers/rxvt_extension.zig");
pub const semantic_prompt = @import("parsers/semantic_prompt.zig");
test {
_ = change_window_icon;
_ = change_window_title;
_ = clipboard_operation;
_ = color;
_ = hyperlink;
_ = iterm2;
_ = kitty_color;
_ = kitty_text_sizing;
_ = mouse_shape;
_ = osc9;
_ = report_pwd;
_ = rxvt_extension;
_ = semantic_prompt;
std.testing.refAllDecls(@This());
}

View File

@@ -98,9 +98,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command {
},
// OSC 9;12 mark prompt start
'2' => {
parser.command = .{
.prompt_start = .{},
};
parser.command = .{ .semantic_prompt = .init(.fresh_line_new_prompt) };
return &parser.command;
},
else => break :conemu,
@@ -1125,7 +1123,7 @@ test "OSC: 9;12: ConEmu mark prompt start 1" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .prompt_start);
try testing.expect(cmd == .semantic_prompt);
}
test "OSC: 9;12: ConEmu mark prompt start 2" {
@@ -1138,5 +1136,5 @@ test "OSC: 9;12: ConEmu mark prompt start 2" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?.*;
try testing.expect(cmd == .prompt_start);
try testing.expect(cmd == .semantic_prompt);
}

File diff suppressed because it is too large Load Diff

View File

@@ -111,8 +111,6 @@ pub const Action = union(Key) {
apc_start,
apc_end,
apc_put: u8,
prompt_end,
end_of_input,
end_hyperlink,
active_status_display: ansi.StatusDisplay,
decaln,
@@ -122,14 +120,12 @@ pub const Action = union(Key) {
progress_report: osc.Command.ProgressReport,
start_hyperlink: StartHyperlink,
clipboard_contents: ClipboardContents,
prompt_start: PromptStart,
prompt_continuation: PromptContinuation,
end_of_command: EndOfCommand,
mouse_shape: MouseShape,
configure_charset: ConfigureCharset,
set_attribute: sgr.Attribute,
kitty_color_report: kitty.color.OSC,
color_operation: ColorOperation,
semantic_prompt: SemanticPrompt,
pub const Key = lib.Enum(
lib_target,
@@ -212,8 +208,6 @@ pub const Action = union(Key) {
"apc_start",
"apc_end",
"apc_put",
"prompt_end",
"end_of_input",
"end_hyperlink",
"active_status_display",
"decaln",
@@ -223,14 +217,12 @@ pub const Action = union(Key) {
"progress_report",
"start_hyperlink",
"clipboard_contents",
"prompt_start",
"prompt_continuation",
"end_of_command",
"mouse_shape",
"configure_charset",
"set_attribute",
"kitty_color_report",
"color_operation",
"semantic_prompt",
},
);
@@ -391,47 +383,6 @@ pub const Action = union(Key) {
}
};
pub const PromptStart = struct {
aid: ?[]const u8,
redraw: bool,
pub const C = extern struct {
aid: lib.String,
redraw: bool,
};
pub fn cval(self: PromptStart) PromptStart.C {
return .{
.aid = .init(self.aid orelse ""),
.redraw = self.redraw,
};
}
};
pub const PromptContinuation = struct {
aid: ?[]const u8,
pub const C = lib.String;
pub fn cval(self: PromptContinuation) PromptContinuation.C {
return .init(self.aid orelse "");
}
};
pub const EndOfCommand = struct {
exit_code: ?u8,
pub const C = extern struct {
exit_code: i16,
};
pub fn cval(self: EndOfCommand) EndOfCommand.C {
return .{
.exit_code = if (self.exit_code) |code| @intCast(code) else -1,
};
}
};
pub const ConfigureCharset = lib.Struct(lib_target, struct {
slot: charsets.Slots,
charset: charsets.Charset,
@@ -448,6 +399,8 @@ pub const Action = union(Key) {
return {};
}
};
pub const SemanticPrompt = osc.Command.SemanticPrompt;
};
/// Returns a type that can process a stream of tty control characters.
@@ -1988,10 +1941,9 @@ pub fn Stream(comptime Handler: type) type {
// 4. hyperlink_start
// 5. report_pwd
// 6. color_operation
// 7. prompt_start
// 8. prompt_end
// 7. semantic_prompt
//
// Together, these 8 commands make up about 96% of all
// Together, these 7 commands make up about 96% of all
// OSC commands encountered in real world scenarios.
//
// Additionally, within the prongs, unlikely branch
@@ -2003,6 +1955,11 @@ pub fn Stream(comptime Handler: type) type {
// ref: https://github.com/qwerasd205/asciinema-stats
switch (cmd) {
.semantic_prompt => |sp| {
@branchHint(.likely);
try self.handler.vt(.semantic_prompt, sp);
},
.change_window_title => |title| {
@branchHint(.likely);
if (!std.unicode.utf8ValidateSlice(title)) {
@@ -2026,30 +1983,6 @@ pub fn Stream(comptime Handler: type) type {
});
},
.prompt_start => |v| {
@branchHint(.likely);
switch (v.kind) {
.primary, .right => try self.handler.vt(.prompt_start, .{
.aid = v.aid,
.redraw = v.redraw,
}),
.continuation, .secondary => try self.handler.vt(.prompt_continuation, .{
.aid = v.aid,
}),
}
},
.prompt_end => {
@branchHint(.likely);
try self.handler.vt(.prompt_end, {});
},
.end_of_input => try self.handler.vt(.end_of_input, {}),
.end_of_command => |end| {
try self.handler.vt(.end_of_command, .{ .exit_code = end.exit_code });
},
.report_pwd => |v| {
@branchHint(.likely);
try self.handler.vt(.report_pwd, .{ .url = v.value });

View File

@@ -153,14 +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(),
.prompt_start => {
self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt;
self.terminal.flags.shell_redraws_prompt = value.redraw;
},
.prompt_continuation => self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt_continuation,
.prompt_end => self.terminal.markSemanticPrompt(.input),
.end_of_input => self.terminal.markSemanticPrompt(.command),
.end_of_command => self.terminal.screens.active.cursor.page_row.semantic_prompt = .input,
.semantic_prompt => self.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),
@@ -216,6 +209,40 @@ pub const Handler = struct {
}
}
fn semanticPrompt(
self: *Handler,
cmd: Action.SemanticPrompt,
) void {
switch (cmd.action) {
.fresh_line_new_prompt => {
const kind = cmd.options.prompt_kind orelse .initial;
switch (kind) {
.initial, .right => {
self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt;
self.terminal.flags.shell_redraws_prompt = cmd.options.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);

View File

@@ -311,8 +311,6 @@ pub const StreamHandler = struct {
},
.kitty_color_report => try self.kittyColorReport(value),
.color_operation => try self.colorOperation(value.op, &value.requests, value.terminator),
.prompt_end => try self.promptEnd(),
.end_of_input => try self.endOfInput(),
.end_hyperlink => try self.endHyperlink(),
.active_status_display => self.terminal.status_display = value,
.decaln => try self.decaln(),
@@ -322,9 +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),
.prompt_start => self.promptStart(value.aid, value.redraw),
.prompt_continuation => self.promptContinuation(value.aid),
.end_of_command => self.endOfCommand(value.exit_code),
.semantic_prompt => self.semanticPrompt(value),
.mouse_shape => try self.setMouseShape(value),
.configure_charset => self.configureCharset(value.slot, value.charset),
.set_attribute => {
@@ -1070,28 +1066,51 @@ pub const StreamHandler = struct {
});
}
inline fn promptStart(self: *StreamHandler, aid: ?[]const u8, redraw: bool) void {
_ = aid;
self.terminal.markSemanticPrompt(.prompt);
self.terminal.flags.shell_redraws_prompt = redraw;
}
fn semanticPrompt(
self: *StreamHandler,
cmd: Stream.Action.SemanticPrompt,
) void {
switch (cmd.action) {
.fresh_line_new_prompt => {
const kind = cmd.options.prompt_kind orelse .initial;
switch (kind) {
.initial, .right => {
self.terminal.markSemanticPrompt(.prompt);
self.terminal.flags.shell_redraws_prompt = cmd.options.redraw;
},
.continuation, .secondary => {
self.terminal.markSemanticPrompt(.prompt_continuation);
},
}
},
inline fn promptContinuation(self: *StreamHandler, aid: ?[]const u8) void {
_ = aid;
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
// bytes, so we just do our best here.
const code: u8 = code: {
const raw: i32 = cmd.options.exit_code orelse 0;
break :code std.math.cast(u8, raw) orelse 1;
};
pub inline fn promptEnd(self: *StreamHandler) !void {
self.terminal.markSemanticPrompt(.input);
}
self.surfaceMessageWriter(.{ .stop_command = code });
},
pub inline fn endOfInput(self: *StreamHandler) !void {
self.terminal.markSemanticPrompt(.command);
self.surfaceMessageWriter(.start_command);
}
inline fn endOfCommand(self: *StreamHandler, exit_code: ?u8) void {
self.surfaceMessageWriter(.{ .stop_command = exit_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.
.end_prompt_start_input_terminate_eol,
.fresh_line,
.new_command,
.prompt_start,
=> {},
}
}
fn reportPwd(self: *StreamHandler, url: []const u8) !void {