Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions etc/shell/ghostel.bash
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,17 @@ __ghostel_osc7() {

# --- Semantic prompt markers (OSC 133) ---

# Emit "command finished" (D) for the previous command, then "prompt start" (A).
# Emit "command finished" (D) for the previous command.
# D is skipped on the very first prompt (no previous command).
# 133;A and 133;B are embedded in PS1 itself (see below) so they fire
# in lockstep with prompt rendering, including readline redraws (bash 5.x
# redraws the prompt after bracketed-paste-mode setup; if 133;A came from
# PROMPT_COMMAND it would only fire once, leaving the redrawn prompt
# cells outside the PROMPT scope).
__ghostel_prompt_start() {
if [[ -n "$__ghostel_prompt_shown" ]]; then
printf '\e]133;D;%s\e\\' "$__ghostel_last_status"
fi
printf '\e]133;A\e\\'
}

# Emit "prompt end / command start" (B).
__ghostel_prompt_end() {
printf '\e]133;B\e\\'
__ghostel_prompt_shown=1
}

Expand All @@ -62,14 +61,31 @@ __ghostel_wrapped_prompt_command() {
__ghostel_prompt_start
__ghostel_osc7
eval "${__ghostel_original_prompt_command:-}"
__ghostel_prompt_end
__ghostel_in_prompt_command=0
}

# Preserve any existing PROMPT_COMMAND.
__ghostel_original_prompt_command="${PROMPT_COMMAND:+$PROMPT_COMMAND}"
PROMPT_COMMAND="__ghostel_wrapped_prompt_command"

# Wrap PS1 with 133;A at the start and 133;B at the end. For multi-line
# prompts we ALSO inject 133;A after every line break in PS1: bash 5.x
# readline redraws only the last visual line of the prompt (CR + reprint,
# no preceding 133;A). Without a 133;A on every line, the redrawn cells
# fall outside the PROMPT scope and become INPUT-tagged.
# We inject after both literal newlines and the bash `\n' PS1 escape
# (which expands to a newline at prompt-render time).
# \[ \] mark the OSC sequence as zero-width for readline's line-wrap math;
# \a is BEL — a valid OSC terminator. We use BEL rather than ST (ESC \)
# because `${var//pat/repl}' eats backslashes in the replacement, which
# would break a multi-line ST-terminated marker.
__ghostel_ps1_a='\[\e]133;A\a\]'
__ghostel_ps1_b='\[\e]133;B\a\]'
PS1="${PS1//$'\n'/$'\n'${__ghostel_ps1_a}}"
PS1="${PS1//\\n/\\n${__ghostel_ps1_a}}"
PS1="${__ghostel_ps1_a}${PS1}${__ghostel_ps1_b}"
unset __ghostel_ps1_a __ghostel_ps1_b

trap '__ghostel_preexec' DEBUG

# Outbound `ssh' wrapper. Activated when the elisp side sets
Expand Down
24 changes: 17 additions & 7 deletions etc/shell/ghostel.fish
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,15 @@ function __ghostel_postexec --on-event fish_postexec
set -g __ghostel_last_status $status
end

# Emit "command finished" (D) + "prompt start" (A) before the prompt.
# Emit "command finished" (D) for the previous command.
# 133;A and 133;B are emitted by wrapping fish_prompt below so they fire
# in lockstep with prompt rendering, including any redraws — emitting from
# `--on-event fish_prompt` handlers (which fire only once per prompt cycle
# and only before fish_prompt) would leave redraws outside the PROMPT scope.
function __ghostel_prompt_start --on-event fish_prompt
if test "$__ghostel_prompt_shown" = 1
printf '\e]133;D;%s\e\\' "$__ghostel_last_status"
end
printf '\e]133;A\e\\'
end

# Emit "prompt end / command start" (B) after the prompt.
function __ghostel_prompt_end --on-event fish_prompt
printf '\e]133;B\e\\'
set -g __ghostel_prompt_shown 1
end

Expand All @@ -46,6 +44,18 @@ function __ghostel_preexec --on-event fish_preexec
printf '\e]133;C\e\\'
end

# Wrap fish_prompt with 133;A at the start and 133;B at the end so they
# fire in lockstep with prompt rendering. If the user redefines fish_prompt
# later, they're responsible for re-sourcing this file.
if functions -q fish_prompt; and not functions -q __ghostel_orig_fish_prompt
functions -c fish_prompt __ghostel_orig_fish_prompt
function fish_prompt
printf '\e]133;A\e\\'
__ghostel_orig_fish_prompt
printf '\e]133;B\e\\'
end
end

# Outbound `ssh' wrapper. See etc/ghostel.bash for the full design
# notes — this is the fish port of the same install-and-cache logic.
if test -n "$GHOSTEL_SSH_INSTALL_TERMINFO"
Expand Down
20 changes: 12 additions & 8 deletions etc/shell/ghostel.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,15 @@ __ghostel_save_status() {
__ghostel_last_status="$?"
}

# Emit "command finished" (D) + "prompt start" (A).
# Emit "command finished" (D) for the previous command.
# 133;A and 133;B are embedded in PROMPT itself (see below) so they fire
# in lockstep with prompt rendering, including readline-style redraws —
# emitting from precmd would only fire once and leave any subsequent
# redraw outside the PROMPT scope.
__ghostel_prompt_start() {
if [[ -n "$__ghostel_prompt_shown" ]]; then
printf '\e]133;D;%s\e\\' "$__ghostel_last_status"
fi
printf '\e]133;A\e\\'
}

# Emit "prompt end / command start" (B).
__ghostel_prompt_end() {
printf '\e]133;B\e\\'
__ghostel_prompt_shown=1
}

Expand All @@ -47,9 +45,15 @@ __ghostel_preexec() {
printf '\e]133;C\e\\'
}

precmd_functions=(__ghostel_save_status __ghostel_prompt_start __ghostel_osc7 "${precmd_functions[@]}" __ghostel_prompt_end)
precmd_functions=(__ghostel_save_status __ghostel_prompt_start __ghostel_osc7 "${precmd_functions[@]}")
preexec_functions=(__ghostel_preexec "${preexec_functions[@]}")

# Wrap PROMPT with 133;A at the start and 133;B at the end so they fire
# in lockstep with prompt rendering, including any redraws. %{ %} mark
# the OSC sequence as zero-width for line-wrap. $'...' is ANSI-C quoting:
# \e is ESC, \\ is a single backslash — so \e\\ is ESC \ (ST).
PROMPT=$'%{\e]133;A\e\\%}'"${PROMPT}"$'%{\e]133;B\e\\%}'

# Outbound `ssh' wrapper. See etc/ghostel.bash for the full design
# notes — this is the zsh port of the same install-and-cache logic.
if [[ -n "$GHOSTEL_SSH_INSTALL_TERMINFO" ]]; then
Expand Down
34 changes: 28 additions & 6 deletions lisp/ghostel.el
Original file line number Diff line number Diff line change
Expand Up @@ -2037,17 +2037,39 @@ Wraps to `point-max' when no link is found before point."
(dotimes (_ (or n 1))
(ghostel--goto-hyperlink 'previous)))

(defun ghostel--detect-urls-skip-p (pos active-bounds)
"Return non-nil if link detection should leave POS alone.
Skips spans already linkified (any `help-echo'), the shell's prompt
decoration (`ghostel-prompt' — e.g. the cwd shown in the prompt is
shell-generated, not content the user pointed at), and the active
prompt's typed input within ACTIVE-BOUNDS (`ghostel-input').
ACTIVE-BOUNDS is a (BOL . EOL) cons covering the cursor's line.
Historical input retains `ghostel-input' from when it was active but
stays linkifiable because it falls outside ACTIVE-BOUNDS."
(or (get-text-property pos 'help-echo)
(get-text-property pos 'ghostel-prompt)
(and active-bounds
(>= pos (car active-bounds))
(<= pos (cdr active-bounds))
(get-text-property pos 'ghostel-input))))

(defun ghostel--detect-urls (&optional begin end)
"Scan a buffer region for plain-text URLs and file:line references.
BEGIN and END default to `point-min' and `point-max' respectively.
Skips regions that already have a `help-echo' property (e.g. from OSC 8).
Skips regions that already have a `help-echo' property (e.g. from OSC 8)
and the user's active input on the current prompt line.
Bounding the scan keeps streaming output from re-scanning the entire
materialized scrollback on every redraw.
Binds `inhibit-read-only' so the scan can attach text properties even
when called from the deferred-detection timer outside the redraw scope."
(let ((begin (or begin (point-min)))
(end (or end (point-max)))
(inhibit-read-only t))
(let* ((begin (or begin (point-min)))
(end (or end (point-max)))
(inhibit-read-only t)
;; Point sits at the live terminal cursor after a redraw, so its
;; line is the prompt the user is currently editing. Capture as
;; buffer-position bounds so the per-match skip check is O(1).
(active-bounds (cons (line-beginning-position)
(line-end-position))))
Comment thread
dakra marked this conversation as resolved.
(save-excursion
;; Pass 1: http(s) URLs
(when ghostel-enable-url-detection
Expand All @@ -2057,7 +2079,7 @@ when called from the deferred-detection timer outside the redraw scope."
end t)
(let ((beg (match-beginning 0))
(mend (match-end 0)))
(unless (get-text-property beg 'help-echo)
(unless (ghostel--detect-urls-skip-p beg active-bounds)
(let ((url (match-string-no-properties 0)))
(put-text-property beg mend 'help-echo url)
(put-text-property beg mend 'mouse-face 'highlight)
Expand All @@ -2082,7 +2104,7 @@ when called from the deferred-detection timer outside the redraw scope."
(while (re-search-forward full-regex end t)
(let ((beg (match-beginning 1))
(mend (match-end 2)))
(unless (get-text-property beg 'help-echo)
(unless (ghostel--detect-urls-skip-p beg active-bounds)
(let* ((path (match-string-no-properties 1))
(loc (match-string-no-properties 2))
(abs-path (expand-file-name path))
Expand Down
1 change: 1 addition & 0 deletions src/emacs.zig
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ pub const Sym = struct {
keymap: Value,
@"ghostel-wrap": Value,
@"ghostel-prompt": Value,
@"ghostel-input": Value,

// Ghostel symbols
@"ghostel-link-map": Value,
Expand Down
80 changes: 66 additions & 14 deletions src/render.zig
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,10 @@ const RowContent = struct {
/// Number of leading characters that are semantic prompt content.
/// Zero if the row has no prompt cells.
prompt_char_len: usize,
/// Character range covering semantic-input cells (OSC 133 B..C).
/// `input_end_char == input_start_char` means no input cells on the row.
input_start_char: usize,
input_end_char: usize,
/// True when the row contains at least one wide (2-cell) character.
has_wide: bool,
};
Expand Down Expand Up @@ -296,6 +300,14 @@ fn buildRowContent(
var trim_char_len: usize = 0;
var prompt_char_len: usize = 0; // chars that are semantic prompt
var in_prompt: bool = true; // track contiguous leading prompt cells
// Track the smallest [start, end) char range covering input cells (OSC 133
// B..C). Set lazily on the first INPUT cell; subsequent INPUT cells extend
// `input_end_char`. Cells with other semantics between two input runs are
// included in the range — in practice INPUT cells are contiguous within a
// prompt line, so this never grows beyond the actual input span.
var input_start_char: usize = 0;
var input_end_char: usize = 0;
var saw_input: bool = false;
var has_wide: bool = false;
var current_style_key: ?CellStyleKey = null;
run_count.* = 0;
Expand All @@ -310,13 +322,22 @@ fn buildRowContent(
continue;
}

// Read the cell's semantic-content tag once and reuse it for both
// prompt-prefix and input-range tracking. Cells without an explicit
// OSC 133 marker default to OUTPUT, so we only do real work on rows
// that the shell has annotated.
var semantic: c_int = gt.c.GHOSTTY_CELL_SEMANTIC_OUTPUT;
_ = gt.c.ghostty_cell_get(raw_cell, gt.c.GHOSTTY_CELL_DATA_SEMANTIC_CONTENT, @ptrCast(&semantic));

// Track leading prompt characters via cell-level semantic content.
if (in_prompt) {
var semantic: c_int = 0; // GHOSTTY_CELL_SEMANTIC_OUTPUT
_ = gt.c.ghostty_cell_get(raw_cell, gt.c.GHOSTTY_CELL_DATA_SEMANTIC_CONTENT, @ptrCast(&semantic));
if (semantic != gt.c.GHOSTTY_CELL_SEMANTIC_PROMPT) {
in_prompt = false;
}
if (in_prompt and semantic != gt.c.GHOSTTY_CELL_SEMANTIC_PROMPT) {
in_prompt = false;
}

const cell_start_char = char_len;
if (semantic == gt.c.GHOSTTY_CELL_SEMANTIC_INPUT and !saw_input) {
input_start_char = cell_start_char;
saw_input = true;
}

// We use a "key" that holds a minimum set of values that are cheap to
Expand Down Expand Up @@ -353,6 +374,7 @@ fn buildRowContent(
char_len += 1;
}
if (in_prompt) prompt_char_len = char_len;
if (saw_input and semantic == gt.c.GHOSTTY_CELL_SEMANTIC_INPUT) input_end_char = char_len;
// Empty cells are blank for trim purposes unless their
// style has a visible attribute (e.g. colored background).
if (runs[run_count.* - 1].style != null) {
Expand All @@ -377,6 +399,7 @@ fn buildRowContent(
char_len += 1; // one codepoint = one Emacs character
}
if (in_prompt) prompt_char_len = char_len;
if (saw_input and semantic == gt.c.GHOSTTY_CELL_SEMANTIC_INPUT) input_end_char = char_len;
// Any cell that libghostty stored a grapheme for was written
// explicitly by the terminal, so it anchors the trim point —
// even if the grapheme happens to be a space (e.g. the space
Expand All @@ -387,15 +410,24 @@ fn buildRowContent(
trim_char_len = char_len;
}

// Trim trailing blank cells. Cap `prompt_char_len' at the new
// `char_len' so the "leading prompt" region never extends past the
// trimmed text. Style runs extending past the trim point are
// clipped by `insertAndStyle' via its `content.char_len' cap.
// Trim trailing blank cells. Cap `prompt_char_len' / input range at the
// new `char_len' so neither region extends past the trimmed text. Style
// runs extending past the trim point are clipped by `insertAndStyle' via
// its `content.char_len' cap.
text_len = trim_text_len;
char_len = trim_char_len;
if (prompt_char_len > char_len) prompt_char_len = char_len;
if (input_end_char > char_len) input_end_char = char_len;
if (input_start_char > input_end_char) input_start_char = input_end_char;

return .{ .byte_len = text_len, .char_len = char_len, .prompt_char_len = prompt_char_len, .has_wide = has_wide };
return .{
.byte_len = text_len,
.char_len = char_len,
.prompt_char_len = prompt_char_len,
.input_start_char = input_start_char,
.input_end_char = input_end_char,
.has_wide = has_wide,
};
}

/// Insert row text and apply style runs.
Expand Down Expand Up @@ -464,10 +496,30 @@ fn insertAndStyle(
env.t(),
);
} else if (isRowPrompt(term)) {
// When OSC 133 marked an input span on this row, paint
// `ghostel-prompt' only over the prefix preceding the input —
// otherwise the typed input gets covered too, defeating the
// historical-input linkification that the skip predicate is
// designed to allow (it short-circuits on `ghostel-prompt`).
const prompt_end: i64 = if (content.input_end_char > content.input_start_char)
row_start + @as(i64, @intCast(content.input_start_char))
else
after_insert - 1; // exclude trailing newline
if (prompt_end > row_start) {
env.putTextProperty(
env.makeInteger(row_start),
env.makeInteger(prompt_end),
emacs.sym.@"ghostel-prompt",
env.t(),
);
}
}

if (content.input_end_char > content.input_start_char) {
env.putTextProperty(
env.makeInteger(row_start),
env.makeInteger(after_insert - 1), // exclude trailing newline
emacs.sym.@"ghostel-prompt",
env.makeInteger(row_start + @as(i64, @intCast(content.input_start_char))),
env.makeInteger(row_start + @as(i64, @intCast(content.input_end_char))),
emacs.sym.@"ghostel-input",
env.t(),
);
}
Expand Down
Loading
Loading