diff --git a/etc/shell/ghostel.bash b/etc/shell/ghostel.bash index 2c6f6bf9..fafabb81 100644 --- a/etc/shell/ghostel.bash +++ b/etc/shell/ghostel.bash @@ -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 } @@ -62,7 +61,6 @@ __ghostel_wrapped_prompt_command() { __ghostel_prompt_start __ghostel_osc7 eval "${__ghostel_original_prompt_command:-}" - __ghostel_prompt_end __ghostel_in_prompt_command=0 } @@ -70,6 +68,24 @@ __ghostel_wrapped_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 diff --git a/etc/shell/ghostel.fish b/etc/shell/ghostel.fish index e6636727..95985e73 100644 --- a/etc/shell/ghostel.fish +++ b/etc/shell/ghostel.fish @@ -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 @@ -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" diff --git a/etc/shell/ghostel.zsh b/etc/shell/ghostel.zsh index 79fe3907..c2ef63b3 100644 --- a/etc/shell/ghostel.zsh +++ b/etc/shell/ghostel.zsh @@ -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 } @@ -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 diff --git a/lisp/ghostel.el b/lisp/ghostel.el index b774129c..1ba88423 100644 --- a/lisp/ghostel.el +++ b/lisp/ghostel.el @@ -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)))) (save-excursion ;; Pass 1: http(s) URLs (when ghostel-enable-url-detection @@ -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) @@ -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)) diff --git a/src/emacs.zig b/src/emacs.zig index b12cc337..82d5fad0 100644 --- a/src/emacs.zig +++ b/src/emacs.zig @@ -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, diff --git a/src/render.zig b/src/render.zig index eef82d80..a54afff2 100644 --- a/src/render.zig +++ b/src/render.zig @@ -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, }; @@ -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; @@ -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 @@ -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) { @@ -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 @@ -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. @@ -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(), ); } diff --git a/test/ghostel-test.el b/test/ghostel-test.el index e9ade41e..1877d16b 100644 --- a/test/ghostel-test.el +++ b/test/ghostel-test.el @@ -2496,6 +2496,86 @@ native URI lookup when Emacs invokes it for tooltip display or clicking." (should (equal test-file opened)) (should (null moved)))))) ; no line → no forward-line +(ert-deftest ghostel-test-detect-urls-skips-active-input () + "Link detection rules around prompts and user input (issue #199). +- `ghostel-prompt' (shell-generated decoration): never linkified. +- `ghostel-input' on the cursor's line (active typing): not linkified — + in tty Emacs RET on a linkified cell hijacks the keystroke. +- `ghostel-input' on other lines (historical typed commands): still + linkified, so users can follow paths in past commands. +- Output (no marker): always linkified." + (let ((test-file (locate-library "ghostel"))) + ;; History line → both file ref and URL linkified. Active line + ;; (cursor's line) → both skipped. Same `ghostel-input' coverage + ;; on both lines, only the cursor's line is protected. + (with-temp-buffer + (let ((default-directory (file-name-directory test-file))) + (insert (format "$ ls %s https://hist.example\n" test-file)) ; line 1: history + (insert (format "$ cat %s https://live.example" test-file)) ; line 2: active + (put-text-property (point-min) (point-max) 'ghostel-input t) + (goto-char (point-max)) ; cursor on line 2 + (let ((ghostel-enable-url-detection t) + (ghostel-enable-file-detection t)) + (ghostel--detect-urls)) + (goto-char (point-min)) + (search-forward test-file nil t) + (let ((he (get-text-property (match-beginning 0) 'help-echo))) + (should (and he (string-prefix-p "fileref:" he)))) ; history file → linked + (search-forward "https://hist.example") + (should (equal "https://hist.example" + (get-text-property (match-beginning 0) 'help-echo))) ; history URL → linked + (search-forward test-file nil t) + (should (null (get-text-property (match-beginning 0) 'help-echo))) ; active file → skipped + (search-forward "https://live.example") + (should (null (get-text-property (match-beginning 0) 'help-echo))))) ; active URL → skipped + ;; Intra-line partial skip: only the `ghostel-input'-marked span is + ;; protected, not the whole active line. A URL outside the span on + ;; the same line still gets linkified. No trailing newline — cursor + ;; stays on the typed line. + (with-temp-buffer + (insert "out https://before.example mid https://typed.example tail") + (let* ((typed (save-excursion + (goto-char (point-min)) + (search-forward "https://typed.example") + (cons (match-beginning 0) (match-end 0))))) + (put-text-property (car typed) (cdr typed) 'ghostel-input t)) + (goto-char (point-max)) ; cursor on line 1 + (let ((ghostel-enable-url-detection t) + (ghostel-enable-file-detection nil)) + (ghostel--detect-urls)) + (goto-char (point-min)) + (search-forward "https://before.example") + (should (equal "https://before.example" + (get-text-property (match-beginning 0) 'help-echo))) ; outside span → linked + (search-forward "https://typed.example") + (should (null (get-text-property (match-beginning 0) 'help-echo)))) ; inside span → skipped + ;; `ghostel-prompt' (prompt prefix) is never linkified — neither on + ;; the active line nor in scrollback. Path appears in the prompt's + ;; cwd display; output below is plain text and stays linkifiable. + (with-temp-buffer + (let ((default-directory (file-name-directory test-file))) + (insert (format "%s λ ls\n" test-file)) ; line 1: prompt prefix + (insert (format "%s\n" test-file)) ; line 2: output + (insert (format "%s λ " test-file)) ; line 3: live prompt + ;; Prompt rows carry `ghostel-prompt' on the prefix; output does not. + (save-excursion + (goto-char (point-min)) + (let ((eol (line-end-position))) + (put-text-property (point-min) eol 'ghostel-prompt t)) + (forward-line 2) + (put-text-property (point) (point-max) 'ghostel-prompt t)) + (goto-char (point-max)) + (let ((ghostel-enable-url-detection nil) + (ghostel-enable-file-detection t)) + (ghostel--detect-urls)) + (goto-char (point-min)) + (search-forward test-file nil t) ; line 1: prompt prefix + (should (null (get-text-property (match-beginning 0) 'help-echo))) + (search-forward test-file nil t) ; line 2: output + (should (get-text-property (match-beginning 0) 'help-echo)) + (search-forward test-file nil t) ; line 3: live prompt prefix + (should (null (get-text-property (match-beginning 0) 'help-echo))))))) + (ert-deftest ghostel-test-delayed-redraw-defers-plain-link-detection () "Redraw-triggered plain-text link detection should run after redraw." (let ((buf (generate-new-buffer " *ghostel-test-delayed-link*"))) @@ -2823,6 +2903,130 @@ native URI lookup when Emacs invokes it for tooltip display or clicking." (should (equal 0 (cdr (car ghostel--prompt-positions))))))) ; exit status stored (kill-buffer buf)))) +;; ----------------------------------------------------------------------- +;; Test: OSC 133 input cells get ghostel-input property +;; ----------------------------------------------------------------------- + +(ert-deftest ghostel-test-osc133-input-text-property () + "Cells between OSC 133 B and C should be marked `ghostel-input'. +This is what keeps `ghostel--detect-urls' from linkifying the user's +in-progress command line — the renderer marks input cells, the elisp +scanner skips them." + (let ((buf (generate-new-buffer " *ghostel-test-osc133-input*"))) + (unwind-protect + (with-current-buffer buf + (let* ((term (ghostel--new 5 40 100)) + (inhibit-read-only t)) + (ghostel--write-input + term "\e]133;A\e\\$ \e]133;B\e\\cd src/main.rs") + (ghostel--redraw term) + (goto-char (point-min)) + (should (search-forward "cd src/main.rs" nil t)) + (let ((path-beg (- (point) (length "cd src/main.rs"))) + (path-end (point))) + (should (get-text-property path-beg 'ghostel-input)) + (should (get-text-property (1- path-end) 'ghostel-input)) + ;; The "$ " prompt prefix should NOT be marked as input. + (should (null (get-text-property + (point-min) 'ghostel-input)))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-osc133-prompt-stops-at-input () + "`ghostel-prompt' must end where `ghostel-input' begins on the row. +Without this, the historical prompt row carries `ghostel-prompt' +across the typed command, and `ghostel--detect-urls-skip-p' refuses +to linkify paths in past commands — even though they are outside the +active input range." + (let ((buf (generate-new-buffer " *ghostel-test-osc133-prompt-input*"))) + (unwind-protect + (with-current-buffer buf + (let* ((term (ghostel--new 5 40 100)) + (inhibit-read-only t)) + (ghostel--write-input + term "\e]133;A\e\\$ \e]133;B\e\\ls /etc/hosts") + (ghostel--redraw term) + (goto-char (point-min)) + (should (search-forward "ls /etc/hosts" nil t)) + (let ((path-beg (- (point) (length "ls /etc/hosts"))) + (path-end (point))) + (should (get-text-property 1 'ghostel-prompt)) ; "$" + (should (get-text-property 2 'ghostel-prompt)) ; " " + (should (null (get-text-property path-beg 'ghostel-prompt))) + (should (null (get-text-property (1- path-end) 'ghostel-prompt))) + (should (get-text-property path-beg 'ghostel-input)) + (should (get-text-property (1- path-end) 'ghostel-input))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-osc133-historical-input-linkifies () + "Historical typed commands keep their links after the prompt advances. +After the row scrolls past the active prompt the typed `/etc/hosts' +must gain a `fileref:' help-echo when `ghostel--detect-urls' runs. +Active prompt rows must NOT — RET on a linkified active-input cell +hijacks the keystroke in tty Emacs." + (let ((buf (generate-new-buffer " *ghostel-test-osc133-historical-link*")) + (target "/etc/hosts")) + (skip-unless (file-exists-p target)) + (unwind-protect + (with-current-buffer buf + (let* ((term (ghostel--new 5 40 100)) + (inhibit-read-only t) + (ghostel-enable-url-detection nil) + (ghostel-enable-file-detection t)) + ;; First prompt: still the active row. Cursor is on the typed + ;; line, so the path inside the input span must NOT be + ;; linkified. + (ghostel--write-input + term (format "\e]133;A\e\\$ \e]133;B\e\\ls %s" target)) + (ghostel--redraw term) + (let ((path-beg (save-excursion + (goto-char (point-min)) + (search-forward target) + (- (point) (length target))))) + ;; Point sits at the live cursor after redraw; that is the + ;; row `ghostel--detect-urls' treats as active. Don't move it. + (ghostel--detect-urls) + (should (null (get-text-property path-beg 'help-echo)))) + + ;; End the input, advance to a fresh prompt — the previous row + ;; is now history. Its `/etc/hosts' should become a fileref. + (ghostel--write-input + term "\e]133;C\e\\\r\n\e]133;D;0\e\\\e]133;A\e\\$ \e]133;B\e\\") + (ghostel--redraw term) + (let ((path-beg (save-excursion + (goto-char (point-min)) + (search-forward target) + (- (point) (length target))))) + (ghostel--detect-urls) + (let ((he (get-text-property path-beg 'help-echo))) + (should (and he (string-prefix-p "fileref:" he))))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-osc133-input-wide-char-boundary () + "Wide input chars take one Emacs char of `ghostel-input'. +The libghostty spacer-tail cell that follows a wide char produces +no Emacs char and must not extend the property region. Trailing +narrow input after the wide char keeps growing the region." + (let ((buf (generate-new-buffer " *ghostel-test-osc133-input-wide*"))) + (unwind-protect + (with-current-buffer buf + (let* ((term (ghostel--new 5 40 100)) + (inhibit-read-only t)) + (ghostel--write-input + term "\e]133;A\e\\$ \e]133;B\e\\日a") + (ghostel--redraw term) + (goto-char (point-min)) + (should (search-forward "日a" nil t)) + ;; "$ " is 2 narrow cells (positions 1-2); "日" is wide + ;; (1 emacs char at position 3, occupying terminal cols 2-3); + ;; "a" is narrow (position 4, terminal col 4). + (should (null (get-text-property 1 'ghostel-input))) ; "$" + (should (null (get-text-property 2 'ghostel-input))) ; " " + (should (get-text-property 3 'ghostel-input)) ; 日 + (should (get-text-property 4 'ghostel-input)) ; a + ;; The newline after "a" is past the input range. + (should (null (get-text-property 5 'ghostel-input))))) + (kill-buffer buf)))) + ;; ----------------------------------------------------------------------- ;; Test: ghostel-command-finish-functions hook ;; -----------------------------------------------------------------------