diff --git a/.claude b/.claude index bf956c08..4bf5762d 160000 --- a/.claude +++ b/.claude @@ -1 +1 @@ -Subproject commit bf956c083736fdf34445e574c8940bac73c69105 +Subproject commit 4bf5762d8df47fddecdedde9c0f23f44723efb16 diff --git a/USER_DOC.md b/USER_DOC.md index ee39d119..63ff105c 100644 --- a/USER_DOC.md +++ b/USER_DOC.md @@ -114,6 +114,12 @@ What is live at every render, and what is not: | `$((…))` | yes | | `$(…)` and backquotes | **no** — the text stays on screen as written; it would fork on every redraw | +**Preview caveat:** `print -P` formats its expanded input again using zsh's +prompt escapes. If a variable contains `%` sequences, `print -rP "[${V}]"` +can interpret them as prompt escapes even though the live prompt leaves the +expanded value literal. Do not treat `print -P` output as an exact preview of +variable-backed prompt segments; this mismatch is tracked in [issue #134](https://github.com/Univers42/hellish/issues/134). + For a computed segment, set a variable from a hook and put the variable in the prompt. `HELLISH_PRECMD_FUNCS` runs once before each prompt (`hellishrc.example`, section 5b): @@ -201,7 +207,24 @@ newest history line that starts with what you have typed appears dim after the cursor. It is a hint, not part of the line: **Right**, **End** or **Ctrl-F** at the end of the line insert it; anywhere else, and when there is no hint, those keys do what they always did. Enter runs only what you -typed, and the hint is wiped from the screen. `unset HELLISH_AUTOSUGGEST` +typed, and the hint is wiped from the screen. + +| key | with a hint | without one | +|---|---|---| +| Right, End, Ctrl-F | insert all of it | forward-char / end-of-line | +| Alt-F, Ctrl-Right | insert its next word; the rest stays as the hint | forward-word | +| Alt-N | show the next older, different matching line | non-incremental forward search | +| Alt-P | go back to the newer one shown before | non-incremental reverse search | + +A word is the blanks at the front of the hint plus the run of characters up +to the next space or tab (so `-m`, `a/b`, `x=1` are one word each). Alt-N +and Alt-P step through distinct lines (same full text offered once), look +at no more than `HELLISH_SUGGEST_MAX` entries, ring the bell at the oldest +and the newest end instead of wrapping, and start over from the newest +match as soon as the line is edited. Right after Alt-N inserts the line +shown, not the newest. A hint a zle widget set as `POSTDISPLAY` is not +cycled or taken by words. In vi insert mode Alt-F / Alt-N / Alt-P work the +same (no Ctrl-Right). `unset HELLISH_AUTOSUGGEST` (or `=0`) turns it off at the next prompt. A key you bound yourself (`bind` / `bindkey` / `.inputrc`) is never taken over. diff --git a/docker/Dockerfile b/docker/Dockerfile index 3a1b97e3..8fea9287 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -44,6 +44,15 @@ FROM ${BASE} # in was a red rung on a commit the rung had built fine an hour earlier. # A mirror that keeps failing four times still fails the build -- it is # the transient, not the broken, that is retried. +# One repo is the exception: when openSUSE-Tumbleweed-Update still will not +# refresh after the retries, it is disabled. A plain install can live with a +# stale repo, but `zypper dup` refuses to run while ANY enabled repo failed +# to refresh, so one unreachable mirror for that repo (which only carries +# rare urgent patches; Tumbleweed itself ships through Oss) turned the +# fallback into a guaranteed red rung after #169 merged, 30 min of +# "Curl error (28)" with the code never compiled. modifyrepo takes the +# alias (repo-update), not the display name; no `|| true`, so an image that +# renames the alias fails loudly instead of silently disabling nothing. # apt gets the same loop, update included: an Ubuntu mirror mid-sync serves # an index that disagrees with its files ("File has unexpected size ... # Mirror sync in progress?", or a 404 on a .deb the index already moved @@ -74,7 +83,8 @@ RUN set -eux; \ elif command -v zypper >/dev/null 2>&1; then \ zretry() { n=0; until "$@"; do n=$((n + 1)); \ [ "$n" -ge 4 ] && return 1; sleep 20; done; }; \ - zretry zypper --non-interactive --gpg-auto-import-keys refresh || true; \ + zretry zypper --non-interactive --gpg-auto-import-keys refresh \ + || zypper --non-interactive modifyrepo --disable repo-update; \ pkgs="gcc clang make readline-devel ncurses-devel git-core bash \ python3 tar gzip which findutils diffutils"; \ if ! zretry zypper --non-interactive install $pkgs; then \ diff --git a/incs/prompt.h b/incs/prompt.h index 6468323a..8eafbe4d 100644 --- a/incs/prompt.h +++ b/incs/prompt.h @@ -103,10 +103,14 @@ typedef struct s_rl char *sg_ent; /* history entry the suggestion comes from */ char *sg_prev; /* the line sg_ent was searched for */ bool sg_known; /* sg_prev / sg_ent describe a finished scan */ + size_t sg_hn; /* history length that scan (and any cycle) saw */ char *sg_txt; /* ghost for the line on screen, or NULL */ char *sg_post; /* POSTDISPLAY a widget set (zle_rl5.c) */ char *sg_post_at; /* the line it was set for */ char *sg_pub; /* what a widget was handed as POSTDISPLAY */ + t_vec sg_cyc; /* history indices offered (size_t) */ + size_t sg_cyc_k; /* which of them is on screen */ + char *sg_cyc_line; /* line the cycle is for; NULL: none */ bool sg_painted; /* a ghost is on screen right now */ int sg_col; /* column it starts at */ /* --- RPROMPT: rendered here, painted by the editor (rl_rprompt.c) --- */ diff --git a/src/infrastructure/rl_private.h b/src/infrastructure/rl_private.h index 97d7f67f..b3f31bbd 100644 --- a/src/infrastructure/rl_private.h +++ b/src/infrastructure/rl_private.h @@ -47,7 +47,7 @@ void rl_preinit(t_rl *l); (rl_slot.c). A slot is one key sequence in one keymap; `fn` names the stock function it holds and the one that replaces it. */ # define RL_NFEAT 2 -# define RL_NSLOTS 17 +# define RL_NSLOTS 25 enum e_feat { @@ -60,7 +60,10 @@ enum e_slot_fn SLOT_PREV, SLOT_NEXT, SLOT_FWD, - SLOT_END + SLOT_END, + SLOT_WORD, + SLOT_OLDER, + SLOT_NEWER }; typedef struct s_slot @@ -72,6 +75,9 @@ typedef struct s_slot } t_slot; t_slot rl_slot_at(int i); +t_slot rl_slot_extra(int j); +void rl_slot_funcs(const t_slot *s, rl_command_func_t **stock, + rl_command_func_t **repl); void rl_slot_sync(t_shell *state); void rl_slot_pre(t_rl *l); void rl_slot_post(t_rl *l); @@ -93,6 +99,14 @@ char *rl_suggest_update(t_shell *state, int cols); char *rl_suggest_live(t_rl *l); char *rl_suggest_find(t_shell *state, t_rl *l); int rl_suggest_accept(int count, int key); +t_shell *rl_suggest_ghost_state(void); +int rl_suggest_bell(void); +int rl_suggest_word(int count, int key); +int rl_suggest_older(int count, int key); +int rl_suggest_newer(int count, int key); +ssize_t rl_suggest_scan(t_shell *state, t_rl *l, size_t from); +void rl_suggest_cyc_clear(t_rl *l); +bool rl_suggest_cyc_sync(t_shell *state, t_rl *l); void rl_suggest_end(t_shell *state); void rl_suggest_stale(void); void rl_suggest_unpaint(void); diff --git a/src/platform/posix/rl_slot.c b/src/platform/posix/rl_slot.c index 199428b3..d05d705b 100644 --- a/src/platform/posix/rl_slot.c +++ b/src/platform/posix/rl_slot.c @@ -20,55 +20,6 @@ ** again (slot_user). The arrows come in two encodings (CSI and the ** application-cursor SS3 form) and two keymaps, hence the rows. */ -/* The whole table, by index: 0-7 are the history keys (Up / Down, in the - CSI and SS3 encodings, emacs then vi), 8-12 the emacs accept keys and - 13-16 the vi ones (which have no ^F). `vi` picks vi_insertion_keymap - over the emacs one. */ -t_slot rl_slot_at(int i) -{ - static const char *keys[] = {"\033[A", "\033[B", "\033OA", "\033OB", - "\033[C", "\033OC", "\006", "\033[F", "\033OF"}; - t_slot s; - int k; - int j; - - s.feat = FEAT_HIST; - s.vi = i / 4; - s.key = keys[i % 4]; - s.fn = SLOT_PREV + i % 2; - if (i < 8) - return (s); - j = i - 8; - k = 4 + j; - s.feat = FEAT_ACCEPT; - s.vi = j > 4; - if (s.vi) - k = j - 1 + (j > 6); - s.key = keys[k]; - s.fn = SLOT_FWD + (k > 6); - return (s); -} - -/* The stock function of a slot, and what replaces it while its feature - is on. */ -static void rl_slot_funcs(const t_slot *s, rl_command_func_t **stock, - rl_command_func_t **repl) -{ - *stock = rl_get_previous_history; - *repl = rl_history_search_backward; - if (s->fn == SLOT_NEXT) - { - *stock = rl_get_next_history; - *repl = rl_history_search_forward; - } - if (s->fn == SLOT_FWD || s->fn == SLOT_END) - *repl = rl_suggest_accept; - if (s->fn == SLOT_FWD) - *stock = rl_forward_char; - if (s->fn == SLOT_END) - *stock = rl_end_of_line; -} - /* A marker no user can bind: a slot still holding it after the user's bind lines ran was not touched by them. */ static int rl_slot_mark(int count, int key) diff --git a/src/platform/posix/rl_slot_tab.c b/src/platform/posix/rl_slot_tab.c new file mode 100644 index 00000000..1df3c412 --- /dev/null +++ b/src/platform/posix/rl_slot_tab.c @@ -0,0 +1,89 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* rl_slot_tab.c :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: dlesieur +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/10/17 12:00:00 by dlesieur #+# #+# */ +/* Updated: 2026/10/17 12:00:00 by dlesieur ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include "rl_private.h" + +/* The table of slots (rl_slot.c holds the logic that binds them). */ + +/* The whole table, by index: 0-7 are the history keys (Up / Down, in the + CSI and SS3 encodings, emacs then vi), 8-12 the emacs accept keys and + 13-16 the vi ones (which have no ^F); 17-24 are the word and cycle keys + (rl_slot_extra). `vi` picks vi_insertion_keymap over the emacs one. */ +t_slot rl_slot_at(int i) +{ + static const char *keys[] = {"\033[A", "\033[B", "\033OA", "\033OB", + "\033[C", "\033OC", "\006", "\033[F", "\033OF"}; + t_slot s; + int k; + int j; + + if (i >= 17) + return (rl_slot_extra(i - 17)); + s.feat = FEAT_HIST; + s.vi = i / 4; + s.key = keys[i % 4]; + s.fn = SLOT_PREV + i % 2; + if (i < 8) + return (s); + j = i - 8; + k = 4 + j; + s.feat = FEAT_ACCEPT; + s.vi = j > 4; + if (s.vi) + k = j - 1 + (j > 6); + s.key = keys[k]; + s.fn = SLOT_FWD + (k > 6); + return (s); +} + +/* Slots 17-24: Alt-F, Ctrl-Right (two encodings) take a word of the + ghost, Alt-N / Alt-P cycle it -- 0-4 in the emacs keymap, 5-7 (Alt-F, + Alt-N, Alt-P) in vi insert mode. `\033O5C` is left alone: readline has + nothing on it, so there is no stock function to give back. */ +t_slot rl_slot_extra(int j) +{ + static const char *keys[] = {"\033f", "\033[1;5C", "\033[5C", + "\033n", "\033p"}; + t_slot s; + int k; + + k = j; + if (j == 5) + k = 0; + if (j > 5) + k = j - 3; + s.feat = FEAT_ACCEPT; + s.vi = j > 4; + s.key = keys[k]; + s.fn = SLOT_WORD + (k > 2) + (k > 3); + return (s); +} + +/* The stock function of a slot, and what replaces it while its feature + is on. In vi insert mode the Alt keys have no stock function (ESC + leaves insert mode), so there the slot is unbound while off. */ +void rl_slot_funcs(const t_slot *s, rl_command_func_t **stock, + rl_command_func_t **repl) +{ + static rl_command_func_t *const stk[] = {rl_get_previous_history, + rl_get_next_history, rl_forward_char, rl_end_of_line, + rl_forward_word, rl_noninc_forward_search, + rl_noninc_reverse_search}; + static rl_command_func_t *const rep[] = {rl_history_search_backward, + rl_history_search_forward, rl_suggest_accept, rl_suggest_accept, + rl_suggest_word, rl_suggest_older, rl_suggest_newer}; + + *stock = stk[s->fn]; + *repl = rep[s->fn]; + if (s->vi && s->fn >= SLOT_WORD) + *stock = NULL; +} diff --git a/src/platform/posix/rl_suggest.c b/src/platform/posix/rl_suggest.c index d0492120..c74a0a5d 100644 --- a/src/platform/posix/rl_suggest.c +++ b/src/platform/posix/rl_suggest.c @@ -23,45 +23,30 @@ ** is a C1 control in its UTF-8 form: running `printf '\e[31m'` once must ** not repaint every later prompt. */ -/* True when `s` holds nothing a terminal would interpret. 0x7f is DEL; the - pair 0xC2 0x80..0x9F is U+0080..U+009F, which some terminals read as a - control sequence introducer. */ -static bool sg_clean(const unsigned char *s) +/* The newest of the last sg_max entries that starts with the first `n` + bytes of the line, is longer than it and is clean (rl_suggest_scan). */ +static char *sg_scan(t_shell *state, t_rl *l) { - size_t i; + ssize_t i; - i = 0; - while (s[i]) - { - if (s[i] < 0x20 || s[i] == 0x7f) - return (false); - if (s[i] == 0xc2 && s[i + 1] >= 0x80 && s[i + 1] < 0xa0) - return (false); - i++; - } - return (true); + i = rl_suggest_scan(state, l, state->hist.hist_cmds.len); + if (i < 0) + return (NULL); + return (((char **)state->hist.hist_cmds.ctx)[i]); } -/* The newest of the last sg_max entries that starts with the first `n` - bytes of the line, is longer than it and is clean. A bounded look keeps - a huge history from slowing a keystroke down. */ -static char *sg_scan(t_shell *state, t_rl *l, size_t n) +/* True when the last scan still answers for the line: the line extends the + one it was for and the entry still matches (or there was none). */ +static bool sg_cached(t_rl *l, size_t n) { - char **a; - size_t i; - size_t seen; + size_t p; - a = (char **)state->hist.hist_cmds.ctx; - i = state->hist.hist_cmds.len; - seen = 0; - while (i > 0 && seen++ < (size_t)l->sg_max) - { - i--; - if (a[i] && !ft_strncmp(a[i], rl_line_buffer, n) - && ft_strlen(a[i]) > n && sg_clean((unsigned char *)a[i])) - return (a[i]); - } - return (NULL); + if (!l->sg_known) + return (false); + p = ft_strlen(l->sg_prev); + return (p <= n && !ft_strncmp(rl_line_buffer, l->sg_prev, p) + && (!l->sg_ent || (ft_strlen(l->sg_ent) > n + && !ft_strncmp(l->sg_ent, rl_line_buffer, n)))); } /* The entry to suggest for the line, or NULL. @@ -75,26 +60,20 @@ static char *sg_scan(t_shell *state, t_rl *l, size_t n) ** it: it only grows between reads, and the cache is dropped per read. */ char *rl_suggest_find(t_shell *state, t_rl *l) { - size_t n; - size_t p; char *e; - n = (size_t)rl_end; - p = 0; - if (l->sg_known) - p = ft_strlen(l->sg_prev); - if (l->sg_known && p <= n && !ft_strncmp(rl_line_buffer, l->sg_prev, p) - && (!l->sg_ent || (ft_strlen(l->sg_ent) > n - && !ft_strncmp(l->sg_ent, rl_line_buffer, n)))) + rl_suggest_cyc_sync(state, l); + if (sg_cached(l, (size_t)rl_end)) return (l->sg_ent); xfree(l->sg_prev); xfree(l->sg_ent); - e = sg_scan(state, l, n); + e = sg_scan(state, l); l->sg_ent = NULL; if (e) l->sg_ent = ft_strdup(e); l->sg_prev = ft_strdup(rl_line_buffer); l->sg_known = true; + l->sg_hn = state->hist.hist_cmds.len; return (l->sg_ent); } diff --git a/src/platform/posix/rl_suggest2.c b/src/platform/posix/rl_suggest2.c index e8b6e5f8..628f734e 100644 --- a/src/platform/posix/rl_suggest2.c +++ b/src/platform/posix/rl_suggest2.c @@ -57,7 +57,9 @@ char *rl_suggest_update(t_shell *state, int cols) /* What the accept keys may insert right now: the ghost of the last frame, if the line still is what it was drawn for. Keys can arrive faster than - frames (a paste), so the entry is matched against the line again. */ + frames (a paste), so the entry is matched against the line again -- and + an alternative shown by a cycle belongs to the line it was cycled for, + not to a longer one typed since. */ char *rl_suggest_live(t_rl *l) { size_t n; @@ -65,6 +67,8 @@ char *rl_suggest_live(t_rl *l) n = (size_t)rl_end; if (!l->sg_txt || rl_point != rl_end) return (NULL); + if (l->sg_cyc_line && ft_strcmp(l->sg_cyc_line, rl_line_buffer)) + return (NULL); if (l->sg_post && !ft_strcmp(l->sg_post_at, rl_line_buffer)) return (l->sg_post); if (!l->sg_post && l->sg_ent && ft_strlen(l->sg_ent) > n diff --git a/src/platform/posix/rl_suggest4.c b/src/platform/posix/rl_suggest4.c index b3781c61..1dd645ab 100644 --- a/src/platform/posix/rl_suggest4.c +++ b/src/platform/posix/rl_suggest4.c @@ -64,6 +64,7 @@ void rl_suggest_end(t_shell *state) xfree(l->sg_post); xfree(l->sg_post_at); xfree(l->sg_pub); + rl_suggest_cyc_clear(l); l->sg_ent = NULL; l->sg_prev = NULL; l->sg_post = NULL; diff --git a/src/platform/posix/rl_suggest5.c b/src/platform/posix/rl_suggest5.c new file mode 100644 index 00000000..484134c1 --- /dev/null +++ b/src/platform/posix/rl_suggest5.c @@ -0,0 +1,120 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* rl_suggest5.c :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: dlesieur +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/10/17 12:00:00 by dlesieur #+# #+# */ +/* Updated: 2026/10/17 12:00:00 by dlesieur ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include "rl_private.h" + +/* Scanning the history for suggestions, and the state of Alt-N / Alt-P +** cycling. A cycle remembers the history INDICES it has shown (sg_cyc, a +** stack: Alt-N pushes the next older distinct one, Alt-P pops back), so +** going back to a newer alternative costs nothing and a revisit never +** re-scans. */ + +/* True when `s` holds nothing a terminal would interpret. 0x7f is DEL; the + pair 0xC2 0x80..0x9F is U+0080..U+009F, which some terminals read as a + control sequence introducer. */ +static bool sg_clean(const unsigned char *s) +{ + size_t i; + + i = 0; + while (s[i]) + { + if (s[i] < 0x20 || s[i] == 0x7f) + return (false); + if (s[i] == 0xc2 && s[i + 1] >= 0x80 && s[i + 1] < 0xa0) + return (false); + i++; + } + return (true); +} + +/* True when an entry with the same full text was already offered in this + cycle. Distinct means distinct text: history keeps duplicates, and + stepping through three copies of one command is not a choice. */ +static bool sg_offered(t_shell *state, t_rl *l, const char *text) +{ + char **a; + size_t *v; + size_t i; + + a = (char **)state->hist.hist_cmds.ctx; + v = (size_t *)l->sg_cyc.ctx; + i = 0; + while (i < l->sg_cyc.len) + { + if (!ft_strcmp(a[v[i]], text)) + return (true); + i++; + } + return (false); +} + +/* The index of the next older entry below `from` that starts with the +** line, is longer than it, is clean and was not offered yet; -1 if none. +** Only the newest sg_max entries are looked at (HELLISH_SUGGEST_MAX), so a +** huge history cannot slow a key down. +** +** Cost: O(sg_max * (line + offered)) per call, offered being the entries +** shown so far in this cycle -- a handful in practice. */ +ssize_t rl_suggest_scan(t_shell *state, t_rl *l, size_t from) +{ + char **a; + size_t floor; + size_t n; + + a = (char **)state->hist.hist_cmds.ctx; + n = (size_t)rl_end; + floor = 0; + if (state->hist.hist_cmds.len > (size_t)l->sg_max) + floor = state->hist.hist_cmds.len - (size_t)l->sg_max; + while (from > floor) + { + from--; + if (a[from] && !ft_strncmp(a[from], rl_line_buffer, n) + && ft_strlen(a[from]) > n && sg_clean((unsigned char *)a[from]) + && !sg_offered(state, l, a[from])) + return ((ssize_t)from); + } + return (-1); +} + +/* Forget the cycle (not the entry on screen). */ +void rl_suggest_cyc_clear(t_rl *l) +{ + xfree(l->sg_cyc.ctx); + l->sg_cyc.ctx = NULL; + l->sg_cyc.len = 0; + l->sg_cyc.cap = 0; + l->sg_cyc_k = 0; + xfree(l->sg_cyc_line); + l->sg_cyc_line = NULL; +} + +/* Any change to the line ends a cycle, and the shown alternative with it: +** the cached scan result is dropped too, or the fast path of +** rl_suggest_find would keep serving the alternative for the longer line +** instead of the newest match. So does a change in the history's length: +** a bind -x widget can run `history -c` mid-line, and the cycle's indices +** would then point past the end. True when it did reset. +** +** Caveat: only the length is compared, so a widget that deletes one entry +** and adds another keeps a cycle whose indices are in bounds but may name +** different text until the line changes. */ +bool rl_suggest_cyc_sync(t_shell *state, t_rl *l) +{ + if (l->sg_hn == state->hist.hist_cmds.len && (!l->sg_cyc_line + || !ft_strcmp(l->sg_cyc_line, rl_line_buffer))) + return (false); + rl_suggest_cyc_clear(l); + l->sg_known = false; + return (true); +} diff --git a/src/platform/posix/rl_suggest6.c b/src/platform/posix/rl_suggest6.c new file mode 100644 index 00000000..a2d3f4d3 --- /dev/null +++ b/src/platform/posix/rl_suggest6.c @@ -0,0 +1,67 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* rl_suggest6.c :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: dlesieur +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/10/17 12:00:00 by dlesieur #+# #+# */ +/* Updated: 2026/10/17 12:00:00 by dlesieur ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include "rl_private.h" +#include "zle.h" + +/* Partial accept (Alt-F, Ctrl-Right) and cycling (Alt-N, Alt-P) of the + inline suggestion. Each falls back to the key's stock readline function + when there is no history ghost to act on. */ + +/* The shell state when a history ghost is on screen and can be acted on: + a ghost a widget set as POSTDISPLAY is not an entry of the history, so + it is neither cycled nor taken a word at a time. */ +t_shell *rl_suggest_ghost_state(void) +{ + t_shell **cell; + + cell = zle_state_cell(); + if (!*cell || (*cell)->rl.sg_post || !rl_suggest_live(&(*cell)->rl)) + return (NULL); + return (*cell); +} + +/* Ring the bell for a key with nowhere to go; the key itself succeeds. */ +int rl_suggest_bell(void) +{ + rl_ding(); + return (0); +} + +/* Alt-F / Ctrl-Right: take the next word of the ghost into the line. A +** word is the blanks (space, tab) at the front of the ghost and the run of +** non-blank bytes after them; nothing else separates words, so punctuation +** stays inside one (`-m`, `a/b`, `x=1`). UTF-8 needs no care: no multibyte +** sequence holds 0x20 or 0x09. The rest stays the ghost: the cached entry +** still matches the longer line, so rl_suggest_find returns it at once. */ +int rl_suggest_word(int count, int key) +{ + t_shell *state; + char *g; + char *w; + size_t n; + + state = rl_suggest_ghost_state(); + if (!state) + return (rl_forward_word(count, key)); + g = rl_suggest_live(&state->rl); + n = 0; + while (g[n] == ' ' || g[n] == '\t') + n++; + while (g[n] && g[n] != ' ' && g[n] != '\t') + n++; + w = ft_strndup(g, n); + rl_suggest_cyc_clear(&state->rl); + rl_insert_text(w); + xfree(w); + return (0); +} diff --git a/src/platform/posix/rl_suggest7.c b/src/platform/posix/rl_suggest7.c new file mode 100644 index 00000000..139f4933 --- /dev/null +++ b/src/platform/posix/rl_suggest7.c @@ -0,0 +1,102 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* rl_suggest7.c :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: dlesieur +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/10/17 12:00:00 by dlesieur #+# #+# */ +/* Updated: 2026/10/17 12:00:00 by dlesieur ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include "rl_private.h" + +/* Cycling: Alt-N and Alt-P. */ + +/* Put the entry at history index `idx` on screen as the ghost. */ +static void sg_show(t_shell *state, size_t idx) +{ + t_rl *l; + char **a; + + l = &state->rl; + a = (char **)state->hist.hist_cmds.ctx; + xfree(l->sg_ent); + l->sg_ent = ft_strdup(a[idx]); + l->sg_txt = l->sg_ent + rl_end; +} + +/* Start a cycle on the ghost on screen: its index is found again (it is + the newest match, see rl_suggest_find) so that only indices are kept. */ +static bool sg_cyc_begin(t_shell *state, t_rl *l) +{ + ssize_t i; + size_t idx; + + i = rl_suggest_scan(state, l, state->hist.hist_cmds.len); + if (i < 0) + return (false); + idx = (size_t)i; + l->sg_cyc.elem_size = sizeof(size_t); + vec_push(&l->sg_cyc, &idx); + l->sg_cyc_k = 0; + l->sg_cyc_line = ft_strdup(rl_line_buffer); + return (true); +} + +/* The next older distinct match, pushed on the cycle. False at the oldest + one: there is nothing more. */ +static bool sg_push_older(t_shell *state, t_rl *l) +{ + ssize_t i; + size_t idx; + + idx = ((size_t *)l->sg_cyc.ctx)[l->sg_cyc.len - 1]; + i = rl_suggest_scan(state, l, idx); + if (i < 0) + return (false); + idx = (size_t)i; + vec_push(&l->sg_cyc, &idx); + return (true); +} + +/* Alt-N: the next older distinct match. At the oldest one there is + nothing more: bell, and the ghost stays (no wrap). */ +int rl_suggest_older(int count, int key) +{ + t_shell *state; + t_rl *l; + + state = rl_suggest_ghost_state(); + if (!state) + return (rl_noninc_forward_search(count, key)); + l = &state->rl; + rl_suggest_cyc_sync(state, l); + if (!l->sg_cyc_line && !sg_cyc_begin(state, l)) + return (rl_suggest_bell()); + if (l->sg_cyc_k + 1 >= l->sg_cyc.len && !sg_push_older(state, l)) + return (rl_suggest_bell()); + l->sg_cyc_k++; + sg_show(state, ((size_t *)l->sg_cyc.ctx)[l->sg_cyc_k]); + return (0); +} + +/* Alt-P: back to the newer alternative shown before. At the newest match + there is nothing newer: bell. */ +int rl_suggest_newer(int count, int key) +{ + t_shell *state; + t_rl *l; + + state = rl_suggest_ghost_state(); + if (!state) + return (rl_noninc_reverse_search(count, key)); + l = &state->rl; + rl_suggest_cyc_sync(state, l); + if (!l->sg_cyc_line || l->sg_cyc_k == 0) + return (rl_suggest_bell()); + l->sg_cyc_k--; + sg_show(state, ((size_t *)l->sg_cyc.ctx)[l->sg_cyc_k]); + return (0); +} diff --git a/tests/autosuggest_test.py b/tests/autosuggest_test.py index a7765f87..5add7bf8 100644 --- a/tests/autosuggest_test.py +++ b/tests/autosuggest_test.py @@ -11,6 +11,11 @@ Cells: both readers (in-process and HELLISH_RL_FORK=1). Each case gets a fresh shell and a history file of its own. +Partial accept (Alt-F, Ctrl-Right): the ghost's leading blanks plus the +next run of non-blank bytes. Cycling (Alt-N older, Alt-P newer): distinct +matching entries, no wrap (bell at either end), reset by any edit. With no +history ghost these keys are their stock readline functions. + Usage: python3 autosuggest_test.py /path/to/hellish [case-name-part [inproc]] """ import fcntl @@ -35,6 +40,9 @@ RIGHT, RIGHT_O = "\x1b[C", "\x1bOC" END, END_O = "\x1b[F", "\x1bOF" LEFT = "\x1b[D" +ALT_F, ALT_N, ALT_P = "\x1bf", "\x1bn", "\x1bp" +CTRL_RIGHT = "\x1b[1;5C" +CTRL_A, CTRL_E = "\x01", "\x05" CTRL_F, CTRL_U, CTRL_C, CTRL_D, CTRL_R, CTRL_G = ( "\x06", "\x15", "\x03", "\x04", "\x12", "\x07") BKSP = "\x7f" @@ -594,12 +602,360 @@ def case_dumb(fork, tag): s.close() + +def case_word(fork, tag): + s = shell(fork, ["git commit -m msg"]) + s.keys("git") + check(tag + "24 W1 ghost before", s.ghost() == " commit -m msg", + repr(s.ghost())) + s.keys(ALT_F) + check(tag + "24 W1 Alt-F takes ' commit'", + s.row() == PROMPT + "git commit -m msg" and s.x() == PW + 10 + and s.ghost() == " -m msg", "x=%d %r" % (s.x(), s.ghost())) + s.keys(ALT_F) + check(tag + "24 W1 Alt-F again takes ' -m'", + s.x() == PW + 13 and s.ghost() == " msg", + "x=%d %r" % (s.x(), s.ghost())) + s.keys(ALT_F) + check(tag + "24 W1 last word, no ghost left", + s.x() == PW + 17 and s.ghost() == "" + and s.row() == PROMPT + "git commit -m msg", + "x=%d %r" % (s.x(), s.ghost())) + start = len(s.buf) + s.run("") + out = bytes(s.buf[start:]).decode("utf-8", "replace") + check(tag + "24 W1 the line that runs is the accepted one", + "git commit -m msg" in out or "command not found" in out + or "not a git" in out or "git:" in out, repr(out)) + s.close() + + +def case_word_ctrl_right(fork, tag): + s = shell(fork, ["git commit -m msg"]) + s.keys("git") + s.keys(CTRL_RIGHT) + s.keys(CTRL_RIGHT) + check(tag + "25 W2 Ctrl-Right = Alt-F", + s.x() == PW + 13 and s.ghost() == " msg", + "x=%d %r" % (s.x(), s.ghost())) + s.keys(CTRL_U + "git") + s.keys("\x1b[5C") + check(tag + "25 W2 CSI 5C form too", s.x() == PW + 10 + and s.ghost() == " -m msg", "x=%d %r" % (s.x(), s.ghost())) + s.close() + + +def case_word_blanks(fork, tag): + s = shell(fork, ["echo two spaces"]) + s.keys("echo") + s.keys(ALT_F) + check(tag + "26 W3 leading blanks come with the word", + s.x() == PW + 9 and s.ghost() == " spaces" + and s.row() == PROMPT + "echo two spaces", + "x=%d %r" % (s.x(), s.ghost())) + s.close() + + +def case_word_stock(fork, tag): + s = shell(fork, ["echo history"]) + s.keys("foo bar baz") + check(tag + "27 W4 no ghost to start with", s.ghost() == "") + s.keys(CTRL_A + ALT_F) + check(tag + "27 W4 Alt-F is forward-word, inserts nothing", + s.x() == PW + 3 and s.row() == PROMPT + "foo bar baz", + "x=%d %r" % (s.x(), s.row())) + s.keys(CTRL_E + CTRL_U + "echo") + s.keys(LEFT) + s.keys(ALT_F) + check(tag + "27 W5 cursor not at the end: stock forward-word (the " + "ghost, back at the end, is still only a ghost)", + s.x() == PW + 4 and s.ghost() == " history" + and s.row() == PROMPT + "echo history", + "x=%d %r" % (s.x(), s.row())) + s.keys(CTRL_A + RIGHT) + s.keys(ALT_F) + check(tag + "27 W5 mid-line: moves over the word, inserts nothing", + s.x() == PW + 4 and s.row() == PROMPT + "echo history", + "x=%d %r" % (s.x(), s.row())) + s.close() + + +def case_word_utf8(fork, tag): + s = shell(fork, ["echo h\u00e9llo w\u00f6rld"]) + s.keys("echo") + s.keys(ALT_F) + check(tag + "28 W6 UTF-8 word, cursor in columns", + s.row() == PROMPT + "echo h\u00e9llo w\u00f6rld" + and s.x() == PW + 10 and s.ghost() == " w\u00f6rld", + "x=%d %r %r" % (s.x(), s.ghost(), s.row())) + s.keys(ALT_F) + check(tag + "28 W6 second UTF-8 word", s.x() == PW + 16 + and s.ghost() == "", "x=%d %r" % (s.x(), s.ghost())) + s.close() + + +def three(fork, extra=(), **kw): + return shell(fork, ["echo a1", "echo a2", "echo a3"] + list(extra), **kw) + + +def case_cycle(fork, tag): + s = three(fork) + s.keys("echo a") + check(tag + "29 C1 newest first", s.ghost() == "3", repr(s.ghost())) + for key, name, want in ((ALT_N, "Alt-N", "2"), (ALT_N, "Alt-N", "1"), + (ALT_N, "Alt-N at the oldest: bell, no wrap", "1"), + (ALT_P, "Alt-P", "2"), (ALT_P, "Alt-P", "3"), + (ALT_P, "Alt-P at the newest: bell", "3")): + s.keys(key) + check(tag + "29 C1 " + name, s.ghost() == want + and s.row() == PROMPT + "echo a" + want + and s.x() == PW + 6, "%r %r" % (s.ghost(), s.row())) + s.keys(ALT_N + ALT_N + ALT_N) + check(tag + "29 C1 bell rung at the oldest end", b"\a" in s.since(), + repr(s.since())) + s.close() + + +def case_cycle_dupes(fork, tag): + s = shell(fork, ["echo a1", "echo a2", "echo a1", "echo a3"]) + s.keys("echo a") + seen = [s.ghost()] + for _ in range(4): + s.keys(ALT_N) + seen.append(s.ghost()) + check(tag + "30 C2 distinct entries, once each, then the end", + seen == ["3", "1", "2", "2", "2"], repr(seen)) + s.close() + + +def case_cycle_reset(fork, tag): + s = three(fork, ["echo ab9"]) + s.keys("echo a") + s.keys(ALT_N + ALT_N) + check(tag + "31 C3 two steps back", s.ghost() == "2", repr(s.ghost())) + s.keys("b") + check(tag + "31 C3 a typed character: newest match for the new line", + s.ghost() == "9", repr(s.ghost())) + s.keys(BKSP) + check(tag + "31 C3 Backspace: newest again", s.ghost() == "b9", + repr(s.ghost())) + s.keys(ALT_N) + check(tag + "31 C3 a new cycle starts from the newest", + s.ghost() == "3", repr(s.ghost())) + s.close() + + +def case_cycle_accept(fork, tag): + s = three(fork) + s.keys("echo a") + s.keys(ALT_N) + s.keys(RIGHT) + check(tag + "32 C4 Right takes the alternative on show", + s.row() == PROMPT + "echo a2" and s.ghost() == "", s.row()) + s.keys(CTRL_U + "echo a") + s.keys(ALT_N + ALT_N) + s.keys(ALT_F) + check(tag + "32 C4 so does Alt-F, and the rest follows that entry", + s.row() == PROMPT + "echo a1", s.row()) + s.keys(CTRL_U + "echo a") + s.keys(ALT_N) + s.keys("\r") + s.settle() + rows = [s.row(y) for y in range(ROWS)] + check(tag + "32 C4 Enter runs only what was typed: the output is 'a'", + PROMPT + "echo a" in rows and "a" in rows + and "a2" not in rows and "a3" not in rows, repr(rows[:8])) + s.close() + + +def case_cycle_max(fork, tag): + s = Session(tempfile.mkdtemp(prefix="autosuggest_"), fork=fork, + env={"HELLISH_AUTOSUGGEST": "1", "HELLISH_SUGGEST_MAX": "2"}) + for n in (1, 2, 3): + s.run("echo a%d" % n) + s.keys("echo a") + seen = [s.ghost()] + s.keys(ALT_N) + seen.append(s.ghost()) + s.keys(ALT_N) + seen.append(s.ghost()) + check(tag + "33 C5 SUGGEST_MAX=2 bounds the cycle", + seen == ["3", "2", "2"], repr(seen)) + s.close() + + +def case_cycle_stock(fork, tag): + s = shell(fork, ["echo hello"]) + s.keys("zzz") + s.keys(ALT_P) + check(tag + "34 C6 no ghost: Alt-P is the non-incremental search " + "(readline's ':' prompt)", s.row() == PROMPT + ":", repr(s.row())) + s.keys(CTRL_G) + s.keys(CTRL_U + "zzz") + s.keys(ALT_N) + check(tag + "34 C6 no ghost: Alt-N is the non-incremental search", + s.row() == PROMPT + ":", repr(s.row())) + s.keys(CTRL_G) + check(tag + "34 C6 still alive", s.alive()) + s.close() + + +def case_cycle_post(fork, tag): + home = tempfile.mkdtemp(prefix="autosuggest_") + s = Session(home, fork=fork, rc=POST_RC) + s.run("echo abc1") + s.run("echo abc2") + s.run("clear") + s.keys("abc") + s.keys(b"\x18\x08") + before = s.ghost() + s.keys(ALT_N) + check(tag + "35 C7 POSTDISPLAY is not cycled: Alt-N is the stock " + "non-incremental search (its own prompt, no history line shown)", + before.strip() == "" and "abc" not in s.row() + and s.row().startswith("IN#>"), + "%r %r" % (before, s.row())) + s.keys(CTRL_G) + check(tag + "35 C7 search aborted: the line is back", + s.row().startswith(PROMPT + "abc") and s.alive(), repr(s.row())) + s.keys(CTRL_U + "abc") + s.keys(b"\x18\x08") + s.keys(ALT_F) + check(tag + "35 C7 nor taken a word at a time: stock forward-word, " + "the hint is still the widget's", s.ghost() == before + and s.x() == PW + 3 and s.row() == PROMPT + "abc" + before, + "%r x=%d" % (s.ghost(), s.x())) + s.close() + + +def case_cycle_user_bind(fork, tag): + home = tempfile.mkdtemp(prefix="autosuggest_") + s = Session(home, fork=fork, rc="HELLISH_AUTOSUGGEST=1\n" + "bind '\"\\en\": backward-char'\n" + "bind '\"\\ef\": backward-char'\n") + s.run("echo a1") + s.run("echo a2") + s.run("clear") + s.keys("echo a") + s.keys(ALT_N) + check(tag + "36 C8 the user's Alt-N wins (backward-char)", + s.x() == PW + 5 and s.row() == PROMPT + "echo a", + "x=%d %r" % (s.x(), s.row())) + s.keys(CTRL_E + ALT_F) + check(tag + "36 C8 the user's Alt-F wins", + s.x() == PW + 5 and s.row() == PROMPT + "echo a", str(s.x())) + s.keys(CTRL_E + ALT_P) + check(tag + "36 C8 Alt-P is still ours (a bell, not a search prompt)", + s.row() == PROMPT + "echo a2" and s.ghost() == "2", s.row()) + s.close() + + +def bound_keys(s, func): + """The `bind -q` answer for a readline function, as the shell prints it.""" + s.run("clear") + s.run("bind -q " + func) + return s.screen_text() + + +def case_cycle_off(fork, tag): + nis = "non-incremental-forward-search-history" + s = shell(fork, ["echo a1", "echo a2"], on=False) + check(tag + "37 C9 off: Alt-N, Alt-P, Alt-F, Ctrl-Right stay on their " + "stock functions (bind -q)", + '"\\en"' in bound_keys(s, nis) + and '"\\ep"' in bound_keys( + s, "non-incremental-reverse-search-history") + and '"\\ef"' in bound_keys(s, "forward-word") + and '"\\e[1;5C"' in bound_keys(s, "forward-word"), + repr(bound_keys(s, nis))) + s.keys("echo a") + s.keys(ALT_N) + check(tag + "37 C9 off: Alt-N is stock (search prompt)", + s.ghost() == "" and s.row() == PROMPT + ":", repr(s.row())) + s.keys(CTRL_G) + s.keys(CTRL_U + "echo a") + s.keys(ALT_P) + check(tag + "37 C9 off: Alt-P is stock", s.ghost() == "" + and s.row() == PROMPT + ":", repr(s.row())) + s.keys(CTRL_G) + s.keys(CTRL_U + "foo bar") + s.keys(CTRL_A + ALT_F) + check(tag + "37 C9 off: Alt-F is forward-word", s.x() == PW + 3 + and s.row() == PROMPT + "foo bar", str(s.x())) + s.keys(CTRL_E + CTRL_U) + s.run("HELLISH_AUTOSUGGEST=1") + check(tag + "37 C9 switched on at the prompt: the keys are ours", + "not bound" in bound_keys(s, nis), repr(bound_keys(s, nis))) + s.keys("echo a") + s.keys(ALT_N) + check(tag + "37 C9 switched on at the prompt: Alt-N cycles", + s.ghost() == "1", repr(s.ghost())) + s.keys(CTRL_U) + s.run("unset HELLISH_AUTOSUGGEST") + check(tag + "37 C9 switched off again: stock keys are back", + '"\\en"' in bound_keys(s, nis), repr(bound_keys(s, nis))) + s.keys("echo a") + s.keys(ALT_N) + check(tag + "37 C9 switched off again: Alt-N is stock", + s.ghost() == "" and s.row() == PROMPT + ":", repr(s.row())) + s.keys(CTRL_G) + s.close() + + +def case_cycle_vi(fork, tag): + s = three(fork) + s.run("set -o vi") + s.keys("echo a") + check(tag + "38 C10 vi insert: ghost", s.ghost() == "3", repr(s.ghost())) + s.keys(ALT_N) + check(tag + "38 C10 vi insert: Alt-N cycles", s.ghost() == "2", + repr(s.ghost())) + s.keys(ALT_P) + check(tag + "38 C10 vi insert: Alt-P cycles back", s.ghost() == "3", + repr(s.ghost())) + s.keys(CTRL_U + "echo") + s.keys(ALT_F) + check(tag + "38 C10 vi insert: Alt-F takes a word", + s.ghost() == "" and s.row().endswith("echo a3"), repr(s.row())) + s.keys(CTRL_U + "echo a") + s.keys(ESC) + time.sleep(0.8) + s.settle() + check(tag + "38 C10 vi command mode: no ghost", + s.row() == "(cmd)" + PROMPT + "echo a", repr(s.row())) + s.close() + +def case_cycle_history_shrinks(fork, tag): + """A bind -x widget that clears the history mid-cycle must not leave the + cycle pointing at entries that are gone (an out-of-bounds read).""" + s = three(fork, ["bind -x '\"\\C-t\": history -c'"]) + s.keys("echo a") + s.keys(ALT_N + ALT_N) + check(tag + "29 C11 cycled before the clear", s.ghost() == "1", + repr(s.ghost())) + s.keys("\x14") + s.keys(ALT_P) + check(tag + "29 C11 no ghost survives a cleared history", + s.ghost() == "" and s.alive(), repr(s.ghost())) + s.keys("\x07\x15") + s.run("echo ok") + check(tag + "29 C11 the line still runs", "ok" in s.screen_text(), + repr(s.screen_text()[-300:])) + s.close() + + + CASES = (case_off_by_default, case_on, case_accept, case_enter_plain, case_newest, case_edit, case_cursor, case_interrupt, case_history_keys, case_search, case_vi, case_escape, case_multiline, case_truncate, case_utf8, case_rprompt, case_paste, case_max, case_unset, case_user_bind, - case_postdisplay, case_resize, case_dumb) + case_postdisplay, case_resize, case_dumb, case_word, + case_word_ctrl_right, case_word_blanks, case_word_stock, + case_word_utf8, case_cycle, case_cycle_dupes, case_cycle_reset, + case_cycle_accept, case_cycle_max, case_cycle_stock, + case_cycle_post, case_cycle_user_bind, case_cycle_off, + case_cycle_vi, case_cycle_history_shrinks) def main(): diff --git a/wiki/interactive.md b/wiki/interactive.md index ceb65304..0748097e 100644 --- a/wiki/interactive.md +++ b/wiki/interactive.md @@ -106,8 +106,11 @@ loaded, and a shell run from a checkout loads it with **PS1 is bilingual.** Both escape languages render in a plain PS1 — paste `%n@%m %~ %#` or `\u@\h \w \$` and either works, while `100% `, csh-style `%> ` and the strftime percents inside -`$(date +%H:%M)` or `\D{%M}` survive literally. `PROMPT` and `print -P` keep exact zsh semantics -(measured against zsh 5.9), and under `set -o zsh`, PS1 *is* PROMPT, as in zsh. +`$(date +%H:%M)` or `\D{%M}` survive literally. `PROMPT` keeps exact zsh semantics (measured against zsh 5.9), and under +`set -o zsh`, PS1 *is* PROMPT, as in zsh. `print -P` uses zsh's prompt +formatter too, but it can re-interpret percent escapes in expanded variable +contents; its output is not always an exact preview of the live prompt (issue +[#134](https://github.com/Univers42/hellish/issues/134)). **What is live, and what is not.** `$VAR`, `${VAR…}`, `$?` and `$((…))` are expanded every time the prompt is drawn. Command substitution is not: `$(…)` and backquotes stay on screen as the diff --git a/wiki/manual.md b/wiki/manual.md index 963ad359..6a6d6592 100644 --- a/wiki/manual.md +++ b/wiki/manual.md @@ -173,7 +173,7 @@ inside the shell: `help` for the grouped list, `help NAME` for one, |---|---| | `HELLISH_BANNER=0\|1` | force the welcome panel off / on | | `HELLISH_HISTORY_PREFIX=0\|off` | Up/Down walk plain history; default on = search by the text before the cursor; read at every prompt | -| `HELLISH_AUTOSUGGEST=1\|on` | inline suggestion (off by default): the newest history line starting with the typed text, dim after the cursor; Right / End / Ctrl-F insert it; never a line with control characters or a newline; read at every prompt | +| `HELLISH_AUTOSUGGEST=1\|on` | inline suggestion (off by default): the newest history line starting with the typed text, dim after the cursor; Right / End / Ctrl-F insert it, Alt-F / Ctrl-Right insert its next word (leading blanks + the run up to the next space or tab), Alt-N / Alt-P cycle through older / newer distinct matches (bell at either end, no wrap; any edit resets); never a line with control characters or a newline; read at every prompt | | `HELLISH_SUGGEST_MAX=N` | history lines the suggestion scans, newest first (default 1000; non-numeric or below 1 = default); read at every prompt | | `POSTDISPLAY` | zle widgets only: the text painted after the cursor, i.e. the suggestion; assign it to show your own until the buffer next changes | | `HELLISH_NO_UPDATE_CHECK=1` | never check for releases; no badge |