From 487b84da0ecff7e2888a03692f1dd8af4c9908ab Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 13:01:02 -0800 Subject: [PATCH 01/38] terminal: add semantic_content enum to cell --- src/terminal/page.zig | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 6a5958681..2f58bf49c 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -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" { From 7a69e2bf868cc2d08d866c9df0b8684de5467473 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 13:10:40 -0800 Subject: [PATCH 02/38] terminal: printCell writes with the current pen's content type --- src/terminal/Screen.zig | 4 ++++ src/terminal/Terminal.zig | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 45fe9dfc6..10d33a3a8 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -134,6 +134,10 @@ 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, + /// The pointers into the page list where the cursor is currently /// located. This makes it faster to move the cursor. page_pin: *PageList.Pin, diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index a955cbcae..ac11ead3e 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -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) { @@ -11236,6 +11237,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 +11250,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" { From 24bf642bdc02f877ba4ea6a5a3aac885921d1aa1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 12:10:17 -0800 Subject: [PATCH 03/38] terminal: start implementing proper semantic prompt behaviors --- src/terminal/Terminal.zig | 60 ++++++++++++++++++++++++++++++++ src/terminal/stream_readonly.zig | 19 ++++++++-- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index ac11ead3e..727b71b58 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"); @@ -1058,6 +1059,65 @@ 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)." + // TODO + + // The "aid" and "cl" options are also valid for this + // command but we don't yet handle these in any meaningful way. + }, + + else => {}, + } +} + +fn semanticPromptSet( + self: *Terminal, + mode: pagepkg.Cell.SemanticContent, +) void { + // We always reset this when we mode change. The caller can set it + // again after if they care. + self.screens.active.cursor.semantic_content_clear_eol = false; + + // Update our mode + self.screens.active.cursor.semantic_content = mode; +} + +// 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. diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 18ed0dd42..75e7cf129 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.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), @@ -212,7 +212,9 @@ pub const Handler = struct { fn semanticPrompt( self: *Handler, cmd: Action.SemanticPrompt, - ) void { + ) !void { + try self.terminal.semanticPrompt(cmd); + switch (cmd.action) { .fresh_line_new_prompt => { const kind = cmd.readOption(.prompt_kind) orelse .initial; @@ -905,3 +907,16 @@ 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); +} From 3fa63204781a158ac171714b3d619b54c9bcfdf1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 13:14:40 -0800 Subject: [PATCH 04/38] terminal: handle fresh_line_new_prompt --- src/terminal/Terminal.zig | 12 +++++- src/terminal/osc/parsers/semantic_prompt.zig | 1 + src/terminal/stream_readonly.zig | 45 +++++++++++++------- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 727b71b58..0f20beb18 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1076,7 +1076,17 @@ pub fn semanticPrompt( // "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)." - // TODO + + // Implementation note: we don't yet differentiate between + // the prompt types (k=) because it isn't of value to us + // currently. This may change in the future. + self.screens.active.cursor.semantic_content = .prompt; + + // This is a kitty-specific flag that notes that the shell + // is capable of redraw. + 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. diff --git a/src/terminal/osc/parsers/semantic_prompt.zig b/src/terminal/osc/parsers/semantic_prompt.zig index f6a0cb593..61aed4988 100644 --- a/src/terminal/osc/parsers/semantic_prompt.zig +++ b/src/terminal/osc/parsers/semantic_prompt.zig @@ -47,6 +47,7 @@ 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 diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 75e7cf129..6eb7353ea 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -216,21 +216,6 @@ pub const Handler = struct { try self.terminal.semanticPrompt(cmd); 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, @@ -240,10 +225,14 @@ pub const Handler = struct { // 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, => {}, + + // Handled by the new action above + .fresh_line, + .fresh_line_new_prompt, + => {}, } } @@ -920,3 +909,27 @@ test "semantic prompt fresh line" { 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); +} From acd7a448e1dc466f0d8da8d3fdd3b5cec1b0c4bc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 14:05:04 -0800 Subject: [PATCH 05/38] terminal: OSC 133 B handling --- src/terminal/Terminal.zig | 7 +++++++ src/terminal/stream_readonly.zig | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 0f20beb18..29f80c6f7 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1070,6 +1070,7 @@ pub fn semanticPrompt( ) !void { switch (cmd.action) { .fresh_line => try self.semanticPromptFreshLine(), + .fresh_line_new_prompt => { // "First do a fresh-line." try self.semanticPromptFreshLine(); @@ -1092,6 +1093,12 @@ pub fn semanticPrompt( // command but we don't yet handle these in any meaningful way. }, + .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.cursor.semantic_content = .input; + }, + else => {}, } } diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 6eb7353ea..fcb6d123f 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -933,3 +933,18 @@ test "semantic prompt fresh line new prompt" { try s.nextSlice("\x1b]133;A;redraw=1\x07"); try testing.expect(t.flags.shell_redraws_prompt); } + +test "semantic prompt end prompt start input" { + 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); +} From af12241d8887201b750e6340efe99f45d3d05dcd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 14:10:57 -0800 Subject: [PATCH 06/38] terminal: OSC 133 P --- src/terminal/Terminal.zig | 23 +++++++++++++++++++++++ src/terminal/stream_readonly.zig | 21 ++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 29f80c6f7..48d59835c 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1093,12 +1093,35 @@ pub fn semanticPrompt( // command but we don't yet handle these in any meaningful way. }, + .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). + + // As noted above, we don't currently utilize the prompt type. + self.screens.active.cursor.semantic_content = .prompt; + }, + .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.cursor.semantic_content = .input; }, + .end_input_start_output => { + // "End of input, and start of output." + self.screens.active.cursor.semantic_content = .output; + }, + + .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.cursor.semantic_content = .output; + }, + else => {}, } } diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index fcb6d123f..af4048973 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -934,7 +934,7 @@ test "semantic prompt fresh line new prompt" { try testing.expect(t.flags.shell_redraws_prompt); } -test "semantic prompt end prompt start input" { +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); @@ -947,4 +947,23 @@ test "semantic prompt end prompt start input" { 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); } From 4d555f878e0e5254bb84492ee9fef5801d7ed041 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 14:17:28 -0800 Subject: [PATCH 07/38] terminal: OSC 133 N --- src/terminal/Terminal.zig | 13 +++++++++++++ src/terminal/stream_readonly.zig | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 48d59835c..0e7cc5d92 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1093,6 +1093,19 @@ pub fn semanticPrompt( // 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: diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index af4048973..5a5f0d65f 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -967,3 +967,35 @@ test "semantic prompt prompt_start" { 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); +} From ae65998d5b8080d1304de7c694f90458564f4b12 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 14:22:25 -0800 Subject: [PATCH 08/38] terminal: OSC 133;I --- src/terminal/Screen.zig | 1 + src/terminal/Terminal.zig | 20 ++++++++++++++++++-- src/terminal/stream_readonly.zig | 16 ++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 10d33a3a8..e92a81117 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -137,6 +137,7 @@ pub const Cursor = struct { /// 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. diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 0e7cc5d92..2782b8d0e 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1082,6 +1082,7 @@ pub fn semanticPrompt( // the prompt types (k=) because it isn't of value to us // currently. This may change in the future. self.screens.active.cursor.semantic_content = .prompt; + self.screens.active.cursor.semantic_content_clear_eol = false; // This is a kitty-specific flag that notes that the shell // is capable of redraw. @@ -1114,17 +1115,26 @@ pub fn semanticPrompt( // As noted above, we don't currently utilize the prompt type. self.screens.active.cursor.semantic_content = .prompt; + self.screens.active.cursor.semantic_content_clear_eol = false; }, .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.cursor.semantic_content = .input; + self.screens.active.cursor.semantic_content_clear_eol = false; + }, + + .end_prompt_start_input_terminate_eol => { + // End of prompt and start of user input, terminated by end-of-line. + self.semanticPromptSet(.input); + self.screens.active.cursor.semantic_content_clear_eol = true; }, .end_input_start_output => { // "End of input, and start of output." self.screens.active.cursor.semantic_content = .output; + self.screens.active.cursor.semantic_content_clear_eol = false; }, .end_command => { @@ -1133,9 +1143,8 @@ pub fn semanticPrompt( // its reasonable at this point to reset our semantic content // state but the spec doesn't really say what to do. self.screens.active.cursor.semantic_content = .output; + self.screens.active.cursor.semantic_content_clear_eol = false; }, - - else => {}, } } @@ -1295,6 +1304,13 @@ pub fn index(self: *Terminal) !void { // Unset pending wrap state self.screens.active.cursor.pending_wrap = false; + // Always reset any semantic content clear-eol state + if (self.screens.active.cursor.semantic_content_clear_eol) { + @branchHint(.unlikely); + self.screens.active.cursor.semantic_content = .output; + self.screens.active.cursor.semantic_content_clear_eol = false; + } + // 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) diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 5a5f0d65f..9ffe617bd 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -999,3 +999,19 @@ test "semantic prompt new_command at column zero" { 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); +} From 84cfb9de1c26db0835baeb13a51f01751cad2db2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 15:14:34 -0800 Subject: [PATCH 09/38] restore old marking behavior so everything keeps working --- src/terminal/stream_readonly.zig | 25 ++++++++++++++++++------- src/termio/stream_handler.zig | 8 ++++++-- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 9ffe617bd..87b0d9788 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -213,9 +213,22 @@ pub const Handler = struct { self: *Handler, cmd: Action.SemanticPrompt, ) !void { - try self.terminal.semanticPrompt(cmd); - 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), .end_command => self.terminal.screens.active.cursor.page_row.semantic_prompt = .input, @@ -226,14 +239,12 @@ pub const Handler = struct { // though we should handle them eventually. .end_prompt_start_input_terminate_eol, .new_command, + .fresh_line, .prompt_start, => {}, - - // Handled by the new action above - .fresh_line, - .fresh_line_new_prompt, - => {}, } + + try self.terminal.semanticPrompt(cmd); } fn setMode(self: *Handler, mode: modes.Mode, enabled: bool) !void { diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 63094b106..b725649f1 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,7 +1069,7 @@ 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; @@ -1113,6 +1113,10 @@ pub const StreamHandler = struct { .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 { From a80b3f34c019653f2e075b09a3dd5bf228a5c10e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 24 Jan 2026 20:03:36 -0800 Subject: [PATCH 10/38] terminal: add semantic_prompt2 to Row to track prompt state --- src/terminal/Terminal.zig | 141 +++++++++++++++++++++++++++++++++----- src/terminal/osc.zig | 1 + src/terminal/page.zig | 24 ++++++- 3 files changed, 147 insertions(+), 19 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 2782b8d0e..063a240f5 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1077,12 +1077,10 @@ pub fn semanticPrompt( // "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)." - - // Implementation note: we don't yet differentiate between - // the prompt types (k=) because it isn't of value to us - // currently. This may change in the future. - self.screens.active.cursor.semantic_content = .prompt; - self.screens.active.cursor.semantic_content_clear_eol = false; + self.semanticPromptSet( + .prompt, + cmd.readOption(.prompt_kind) orelse .initial, + ); // This is a kitty-specific flag that notes that the shell // is capable of redraw. @@ -1112,29 +1110,27 @@ pub fn semanticPrompt( // 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). - - // As noted above, we don't currently utilize the prompt type. - self.screens.active.cursor.semantic_content = .prompt; - self.screens.active.cursor.semantic_content_clear_eol = false; + self.semanticPromptSet( + .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.cursor.semantic_content = .input; - self.screens.active.cursor.semantic_content_clear_eol = false; + self.semanticPromptSet(.input, .initial); }, .end_prompt_start_input_terminate_eol => { // End of prompt and start of user input, terminated by end-of-line. - self.semanticPromptSet(.input); + self.semanticPromptSet(.input, .initial); self.screens.active.cursor.semantic_content_clear_eol = true; }, .end_input_start_output => { // "End of input, and start of output." - self.screens.active.cursor.semantic_content = .output; - self.screens.active.cursor.semantic_content_clear_eol = false; + self.semanticPromptSet(.output, .initial); }, .end_command => { @@ -1142,8 +1138,7 @@ pub fn semanticPrompt( // 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.cursor.semantic_content = .output; - self.screens.active.cursor.semantic_content_clear_eol = false; + self.semanticPromptSet(.output, .initial); }, } } @@ -1151,6 +1146,7 @@ pub fn semanticPrompt( fn semanticPromptSet( self: *Terminal, mode: pagepkg.Cell.SemanticContent, + kind: osc.semantic_prompt.PromptKind, ) void { // We always reset this when we mode change. The caller can set it // again after if they care. @@ -1158,6 +1154,20 @@ fn semanticPromptSet( // Update our mode self.screens.active.cursor.semantic_content = mode; + + // We only need to update our row marker for prompt types. We + // use a switch in case new modes are introduced so the compiler + // can force us to handle them. + switch (mode) { + .input, .output => return, + .prompt => {}, + } + + // Last prompt type wins + self.screens.active.cursor.page_row.semantic_prompt2 = switch (kind) { + .initial, .right => .prompt, + .continuation, .secondary => .prompt_continuation, + }; } // OSC 133;L @@ -1304,7 +1314,11 @@ pub fn index(self: *Terminal) !void { // Unset pending wrap state self.screens.active.cursor.pending_wrap = false; - // Always reset any semantic content clear-eol state + // 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 (self.screens.active.cursor.semantic_content_clear_eol) { @branchHint(.unlikely); self.screens.active.cursor.semantic_content = .output; @@ -11314,6 +11328,97 @@ 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_prompt2); + } + + // 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(.no_prompt, row.semantic_prompt2); + } +} + +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_prompt2); + } + + // 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_prompt2); + } +} + test "Terminal: cursorIsAtPrompt" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 3, .rows = 2 }); 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/page.zig b/src/terminal/page.zig index 2f58bf49c..3747a8e6a 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1902,6 +1902,17 @@ pub const Row = packed struct(u64) { /// 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_prompt2: SemanticPrompt2 = .no_prompt, + /// True if this row contains a virtual placeholder for the Kitty /// graphics protocol. (U+10EEEE) // Note: We keep this as memory-using even if the kitty graphics @@ -1922,7 +1933,18 @@ pub const Row = packed struct(u64) { /// screen. dirty: bool = false, - _padding: u22 = 0, + _padding: u20 = 0, + + /// The semantic prompt state of the row. See `semantic_prompt`. + pub const SemanticPrompt2 = enum(u2) { + /// No prompt cells in this row. + no_prompt = 0, + /// Prompt cells exist in this row. + prompt = 1, + /// Prompt cells exist in this row that had k=c set (continuation) + /// line. This is used as a way to + prompt_continuation = 2, + }; /// Semantic prompt type. pub const SemanticPrompt = enum(u3) { From 3c0fe022387ca4cf649e12b02d1a05682728125d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Jan 2026 13:54:53 -0800 Subject: [PATCH 11/38] terminal: PageList.promptIterator --- src/terminal/PageList.zig | 350 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 350 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 35826d97e..2d4585f60 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"); @@ -4213,6 +4214,147 @@ pub fn diagram( /// 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| { + const rac = p.rowAndCell(); + switch (rac.row.semantic_prompt2) { + // This row isn't a prompt. Keep looking. + .no_prompt => current = p.down(1), + + // 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 => { + // Skip over any continuation lines that follow this prompt + var end_pin = p; + while (end_pin.down(1)) |next_pin| : (end_pin = next_pin) { + switch (next_pin.rowAndCell().row.semantic_prompt2) { + .prompt_continuation => {}, + .prompt, .no_prompt => { + self.current = next_pin; + return p.left(p.x); + }, + } + } else { + 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| { + const rac = p.rowAndCell(); + switch (rac.row.semantic_prompt2) { + // This row isn't a prompt. Keep looking. + .no_prompt => current = p.up(1), + + // This is a prompt line. + .prompt => { + self.current = p.up(1); + // We want to make sure our x is 0 + 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 => while (current.?.up(1)) |prior| { + switch (prior.rowAndCell().row.semantic_prompt2) { + // No prompt. We know this line is bad, so we move + // our cursor to the NEXT line and then return the + // PREVIOUS line we looked at which we know was good. + .no_prompt => { + self.current = prior.up(1); + return current.?.left(current.?.x); + }, + + // Prompt continuation, keep looking. + .prompt_continuation => current = prior, + + // Prompt! Found it! + .prompt => { + self.current = prior.up(1); + return prior.left(prior.x); + }, + } + } else { + // 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 +4958,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 +7719,202 @@ 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_prompt2 = .prompt; + } + // Continuation + { + const rac = page.getRowAndCell(0, 6); + rac.row.semantic_prompt2 = .prompt; + } + { + const rac = page.getRowAndCell(0, 7); + rac.row.semantic_prompt2 = .prompt_continuation; + } + { + const rac = page.getRowAndCell(0, 8); + rac.row.semantic_prompt2 = .prompt_continuation; + } + // Broken continuation that has non-prompts in between + { + const rac = page.getRowAndCell(0, 12); + rac.row.semantic_prompt2 = .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_prompt2 = .prompt; + } + // Continuation (prompt on row 6, continuation on rows 7-8) + { + const rac = page.getRowAndCell(0, 6); + rac.row.semantic_prompt2 = .prompt; + } + { + const rac = page.getRowAndCell(0, 7); + rac.row.semantic_prompt2 = .prompt_continuation; + } + { + const rac = page.getRowAndCell(0, 8); + rac.row.semantic_prompt2 = .prompt_continuation; + } + // Broken continuation that has non-prompts in between (orphaned continuation at row 12) + { + const rac = page.getRowAndCell(0, 12); + rac.row.semantic_prompt2 = .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_prompt2 = .prompt_continuation; + } + { + const rac = page.getRowAndCell(0, 1); + rac.row.semantic_prompt2 = .prompt_continuation; + } + // Normal prompt later + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt2 = .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_prompt2 = .prompt; + } + { + const rac = page.getRowAndCell(0, 3); + rac.row.semantic_prompt2 = .prompt_continuation; + } + { + const rac = page.getRowAndCell(0, 4); + rac.row.semantic_prompt2 = .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 erase" { const testing = std.testing; const alloc = testing.allocator; From 123e4ea3253f8203f2d8c3b89af0f26714da63f9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Jan 2026 14:23:27 -0800 Subject: [PATCH 12/38] terminal: PageList delta_prompt scroll uses new promptIterator --- src/terminal/PageList.zig | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 2d4585f60..f8383bf8d 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2669,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; } @@ -6595,11 +6600,11 @@ test "PageList: jump zero prompts" { const page = &s.pages.first.?.data; { const rac = page.getRowAndCell(0, 1); - rac.row.semantic_prompt = .prompt; + rac.row.semantic_prompt2 = .prompt; } { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt = .prompt; + rac.row.semantic_prompt2 = .prompt; } s.scroll(.{ .delta_prompt = 0 }); @@ -6623,11 +6628,11 @@ test "Screen: jump back one prompt" { const page = &s.pages.first.?.data; { const rac = page.getRowAndCell(0, 1); - rac.row.semantic_prompt = .prompt; + rac.row.semantic_prompt2 = .prompt; } { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt = .prompt; + rac.row.semantic_prompt2 = .prompt; } // Jump back From c74889124a8dea94d3abd5b6b3e8e839ae3b386c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Jan 2026 14:26:04 -0800 Subject: [PATCH 13/38] terminal: PageList uses new semantic_prompt2 --- src/terminal/PageList.zig | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index f8383bf8d..600cffcec 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1229,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_prompt2 != .no_prompt) cols_len = 1; } // Handle tracked pin adjustments. @@ -1973,13 +1973,13 @@ 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_prompt2 != .no_prompt) return len - 1; return len; } fn copyRowMetadata(self: *ReflowCursor, other: *const Row) void { - self.page_row.semantic_prompt = other.semantic_prompt; + self.page_row.semantic_prompt2 = other.semantic_prompt2; } }; @@ -10254,7 +10254,7 @@ test "PageList resize reflow more cols no reflow preserves semantic prompt" { try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 1); - rac.row.semantic_prompt = .prompt; + rac.row.semantic_prompt2 = .prompt; } // Resize @@ -10266,7 +10266,7 @@ test "PageList resize reflow more cols no reflow preserves semantic prompt" { try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 1); - try testing.expect(rac.row.semantic_prompt == .prompt); + try testing.expect(rac.row.semantic_prompt2 == .prompt); } } @@ -10829,7 +10829,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt" { const page = &s.pages.first.?.data; { const rac = page.getRowAndCell(0, 1); - rac.row.semantic_prompt = .prompt; + rac.row.semantic_prompt2 = .prompt; } for (0..s.cols) |x| { const rac = page.getRowAndCell(x, 1); @@ -10851,12 +10851,12 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt" { const p = s.pin(.{ .active = .{ .y = 1 } }).?; const rac = p.rowAndCell(); try testing.expect(rac.row.wrap); - try testing.expect(rac.row.semantic_prompt == .prompt); + try testing.expect(rac.row.semantic_prompt2 == .prompt); } { const p = s.pin(.{ .active = .{ .y = 2 } }).?; const rac = p.rowAndCell(); - try testing.expect(rac.row.semantic_prompt == .prompt); + try testing.expect(rac.row.semantic_prompt2 == .prompt); } } } @@ -10871,7 +10871,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt on fi try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 0); - rac.row.semantic_prompt = .prompt; + rac.row.semantic_prompt2 = .prompt; } // Resize @@ -10883,7 +10883,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt on fi try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 0); - try testing.expect(rac.row.semantic_prompt == .prompt); + try testing.expect(rac.row.semantic_prompt2 == .prompt); } } @@ -10897,7 +10897,7 @@ test "PageList resize reflow less cols wrap preserves semantic prompt" { try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 0); - rac.row.semantic_prompt = .prompt; + rac.row.semantic_prompt2 = .prompt; } // Resize @@ -10909,7 +10909,7 @@ test "PageList resize reflow less cols wrap preserves semantic prompt" { try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 0); - try testing.expect(rac.row.semantic_prompt == .prompt); + try testing.expect(rac.row.semantic_prompt2 == .prompt); } } From f9aa7597672d80c3811a9f2f6a324c84a2fd7a9c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Jan 2026 14:35:46 -0800 Subject: [PATCH 14/38] terminal: promptIterator needs to respect limits --- src/terminal/PageList.zig | 151 ++++++++++++++++++++++++++++++-------- 1 file changed, 121 insertions(+), 30 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 600cffcec..bff31fd3f 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -4253,31 +4253,44 @@ pub const PromptIterator = struct { // We need to traverse downwards and look for prompts. var current: ?Pin = start; - while (current) |p| { + 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_prompt2) { // This row isn't a prompt. Keep looking. - .no_prompt => current = p.down(1), + .no_prompt => 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 => { - // Skip over any continuation lines that follow this prompt + // 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_prompt2) { - .prompt_continuation => {}, + .prompt_continuation => if (self.limit) |limit| { + if (limit.eql(next_pin)) break; + }, + .prompt, .no_prompt => { self.current = next_pin; return p.left(p.x); }, } - } else { - self.current = null; - return p.left(p.x); } + + self.current = null; + return p.left(p.x); }, } } @@ -4293,16 +4306,18 @@ pub const PromptIterator = struct { // We need to traverse upwards and look for prompts. var current: ?Pin = start; - while (current) |p| { + 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_prompt2) { // This row isn't a prompt. Keep looking. - .no_prompt => current = p.up(1), + .no_prompt => if (at_limit) break, // This is a prompt line. .prompt => { - self.current = p.up(1); - // We want to make sure our x is 0 + self.current = if (at_limit) null else p.up(1); return p.left(p.x); }, @@ -4310,26 +4325,37 @@ pub const PromptIterator = struct { // 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 => while (current.?.up(1)) |prior| { - switch (prior.rowAndCell().row.semantic_prompt2) { - // No prompt. We know this line is bad, so we move - // our cursor to the NEXT line and then return the - // PREVIOUS line we looked at which we know was good. - .no_prompt => { - self.current = prior.up(1); - return current.?.left(current.?.x); - }, - - // Prompt continuation, keep looking. - .prompt_continuation => current = prior, - - // Prompt! Found it! - .prompt => { - self.current = prior.up(1); - return prior.left(prior.x); - }, + .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); } - } else { + + 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_prompt2) { + // No prompt. That means our last pin is good! + .no_prompt => { + 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); @@ -7920,6 +7946,71 @@ test "PageList promptIterator right_down with prompt before continuation" { 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_prompt2 = .prompt; + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt2 = .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_prompt2 = .prompt; + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt2 = .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 erase" { const testing = std.testing; const alloc = testing.allocator; From 4dd5df6c05f6562c28f8e35bd78e687a92389fc6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 25 Jan 2026 15:08:05 -0800 Subject: [PATCH 15/38] terminal: PageList.highlightSemanticContent --- src/terminal/PageList.zig | 1208 +++++++++++++++++++++++++++++++++++++ 1 file changed, 1208 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index bff31fd3f..c7ff0fc8d 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -4216,6 +4216,151 @@ 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 }; @@ -8011,6 +8156,1069 @@ test "PageList promptIterator left_up limit inclusive" { 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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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_prompt2 = .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; From fd016fdb2a66d9490f491ba43220a055069f18d0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 09:22:06 -0800 Subject: [PATCH 16/38] terminal: move cursor semantic content functions into Screen --- src/terminal/Screen.zig | 34 ++++++++++++++++++++++++ src/terminal/Terminal.zig | 54 ++++++++++----------------------------- 2 files changed, 48 insertions(+), 40 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index e92a81117..268ae934a 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"); @@ -151,6 +152,39 @@ pub const Cursor = struct { alloc.destroy(link); } } + + /// 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 setSemanticContent(self: *Cursor, t: union(enum) { + prompt: osc.semantic_prompt.PromptKind, + output, + input: enum { clear_explicit, clear_eol }, + }) void { + switch (t) { + .output => { + self.semantic_content = .output; + self.semantic_content_clear_eol = false; + }, + + .input => |clear| { + self.semantic_content = .input; + self.semantic_content_clear_eol = switch (clear) { + .clear_explicit => false, + .clear_eol => true, + }; + }, + + .prompt => |kind| { + self.semantic_content = .prompt; + self.semantic_content_clear_eol = false; + self.page_row.semantic_prompt2 = switch (kind) { + .initial, .right => .prompt, + .continuation, .secondary => .prompt_continuation, + }; + }, + } + } }; /// Saved cursor state. diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 063a240f5..dc649c5b1 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1077,10 +1077,9 @@ pub fn semanticPrompt( // "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.semanticPromptSet( - .prompt, - cmd.readOption(.prompt_kind) orelse .initial, - ); + self.screens.active.cursor.setSemanticContent(.{ + .prompt = cmd.readOption(.prompt_kind) orelse .initial, + }); // This is a kitty-specific flag that notes that the shell // is capable of redraw. @@ -1110,27 +1109,29 @@ pub fn semanticPrompt( // 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.semanticPromptSet( - .prompt, - cmd.readOption(.prompt_kind) orelse .initial, - ); + self.screens.active.cursor.setSemanticContent(.{ + .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.semanticPromptSet(.input, .initial); + self.screens.active.cursor.setSemanticContent(.{ + .input = .clear_explicit, + }); }, .end_prompt_start_input_terminate_eol => { // End of prompt and start of user input, terminated by end-of-line. - self.semanticPromptSet(.input, .initial); - self.screens.active.cursor.semantic_content_clear_eol = true; + self.screens.active.cursor.setSemanticContent(.{ + .input = .clear_eol, + }); }, .end_input_start_output => { // "End of input, and start of output." - self.semanticPromptSet(.output, .initial); + self.screens.active.cursor.setSemanticContent(.output); }, .end_command => { @@ -1138,38 +1139,11 @@ pub fn semanticPrompt( // 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.semanticPromptSet(.output, .initial); + self.screens.active.cursor.setSemanticContent(.output); }, } } -fn semanticPromptSet( - self: *Terminal, - mode: pagepkg.Cell.SemanticContent, - kind: osc.semantic_prompt.PromptKind, -) void { - // We always reset this when we mode change. The caller can set it - // again after if they care. - self.screens.active.cursor.semantic_content_clear_eol = false; - - // Update our mode - self.screens.active.cursor.semantic_content = mode; - - // We only need to update our row marker for prompt types. We - // use a switch in case new modes are introduced so the compiler - // can force us to handle them. - switch (mode) { - .input, .output => return, - .prompt => {}, - } - - // Last prompt type wins - self.screens.active.cursor.page_row.semantic_prompt2 = switch (kind) { - .initial, .right => .prompt, - .continuation, .secondary => .prompt_continuation, - }; -} - // OSC 133;L fn semanticPromptFreshLine(self: *Terminal) !void { const left_margin = if (self.screens.active.cursor.x < self.scrolling_region.left) From 07dce38cc533ca10df36f50c470afa283c0bcfb1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 09:39:44 -0800 Subject: [PATCH 17/38] terminal: Screen tracks semantic content seen --- src/terminal/Screen.zig | 86 +++++++++++++++++++++++---------------- src/terminal/Terminal.zig | 12 +++--- 2 files changed, 58 insertions(+), 40 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 268ae934a..09e5d19df 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -77,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 @@ -88,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. @@ -152,39 +166,6 @@ pub const Cursor = struct { alloc.destroy(link); } } - - /// 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 setSemanticContent(self: *Cursor, t: union(enum) { - prompt: osc.semantic_prompt.PromptKind, - output, - input: enum { clear_explicit, clear_eol }, - }) void { - switch (t) { - .output => { - self.semantic_content = .output; - self.semantic_content_clear_eol = false; - }, - - .input => |clear| { - self.semantic_content = .input; - self.semantic_content_clear_eol = switch (clear) { - .clear_explicit => false, - .clear_eol => true, - }; - }, - - .prompt => |kind| { - self.semantic_content = .prompt; - self.semantic_content_clear_eol = false; - self.page_row.semantic_prompt2 = switch (kind) { - .initial, .right => .prompt, - .continuation, .secondary => .prompt_continuation, - }; - }, - } - } }; /// Saved cursor state. @@ -397,6 +378,7 @@ pub fn reset(self: *Screen) void { self.charset = .{}; self.kitty_keyboard = .{}; self.protected_mode = .off; + self.flags = .{}; self.clearSelection(); } @@ -2363,6 +2345,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_prompt2 = 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 @@ -3874,7 +3892,7 @@ test "Screen eraseRows active partial" { } } -test "Screen: clearPrompt" { +test "Screen: clearPrompt single line prompt" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index dc649c5b1..2bbec67dc 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1077,7 +1077,7 @@ pub fn semanticPrompt( // "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.cursor.setSemanticContent(.{ + self.screens.active.cursorSetSemanticContent(.{ .prompt = cmd.readOption(.prompt_kind) orelse .initial, }); @@ -1109,7 +1109,7 @@ pub fn semanticPrompt( // 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.cursor.setSemanticContent(.{ + self.screens.active.cursorSetSemanticContent(.{ .prompt = cmd.readOption(.prompt_kind) orelse .initial, }); }, @@ -1117,21 +1117,21 @@ pub fn semanticPrompt( .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.cursor.setSemanticContent(.{ + 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.cursor.setSemanticContent(.{ + self.screens.active.cursorSetSemanticContent(.{ .input = .clear_eol, }); }, .end_input_start_output => { // "End of input, and start of output." - self.screens.active.cursor.setSemanticContent(.output); + self.screens.active.cursorSetSemanticContent(.output); }, .end_command => { @@ -1139,7 +1139,7 @@ pub fn semanticPrompt( // 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.cursor.setSemanticContent(.output); + self.screens.active.cursorSetSemanticContent(.output); }, } } From b62ac468dcc8999bd99d45da305323238390b3c6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 09:50:36 -0800 Subject: [PATCH 18/38] terminal: change Screen.resize to take an options struct --- src/terminal/Screen.zig | 147 +++++++++++++++++--------------------- src/terminal/Terminal.zig | 18 ++--- 2 files changed, 77 insertions(+), 88 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 09e5d19df..39b507ea4 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1651,12 +1651,21 @@ 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, +}; + /// 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 @@ -1666,29 +1675,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(); @@ -1744,9 +1731,9 @@ fn resizeInternal( // 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 }, }); @@ -1773,7 +1760,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; } @@ -5787,7 +5774,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); @@ -5805,7 +5792,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); @@ -5840,7 +5827,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); @@ -5875,7 +5862,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); @@ -5896,7 +5883,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 = .{} }); @@ -5913,7 +5900,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 = .{} }); @@ -5931,7 +5918,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 = .{} }); @@ -5948,7 +5935,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 = .{} }); @@ -5972,7 +5959,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); @@ -6004,7 +5991,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); @@ -6033,7 +6020,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); @@ -6060,7 +6047,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); @@ -6104,7 +6091,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" { @@ -6133,7 +6120,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); @@ -6160,7 +6147,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 = .{} }); @@ -6190,7 +6177,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); @@ -6223,7 +6210,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); @@ -6259,7 +6246,7 @@ test "Screen: resize more cols no reflow preserves semantic prompt" { try s.testWriteSemanticString("2EFGH\n", .prompt); try s.testWriteSemanticString("3IJKL", .unknown); - try s.resize(10, 3); + try s.resize(.{ .cols = 10, .rows = 3, .reflow = false }); const expected = "1ABCD\n2EFGH\n3IJKL"; { @@ -6316,7 +6303,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); @@ -6356,7 +6343,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); @@ -6401,7 +6388,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); @@ -6442,7 +6429,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); @@ -6481,7 +6468,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); @@ -6527,7 +6514,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); @@ -6555,7 +6542,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); @@ -6584,7 +6571,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); @@ -6624,7 +6611,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 = .{} }); @@ -6652,7 +6639,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 = .{} }); @@ -6683,7 +6670,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 = .{} }); @@ -6717,7 +6704,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). @@ -6749,7 +6736,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); @@ -6786,7 +6773,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); @@ -6813,7 +6800,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 = .{} }); @@ -6837,7 +6824,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 = .{} }); @@ -6870,7 +6857,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); @@ -6905,7 +6892,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 = .{} }); @@ -6946,7 +6933,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 = .{} }); @@ -6988,7 +6975,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 = .{} }); @@ -7024,7 +7011,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 = .{} }); @@ -7053,7 +7040,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); @@ -7066,7 +7053,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); @@ -7079,7 +7066,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); @@ -7113,7 +7100,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); @@ -7152,7 +7139,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); @@ -7191,7 +7178,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); @@ -7233,7 +7220,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); @@ -7284,7 +7271,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); @@ -7330,7 +7317,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); @@ -9047,7 +9034,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 2bbec67dc..83e54482b 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2685,16 +2685,18 @@ pub fn resize( { 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), + }); // 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; From 142f8ca6dbbf6ff8037a0e3d8afd3860138cd0f1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 10:25:03 -0800 Subject: [PATCH 19/38] terminal: Screen.selectLine uses new semantic boundaries --- src/terminal/Screen.zig | 452 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 430 insertions(+), 22 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 39b507ea4..b32431db3 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2500,16 +2500,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; @@ -2520,13 +2540,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; @@ -2544,13 +2569,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) { @@ -3126,6 +3170,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_prompt2 = .prompt_continuation, + } continue; } @@ -3168,6 +3218,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. @@ -3189,6 +3240,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. @@ -3207,6 +3259,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. @@ -3219,6 +3272,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. @@ -7688,9 +7742,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 = .{} }); @@ -7705,14 +7761,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 = .{ @@ -7731,6 +7786,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; From ed0979cb0c21d3f2b167bdc1c9f2326bf92c2868 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 10:50:03 -0800 Subject: [PATCH 20/38] terminal: selectOutput uses new semantic prompt logic --- src/terminal/Screen.zig | 236 +++++++++++++++++----------------------- 1 file changed, 101 insertions(+), 135 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index b32431db3..7af157493 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2837,100 +2837,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); + return .init(hl.start, hl.end, false); } /// Returns the selection bounds for the prompt at the given point. If the @@ -8512,59 +8472,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, @@ -8576,21 +8541,22 @@ 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); + } + // Click on input should return null + { + try testing.expect(s.selectOutput(s.pages.pin(.{ .active = .{ + .x = 5, + .y = 8, } }).?) == null); } } From 047914c7b51da4d0e36c65e570f04f3c56c61c71 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 11:40:38 -0800 Subject: [PATCH 21/38] terminal: promptPath uses new semantic_prompt logic --- src/terminal/Screen.zig | 337 +++++++++------------------------------- 1 file changed, 77 insertions(+), 260 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 7af157493..1a2d6ead5 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2893,87 +2893,6 @@ pub fn selectOutput(self: *Screen, pin: Pin) ?Selection { return .init(hl.start, hl.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); -} - pub const LineIterator = struct { screen: *const Screen, current: ?Pin = null, @@ -3017,8 +2936,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_prompt2) { + .prompt, .prompt_continuation => {}, + .no_prompt => 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)) @@ -8561,169 +8488,6 @@ test "Screen: selectOutput" { } } -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 - { - // 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, - .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()).?); - } -} - test "Screen: promptPath" { const testing = std.testing; const alloc = testing.allocator; @@ -8731,22 +8495,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_prompt2 = .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_prompt2 = .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_prompt2 = .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 { @@ -8789,12 +8605,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); } } From 3aaafa2ddabc8f2812e03b88bd43ce46c22265e2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 11:48:15 -0800 Subject: [PATCH 22/38] terminal: Screen testWriteString should set prompt_continuation for soft --- src/terminal/Screen.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 1a2d6ead5..c6b1a15b5 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -3095,6 +3095,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_prompt2 = .prompt_continuation, + } } assert(width == 1 or width == 2); From 0f05c2b71a7049fcd1ae5e5af01624b3602ec20f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 12:08:47 -0800 Subject: [PATCH 23/38] terminal: fix resize test to use new semantic prompt logic --- src/terminal/Screen.zig | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index c6b1a15b5..4f12f4b94 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -6187,9 +6187,12 @@ 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(.{ .cols = 10, .rows = 3, .reflow = false }); @@ -6208,15 +6211,15 @@ 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_prompt2 == .no_prompt); } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; - try testing.expect(list_cell.row.semantic_prompt == .prompt); + try testing.expect(list_cell.row.semantic_prompt2 == .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_prompt2 == .no_prompt); } } From 112db8211debca11b3455bdf37be5592c768c2dd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 12:42:23 -0800 Subject: [PATCH 24/38] terminal: remove clearPrompt and integrate it into resize --- src/terminal/Screen.zig | 278 ++++++++++++++++---------------------- src/terminal/Terminal.zig | 18 +-- 2 files changed, 125 insertions(+), 171 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 4f12f4b94..76cf434ce 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1461,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. /// @@ -1662,6 +1607,12 @@ pub const Resize = struct { /// smaller and the maximum scrollback size is exceeded, data will be /// lost from the top of the scrollback. reflow: bool = true, + + /// Set this to true 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. + prompt_redraw: bool = false, }; /// Resize the screen. The rows or cols can be bigger or smaller. @@ -1729,6 +1680,37 @@ pub inline fn resize( }; defer if (saved_cursor_pin) |p| self.pages.untrackPin(p); + // If our cursor is on a prompt line, then we clear the prompt so + // the shell can redraw it. This works with OSC133 semantic prompts. + if (opts.prompt_redraw and + self.cursor.page_row.semantic_prompt2 != .no_prompt) + prompt: { + 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 = opts.rows, @@ -3189,29 +3171,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; @@ -3824,88 +3783,6 @@ test "Screen eraseRows active partial" { } } -test "Screen: clearPrompt single line prompt" { - 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; @@ -7289,6 +7166,87 @@ 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: select untracked" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 83e54482b..4498fb798 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -80,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: bool = 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. @@ -1082,7 +1081,8 @@ pub fn semanticPrompt( }); // This is a kitty-specific flag that notes that the shell - // is capable of redraw. + // 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; } @@ -2680,15 +2680,11 @@ 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(); - } 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 From 917a42876ea44dd2b7ba69c4b0b328a0bf1bf015 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 13:02:58 -0800 Subject: [PATCH 25/38] terminal: cursorIsAtPrompt uses new APIs --- src/terminal/Terminal.zig | 50 +++++++++++++-------------------------- 1 file changed, 16 insertions(+), 34 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 4498fb798..d33729be9 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1198,29 +1198,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_prompt2 != .no_prompt) 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 @@ -11397,27 +11383,23 @@ test "Terminal: cursorIsAtPrompt" { defer t.deinit(alloc); try testing.expect(!t.cursorIsAtPrompt()); - t.markSemanticPrompt(.prompt); + try t.semanticPrompt(.init(.prompt_start)); try testing.expect(t.cursorIsAtPrompt()); // Input is also a prompt - t.markSemanticPrompt(.input); - try testing.expect(t.cursorIsAtPrompt()); - - // Newline -- we expect we're still at a prompt if we received - // prompt stuff before. - try t.linefeed(); + try t.semanticPrompt(.init(.end_prompt_start_input)); try testing.expect(t.cursorIsAtPrompt()); // But once we say we're starting output, we're not a prompt - t.markSemanticPrompt(.command); - try testing.expect(!t.cursorIsAtPrompt()); + try t.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()); } @@ -11427,13 +11409,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()); } From 10bc88766bf541434980e4122d3f35d9ac1a2581 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 13:13:19 -0800 Subject: [PATCH 26/38] terminal: soft wrap preserves new semantic prompt state --- src/terminal/Terminal.zig | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index d33729be9..a40022c33 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -754,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_prompt2 = .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 @@ -4323,19 +4336,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_prompt2); } { 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_prompt2); } } From 1b2376d3662caf36d350913ce9d0bdaac9f0a772 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 13:22:12 -0800 Subject: [PATCH 27/38] terminal: remove last semantic_prompt usage from Terminal --- src/terminal/Terminal.zig | 21 ++--------------- src/terminal/stream_readonly.zig | 40 +------------------------------- src/termio/Termio.zig | 5 ++-- src/termio/stream_handler.zig | 25 ++++---------------- 4 files changed, 10 insertions(+), 81 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index a40022c33..c9eb6a912 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1189,19 +1189,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). @@ -2360,19 +2347,15 @@ pub fn eraseDisplay( ); while (it.next()) |p| { const row = p.rowAndCell().row; - switch (row.semantic_prompt) { + switch (row.semantic_prompt2) { // 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 => {}, + .no_prompt => break :at_prompt, } } else break :at_prompt; diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 87b0d9788..91532c9d5 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 => try 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,44 +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.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), - .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, - .new_command, - .fresh_line, - .prompt_start, - => {}, - } - - try self.terminal.semanticPrompt(cmd); - } - fn setMode(self: *Handler, mode: modes.Mode, enabled: bool) !void { // Set the mode on the terminal self.terminal.modes.set(mode, enabled); 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 b725649f1..bc3edd185 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1071,26 +1071,10 @@ pub const StreamHandler = struct { cmd: Stream.Action.SemanticPrompt, ) !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,12 +1087,11 @@ 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, => {}, From 5f77b0ed98658c279e0fa37e8a500e7663afd9ce Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 13:26:33 -0800 Subject: [PATCH 28/38] terminal: remove old semantic_prompt --- src/renderer/row.zig | 6 +++--- src/terminal/page.zig | 31 ++----------------------------- 2 files changed, 5 insertions(+), 32 deletions(-) diff --git a/src/renderer/row.zig b/src/renderer/row.zig index 933bb338b..38b8540f9 100644 --- a/src/renderer/row.zig +++ b/src/renderer/row.zig @@ -15,9 +15,9 @@ pub fn neverExtendBg( // Any semantic prompts should not have their background extended // 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 => {}, + switch (row.semantic_prompt2) { + .prompt, .prompt_continuation => return true, + .no_prompt => {}, } for (0.., cells) |x, *cell| { diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 3747a8e6a..31879aaf4 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1898,10 +1898,6 @@ 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 @@ -1933,9 +1929,9 @@ pub const Row = packed struct(u64) { /// screen. dirty: bool = false, - _padding: u20 = 0, + _padding: u23 = 0, - /// The semantic prompt state of the row. See `semantic_prompt`. + /// The semantic prompt state of the row. See `semantic_prompt2`. pub const SemanticPrompt2 = enum(u2) { /// No prompt cells in this row. no_prompt = 0, @@ -1946,29 +1942,6 @@ pub const Row = packed struct(u64) { prompt_continuation = 2, }; - /// Semantic prompt type. - pub const SemanticPrompt = enum(u3) { - /// Unknown, the running application didn't tell us for this line. - unknown = 0, - - /// This is a prompt line, meaning it only contains the shell prompt. - /// For poorly behaving shells, this may also be the input. - prompt = 1, - 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 /// row structure (graphemes, styles, etc.) pub inline fn managedMemory(self: Row) bool { From c3e15a5cb6e5b0c7feb3f142f373fecb2f7c37e4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 26 Jan 2026 13:27:44 -0800 Subject: [PATCH 29/38] terminal: rename semantic prompt --- src/renderer/row.zig | 2 +- src/terminal/PageList.zig | 142 +++++++++++++++++++------------------- src/terminal/Screen.zig | 22 +++--- src/terminal/Terminal.zig | 18 ++--- src/terminal/page.zig | 6 +- 5 files changed, 95 insertions(+), 95 deletions(-) diff --git a/src/renderer/row.zig b/src/renderer/row.zig index 38b8540f9..0f59359dc 100644 --- a/src/renderer/row.zig +++ b/src/renderer/row.zig @@ -15,7 +15,7 @@ pub fn neverExtendBg( // Any semantic prompts should not have their background extended // because prompts often contain special formatting (such as // powerline) that looks bad when extended. - switch (row.semantic_prompt2) { + switch (row.semantic_prompt) { .prompt, .prompt_continuation => return true, .no_prompt => {}, } diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index c7ff0fc8d..afd6eedf2 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1229,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_prompt2 != .no_prompt) cols_len = 1; + if (cols_len == 0 and src_row.semantic_prompt != .no_prompt) cols_len = 1; } // Handle tracked pin adjustments. @@ -1973,13 +1973,13 @@ 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_prompt2 != .no_prompt) return len - 1; + if (self.page_row.semantic_prompt != .no_prompt) return len - 1; return len; } fn copyRowMetadata(self: *ReflowCursor, other: *const Row) void { - self.page_row.semantic_prompt2 = other.semantic_prompt2; + self.page_row.semantic_prompt = other.semantic_prompt; } }; @@ -4403,7 +4403,7 @@ pub const PromptIterator = struct { const at_limit = if (self.limit) |limit| limit.eql(p) else false; const rac = p.rowAndCell(); - switch (rac.row.semantic_prompt2) { + switch (rac.row.semantic_prompt) { // This row isn't a prompt. Keep looking. .no_prompt => if (at_limit) break, @@ -4422,7 +4422,7 @@ pub const PromptIterator = struct { // 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_prompt2) { + switch (next_pin.rowAndCell().row.semantic_prompt) { .prompt_continuation => if (self.limit) |limit| { if (limit.eql(next_pin)) break; }, @@ -4456,7 +4456,7 @@ pub const PromptIterator = struct { const at_limit = if (self.limit) |limit| limit.eql(p) else false; const rac = p.rowAndCell(); - switch (rac.row.semantic_prompt2) { + switch (rac.row.semantic_prompt) { // This row isn't a prompt. Keep looking. .no_prompt => if (at_limit) break, @@ -4483,7 +4483,7 @@ pub const PromptIterator = struct { if (limit.eql(prior)) break; } - switch (prior.rowAndCell().row.semantic_prompt2) { + switch (prior.rowAndCell().row.semantic_prompt) { // No prompt. That means our last pin is good! .no_prompt => { self.current = prior; @@ -6771,11 +6771,11 @@ test "PageList: jump zero prompts" { const page = &s.pages.first.?.data; { const rac = page.getRowAndCell(0, 1); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } s.scroll(.{ .delta_prompt = 0 }); @@ -6799,11 +6799,11 @@ test "Screen: jump back one prompt" { const page = &s.pages.first.?.data; { const rac = page.getRowAndCell(0, 1); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Jump back @@ -7906,25 +7906,25 @@ test "PageList promptIterator left_up" { // Normal prompt { const rac = page.getRowAndCell(0, 3); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Continuation { const rac = page.getRowAndCell(0, 6); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } { const rac = page.getRowAndCell(0, 7); - rac.row.semantic_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; } { const rac = page.getRowAndCell(0, 8); - rac.row.semantic_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; } // Broken continuation that has non-prompts in between { const rac = page.getRowAndCell(0, 12); - rac.row.semantic_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; } var it = s.promptIterator(.left_up, .{ .screen = .{} }, null); @@ -7963,25 +7963,25 @@ test "PageList promptIterator right_down" { // Normal prompt { const rac = page.getRowAndCell(0, 3); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Continuation (prompt on row 6, continuation on rows 7-8) { const rac = page.getRowAndCell(0, 6); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } { const rac = page.getRowAndCell(0, 7); - rac.row.semantic_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; } { const rac = page.getRowAndCell(0, 8); - rac.row.semantic_prompt2 = .prompt_continuation; + 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_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; } var it = s.promptIterator(.right_down, .{ .screen = .{} }, null); @@ -8021,16 +8021,16 @@ test "PageList promptIterator right_down continuation at start" { // Prompt continuation at row 0 (no prior rows - simulates trimmed scrollback) { const rac = page.getRowAndCell(0, 0); - rac.row.semantic_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; } { const rac = page.getRowAndCell(0, 1); - rac.row.semantic_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; } // Normal prompt later { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } var it = s.promptIterator(.right_down, .{ .screen = .{} }, null); @@ -8065,15 +8065,15 @@ test "PageList promptIterator right_down with prompt before continuation" { // Starting iteration from row 3 should still find the prompt at row 2 { const rac = page.getRowAndCell(0, 2); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } { const rac = page.getRowAndCell(0, 3); - rac.row.semantic_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; } { const rac = page.getRowAndCell(0, 4); - rac.row.semantic_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; } // Start iteration from row 3 (middle of the continuation) @@ -8103,12 +8103,12 @@ test "PageList promptIterator right_down limit inclusive" { // Prompt on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Iterate with limit at row 5 (the prompt row) - should include it @@ -8135,12 +8135,12 @@ test "PageList promptIterator left_up limit inclusive" { // Prompt on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Iterate with limit at row 10 (the prompt row) - should include it @@ -8168,7 +8168,7 @@ test "PageList highlightSemanticContent prompt" { // Prompt on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // Start the prompt for the first 5 cols for (0..5) |x| { @@ -8193,7 +8193,7 @@ test "PageList highlightSemanticContent prompt" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } const hl = s.highlightSemanticContent( @@ -8222,7 +8222,7 @@ test "PageList highlightSemanticContent prompt with output" { // Prompt on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 3 cols are prompt for (0..3) |x| { @@ -8257,7 +8257,7 @@ test "PageList highlightSemanticContent prompt with output" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting from prompt should include prompt and input, but stop at output @@ -8287,7 +8287,7 @@ test "PageList highlightSemanticContent prompt multiline" { // Prompt starts on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First row is all prompt for (0..10) |x| { @@ -8313,7 +8313,7 @@ test "PageList highlightSemanticContent prompt multiline" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting should span both rows @@ -8343,7 +8343,7 @@ test "PageList highlightSemanticContent prompt only" { // Prompt on row 5 with only prompt content (no input) { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; for (0..5) |x| { const cell = page.getRowAndCell(x, 5).cell; @@ -8357,7 +8357,7 @@ test "PageList highlightSemanticContent prompt only" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting should only include the prompt cells @@ -8387,7 +8387,7 @@ test "PageList highlightSemanticContent prompt to end of screen" { // Single prompt on row 15, no following prompt { const rac = page.getRowAndCell(0, 15); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; for (0..3) |x| { const cell = page.getRowAndCell(x, 15).cell; @@ -8435,7 +8435,7 @@ test "PageList highlightSemanticContent input basic" { // Prompt on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 3 cols are prompt for (0..3) |x| { @@ -8460,7 +8460,7 @@ test "PageList highlightSemanticContent input basic" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting input should only include input cells @@ -8490,7 +8490,7 @@ test "PageList highlightSemanticContent input with output" { // Prompt on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 2 cols are prompt for (0..2) |x| { @@ -8525,7 +8525,7 @@ test "PageList highlightSemanticContent input with output" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting input should stop at output @@ -8555,7 +8555,7 @@ test "PageList highlightSemanticContent input multiline with continuation" { // Prompt on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 2 cols are prompt for (0..2) |x| { @@ -8602,7 +8602,7 @@ test "PageList highlightSemanticContent input multiline with continuation" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting input should span both rows, skipping continuation prompts @@ -8632,7 +8632,7 @@ test "PageList highlightSemanticContent input no input returns null" { // Prompt on row 5 with only prompt, then immediately output { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 3 cols are prompt for (0..3) |x| { @@ -8657,7 +8657,7 @@ test "PageList highlightSemanticContent input no input returns null" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting input should return null when there's no input @@ -8680,7 +8680,7 @@ test "PageList highlightSemanticContent input to end of screen" { // Single prompt on row 15, no following prompt { const rac = page.getRowAndCell(0, 15); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; for (0..2) |x| { const cell = page.getRowAndCell(x, 15).cell; @@ -8728,7 +8728,7 @@ test "PageList highlightSemanticContent input prompt only returns null" { // Prompt on row 5 with only prompt content, no input or output { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // All cells are prompt for (0..10) |x| { @@ -8752,7 +8752,7 @@ test "PageList highlightSemanticContent input prompt only returns null" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting input should return null when there's only prompts @@ -8775,7 +8775,7 @@ test "PageList highlightSemanticContent output basic" { // Prompt on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 2 cols are prompt for (0..2) |x| { @@ -8816,7 +8816,7 @@ test "PageList highlightSemanticContent output basic" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting output should only include output cells @@ -8846,7 +8846,7 @@ test "PageList highlightSemanticContent output multiline" { // Prompt on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 2 cols are prompt for (0..2) |x| { @@ -8907,7 +8907,7 @@ test "PageList highlightSemanticContent output multiline" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting output should span multiple rows @@ -8937,7 +8937,7 @@ test "PageList highlightSemanticContent output stops at next prompt" { // Prompt on row 5 { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 2 cols are prompt for (0..2) |x| { @@ -8992,7 +8992,7 @@ test "PageList highlightSemanticContent output stops at next prompt" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting output should stop before prompt/input @@ -9022,7 +9022,7 @@ test "PageList highlightSemanticContent output to end of screen" { // Single prompt on row 15, no following prompt { const rac = page.getRowAndCell(0, 15); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; for (0..2) |x| { const cell = page.getRowAndCell(x, 15).cell; @@ -9094,7 +9094,7 @@ test "PageList highlightSemanticContent output no output returns null" { // Prompt on row 5 with only prompt and input, no output { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 3 cols are prompt for (0..3) |x| { @@ -9128,7 +9128,7 @@ test "PageList highlightSemanticContent output no output returns null" { // Prompt on row 10 (no output between prompts) { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting output should return null when there's no output @@ -9154,7 +9154,7 @@ test "PageList highlightSemanticContent output skips empty cells" { // Prompt on row 5 - only fills first 3 cells, rest are empty with default .output { const rac = page.getRowAndCell(0, 5); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 3 cols are prompt with text for (0..3) |x| { @@ -9199,7 +9199,7 @@ test "PageList highlightSemanticContent output skips empty cells" { // Prompt on row 10 { const rac = page.getRowAndCell(0, 10); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Highlighting output should skip empty cells on rows 5-6 and find @@ -11553,7 +11553,7 @@ test "PageList resize reflow more cols no reflow preserves semantic prompt" { try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 1); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Resize @@ -11565,7 +11565,7 @@ test "PageList resize reflow more cols no reflow preserves semantic prompt" { try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 1); - try testing.expect(rac.row.semantic_prompt2 == .prompt); + try testing.expect(rac.row.semantic_prompt == .prompt); } } @@ -12128,7 +12128,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt" { const page = &s.pages.first.?.data; { const rac = page.getRowAndCell(0, 1); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } for (0..s.cols) |x| { const rac = page.getRowAndCell(x, 1); @@ -12150,12 +12150,12 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt" { const p = s.pin(.{ .active = .{ .y = 1 } }).?; const rac = p.rowAndCell(); try testing.expect(rac.row.wrap); - try testing.expect(rac.row.semantic_prompt2 == .prompt); + try testing.expect(rac.row.semantic_prompt == .prompt); } { const p = s.pin(.{ .active = .{ .y = 2 } }).?; const rac = p.rowAndCell(); - try testing.expect(rac.row.semantic_prompt2 == .prompt); + try testing.expect(rac.row.semantic_prompt == .prompt); } } } @@ -12170,7 +12170,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt on fi try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 0); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Resize @@ -12182,7 +12182,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt on fi try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 0); - try testing.expect(rac.row.semantic_prompt2 == .prompt); + try testing.expect(rac.row.semantic_prompt == .prompt); } } @@ -12196,7 +12196,7 @@ test "PageList resize reflow less cols wrap preserves semantic prompt" { try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 0); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; } // Resize @@ -12208,7 +12208,7 @@ test "PageList resize reflow less cols wrap preserves semantic prompt" { try testing.expect(s.pages.first == s.pages.last); const page = &s.pages.first.?.data; const rac = page.getRowAndCell(0, 0); - try testing.expect(rac.row.semantic_prompt2 == .prompt); + try testing.expect(rac.row.semantic_prompt == .prompt); } } diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 76cf434ce..2fbfbafb9 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1683,7 +1683,7 @@ pub inline fn resize( // If our cursor is on a prompt line, then we clear the prompt so // the shell can redraw it. This works with OSC133 semantic prompts. if (opts.prompt_redraw and - self.cursor.page_row.semantic_prompt2 != .no_prompt) + self.cursor.page_row.semantic_prompt != .no_prompt) prompt: { const start = start: { var it = self.cursor.page_pin.promptIterator( @@ -2342,7 +2342,7 @@ pub fn cursorSetSemanticContent(self: *Screen, t: union(enum) { self.flags.semantic_content = true; cursor.semantic_content = .prompt; cursor.semantic_content_clear_eol = false; - cursor.page_row.semantic_prompt2 = switch (kind) { + cursor.page_row.semantic_prompt = switch (kind) { .initial, .right => .prompt, .continuation, .secondary => .prompt_continuation, }; @@ -2920,7 +2920,7 @@ pub fn promptPath( } { // Verify "from" is on a prompt row before calling highlightSemanticContent. // highlightSemanticContent asserts the starting point is a prompt. - switch (from.rowAndCell().row.semantic_prompt2) { + switch (from.rowAndCell().row.semantic_prompt) { .prompt, .prompt_continuation => {}, .no_prompt => return .{ .x = 0, .y = 0 }, } @@ -3043,7 +3043,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { self.cursorSetSemanticContent(.output); } else switch (self.cursor.semantic_content) { .input, .output => {}, - .prompt => self.cursor.page_row.semantic_prompt2 = .prompt_continuation, + .prompt => self.cursor.page_row.semantic_prompt = .prompt_continuation, } continue; } @@ -3079,7 +3079,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { self.cursor.page_row.wrap_continuation = true; switch (self.cursor.semantic_content) { .input, .output => {}, - .prompt => self.cursor.page_row.semantic_prompt2 = .prompt_continuation, + .prompt => self.cursor.page_row.semantic_prompt = .prompt_continuation, } } @@ -6088,15 +6088,15 @@ 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_prompt2 == .no_prompt); + try testing.expect(list_cell.row.semantic_prompt == .no_prompt); } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; - try testing.expect(list_cell.row.semantic_prompt2 == .prompt); + try testing.expect(list_cell.row.semantic_prompt == .prompt); } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; - try testing.expect(list_cell.row.semantic_prompt2 == .no_prompt); + try testing.expect(list_cell.row.semantic_prompt == .no_prompt); } } @@ -8474,7 +8474,7 @@ test "Screen: promptPath" { // Row 2: prompt (with prompt cells) and input { const rac = page.getRowAndCell(0, 2); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; // First 3 cols are prompt for (0..3) |x| { const cell = page.getRowAndCell(x, 2).cell; @@ -8497,7 +8497,7 @@ test "Screen: promptPath" { // Row 3: continuation line with input cells (same prompt block) { const rac = page.getRowAndCell(0, 3); - rac.row.semantic_prompt2 = .prompt_continuation; + rac.row.semantic_prompt = .prompt_continuation; for (0..6) |x| { const cell = page.getRowAndCell(x, 3).cell; cell.* = .{ @@ -8510,7 +8510,7 @@ test "Screen: promptPath" { // Row 6: next prompt + input on same line { const rac = page.getRowAndCell(0, 6); - rac.row.semantic_prompt2 = .prompt; + rac.row.semantic_prompt = .prompt; for (0..2) |x| { const cell = page.getRowAndCell(x, 6).cell; cell.* = .{ diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index c9eb6a912..b8f83e781 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -776,7 +776,7 @@ fn printWrap(self: *Terminal) !void { cursor.semantic_content_clear_eol = old_semantic_clear; switch (old_semantic) { .output, .input => {}, - .prompt => cursor.page_row.semantic_prompt2 = .prompt_continuation, + .prompt => cursor.page_row.semantic_prompt = .prompt_continuation, } if (mark_wrap) { @@ -1200,7 +1200,7 @@ pub fn cursorIsAtPrompt(self: *Terminal) bool { // 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_prompt2 != .no_prompt) return true; + if (cursor.page_row.semantic_prompt != .no_prompt) return true; // Otherwise, determine our cursor state return switch (cursor.semantic_content) { @@ -2347,7 +2347,7 @@ pub fn eraseDisplay( ); while (it.next()) |p| { const row = p.rowAndCell().row; - switch (row.semantic_prompt2) { + switch (row.semantic_prompt) { // If we're at a prompt or input area, then we are at a prompt. .prompt, .prompt_continuation, @@ -4328,11 +4328,11 @@ test "Terminal: soft wrap with semantic prompt" { for ("hello") |c| try t.print(c); { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(.prompt, list_cell.row.semantic_prompt2); + 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(.prompt_continuation, list_cell.row.semantic_prompt2); + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); } } @@ -11302,7 +11302,7 @@ test "Terminal: semantic prompt" { try testing.expectEqual(.prompt, cell.semantic_content); const row = list_cell.row; - try testing.expectEqual(.prompt, row.semantic_prompt2); + try testing.expectEqual(.prompt, row.semantic_prompt); } // Start input but end it on EOL @@ -11323,7 +11323,7 @@ test "Terminal: semantic prompt" { try testing.expectEqual(.output, cell.semantic_content); const row = list_cell.row; - try testing.expectEqual(.no_prompt, row.semantic_prompt2); + try testing.expectEqual(.no_prompt, row.semantic_prompt); } } @@ -11346,7 +11346,7 @@ test "Terminal: semantic prompt continuations" { try testing.expectEqual(.prompt, cell.semantic_content); const row = list_cell.row; - try testing.expectEqual(.prompt, row.semantic_prompt2); + try testing.expectEqual(.prompt, row.semantic_prompt); } // Start input but end it on EOL @@ -11370,7 +11370,7 @@ test "Terminal: semantic prompt continuations" { try testing.expectEqual(.prompt, cell.semantic_content); const row = list_cell.row; - try testing.expectEqual(.prompt_continuation, row.semantic_prompt2); + try testing.expectEqual(.prompt_continuation, row.semantic_prompt); } } diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 31879aaf4..83e42a4d9 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1907,7 +1907,7 @@ pub const Row = packed struct(u64) { /// 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_prompt2: SemanticPrompt2 = .no_prompt, + semantic_prompt: SemanticPrompt = .no_prompt, /// True if this row contains a virtual placeholder for the Kitty /// graphics protocol. (U+10EEEE) @@ -1931,8 +1931,8 @@ pub const Row = packed struct(u64) { _padding: u23 = 0, - /// The semantic prompt state of the row. See `semantic_prompt2`. - pub const SemanticPrompt2 = enum(u2) { + /// The semantic prompt state of the row. See `semantic_prompt`. + pub const SemanticPrompt = enum(u2) { /// No prompt cells in this row. no_prompt = 0, /// Prompt cells exist in this row. From a4b7a766fe62628dd4cb77714a22a31864dff464 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 31 Jan 2026 11:02:21 -0800 Subject: [PATCH 30/38] PR review --- src/renderer/row.zig | 2 +- src/terminal/PageList.zig | 12 ++++++------ src/terminal/Screen.zig | 8 ++++---- src/terminal/Terminal.zig | 6 +++--- src/terminal/page.zig | 13 +++++++++---- 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/renderer/row.zig b/src/renderer/row.zig index 0f59359dc..74a641012 100644 --- a/src/renderer/row.zig +++ b/src/renderer/row.zig @@ -17,7 +17,7 @@ pub fn neverExtendBg( // powerline) that looks bad when extended. switch (row.semantic_prompt) { .prompt, .prompt_continuation => return true, - .no_prompt => {}, + .none => {}, } for (0.., cells) |x, *cell| { diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index afd6eedf2..71534d0aa 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1229,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 != .no_prompt) cols_len = 1; + if (cols_len == 0 and src_row.semantic_prompt != .none) cols_len = 1; } // Handle tracked pin adjustments. @@ -1973,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 != .no_prompt) return len - 1; + if (self.page_row.semantic_prompt != .none) return len - 1; return len; } @@ -4405,7 +4405,7 @@ pub const PromptIterator = struct { const rac = p.rowAndCell(); switch (rac.row.semantic_prompt) { // This row isn't a prompt. Keep looking. - .no_prompt => if (at_limit) break, + .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 @@ -4427,7 +4427,7 @@ pub const PromptIterator = struct { if (limit.eql(next_pin)) break; }, - .prompt, .no_prompt => { + .prompt, .none => { self.current = next_pin; return p.left(p.x); }, @@ -4458,7 +4458,7 @@ pub const PromptIterator = struct { const rac = p.rowAndCell(); switch (rac.row.semantic_prompt) { // This row isn't a prompt. Keep looking. - .no_prompt => if (at_limit) break, + .none => if (at_limit) break, // This is a prompt line. .prompt => { @@ -4485,7 +4485,7 @@ pub const PromptIterator = struct { switch (prior.rowAndCell().row.semantic_prompt) { // No prompt. That means our last pin is good! - .no_prompt => { + .none => { self.current = prior; return end_pin.left(end_pin.x); }, diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 2fbfbafb9..05b84d25f 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1683,7 +1683,7 @@ pub inline fn resize( // If our cursor is on a prompt line, then we clear the prompt so // the shell can redraw it. This works with OSC133 semantic prompts. if (opts.prompt_redraw and - self.cursor.page_row.semantic_prompt != .no_prompt) + self.cursor.page_row.semantic_prompt != .none) prompt: { const start = start: { var it = self.cursor.page_pin.promptIterator( @@ -2922,7 +2922,7 @@ pub fn promptPath( // highlightSemanticContent asserts the starting point is a prompt. switch (from.rowAndCell().row.semantic_prompt) { .prompt, .prompt_continuation => {}, - .no_prompt => return .{ .x = 0, .y = 0 }, + .none => return .{ .x = 0, .y = 0 }, } // Get our prompt bounds assuming "from" is at a prompt. @@ -6088,7 +6088,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 == .no_prompt); + try testing.expect(list_cell.row.semantic_prompt == .none); } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; @@ -6096,7 +6096,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 == .no_prompt); + try testing.expect(list_cell.row.semantic_prompt == .none); } } diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index b8f83e781..4635b2a58 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1200,7 +1200,7 @@ pub fn cursorIsAtPrompt(self: *Terminal) bool { // 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 != .no_prompt) return true; + if (cursor.page_row.semantic_prompt != .none) return true; // Otherwise, determine our cursor state return switch (cursor.semantic_content) { @@ -2355,7 +2355,7 @@ pub fn eraseDisplay( // If we have command output, then we're most certainly not // at a prompt. - .no_prompt => break :at_prompt, + .none => break :at_prompt, } } else break :at_prompt; @@ -11323,7 +11323,7 @@ test "Terminal: semantic prompt" { try testing.expectEqual(.output, cell.semantic_content); const row = list_cell.row; - try testing.expectEqual(.no_prompt, row.semantic_prompt); + try testing.expectEqual(.none, row.semantic_prompt); } } diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 83e42a4d9..61507dc75 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1907,7 +1907,7 @@ pub const Row = packed struct(u64) { /// 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 = .no_prompt, + semantic_prompt: SemanticPrompt = .none, /// True if this row contains a virtual placeholder for the Kitty /// graphics protocol. (U+10EEEE) @@ -1934,11 +1934,16 @@ pub const Row = packed struct(u64) { /// The semantic prompt state of the row. See `semantic_prompt`. pub const SemanticPrompt = enum(u2) { /// No prompt cells in this row. - no_prompt = 0, - /// Prompt cells exist 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 + /// 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, }; From f14a1306cd226b3c99b802d4b5fbf0ab97fffd62 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 31 Jan 2026 11:07:26 -0800 Subject: [PATCH 31/38] renderer: semantic prompt overlay --- src/inspector/widgets/renderer.zig | 84 +++++++++--- src/renderer/Overlay.zig | 204 +++++++++++++++++++++++++++-- 2 files changed, 263 insertions(+), 25 deletions(-) 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/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(); +} From e7e3903151b5644c56d19939439399161085fcb1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 31 Jan 2026 13:41:50 -0800 Subject: [PATCH 32/38] inspector: show if we've seen semantic content in screen --- src/inspector/widgets/screen.zig | 28 +++++++++++++++++++++------- src/inspector/widgets/surface.zig | 19 +++++++++++-------- 2 files changed, 32 insertions(+), 15 deletions(-) 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); } From 4bee8202a8e3385bd50144252b8c86b1683b2a3a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 31 Jan 2026 14:30:24 -0800 Subject: [PATCH 33/38] shell-integration/bash: mark each line in multiline prompts as secondary Insert OSC 133 A k=s marks after each newline in PS1, so that all lines following the first are marked as secondary prompts. This prevents ghostty from erasing leading lines during terminal resize. --- src/shell-integration/bash/ghostty.bash | 117 +++++++++++++----------- 1 file changed, 64 insertions(+), 53 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 799d0cff6..a5417f1b6 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\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;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) From 918c2934a36d275dde002e4e1bf757e46f3fa927 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 31 Jan 2026 15:09:43 -0800 Subject: [PATCH 34/38] terminal: add redraw=last for bash for OSC133 --- src/Surface.zig | 2 +- src/shell-integration/bash/ghostty.bash | 4 +- src/terminal/Screen.zig | 153 +++++++++++++++---- src/terminal/Terminal.zig | 2 +- src/terminal/osc/parsers/semantic_prompt.zig | 50 ++++-- src/terminal/stream_readonly.zig | 2 +- 6 files changed, 170 insertions(+), 43 deletions(-) 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/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index a5417f1b6..40fd71b19 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -201,7 +201,7 @@ function __ghostty_precmd() { # 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\a\]'$PS1'\[\e]133;B\a\]' + 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 @@ -240,7 +240,7 @@ function __ghostty_precmd() { fi # Fresh line and start of prompt. - builtin printf "\e]133;A;aid=%s\a" "$BASHPID" + builtin printf "\e]133;A;redraw=last;aid=%s\a" "$BASHPID" _ghostty_executing=0 } diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 05b84d25f..bf35b75df 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1608,11 +1608,11 @@ pub const Resize = struct { /// lost from the top of the scrollback. reflow: bool = true, - /// Set this to true to enable prompt redraw on resize. This signals + /// 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. - prompt_redraw: bool = false, + /// 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. @@ -1682,32 +1682,47 @@ pub inline fn resize( // If our cursor is on a prompt line, then we clear the prompt so // the shell can redraw it. This works with OSC133 semantic prompts. - if (opts.prompt_redraw and + if (opts.prompt_redraw != .false and self.cursor.page_row.semantic_prompt != .none) prompt: { - 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; - }; - }; + switch (opts.prompt_redraw) { + .false => unreachable, - // 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); + // 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); + } + }, } } @@ -7191,7 +7206,7 @@ test "Screen: resize more cols with cursor at prompt" { try s.resize(.{ .cols = 20, .rows = 3, - .prompt_redraw = true, + .prompt_redraw = .true, }); // Cursor should not move @@ -7232,7 +7247,7 @@ test "Screen: resize more cols with cursor not at prompt" { try s.resize(.{ .cols = 20, .rows = 3, - .prompt_redraw = true, + .prompt_redraw = .true, }); // Cursor should not move @@ -7247,6 +7262,88 @@ test "Screen: resize more cols with cursor not at prompt" { } } +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_eol }); + 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); + } + + // Move cursor back to the prompt line (row 1) + s.cursorAbsolute(7, 1); + + try s.resize(.{ + .cols = 20, + .rows = 4, + .prompt_redraw = .last, + }); + + // With .last, only the first prompt line ("> ") should be cleared, + // but subsequent input lines ("hello", "world") remain + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE\n\nworld"; + 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: select untracked" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 4635b2a58..10e6f1630 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -83,7 +83,7 @@ flags: packed struct { // 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: bool = true, + 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. diff --git a/src/terminal/osc/parsers/semantic_prompt.zig b/src/terminal/osc/parsers/semantic_prompt.zig index 61aed4988..9014312f4 100644 --- a/src/terminal/osc/parsers/semantic_prompt.zig +++ b/src/terminal/osc/parsers/semantic_prompt.zig @@ -49,10 +49,8 @@ pub const Option = enum { 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 @@ -82,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, @@ -170,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, @@ -209,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 { @@ -514,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" { @@ -528,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" { @@ -871,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/stream_readonly.zig b/src/terminal/stream_readonly.zig index 91532c9d5..eca13bf06 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -904,7 +904,7 @@ test "semantic prompt fresh line new prompt" { // 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); + try testing.expect(t.flags.shell_redraws_prompt == .true); } test "semantic prompt end of input, then start output" { From 92d6dde583d60ead1e4ca6761643637d0342bc2b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 31 Jan 2026 19:31:06 -0800 Subject: [PATCH 35/38] shell-integration/zsh: set proper input and secondary prompt marks --- src/shell-integration/zsh/ghostty-integration | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 From 853fee9496504c175c389ce487fba76c071a11b8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 31 Jan 2026 20:31:49 -0800 Subject: [PATCH 36/38] terminal: when semantic cursor is prompt, assume newline is prompt This works around Fish (at least v4.2) having a non-compliant OSC133 implementation paired with not having the hooks to fix this via shell integration. We have to instead resort to heuristics in the terminal emulator. Womp, womp. The issue is that Fish does not emit OSC133 secondary prompt (`k=s`) markers at the beginning of continuation lines. And, since Fish doesn't provide a PS2-equivalent, we can't do this via shell integration. We fix this by assuming on newline (`\n`) that a cursor that is already painting prompt cells is continuing a prior prompt line, and pre-emptively mark it as a prompt line. But this has two further issues we have to work around: 1. Newline/index (`\n`) is one of the _hottest path_ functions in terminal emulation. It sucks to add any new conditional logic here. We do our best to gate this on unlikely conditions that the branch predictor can easily optimize away. 2. Fish also emits these for auto-complete hints that may be deleted later. So, we also have to handle the scenario where a prompt is continued, then replaced by command output, and fix up the prompt continuation flag to go back to output mode. Point 2 is ALMOST automatically handled, because Fish does emit a `CSI J` (erase display below) to erase the auto-complete hint. This resets all our rows back to output rows. **Unfortunately**, Fish emits `\n` before triggering the preexec hooks which set OSC133C. So we get the newline logic FIRST (sets the prompt line), THEN sets the output cursor. If they switched ordering here everything would just work (with the one heuristic). But now, we need two! To address this, I put some extra heuristic logic in the OSC133C (output starting) handler: if our row is marked as a prompt AND our cursor is at x=0, we assume that the prompt continuation was deleted and we unmark it. I put the heuristic logic dependent on OSC133C because that's way colder of a path than putting something in `printCell` (which is the actual hottest path in Ghostty). We could get more rigorous here by also checking if every cell is empty but that doesn't seem to be necessary at this time for any Fish version I've tested. I hope thats correct. I'd really love for Fish to improve their OSC133 implementation to conform more closely to the terminal-wg spec, but we're going to need these workarounds indefinitely to handle older Fish versions anyway. --- src/terminal/Terminal.zig | 326 ++++++++++++++++++++++++++++++++++---- 1 file changed, 293 insertions(+), 33 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 10e6f1630..4d4f312f2 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1145,6 +1145,21 @@ pub fn semanticPrompt( .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 => { @@ -1271,28 +1286,53 @@ pub fn tabReset(self: *Terminal) void { /// /// This unsets the pending wrap state without wrapping. pub fn index(self: *Terminal) !void { - // Unset pending wrap state - self.screens.active.cursor.pending_wrap = false; + const screen: *Screen = self.screens.active; - // 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 (self.screens.active.cursor.semantic_content_clear_eol) { + // Unset pending wrap state + 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); - self.screens.active.cursor.semantic_content = .output; - self.screens.active.cursor.semantic_content_clear_eol = false; - } + + // If we're prompting and do a newline, immediately assume + // that the new row is a prompt continuation. This is to work + // around shells that don't send OSC 133 k=s sequences for + // continuations (fish as v4.3, which also doesn't have a way + // to do PS2-style prompts to fix this ourself!). + // + // This can be a false positive if the shell changes content + // type later and outputs something. We handle that in the + // semanticPrompt function. + if (screen.cursor.semantic_content == .prompt) { + screen.cursorSetSemanticContent(.{ + .prompt = .secondary, + }); + } + + // 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 { + // 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; @@ -1301,13 +1341,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. @@ -1315,7 +1355,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; } @@ -1329,7 +1369,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; @@ -1339,9 +1379,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, ); @@ -1350,26 +1390,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); } } @@ -11374,20 +11414,240 @@ test "Terminal: semantic prompt continuations" { } } +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 NOT be marked as prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.none, list_cell.row.semantic_prompt); + } +} + +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()); try t.semanticPrompt(.init(.prompt_start)); try testing.expect(t.cursorIsAtPrompt()); + for ("$ ") |c| try t.print(c); // Input is also a prompt 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 + // (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()); From 8811d9b0553781be4d7ca3eaf89c6723f5d7fc33 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 31 Jan 2026 20:51:42 -0800 Subject: [PATCH 37/38] terminal: for prompt redraw, assume at a prompt if at input line Nu properly marks input areas with OSC 133 B, but if it spans multiple lines it doesn't mark the continuation lines with the k=s sequence. Our prompt redraw logic before only cleared explicitly designated prompt lines. But if the input line is multi-line and the continuation lines are not marked, those lines would not be cleared, leading to visual issues on resize. To workaround this, we assume that if the current cursor semantic content is anything other than "command output" (default), then we're probably at a prompt line and should clear from there all the way up. --- src/terminal/Screen.zig | 75 +++++++++++++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index bf35b75df..2d27eb2d6 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1680,10 +1680,18 @@ pub inline fn resize( }; defer if (saved_cursor_pin) |p| self.pages.untrackPin(p); - // If our cursor is on a prompt line, then we clear the prompt so - // the shell can redraw it. This works with OSC133 semantic prompts. + // 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.page_row.semantic_prompt != .none) + self.cursor.semantic_content != .output) prompt: { switch (opts.prompt_redraw) { .false => unreachable, @@ -7273,7 +7281,7 @@ test "Screen: resize with prompt_redraw last clears only one line" { try s.testWriteString("ABCDE\n"); s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); - s.cursorSetSemanticContent(.{ .input = .clear_eol }); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("hello\n"); try s.testWriteString("world"); // zig fmt: on @@ -7285,21 +7293,18 @@ test "Screen: resize with prompt_redraw last clears only one line" { try testing.expectEqualStrings(expected, contents); } - // Move cursor back to the prompt line (row 1) - s.cursorAbsolute(7, 1); - + // 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 first prompt line ("> ") should be cleared, - // but subsequent input lines ("hello", "world") remain + // 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\nworld"; + const expected = "ABCDE\n> hello"; try testing.expectEqualStrings(expected, contents); } } @@ -7344,6 +7349,56 @@ test "Screen: resize with prompt_redraw last multiline prompt clears only last l } } +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; From a909a1f120f058e7ad934698ac4c473ce8cd48d4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 1 Feb 2026 13:01:01 -0800 Subject: [PATCH 38/38] terminal: mark newlines for input lines as prompt continuation rows --- src/terminal/Terminal.zig | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 4d4f312f2..31bc94d17 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1296,21 +1296,6 @@ pub fn index(self: *Terminal) !void { defer if (screen.cursor.semantic_content != .output) { @branchHint(.unlikely); - // If we're prompting and do a newline, immediately assume - // that the new row is a prompt continuation. This is to work - // around shells that don't send OSC 133 k=s sequences for - // continuations (fish as v4.3, which also doesn't have a way - // to do PS2-style prompts to fix this ourself!). - // - // This can be a false positive if the shell changes content - // type later and outputs something. We handle that in the - // semanticPrompt function. - if (screen.cursor.semantic_content == .prompt) { - screen.cursorSetSemanticContent(.{ - .prompt = .secondary, - }); - } - // Always reset any semantic content clear-eol state. // // The specification is not clear what "end-of-line" means. If we @@ -1319,6 +1304,16 @@ pub fn index(self: *Terminal) !void { 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. @@ -11469,14 +11464,17 @@ test "Terminal: index in input mode does not mark new row as prompt" { t.carriageReturn(); try t.linefeed(); - // The new row should NOT be marked as prompt continuation + // 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(.none, list_cell.row.semantic_prompt); + 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" {