feat(terminal): multi-line prompt editor + I-beam mouse pointer (#80)

* feat(terminal): multi-line command editor at the prompt

The inline prompt editor was single-line: a pasted multi-line command kept
its newlines inline but rendered them as blank cells in one flex-wrapped row,
flattening the command into a ragged blob with the original indentation left
as runs of spaces.

Make the editor genuinely line-aware:
- render_input_bar splits the buffer on '\n' and stacks each logical line as
  its own flex-wrap row, so newlines become real breaks (soft-wrap within a
  line is unchanged); ghost autosuggestion is suppressed for multi-line input.
- submit replays each embedded newline as an Enter so the shell's own line
  editor assembles the command (backslash / open-quote continuation, PS2).
- up/down move the caret between visual rows with a sticky goal column,
  falling through to history recall only from the top/bottom row.
- Home/End (Ctrl-A/E) act within the current logical line.
- click mapping treats '\n' as a hard break (past a line's content snaps to
  its end, not onto the next line).
- Shift+Enter / Opt+Enter insert a newline to author multi-line commands.

Adds tests for logical-line Home/End and newline-aware click mapping.

* fix(terminal): I-beam mouse pointer over selectable text

The surface only set a pointer style (PointingHand) over hovered links, so
everywhere else the OS default arrow showed over the terminal text. Default
to an I-beam over the text like every other terminal, keep the pointing hand
for links, and leave the arrow only once a program takes over mouse reporting
(matching Terminal.app / iTerm).

* style: cargo fmt

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
l0ng-ai
2026-07-14 17:53:38 +08:00
committed by GitHub
co-authored by l0ng-ai
parent eb6c7ca3b3
commit 54825d3275
3 changed files with 314 additions and 46 deletions
+54 -2
View File
@@ -277,8 +277,31 @@ impl CmdEditor {
}
}
/// Char index of the start of the logical line containing `idx` — just after
/// the preceding `'\n'`, or `0`. A multi-line buffer (from a pasted command)
/// keeps its `'\n'`s inline; Home / Ctrl-A act within the current line.
pub fn line_start(&self, idx: usize) -> usize {
let mut s = idx.min(self.chars.len());
while s > 0 && self.chars[s - 1] != '\n' {
s -= 1;
}
s
}
/// Char index of the end of the logical line containing `idx` — the next
/// `'\n'`, or the buffer end.
pub fn line_end(&self, idx: usize) -> usize {
let mut e = idx.min(self.chars.len());
while e < self.chars.len() && self.chars[e] != '\n' {
e += 1;
}
e
}
/// Move to the start of the current logical line (Home / Ctrl-A). On a
/// single-line buffer this is column 0, unchanged.
pub fn move_home(&mut self) {
self.cursor = 0;
self.cursor = self.line_start(self.cursor);
}
/// Place the cursor at char index `idx` (clamped to the line length). Used to
@@ -287,8 +310,10 @@ impl CmdEditor {
self.cursor = idx.min(self.chars.len());
}
/// Move to the end of the current logical line (End / Ctrl-E). On a
/// single-line buffer this is the buffer end, unchanged.
pub fn move_end(&mut self) {
self.cursor = self.chars.len();
self.cursor = self.line_end(self.cursor);
}
/// Move left to the start of the previous word (skip trailing whitespace, then
@@ -739,6 +764,33 @@ mod tests {
assert_eq!(e.selected_text().as_deref(), Some("ab"));
}
#[test]
fn home_end_are_logical_line_relative_in_a_multiline_buffer() {
// A pasted multi-line command keeps its '\n's inline; Home/End act within
// the line the caret sits on, not the whole buffer.
let mut e = ed("one\ntwo\nthree", 5); // caret in "two" (after 't', 'w')
e.move_home();
assert_eq!(e.cursor(), 4, "start of the 'two' line");
e.move_end();
assert_eq!(e.cursor(), 7, "end of the 'two' line (before the '\\n')");
// First line: Home is column 0, End is just before the first '\n'.
e.set_cursor(1);
e.move_home();
assert_eq!(e.cursor(), 0);
e.move_end();
assert_eq!(e.cursor(), 3);
// Last line has no trailing '\n': End is the buffer end.
e.set_cursor(10);
e.move_end();
assert_eq!(e.cursor(), 13);
// A single-line buffer is unaffected: Home/End are the buffer edges.
let mut s = ed("git push", 4);
s.move_home();
assert_eq!(s.cursor(), 0);
s.move_end();
assert_eq!(s.cursor(), 8);
}
#[test]
fn set_with_cursor_sets_line_and_clamps() {
let mut e = ed("abc", 1);
+9 -3
View File
@@ -1550,10 +1550,16 @@ impl Element for TerminalElement {
self.register_mouse_handlers(geom, bounds, prepaint.hitbox.id, window);
// A pointing-hand cursor over a hovered link reinforces that it's
// clickable (Cmd+click opens it).
if self.view.read(cx).hovered_link.is_some() {
// Mouse pointer over the surface: a pointing hand over a hovered link
// (Cmd+click opens it); otherwise an I-beam over the selectable text,
// like every other terminal. Once a program takes over mouse reporting
// the pointer stays the default arrow, signalling "the app owns this"
// (matching Terminal.app / iTerm).
let view = self.view.read(cx);
if view.hovered_link.is_some() {
window.set_cursor_style(CursorStyle::PointingHand, &prepaint.hitbox);
} else if !view.mouse_mode() {
window.set_cursor_style(CursorStyle::IBeam, &prepaint.hitbox);
}
if let Some(start) = fps_start {
+251 -41
View File
@@ -324,6 +324,11 @@ pub struct TerminalView {
/// double-click selected, so the drag can grow the selection by whole words
/// (keeping the anchor word intact). `None` for a plain char-granular drag.
editor_drag_word: Option<(usize, usize)>,
/// Sticky target column for vertical caret motion (↑/↓ across a multi-line
/// buffer). Set on the first vertical step from the caret's current visual
/// column and preserved across a run of ↑/↓ so passing through a short line
/// doesn't lose the column; any other motion or edit clears it (`None`).
editor_goal_col: Option<usize>,
/// The URL currently under the mouse (an OSC 8 hyperlink or a bare URL found
/// in the row text), if any. Drives the hover underline and the pointing-hand
/// cursor that mark a link as clickable. Stored in scroll-stable grid
@@ -914,6 +919,7 @@ impl TerminalView {
editor_selecting: false,
editor_select_gesture: false,
editor_drag_word: None,
editor_goal_col: None,
hovered_link: None,
_focus_subs: focus_subs,
}
@@ -1372,6 +1378,11 @@ impl TerminalView {
let m = &ks.modifiers;
let key = ks.key.as_str();
self.cursor_visible = true;
// Any key other than a vertical step drops the sticky goal column, so the
// next ↑/↓ takes its column from wherever the caret ends up.
if key != "up" && key != "down" {
self.editor_goal_col = None;
}
// A reverse search, when active, owns the keyboard.
if self.reverse_search.is_some() {
@@ -1521,6 +1532,14 @@ impl TerminalView {
match key {
"enter" => {
// Shift+Enter / Opt+Enter inserts a newline to author (or extend)
// a multi-line command; a plain Enter submits the whole buffer.
if (m.shift || m.alt) && !m.control && !m.platform {
self.cmd.insert_str("\n");
self.history_nav = None;
cx.notify();
return;
}
self.submit_command(cx);
return;
}
@@ -1574,11 +1593,23 @@ impl TerminalView {
"home" => self.editor_move_edge(false, m.shift),
"end" => self.editor_move_edge(true, m.shift),
"up" => {
self.history_prev(cx);
// Within a multi-line buffer ↑ moves up a visual row; from the
// top row it recalls the previous history entry.
if self.editor_move_v(false, m.shift) {
cx.notify();
} else {
self.history_prev(cx);
}
return;
}
"down" => {
self.history_next(cx);
// The mirror of ↑: down a visual row, or newer history from the
// bottom row.
if self.editor_move_v(true, m.shift) {
cx.notify();
} else {
self.history_next(cx);
}
return;
}
"escape" => {
@@ -1736,6 +1767,79 @@ impl TerminalView {
}
}
/// Vertical caret motion across a multi-line / wrapped input buffer (↑/↓),
/// with a sticky goal column so passing through a short line keeps the
/// target column. Returns `true` if the caret moved within the buffer;
/// `false` means it was already on the top row (↑) or bottom row (↓), so the
/// caller falls through to history recall — matching how fish/zsh edit a
/// multi-line line. Shift extends the selection.
fn editor_move_v(&mut self, down: bool, shift: bool) -> bool {
let Some((_, scol)) = self.cursor_cell() else {
return false;
};
let cols = self.terminal.term.lock().columns().max(1);
let chars: Vec<char> = self.cmd.text().chars().collect();
let len = chars.len();
let (positions, _r, _c) = input_char_positions(&chars, scol, cols);
// The caret renders on the cell of the char it sits before, or on a
// trailing slot at the buffer end (a fresh row when the buffer ends in a
// newline).
let end_caret = if len == 0 {
(0usize, scol)
} else {
let (r, c, w) = positions[len - 1];
if chars[len - 1] == '\n' {
(r + 1, 0)
} else {
(r, c + w)
}
};
let (cur_row, cur_col) = if self.cmd.cursor() < len {
let (r, c, _) = positions[self.cmd.cursor()];
(r, c)
} else {
end_caret
};
let mut max_row = positions.iter().map(|&(r, _, _)| r).max().unwrap_or(0);
if chars.last() == Some(&'\n') {
max_row += 1;
}
// On the boundary row in the travel direction, defer to history recall.
if (down && cur_row >= max_row) || (!down && cur_row == 0) {
self.editor_goal_col = None;
return false;
}
let target = if down { cur_row + 1 } else { cur_row - 1 };
let goal = *self.editor_goal_col.get_or_insert(cur_col);
// Land on the caret slot of the target row nearest the goal column. Char
// `i`'s slot is the caret *before* it; the buffer-end slot is `len`.
let mut best: Option<(usize, usize)> = None; // (index, |col - goal|)
for (i, &(r, c, _)) in positions.iter().enumerate() {
if r == target {
let dist = c.abs_diff(goal);
if best.is_none_or(|(_, bd)| dist < bd) {
best = Some((i, dist));
}
}
}
if end_caret.0 == target {
let dist = end_caret.1.abs_diff(goal);
if best.is_none_or(|(_, bd)| dist < bd) {
best = Some((len, dist));
}
}
let Some((idx, _)) = best else {
return false;
};
if shift {
self.cmd.begin_selection();
} else {
self.cmd.clear_selection();
}
self.cmd.set_cursor(idx);
true
}
fn has_selection(&self) -> bool {
self.terminal.term.lock().selection.is_some()
}
@@ -1811,6 +1915,7 @@ impl TerminalView {
let trimmed = text.strip_suffix('\n').unwrap_or(&text);
self.cmd.insert_str(trimmed);
self.history_nav = None;
self.editor_goal_col = None;
self.close_completion();
self.cursor_visible = true;
cx.notify();
@@ -2290,6 +2395,7 @@ impl TerminalView {
}
}
self.editor_select_gesture = true;
self.editor_goal_col = None;
self.close_completion();
self.cursor_visible = true;
cx.notify();
@@ -2499,7 +2605,10 @@ impl TerminalView {
// "ls" strays + "pwd\r" runs `lspwd`. Wipe first: FIFO puts the ^U
// ahead of the line bytes.
self.wipe_pending_typeahead();
let mut bytes = line.into_bytes();
// Replay each embedded newline as an Enter so the shell's own line editor
// assembles the multi-line command — backslash / open-quote continuation
// with its PS2 prompts — exactly as if it had been typed line by line.
let mut bytes = line.replace('\n', "\r").into_bytes();
bytes.push(b'\r');
self.terminal.write(bytes);
self.cmd.clear();
@@ -3009,6 +3118,7 @@ impl TerminalView {
// re-filters to the extended word (and closes once nothing matches).
self.cmd.insert_str(text);
self.history_nav = None;
self.editor_goal_col = None;
self.completion_refilter();
self.cursor_visible = true;
cx.notify();
@@ -3584,35 +3694,73 @@ impl TerminalView {
d.into_any_element()
};
// Leading spacer the width of the shell prompt: the first input row begins
// right after the prompt, while wrapped rows start at the grid's left edge
// — matching how a real terminal wraps a long command line.
let mut children: Vec<gpui::AnyElement> = vec![
div()
.flex_none()
.w(cell_w * (ccol as f32))
.h(lh)
.into_any_element(),
];
// A blank cell of the given width and the line height — used for the
// leading prompt spacer and for a selected/caret slot standing in for a
// hard line break.
let blank = move |w: gpui::Pixels| div().flex_none().w(w).h(lh);
// The buffer's logical lines, each rendered as its own `flex_wrap` row and
// stacked in a column, so an embedded `'\n'` (from a pasted multi-line
// command, or Shift/Opt+Enter) shows as a real line break instead of
// flowing into one wrapped blob. Within a line, soft-wrapping is left to
// `flex_wrap` exactly as before. `lines` grows a fresh row on each `'\n'`.
let mut lines: Vec<Vec<gpui::AnyElement>> = vec![vec![
// Leading spacer the width of the shell prompt: the first line begins
// right after the prompt; continuation lines start at the grid's left
// edge, matching how the shell lays a multi-line command out.
blank(cell_w * (ccol as f32)).into_any_element(),
]];
// Ghost suggestion only makes sense for a single-line command (it completes
// the whole history entry); suppress it once the buffer holds a newline.
let is_multiline = chars.contains(&'\n');
for i in 0..len {
// IME pre-edit shows underlined at the caret; the bar caret is hidden
// while composing.
if i == cursor && has_marked {
for mc in marked.chars() {
children.push(cell(fg, mc, false, false, true));
lines
.last_mut()
.unwrap()
.push(cell(fg, mc, false, false, true));
}
}
if chars[i] == '\n' {
// The newline is a hard break, not a glyph. If the caret sits on it
// (end of this visual line) draw a trailing caret slot before the
// break so it stays visible; if the newline falls inside a selection
// draw a thin selected slot so a multi-line selection reads across
// the break. Then start the next row.
if selection.is_none() && !has_marked && cursor_on && cursor == i {
lines.last_mut().unwrap().push(
blank(cell_w)
.relative()
.child(caret_bar())
.into_any_element(),
);
} else if selection.is_some_and(|(s, e)| i >= s && i < e) {
lines
.last_mut()
.unwrap()
.push(blank(cell_w).bg(sel_bg).into_any_element());
}
lines.push(Vec::new());
continue;
}
let selected = selection.is_some_and(|(s, e)| i >= s && i < e);
let caret = selection.is_none() && !has_marked && cursor_on && cursor == i;
children.push(cell(colors[i], chars[i], selected, caret, false));
lines
.last_mut()
.unwrap()
.push(cell(colors[i], chars[i], selected, caret, false));
}
// Ghost autosuggestion remainder (only when caret is at the end, no
// selection / IME), computed up front so the end-of-line caret can ride on
// the first ghost cell instead of needing its own (which would push the
// ghost a full cell to the right).
let ghost: Option<String> = if selection.is_none() && !has_marked {
// selection / IME / newline), computed up front so the end-of-line caret can
// ride on the first ghost cell instead of needing its own (which would push
// the ghost a full cell to the right).
let ghost: Option<String> = if selection.is_none() && !has_marked && !is_multiline {
self.ghost_suggestion()
.map(|full| full.chars().skip(len).collect::<String>())
.filter(|r| !r.is_empty())
@@ -3620,31 +3768,44 @@ impl TerminalView {
None
};
// Caret / pre-edit at end of line.
// Caret / pre-edit at the end of the buffer — lands on the last row (a
// fresh empty row when the buffer ends in a newline).
if cursor == len {
let last = lines.last_mut().unwrap();
if has_marked {
for mc in marked.chars() {
children.push(cell(fg, mc, false, false, true));
last.push(cell(fg, mc, false, false, true));
}
} else if ghost.is_none() {
// No ghost following: a trailing cell carries the caret (and is the
// click target for "end of line").
let mut tail = div().relative().flex_none().w(cell_w).h(lh);
let mut tail = blank(cell_w).relative();
if selection.is_none() && cursor_on {
tail = tail.child(caret_bar());
}
children.push(tail.into_any_element());
last.push(tail.into_any_element());
}
// else: the caret rides on the first ghost cell below.
}
if let Some(rem) = ghost {
let last = lines.last_mut().unwrap();
for (gi, gc) in rem.chars().enumerate() {
let caret = gi == 0 && cursor == len && cursor_on;
children.push(cell(muted, gc, false, caret, false));
last.push(cell(muted, gc, false, caret, false));
}
}
let rows = lines.into_iter().map(move |cells| {
div()
.flex()
.flex_wrap()
.items_center()
.w_full()
.min_h(lh)
.children(cells)
});
div()
.absolute()
.left(px(GRID_PAD_X))
@@ -3652,15 +3813,14 @@ impl TerminalView {
.right_4()
.min_h(lh)
.flex()
.flex_wrap()
.items_center()
.flex_col()
// Transparent: the text overlays the grid in place, reading as a
// natural continuation of the shell prompt rather than a separate bar.
.font_family(self.font.family.clone())
.text_size(self.font_size)
.line_height(lh)
.text_color(fg)
.children(children)
.children(rows)
}
/// The floating completion menu, shown below the word while a completion is
@@ -4552,6 +4712,38 @@ fn menu_layout(
/// first char snaps to that char; past a row's content snaps to the next row's
/// first char (or the line end). Rows beyond the input return `len` with
/// `clamp` (for drags) and `None` without (so the click isn't an editor click).
/// Visual `(row, start-col, width)` of every char in the wrapped input line,
/// matching `render_input_bar`'s layout: char 0 starts at column `scol` (right
/// after the prompt), a `'\n'` is a hard break to column 0 of the next row (and
/// occupies no cell — width 0), and within a line a char that would overflow
/// wraps whole to column 0 of the next row. Also returns the pen `(row, col)`
/// after the last char, so callers can place the trailing end-of-line caret.
fn input_char_positions(
chars: &[char],
scol: usize,
cols: usize,
) -> (Vec<(usize, usize, usize)>, usize, usize) {
let mut positions: Vec<(usize, usize, usize)> = Vec::with_capacity(chars.len());
let mut r = 0usize;
let mut c = scol;
for &ch in chars {
if ch == '\n' {
positions.push((r, c, 0));
r += 1;
c = 0;
continue;
}
let w = display_width(ch).max(1);
if c + w > cols {
r += 1;
c = 0;
}
positions.push((r, c, w));
c += w;
}
(positions, r, c)
}
fn wrapped_click_index(
chars: &[char],
scol: usize,
@@ -4561,19 +4753,9 @@ fn wrapped_click_index(
clamp: bool,
) -> Option<usize> {
let len = chars.len();
// `positions[i]` is the (row, start-col, width) of char `i`.
let mut positions: Vec<(usize, usize, usize)> = Vec::with_capacity(len);
let mut r = 0usize;
let mut c = scol;
for &ch in chars {
let w = display_width(ch).max(1);
if c + w > cols {
r += 1;
c = 0;
}
positions.push((r, c, w));
c += w;
}
// `positions[i]` is the (row, start-col, width) of char `i`; `r`/`c` are the
// pen position after the last char.
let (positions, r, c) = input_char_positions(chars, scol, cols);
// The renderer appends a one-cell end-of-line caret slot after the last
// char; when the content exactly fills its row, that slot wraps to the next
// row (where the caret is visibly drawn), so clicks there must still count
@@ -4594,7 +4776,16 @@ fn wrapped_click_index(
return Some(fi);
}
}
// Past the row's content → start of the next row, or end of line.
// Past the row's content. If the row ends at a hard line break, snap to that
// newline — the end of this logical line — rather than jumping onto the next
// line. (A soft-wrapped row has no newline, so it continues below.)
if let Some(last) = positions.iter().rposition(|&(pr, _, _)| pr == target) {
if chars[last] == '\n' {
return Some(last);
}
}
// Otherwise the line soft-wraps: snap to the first char of the next visual
// row, or the buffer end.
match positions.iter().position(|&(pr, _, _)| pr > target) {
Some(ni) => Some(ni),
None => Some(len),
@@ -5135,6 +5326,25 @@ mod tests {
assert_eq!(wrapped_click_index(&chars, 4, 10, 0, 2, false), None);
}
#[test]
fn wrapped_click_index_treats_newlines_as_hard_breaks() {
// "a\nbc" after a 4-col prompt lays out as row 0 = "a" (col 4) and
// row 1 = "bc" (cols 0..2). Indices: 0='a', 1='\n', 2='b', 3='c'.
assert_eq!(click("a\nbc", 4, 80, 4, 0), Some(0)); // 'a'
assert_eq!(click("a\nbc", 4, 80, 0, 1), Some(2)); // 'b' on the next line
assert_eq!(click("a\nbc", 4, 80, 1, 1), Some(3)); // 'c'
// Clicking past the end of the first line snaps to the newline (the end
// of that logical line), not onto the second line.
assert_eq!(click("a\nbc", 4, 80, 40, 0), Some(1));
// Past the last line's content → buffer end.
assert_eq!(click("a\nbc", 4, 80, 40, 1), Some(4));
// A blank line in the middle ("a\n\nb") is its own row; clicking it lands
// on that empty line rather than falling through to "b".
// Indices: 0='a', 1='\n', 2='\n', 3='b'. Row 1 holds the second newline.
assert_eq!(click("a\n\nb", 4, 80, 3, 1), Some(2));
assert_eq!(click("a\n\nb", 4, 80, 0, 2), Some(3)); // 'b' on row 2
}
#[test]
fn menu_layout_prefers_below_and_flips_above_when_cramped() {
// Plenty of room below: all 5 rows drop under the input row.