Add support for OSC133 cl=line, bash and zsh support (#10542)

Related to #1966

This adds support for OSC133 `cl=line` (single line movement with
left/right arrow keys) and modifies our bash and zsh shell integration
to advertise support for it. With this, bash and zsh both support click
to move at the prompt without any modifiers:


https://github.com/user-attachments/assets/7f6cb0b8-390c-4136-8c25-059b21b138c5

This also removes our legacy `promptPath` and related functionality
(pressing alt) since this is superior and there's no reason to keep that
around.
This commit is contained in:
Mitchell Hashimoto
2026-02-02 17:12:08 -08:00
committed by GitHub
4 changed files with 722 additions and 298 deletions

View File

@@ -3974,7 +3974,11 @@ pub fn mouseButtonCallback(
// and we support some kind of click events, then we need to
// move to it.
if (self.maybePromptClick()) |handled| {
if (handled) return true;
if (handled) {
// Moving always resets the click count so that we don't highlight.
self.mouse.left_click_count = 0;
return true;
}
} else |err| {
log.warn("error processing prompt click err={}", .{err});
}
@@ -4019,25 +4023,6 @@ pub fn mouseButtonCallback(
}
}
// For left button click release we check if we are moving our cursor.
if (button == .left and
action == .release and
mods.alt)
click_move: {
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
// If we have a selection then we do not do click to move because
// it means that we moved our cursor while pressing the mouse button.
if (self.io.terminal.screens.active.selection != null) break :click_move;
// Moving always resets the click count so that we don't highlight.
self.mouse.left_click_count = 0;
const pin = self.mouse.left_click_pin orelse break :click_move;
try self.clickMoveCursor(pin.*);
return true;
}
// For left button clicks we always record some information for
// selection/highlighting purposes.
if (button == .left and action == .press) click: {
@@ -4374,69 +4359,29 @@ fn maybePromptClick(self: *Surface) !bool {
} }, .locked);
},
.cl => |cl| {
// TODO: Handle these
_ = cl;
.cl => {
const left_arrow = if (t.modes.get(.cursor_keys)) "\x1bOD" else "\x1b[D";
const right_arrow = if (t.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C";
const move = screen.promptClickMove(click_pin);
for (0..move.left) |_| {
self.queueIo(
.{ .write_stable = left_arrow },
.locked,
);
}
for (0..move.right) |_| {
self.queueIo(
.{ .write_stable = right_arrow },
.locked,
);
}
},
}
return true;
}
/// Performs the "click-to-move" logic to move the cursor to the given
/// screen point if possible. This works by converting the path to the
/// given point into a series of arrow key inputs.
fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void {
// If click-to-move is disabled then we're done.
if (!self.config.cursor_click_to_move) return;
const t = &self.io.terminal;
// Click to move cursor only works on the primary screen where prompts
// exist. This means that alt screen multiplexers like tmux will not
// support this feature. It is just too messy.
if (t.screens.active_key != .primary) return;
// 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.screens.active.semantic_prompt.seen) return;
// Get our path
const from = t.screens.active.cursor.page_pin.*;
const path = t.screens.active.promptPath(from, to);
log.debug("click-to-move-cursor from={} to={} path={}", .{ from, to, path });
// If we aren't moving at all, fast path out of here.
if (path.x == 0 and path.y == 0) return;
// Convert our path to arrow key inputs. Yes, that is how this works.
// Yes, that is pretty sad. Yes, this could backfire in various ways.
// But its the best we can do.
// We do Y first because it prevents any weird wrap behavior.
if (path.y != 0) {
const arrow = if (path.y < 0) arrow: {
break :arrow if (t.modes.get(.cursor_keys)) "\x1bOA" else "\x1b[A";
} else arrow: {
break :arrow if (t.modes.get(.cursor_keys)) "\x1bOB" else "\x1b[B";
};
for (0..@abs(path.y)) |_| {
self.queueIo(.{ .write_stable = arrow }, .locked);
}
}
if (path.x != 0) {
const arrow = if (path.x < 0) arrow: {
break :arrow if (t.modes.get(.cursor_keys)) "\x1bOD" else "\x1b[D";
} else arrow: {
break :arrow if (t.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C";
};
for (0..@abs(path.x)) |_| {
self.queueIo(.{ .write_stable = arrow }, .locked);
}
}
}
const Link = struct {
action: input.Link.Action,
selection: terminal.Selection,

View File

@@ -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;redraw=last\a\]'$PS1'\[\e]133;B\a\]'
PS1='\[\e]133;A;redraw=last;cl=line\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;redraw=last;aid=%s\a" "$BASHPID"
builtin printf "\e]133;A;redraw=last;cl=line;aid=%s\a" "$BASHPID"
_ghostty_executing=0
}

View File

@@ -121,7 +121,7 @@ _ghostty_deferred_init() {
fi
fi
builtin local mark1=$'%{\e]133;A\a%}'
builtin local mark1=$'%{\e]133;A;cl=line\a%}'
if [[ -o prompt_percent ]]; then
builtin typeset -g precmd_functions
if [[ ${precmd_functions[-1]} == _ghostty_precmd ]]; then

View File

@@ -2942,49 +2942,185 @@ pub fn lineIterator(self: *const Screen, start: Pin) LineIterator {
};
}
/// Returns the change in x/y that is needed to reach "to" from "from"
/// within a prompt. If "to" is before or after the prompt bounds then
/// the result will be bounded to the prompt.
pub const PromptClickMove = struct {
left: usize,
right: usize,
pub const zero = PromptClickMove{
.left = 0,
.right = 0,
};
};
/// Determine the inputs necessary to move the cursor to the given
/// click location within a prompt input area.
///
/// This feature requires shell integration. If shell integration is not
/// enabled, this will always return zero for both x and y (no path).
pub fn promptPath(
/// If the cursor isn't currently at a prompt input location, this
/// returns no movement.
///
/// This feature depends on well-behaved OSC133 shell integration. Specifically,
/// this only moves over designated input areas (OSC 133 B). It is assumed
/// that the shell will only move the cursor to input cells, so prompt cells
/// and other blank cells are ignored as part of the movement calculation.
pub fn promptClickMove(
self: *Screen,
from: Pin,
to: Pin,
) struct {
x: isize,
y: isize,
} {
// Verify "from" is on a prompt row before calling highlightSemanticContent.
// highlightSemanticContent asserts the starting point is a prompt.
switch (from.rowAndCell().row.semantic_prompt) {
.prompt, .prompt_continuation => {},
.none => return .{ .x = 0, .y = 0 },
click_pin: Pin,
) PromptClickMove {
// If we're not at an input cell with our cursor, no movement will
// ever be possible.
if (self.cursor.semantic_content != .input and
self.cursor.page_cell.semantic_content != .input) return .zero;
return switch (self.semantic_prompt.click) {
// None doesn't support movement and click_events must use a
// different mechanism (SGR mouse events) that callers must handle.
.none, .click_events => .zero,
.cl => |cl| switch (cl) {
// All of these currently use dumb line-based navigation.
// But eventually we'll support more.
.line,
.multiple,
.conservative_vertical,
.smart_vertical,
=> self.promptClickLine(click_pin),
},
};
}
/// Determine the inputs required to move from the cursor to the given
/// click location. If the cursor isn't currently at a prompt input
/// location, this will return zero.
///
/// This currently only supports moving a single line.
fn promptClickLine(self: *Screen, click_pin: Pin) PromptClickMove {
// If our click pin is our cursor pin, no movement is needed.
// Do this early so we can assume later that they are different.
const cursor_pin = self.cursor.page_pin.*;
if (cursor_pin.eql(click_pin)) return .zero;
// If our cursor is before our click, we're only emitting right inputs.
if (cursor_pin.before(click_pin)) {
var count: usize = 0;
// We go row-by-row because soft-wrapped rows are still a single
// line to a shell, so we can't just look at our page row.
var row_it = cursor_pin.rowIterator(
.right_down,
click_pin,
);
row_it: while (row_it.next()) |row_pin| {
const rac = row_pin.rowAndCell();
const cells = row_pin.node.data.getCells(rac.row);
// Determine if this row is our cursor.
const is_cursor_row = row_pin.node == cursor_pin.node and
row_pin.y == cursor_pin.y;
// If this is not the cursor row, verify it's still part of the
// continuation of our starting prompt.
if (!is_cursor_row and
rac.row.semantic_prompt != .prompt_continuation) break;
// Determine where our input starts.
const start_x: usize = start_x: {
// If this is our cursor row then we start after the cursor.
if (is_cursor_row) break :start_x cursor_pin.x + 1;
// Otherwise, we start at the first input cell, because
// we expect the shell to properly translate arrows across
// lines to the start of the input. Some shells indent
// where input starts on subsequent lines so we must do
// this.
for (cells, 0..) |cell, x| {
if (cell.semantic_content == .input) break :start_x x;
}
// We never found an input cell, so we need to move to the
// next row.
break :start_x cells.len;
};
// Iterate over the input cells and assume arrow keys only
// jump to input cells.
for (cells[start_x..], start_x..) |cell, x| {
// Ignore non-input cells, but allow breaks. We assume
// the shell will translate arrow keys to only input
// areas.
if (cell.semantic_content != .input) continue;
// Increment our input count
count += 1;
// If this is our target, we're done.
if (row_pin.node == click_pin.node and
row_pin.y == click_pin.y and
x == click_pin.x)
break :row_it;
}
// If this row isn't soft-wrapped, we need to break out
// because line based moving only handles single lines.
// We're done!
if (!rac.row.wrap) {
// If we never found our pin, that means we clicked further
// right/beyond it. If we're already on a non-empty input cell
// then we add one so we can move to the newest, empty cell
// at the end, matching typical editor behavior.
if (self.cursor.page_cell.semantic_content == .input) count += 1;
break;
}
}
return .{ .left = 0, .right = count };
}
// Get our prompt bounds assuming "from" is at a prompt.
const hl = self.pages.highlightSemanticContent(from, .prompt) orelse return .{ .x = 0, .y = 0 };
const bounds: Selection = .init(hl.start, hl.end, false);
// Otherwise, cursor is after click, so we're emitting left inputs.
var count: usize = 0;
// Get our actual "to" point clamped to the bounds of the prompt.
const to_clamped = if (bounds.contains(self, to))
to
else if (to.before(bounds.start()))
bounds.start()
else
bounds.end();
// We go row-by-row because soft-wrapped rows are still a single
// line to a shell, so we can't just look at our page row.
var row_it = cursor_pin.rowIterator(
.left_up,
click_pin,
);
row_it: while (row_it.next()) |row_pin| {
const rac = row_pin.rowAndCell();
const cells = row_pin.node.data.getCells(rac.row);
// Convert to points
const from_pt = self.pages.pointFromPin(.screen, from).?.screen;
const to_pt = self.pages.pointFromPin(.screen, to_clamped).?.screen;
// Determine the length of the cells we look at in this row.
const end_len: usize = end_len: {
// If this is our cursor row then we end before the cursor.
if (row_pin.node == cursor_pin.node and
row_pin.y == cursor_pin.y) break :end_len cursor_pin.x;
// Basic math to calculate our path.
const from_x: isize = @intCast(from_pt.x);
const from_y: isize = @intCast(from_pt.y);
const to_x: isize = @intCast(to_pt.x);
const to_y: isize = @intCast(to_pt.y);
return .{ .x = to_x - from_x, .y = to_y - from_y };
// Otherwise, we end at the last cell in the row.
break :end_len cells.len;
};
// Iterate backwards over the input cells.
for (0..end_len) |rev_x| {
const x: usize = end_len - 1 - rev_x;
const cell = cells[x];
// Ignore non-input cells.
if (cell.semantic_content != .input) continue;
// Increment our input count
count += 1;
// If this is our target, we're done.
if (row_pin.node == click_pin.node and
row_pin.y == click_pin.y and
x == click_pin.x)
break :row_it;
}
// If this row is not a wrap continuation, then break out
if (!rac.row.wrap_continuation) break;
}
return .{ .left = count, .right = 0 };
}
/// Dump the screen to a string. The writer given should be buffered;
@@ -3080,8 +3216,8 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
if (self.cursor.semantic_content_clear_eol) {
self.cursorSetSemanticContent(.output);
} else switch (self.cursor.semantic_content) {
.input, .output => {},
.prompt => self.cursor.page_row.semantic_prompt = .prompt_continuation,
.output => {},
.prompt, .input => self.cursor.page_row.semantic_prompt = .prompt_continuation,
}
continue;
}
@@ -3116,8 +3252,8 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
self.cursorHorizontalAbsolute(0);
self.cursor.page_row.wrap_continuation = true;
switch (self.cursor.semantic_content) {
.input, .output => {},
.prompt => self.cursor.page_row.semantic_prompt = .prompt_continuation,
.output => {},
.input, .prompt => self.cursor.page_row.semantic_prompt = .prompt_continuation,
}
}
@@ -7364,56 +7500,6 @@ 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;
@@ -8620,134 +8706,6 @@ test "Screen: selectOutput" {
}
}
test "Screen: promptPath" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 });
defer s.deinit();
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
{
const rac = page.getRowAndCell(0, 2);
rac.row.semantic_prompt = .prompt;
// First 3 cols are prompt
for (0..3) |x| {
const cell = page.getRowAndCell(x, 2).cell;
cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = 'P' },
.semantic_content = .prompt,
};
}
// Next cols are input
for (3..10) |x| {
const cell = page.getRowAndCell(x, 2).cell;
cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = 'I' },
.semantic_content = .input,
};
}
}
// Row 3: continuation line with input cells (same prompt block)
{
const rac = page.getRowAndCell(0, 3);
rac.row.semantic_prompt = .prompt_continuation;
for (0..6) |x| {
const cell = page.getRowAndCell(x, 3).cell;
cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = 'I' },
.semantic_content = .input,
};
}
}
// Row 6: next prompt + input on same line
{
const rac = page.getRowAndCell(0, 6);
rac.row.semantic_prompt = .prompt;
for (0..2) |x| {
const cell = page.getRowAndCell(x, 6).cell;
cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = '$' },
.semantic_content = .prompt,
};
}
for (2..8) |x| {
const cell = page.getRowAndCell(x, 6).cell;
cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = 'i' },
.semantic_content = .input,
};
}
}
// From is not in the prompt
{
const path = s.promptPath(
s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
s.pages.pin(.{ .active = .{ .x = 0, .y = 2 } }).?,
);
try testing.expectEqual(@as(isize, 0), path.x);
try testing.expectEqual(@as(isize, 0), path.y);
}
// Same line
{
const path = s.promptPath(
s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?,
s.pages.pin(.{ .active = .{ .x = 3, .y = 2 } }).?,
);
try testing.expectEqual(@as(isize, -3), path.x);
try testing.expectEqual(@as(isize, 0), path.y);
}
// Different lines
{
const path = s.promptPath(
s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?,
s.pages.pin(.{ .active = .{ .x = 3, .y = 3 } }).?,
);
try testing.expectEqual(@as(isize, -3), path.x);
try testing.expectEqual(@as(isize, 1), path.y);
}
// To is out of bounds before
{
const path = s.promptPath(
s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?,
s.pages.pin(.{ .active = .{ .x = 3, .y = 1 } }).?,
);
try testing.expectEqual(@as(isize, -6), path.x);
try testing.expectEqual(@as(isize, 0), path.y);
}
// 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, -1), path.x);
try testing.expectEqual(@as(isize, 1), path.y);
}
}
test "Screen: selectionString basic" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -9871,3 +9829,524 @@ test "selectionString map allocation failure cleanup" {
// If this test passes without memory leaks (when run with testing.allocator),
// it means the errdefer properly cleaned up map.string when toOwnedSlice failed.
}
test "Screen: promptClickMove line right basic" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 });
defer s.deinit();
// Enable line click mode
s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input
s.cursorSetSemanticContent(.{ .prompt = .initial });
try s.testWriteString("> ");
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
try s.testWriteString("hello");
// Move cursor back to start of input (column 2, the 'h')
s.cursorAbsolute(2, 0);
// Click on first 'l' (column 4), should require 2 right movements (h->e->l)
const click_pin = s.pages.pin(.{ .active = .{ .x = 4, .y = 0 } }).?;
const result = s.promptClickMove(click_pin);
try testing.expectEqual(@as(usize, 2), result.right);
try testing.expectEqual(@as(usize, 0), result.left);
}
test "Screen: promptClickMove line right cursor not on input" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 });
defer s.deinit();
// Enable line click mode
s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input
s.cursorSetSemanticContent(.{ .prompt = .initial });
try s.testWriteString("> ");
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
try s.testWriteString("hello");
s.cursorSetSemanticContent(.output);
// Move cursor back to prompt area (column 0, the '>')
s.cursorAbsolute(0, 0);
// Cursor is on prompt, not input - should return zero
const click_pin = s.pages.pin(.{ .active = .{ .x = 4, .y = 0 } }).?;
const result = s.promptClickMove(click_pin);
try testing.expectEqual(PromptClickMove.zero, result);
}
test "Screen: promptClickMove line right click on same position" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 });
defer s.deinit();
// Enable line click mode
s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input
s.cursorSetSemanticContent(.{ .prompt = .initial });
try s.testWriteString("> ");
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
try s.testWriteString("hello");
// Move cursor to column 4
s.cursorAbsolute(4, 0);
// Click on same position - no movement needed
const click_pin = s.pages.pin(.{ .active = .{ .x = 4, .y = 0 } }).?;
const result = s.promptClickMove(click_pin);
try testing.expectEqual(PromptClickMove.zero, result);
}
test "Screen: promptClickMove line right skips non-input cells" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 });
defer s.deinit();
// Enable line click mode
s.semantic_prompt.click = .{ .cl = .line };
// Write: "> h" then output "X" then input "llo"
s.cursorSetSemanticContent(.{ .prompt = .initial });
try s.testWriteString("> ");
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
try s.testWriteString("h");
s.cursorSetSemanticContent(.output);
try s.testWriteString("X");
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
try s.testWriteString("llo");
// Move cursor to column 2 (the 'h')
s.cursorAbsolute(2, 0);
// Click on 'l' at column 5 - should skip the 'X' output cell
// Movement: h (start) -> l (col 4) -> l (col 5) = 2 right movements
const click_pin = s.pages.pin(.{ .active = .{ .x = 5, .y = 0 } }).?;
const result = s.promptClickMove(click_pin);
try testing.expectEqual(@as(usize, 2), result.right);
try testing.expectEqual(@as(usize, 0), result.left);
}
test "Screen: promptClickMove line right soft-wrapped line" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 });
defer s.deinit();
// Enable line click mode
s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input that wraps
s.cursorSetSemanticContent(.{ .prompt = .initial });
try s.testWriteString("> ");
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
// Write 8 chars of input, first row has 2 for prompt + 8 input = 10 cols
try s.testWriteString("abcdefgh");
// Continue on next row (soft-wrapped)
try s.testWriteString("ij");
// Verify soft wrap occurred
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("> abcdefgh\nij", contents);
}
// Move cursor to column 2 (the 'a')
s.cursorAbsolute(2, 0);
// Click on 'j' at column 1, row 1 - should count all input cells
// Movement: a->b->c->d->e->f->g->h->i->j = 9 right movements
const click_pin = s.pages.pin(.{ .active = .{ .x = 1, .y = 1 } }).?;
const result = s.promptClickMove(click_pin);
try testing.expectEqual(@as(usize, 9), result.right);
try testing.expectEqual(@as(usize, 0), result.left);
}
test "Screen: promptClickMove disabled when click is none" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 });
defer s.deinit();
// Click mode is .none by default (disabled)
try testing.expectEqual(Screen.SemanticPrompt.SemanticClick.none, s.semantic_prompt.click);
// Write a prompt and input
s.cursorSetSemanticContent(.{ .prompt = .initial });
try s.testWriteString("> ");
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
try s.testWriteString("hello");
// Move cursor to start of input
s.cursorAbsolute(2, 0);
// Click should return zero since click mode is disabled
const click_pin = s.pages.pin(.{ .active = .{ .x = 4, .y = 0 } }).?;
const result = s.promptClickMove(click_pin);
try testing.expectEqual(PromptClickMove.zero, result);
}
test "Screen: promptClickMove line right stops at hard wrap" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 });
defer s.deinit();
// Enable line click mode
s.semantic_prompt.click = .{ .cl = .line };
// Write prompt and input on first line
s.cursorSetSemanticContent(.{ .prompt = .initial });
try s.testWriteString("> ");
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
try s.testWriteString("hello");
// Hard wrap (newline)
try s.testWriteString("\n");
try s.testWriteString("world");
// Move cursor to column 2 (the 'h')
s.cursorAbsolute(2, 0);
// Click on 'w' at column 0, row 1 - but line mode stops at hard wrap
// Should only move to end of first line: h->e->l->l->o = 4 movements
const click_pin = s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?;
const result = s.promptClickMove(click_pin);
// Should stop at end of first line, not cross hard wrap
try testing.expectEqual(@as(usize, 5), result.right);
try testing.expectEqual(@as(usize, 0), result.left);
}
test "Screen: promptClickMove line right stops at non-continuation row" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 });
defer s.deinit();
// Enable line click mode
s.semantic_prompt.click = .{ .cl = .line };
// Row 0: PROMPT "> hello"
s.cursorSetSemanticContent(.{ .prompt = .initial });
try s.testWriteString("> ");
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
try s.testWriteString("hello\n");
// Row 1: CONTINUATION "world"
s.cursorSetSemanticContent(.{ .prompt = .continuation });
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
try s.testWriteString("world\n");
// Row 2: NEW PROMPT "> again"
s.cursorSetSemanticContent(.{ .prompt = .initial });
try s.testWriteString("> ");
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
try s.testWriteString("again");
// Verify content
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("> hello\nworld\n> again", contents);
}
// Move cursor to 'w' at column 0, row 1
s.cursorAbsolute(0, 1);
// Click on 'a' at column 2, row 2 - but row 2 is a new prompt
// Should stop at end of "world": w->o->r->l->d = 4 movements
const click_pin = s.pages.pin(.{ .active = .{ .x = 2, .y = 2 } }).?;
const result = s.promptClickMove(click_pin);
// Should stop at 'd' (end of world), not cross to new prompt
try testing.expectEqual(@as(usize, 5), result.right);
try testing.expectEqual(@as(usize, 0), result.left);
}
test "Screen: promptClickMove line left basic" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 });
defer s.deinit();
// Enable line click mode
s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input
s.cursorSetSemanticContent(.{ .prompt = .initial });
try s.testWriteString("> ");
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
try s.testWriteString("hello");
// Cursor is at column 7 (after 'o'), move it to column 6 (the 'o')
s.cursorAbsolute(6, 0);
// Click on 'h' (column 2), should require 4 left movements (o->l->l->e->h)
const click_pin = s.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?;
const result = s.promptClickMove(click_pin);
try testing.expectEqual(@as(usize, 4), result.left);
try testing.expectEqual(@as(usize, 0), result.right);
}
test "Screen: promptClickMove line left skips non-input cells" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 });
defer s.deinit();
// Enable line click mode
s.semantic_prompt.click = .{ .cl = .line };
// Write: "> h" then output "X" then input "llo"
s.cursorSetSemanticContent(.{ .prompt = .initial });
try s.testWriteString("> ");
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
try s.testWriteString("h");
s.cursorSetSemanticContent(.output);
try s.testWriteString("X");
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
try s.testWriteString("llo");
// Move cursor to column 6 (the 'o')
s.cursorAbsolute(6, 0);
// Click on 'h' at column 2 - should skip the 'X' output cell
// Movement: o->l->l->h = 3 left movements (skipping X)
const click_pin = s.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?;
const result = s.promptClickMove(click_pin);
try testing.expectEqual(@as(usize, 3), result.left);
try testing.expectEqual(@as(usize, 0), result.right);
}
test "Screen: promptClickMove line left soft-wrapped line" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 });
defer s.deinit();
// Enable line click mode
s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input that wraps
s.cursorSetSemanticContent(.{ .prompt = .initial });
try s.testWriteString("> ");
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
// Write 8 chars of input, first row has 2 for prompt + 8 input = 10 cols
try s.testWriteString("abcdefgh");
// Continue on next row (soft-wrapped)
try s.testWriteString("ij");
// Verify soft wrap occurred
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("> abcdefgh\nij", contents);
}
// Cursor is at column 2, row 1 (after 'j'). Move to 'j' at column 1.
s.cursorAbsolute(1, 1);
// Click on 'a' at column 2, row 0 - should count all input cells backwards
// Movement: j->i->h->g->f->e->d->c->b->a = 9 left movements
const click_pin = s.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?;
const result = s.promptClickMove(click_pin);
try testing.expectEqual(@as(usize, 9), result.left);
try testing.expectEqual(@as(usize, 0), result.right);
}
test "Screen: promptClickMove line left stops at hard wrap" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 });
defer s.deinit();
// Enable line click mode
s.semantic_prompt.click = .{ .cl = .line };
// Write prompt and input on first line, then hard wrap
s.cursorSetSemanticContent(.{ .prompt = .initial });
try s.testWriteString("> ");
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
try s.testWriteString("hello");
// Hard wrap (newline)
try s.testWriteString("\n");
try s.testWriteString("world");
// Move cursor to 'd' at column 4, row 1 (an actual input cell)
s.cursorAbsolute(4, 1);
// Click on 'h' at column 2, row 0 - but line mode stops at hard wrap
// Should only move to start of second line, not cross to row 0
const click_pin = s.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?;
const result = s.promptClickMove(click_pin);
// Should stop at start of second line: d->l->r->o->w = 4 movements
try testing.expectEqual(@as(usize, 4), result.left);
try testing.expectEqual(@as(usize, 0), result.right);
}
test "Screen: promptClickMove click right of input same line" {
const testing = std.testing;
const alloc = testing.allocator;
// Set up: "> hello" where "> " is prompt and "hello" is input
// Clicking to the right of the 'o' should move cursor past the input
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 });
defer s.deinit();
// Enable line click mode
s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input
s.cursorSetSemanticContent(.{ .prompt = .initial });
try s.testWriteString("> ");
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
try s.testWriteString("hello");
// Move cursor to start of input (column 2, the 'h')
s.cursorAbsolute(2, 0);
// Click beyond the input (column 15) - should move to one past the 'o'
const click_pin = s.pages.pin(.{ .active = .{ .x = 15, .y = 0 } }).?;
const result = s.promptClickMove(click_pin);
try testing.expectEqual(@as(usize, 5), result.right);
try testing.expectEqual(@as(usize, 0), result.left);
}
test "Screen: promptClickMove click right of input cursor at end" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 });
defer s.deinit();
// Enable line click mode
s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input
s.cursorSetSemanticContent(.{ .prompt = .initial });
try s.testWriteString("> ");
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
try s.testWriteString("hello");
// Cursor is already at column 7 (one past 'o') after writing
// Click beyond the input (column 15) - no movement needed since
// cursor is already at the end position
const click_pin = s.pages.pin(.{ .active = .{ .x = 15, .y = 0 } }).?;
const result = s.promptClickMove(click_pin);
try testing.expectEqual(@as(usize, 0), result.right);
try testing.expectEqual(@as(usize, 0), result.left);
}
test "Screen: promptClickMove click right of input on lower line" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 });
defer s.deinit();
// Enable line click mode
s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input
s.cursorSetSemanticContent(.{ .prompt = .initial });
try s.testWriteString("> ");
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
try s.testWriteString("hello");
// Move cursor to start of input (column 2, the 'h')
s.cursorAbsolute(2, 0);
// Click on a lower line (row 1) - should move to end of input
// This is outside the prompt area so should clamp to end
const click_pin = s.pages.pin(.{ .active = .{ .x = 5, .y = 1 } }).?;
const result = s.promptClickMove(click_pin);
// From 'h', we need to pass e, l, l, o (4 cells) + 1 past end = 5
try testing.expectEqual(@as(usize, 5), result.right);
try testing.expectEqual(@as(usize, 0), result.left);
}
test "Screen: promptClickMove click right of input cursor at end lower line" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 });
defer s.deinit();
// Enable line click mode
s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input
s.cursorSetSemanticContent(.{ .prompt = .initial });
try s.testWriteString("> ");
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
try s.testWriteString("hello");
// Cursor is at column 7 after writing (one past 'o')
// Click on a lower line (row 1) - cursor already at end, no movement needed
const click_pin = s.pages.pin(.{ .active = .{ .x = 5, .y = 1 } }).?;
const result = s.promptClickMove(click_pin);
try testing.expectEqual(@as(usize, 0), result.right);
try testing.expectEqual(@as(usize, 0), result.left);
}
test "Screen: promptClickMove click right of input cursor on last char" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 });
defer s.deinit();
// Enable line click mode
s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input
s.cursorSetSemanticContent(.{ .prompt = .initial });
try s.testWriteString("> ");
s.cursorSetSemanticContent(.{ .input = .clear_explicit });
try s.testWriteString("hello");
// Move cursor to last input char (column 6, the 'o')
s.cursorAbsolute(6, 0);
// Click beyond the input (column 15)
const click_pin = s.pages.pin(.{ .active = .{ .x = 15, .y = 0 } }).?;
const result = s.promptClickMove(click_pin);
try testing.expectEqual(@as(usize, 1), result.right);
try testing.expectEqual(@as(usize, 0), result.left);
}