diff --git a/README.md b/README.md index 6caae8e..ffe7c49 100644 --- a/README.md +++ b/README.md @@ -182,11 +182,11 @@ printed without an interactive session. Interactive keys include live `/` search (case-insensitive, finds as you type) and `n`/`N` next/previous match. `^less` still runs the external binary. -External tools that would spawn system `less` (`systemctl`, `git log`, `man`, …) -are captured with nested pagers forced to `cat`, then shown through the **same -builtin pager** when the text does not fit on one screen. Full-screen programs -that need a real TTY (`vim`, `htop`, `sudo`, `ssh`, …) still inherit the -terminal. Use `^less` / a path for the external pager binary. +Most external commands inherit a live TTY so long-lived processes (`nix run`, +dev servers, builds) stream output as they run. Tools that would spawn system +`less` (`systemctl`, `git log`, `man`, …) are still captured with nested pagers +forced to `cat`, then shown through the **same builtin pager** when the text +does not fit on one screen. Use `^less` / a path for the external pager binary. Unknown command names fall through to external executables on `PATH`. diff --git a/src/gleshell.gleam b/src/gleshell.gleam index 434e788..4da1047 100644 --- a/src/gleshell.gleam +++ b/src/gleshell.gleam @@ -266,10 +266,10 @@ fn print_value(value: value.Value, allow_page: Bool) -> Nil { Nothing -> Nil _ -> // External commands that used PTY relay already streamed output to the - // terminal (vim, sudo, …). Skip re-printing when that flag is still set. - // Everything else (including systemctl/git/man with nested pagers forced - // to cat) is captured and shown here — through the builtin pager when - // allowed and the text does not fit on one screen (less -F style). + // terminal (servers, vim, sudo, …). Skip re-printing when that flag is + // still set. Nested-pager tools (systemctl/git/man) are captured and + // shown here — through the builtin pager when allowed and the text does + // not fit on one screen (less -F style). case sys.take_output_shown() { True -> Nil False -> { diff --git a/src/gleshell/eval.gleam b/src/gleshell/eval.gleam index a425b56..68a2d1c 100644 --- a/src/gleshell/eval.gleam +++ b/src/gleshell/eval.gleam @@ -59,15 +59,15 @@ fn eval_statement(env: Env, stmt: Statement) -> EvalResult { } } } - // Bare expression: last stage may take a live TTY only if it needs one - // (vim, sudo, …). Pager-using tools (systemctl, git, man) are captured - // and shown via gleshell's builtin pager instead of system less. + // Bare expression: last stage gets a live TTY by default so long-lived + // processes stream. Pager-default tools (systemctl, git, man) stay captured + // and are shown via gleshell's builtin pager instead of system less. parser.Expr(pipeline) -> eval_pipeline(env, pipeline, Nothing, True) } } -/// `allow_tty` — when True, the last pipeline stage *may* run on a live TTY -/// if `wants_tty` says it needs one (editors, TUIs, auth). Otherwise output +/// `allow_tty` — when True, the last pipeline stage runs on a live TTY unless +/// it is a known nested-pager tool (`captures_for_pager`). Otherwise output /// is captured (nested pagers → cat) for gleshell's pager. fn eval_pipeline( env: Env, @@ -249,10 +249,9 @@ fn run_external( ) -> EvalResult { // Pipeline input becomes the external's stdin (Unix-style `cmd | less`). let stdin = stdin_bytes(input) - // Prefer capture so tools that would spawn system `less` (systemctl, git, - // man, …) dump full output with nested pagers disabled. The REPL then pages - // through gleshell's builtin pager. Only true TUI/TTY programs inherit a - // live terminal (vim, htop, sudo, ssh, …). + // Live TTY by default so long-lived processes (servers, builds, `nix run`) + // stream output. Only tools that open system `less` by default are captured + // with nested pagers forced to cat; the REPL then pages via gleshell's pager. let result = case interactive && wants_tty(name) { True -> sys.run_cmd_tty(name, str_args, stdin) False -> sys.run_cmd(name, str_args, stdin) @@ -283,35 +282,20 @@ fn stdin_bytes(input: Value) -> String { } } -/// True when the external must own a live TTY (full-screen UI, password -/// prompts, REPLs). Everything else is captured so systemctl/git/man use -/// gleshell's pager instead of spawning system `less`. +/// True when the external should own a live TTY (stream + interactive). +/// Default True: servers, builds, and ordinary tools show output as they run. +/// Only nested-pager tools are captured so systemctl/git/man use gleshell's +/// pager instead of spawning system `less`. fn wants_tty(name: String) -> Bool { + !captures_for_pager(name) +} + +/// Tools that open system `less`/`more` by default. Capture with nested pagers +/// forced to cat; the interactive REPL shows long output in the builtin pager. +fn captures_for_pager(name: String) -> Bool { let base = command_basename(name) case base { - // Editors - "vi" | "vim" | "nvim" | "nano" | "emacs" | "emacsclient" | "helix" | "hx" - | "kak" | "micro" | "ed" | "joe" -> True - // Explicit system pagers (builtin `less` is separate; `^less` hits this) - "less" | "more" | "most" -> True - // Process / system monitors - "top" | "htop" | "btop" | "glances" | "iotop" | "nethogs" | "nvtop" - | "gtop" | "watch" -> True - // File managers / TUIs - "ranger" | "mc" | "nnn" | "lf" | "yazi" | "xplr" | "tig" | "gitui" - | "lazygit" | "k9s" -> True - // Interactive filters - "fzf" | "peco" | "sk" -> True - // Shells / REPLs - "sh" | "bash" | "zsh" | "fish" | "nu" | "python" | "python3" | "ipython" - | "node" | "irb" | "pry" | "lua" | "psql" | "mysql" | "redis-cli" - | "sqlite3" | "iex" | "erl" -> True - // Remote / auth (need a controlling TTY) - "ssh" | "sftp" | "scp" | "mosh" | "telnet" | "sudo" | "doas" | "run0" - | "pkexec" | "su" | "login" | "passwd" | "ssh-add" -> True - // Multiplexers / debuggers / network TUI - "tmux" | "screen" | "zellij" | "gdb" | "lldb" | "cgdb" | "nmtui" - | "alsamixer" | "pulsemixer" | "bluetoothctl" -> True + "systemctl" | "journalctl" | "man" | "info" | "git" -> True _ -> False } } diff --git a/src/gleshell/highlight.gleam b/src/gleshell/highlight.gleam index 6c478d8..a0c9491 100644 --- a/src/gleshell/highlight.gleam +++ b/src/gleshell/highlight.gleam @@ -464,5 +464,6 @@ fn is_ident_start(c: String) -> Bool { fn is_ident_continue(c: String) -> Bool { // Path-ish chars covered by is_ident_start (`.` `/` `~`); keep `-` mid-token. // `#` mid-token for flake refs (must match lexer); bare `#` is still a comment. - is_ident_start(c) || is_digit(c) || c == "-" || c == "#" + // `@` mid-token for SSH/git URLs (must match lexer). + is_ident_start(c) || is_digit(c) || c == "-" || c == "#" || c == "@" } diff --git a/src/gleshell/lexer.gleam b/src/gleshell/lexer.gleam index 77edfa9..cd25881 100644 --- a/src/gleshell/lexer.gleam +++ b/src/gleshell/lexer.gleam @@ -286,5 +286,6 @@ fn is_ident_continue(c: String) -> Bool { // Path-ish chars: letters/digits already covered; keep `.` `/` `-` `~` mid-token. // `#` mid-token for flake refs (`nixpkgs#hello`, `.#package`); bare `#` still // starts a comment at a word boundary (handled in do_tokenize). - is_ident_start(c) || is_digit(c) || c == "-" || c == "#" + // `@` mid-token for SSH/git URLs (`git@host:path`, `user@host`). + is_ident_start(c) || is_digit(c) || c == "-" || c == "#" || c == "@" } diff --git a/src/gleshell_ffi.erl b/src/gleshell_ffi.erl index 021e4e2..9a90abf 100644 --- a/src/gleshell_ffi.erl +++ b/src/gleshell_ffi.erl @@ -428,9 +428,11 @@ run_as_shell(Fun) when is_function(Fun, 0) -> %% competing get_chars clients (killing those dropped the %% first post-command key — empty ↑ after nix/sleep/…). start_stdin_mux(), + enable_bracketed_paste(), try Fun() after + disable_bracketed_paste(), stop_stdin_mux(), save_line_history() end, @@ -616,6 +618,8 @@ raw_get_line(Prompt) when is_binary(Prompt) -> ), put(gleshell_history, History), put(gleshell_input_rows, 1), + put(gleshell_key_q, []), + put(gleshell_key_unread, []), redraw(PromptList, [], []), raw_loop(PromptList, [], [], History, 0, <<>>). @@ -634,9 +638,20 @@ raw_loop(Prompt, Left, Right, History, HistPos, Saved) -> io:put_chars("\r\n"), push_history(Line), {ok, Line}; + paste_start -> + %% Bracketed paste: insert literally (no key bindings), one redraw. + NewLeft = read_bracketed_paste(Left), + redraw(Prompt, NewLeft, Right), + raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); + paste_end -> + %% Stray end marker (paste not open) — ignore. + raw_loop(Prompt, Left, Right, History, HistPos, Saved); {char, C} when is_integer(C), C >= 32, C =/= 127 -> - %% Printable Unicode codepoint - NewLeft = [C | Left], + %% Printable Unicode codepoint. Drain any already-queued paste burst + %% so we insert every letter and only redraw once (avoids drops when + %% a multi-char read or ESC fragment would otherwise lose bytes). + Burst = drain_printable_burst(C), + NewLeft = lists:reverse(Burst) ++ Left, redraw(Prompt, NewLeft, Right), raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); backspace -> @@ -1737,37 +1752,80 @@ bin_to_buffer(List) when is_list(List) -> %% When the stdin mux is running (raw REPL), all key bytes come from that %% process so PTY relay / interrupt watch can retarget without spawning a %% second get_chars client (see start_stdin_mux/0). +%% +%% Push-back queue (`gleshell_key_q`): codepoints waiting to be consumed. +%% Critical when a single io payload decodes to multiple characters — the old +%% `<>` path kept only the first and dropped the rest, which +%% turned paste bursts like "gleam" into "geam" / "gem" / etc. %% --------------------------------------------------------------------------- read_key() -> - case read_key_byte() of - eof -> - eof; - {error, Reason} -> - {error, Reason}; - C when is_integer(C) -> - decode_key(C, <<>>) + case pop_unread_key() of + {ok, K} -> + K; + empty -> + case read_key_byte() of + eof -> + eof; + {error, Reason} -> + {error, Reason}; + C when is_integer(C) -> + decode_key(C) + end + end. + +%% Non-blocking: next already-queued codepoint or mux message, else `none`. +try_read_key_byte() -> + case pop_key_q() of + {ok, C} -> + C; + empty -> + case get(gleshell_stdin_mux) of + Mux when is_pid(Mux) -> + receive + {gleshell_stdin, eof} -> + eof; + {gleshell_stdin, {error, Reason}} -> + {error, Reason}; + {gleshell_stdin, Data} -> + case enqueue_key_data(Data) of + empty -> + try_read_key_byte(); + C when is_integer(C) -> + C + end + after 0 -> + none + end; + _ -> + none + end end. %% One logical input unit (codepoint or raw byte) for the line editor / CSI. read_key_byte() -> - case get(gleshell_stdin_mux) of - Mux when is_pid(Mux) -> - receive - {gleshell_stdin, eof} -> - eof; - {gleshell_stdin, {error, Reason}} -> - {error, Reason}; - {gleshell_stdin, Data} -> - case key_data_to_codepoint(Data) of - empty -> - read_key_byte(); - C when is_integer(C) -> - C - end - end; - _ -> - read_key_byte_direct() + case pop_key_q() of + {ok, C} -> + C; + empty -> + case get(gleshell_stdin_mux) of + Mux when is_pid(Mux) -> + receive + {gleshell_stdin, eof} -> + eof; + {gleshell_stdin, {error, Reason}} -> + {error, Reason}; + {gleshell_stdin, Data} -> + case enqueue_key_data(Data) of + empty -> + read_key_byte(); + C when is_integer(C) -> + C + end + end; + _ -> + read_key_byte_direct() + end end. read_key_byte_direct() -> @@ -1777,7 +1835,7 @@ read_key_byte_direct() -> {error, Reason} -> {error, Reason}; Data -> - case key_data_to_codepoint(Data) of + case enqueue_key_data(Data) of empty -> read_key_byte_direct(); C when is_integer(C) -> @@ -1785,47 +1843,200 @@ read_key_byte_direct() -> end end. -key_data_to_codepoint(Data) -> +%% Decode IO data into codepoints: return the first, queue the rest. +enqueue_key_data(Data) -> Bin = io_data_to_bin(Data), case Bin of <<>> -> empty; - <> -> - C; - <> when is_integer(C) -> - C; _ -> case unicode:characters_to_list(Bin) of - [C | _] when is_integer(C) -> + [C | Rest] when is_integer(C) -> + unread_codepoints(Rest), C; + [] -> + empty; + {incomplete, Done, _Rest} when is_list(Done) -> + case Done of + [C | Rest] when is_integer(C) -> + unread_codepoints(Rest), + C; + [] -> + %% Incomplete UTF-8 lead — keep raw bytes so we do + %% not invent latin1 garbage; wait for more data. + unread_codepoints(binary_to_byte_list(Bin)), + case pop_key_q() of + {ok, C} -> C; + empty -> empty + end; + _ -> + empty + end; + {error, Done, _Rest} when is_list(Done) -> + case Done of + [C | Rest] when is_integer(C) -> + unread_codepoints(Rest), + C; + _ -> + fallback_first_byte(Bin) + end; _ -> - empty + fallback_first_byte(Bin) end end. -decode_key($\r, _) -> enter; -decode_key($\n, _) -> enter; -decode_key($\t, _) -> tab; -decode_key(127, _) -> backspace; -decode_key($\b, _) -> backspace; -decode_key(1, _) -> ctrl_a; -decode_key(5, _) -> ctrl_e; -decode_key(4, _) -> ctrl_d; -decode_key(3, _) -> ctrl_c; -decode_key(11, _) -> ctrl_k; -decode_key(21, _) -> ctrl_u; -decode_key(23, _) -> ctrl_w; -decode_key(12, _) -> ctrl_l; -decode_key(18, _) -> ctrl_r; -decode_key(6, _) -> ctrl_f; -decode_key(16, _) -> ctrl_p; -decode_key(14, _) -> ctrl_n; -decode_key(7, _) -> ctrl_g; -decode_key(?ESC, _) -> +fallback_first_byte(<>) when is_integer(C) -> + unread_codepoints(binary_to_byte_list(Rest)), + C; +fallback_first_byte(_) -> + empty. + +binary_to_byte_list(Bin) when is_binary(Bin) -> + binary_to_list(Bin). + +pop_key_q() -> + case get(gleshell_key_q) of + [C | Rest] when is_integer(C) -> + put(gleshell_key_q, Rest), + {ok, C}; + _ -> + put(gleshell_key_q, []), + empty + end. + +%% Prepend so the next pop returns these in order (Cs is chronological). +unread_codepoints([]) -> + ok; +unread_codepoints(Cs) when is_list(Cs) -> + Q = + case get(gleshell_key_q) of + Q0 when is_list(Q0) -> Q0; + _ -> [] + end, + put(gleshell_key_q, Cs ++ Q), + ok. + +unread_key(Key) -> + case get(gleshell_key_unread) of + L when is_list(L) -> + put(gleshell_key_unread, [Key | L]); + _ -> + put(gleshell_key_unread, [Key]) + end. + +pop_unread_key() -> + case get(gleshell_key_unread) of + [K | Rest] -> + put(gleshell_key_unread, Rest), + {ok, K}; + _ -> + put(gleshell_key_unread, []), + empty + end. + +%% Drain a paste/typeahead burst of printable chars already in the queue/mailbox. +%% Acc starts with the first char; returns chronological list of codepoints. +drain_printable_burst(First) when is_integer(First) -> + drain_printable_burst_loop([First]). + +drain_printable_burst_loop(AccRev) -> + case try_next_printable() of + {char, C} -> + drain_printable_burst_loop([C | AccRev]); + none -> + %% Brief yield: mux may still be forwarding the rest of a paste. + receive + after 2 -> + case try_next_printable() of + {char, C} -> + drain_printable_burst_loop([C | AccRev]); + none -> + lists:reverse(AccRev) + end + end + end. + +try_next_printable() -> + case pop_unread_key() of + {ok, {char, C}} when is_integer(C), C >= 32, C =/= 127 -> + {char, C}; + {ok, Other} -> + unread_key(Other), + none; + empty -> + case try_read_key_byte() of + none -> + none; + eof -> + unread_key(eof), + none; + {error, Reason} -> + unread_key({error, Reason}), + none; + C when is_integer(C) -> + case decode_key(C) of + {char, Ch} when is_integer(Ch), Ch >= 32, Ch =/= 127 -> + {char, Ch}; + Other -> + unread_key(Other), + none + end + end + end. + +%% Bracketed paste body until paste_end. Newlines → space (single-line editor). +read_bracketed_paste(Left) -> + case read_key() of + paste_end -> + Left; + paste_start -> + read_bracketed_paste(Left); + {char, C} when is_integer(C), C >= 32, C =/= 127 -> + read_bracketed_paste([C | Left]); + enter -> + read_bracketed_paste([$\s | Left]); + eof -> + Left; + {error, _} -> + Left; + ctrl_c -> + Left; + _Other -> + read_bracketed_paste(Left) + end. + +enable_bracketed_paste() -> + %% Ask the terminal to wrap pastes in ESC[200~ … ESC[201~. + catch io:put_chars("\e[?2004h"), + ok. + +disable_bracketed_paste() -> + catch io:put_chars("\e[?2004l"), + ok. + +decode_key($\r) -> enter; +decode_key($\n) -> enter; +decode_key($\t) -> tab; +decode_key(127) -> backspace; +decode_key($\b) -> backspace; +decode_key(1) -> ctrl_a; +decode_key(5) -> ctrl_e; +decode_key(4) -> ctrl_d; +decode_key(3) -> ctrl_c; +decode_key(11) -> ctrl_k; +decode_key(21) -> ctrl_u; +decode_key(23) -> ctrl_w; +decode_key(12) -> ctrl_l; +decode_key(18) -> ctrl_r; +decode_key(6) -> ctrl_f; +decode_key(16) -> ctrl_p; +decode_key(14) -> ctrl_n; +decode_key(7) -> ctrl_g; +decode_key(?ESC) -> read_escape(); -decode_key(C, _) when is_integer(C), C >= 32 -> +decode_key(C) when is_integer(C), C >= 32 -> {char, C}; -decode_key(_, _) -> +decode_key(_) -> other. read_escape() -> @@ -1849,15 +2060,23 @@ read_escape() -> $D -> left; $H -> home; $F -> 'end'; - _ -> other + Other when is_integer(Other) -> + %% Do not drop the follow-up byte (paste / unbound meta). + unread_codepoints([Other]), + other; + _ -> + other end; %% Alt+letter arrives as ESC then the letter (meta). $f -> alt_f; $F -> alt_f; + C when is_integer(C), C >= 32 -> + %% Unbound Alt+key or ESC fragment mid-paste: keep the letter. + %% (Old path returned `esc` and *discarded* C — "gleam" → "geam".) + {char, C}; _ -> - %% Unknown ESC sequence — Escape is the usual cancel key in TUIs. esc end. @@ -1884,6 +2103,8 @@ read_csi_params(Acc) -> "6" -> page_down; "7" -> home; "8" -> 'end'; + "200" -> paste_start; + "201" -> paste_end; _ -> other end; $A -> diff --git a/test/gleshell_test.gleam b/test/gleshell_test.gleam index affe050..37da51d 100644 --- a/test/gleshell_test.gleam +++ b/test/gleshell_test.gleam @@ -102,6 +102,39 @@ pub fn lexer_flake_ref_hash_test() { Nil } +pub fn lexer_ssh_user_at_host_test() { + // Mid-token `@` is part of the bareword (git SSH URLs, user@host). + let assert Ok(tokens) = + lexer.tokenize("git clone git@tangled.org:tranquil.farm/tranquil-pds") + let assert [ + lexer.Ident("git"), + lexer.Ident("clone"), + lexer.Ident("git@tangled.org"), + lexer.Colon, + lexer.Ident("tranquil.farm/tranquil-pds"), + lexer.Eof, + ] = tokens + // Bare `@` at a word boundary is still an error (not a valid bareword start). + let assert Error(lexer.LexError("unexpected character '@'", 0)) = + lexer.tokenize("@alone") + Nil +} + +pub fn parse_ssh_git_url_test() { + // Colon glue reassembly: `git@host:path` → one argv string. + let assert Ok(parser.Expr(parser.Pipeline([ + parser.Command("git", args, False), + ]))) = + parser.parse("git clone git@tangled.org:tranquil.farm/tranquil-pds") + let assert [ + parser.ValueArg(parser.Lit(String("clone"))), + parser.ValueArg( + parser.Lit(String("git@tangled.org:tranquil.farm/tranquil-pds")), + ), + ] = args + Nil +} + pub fn parse_flake_ref_hash_test() { let assert Ok(parser.Expr(parser.Pipeline([ parser.Command("nix", args, False), @@ -242,7 +275,7 @@ pub fn eval_pipeline_reverse_first_test() { /// Pipeline input must become the external's stdin (`cat f | less`, `echo hi | wc`). /// Use `let` so the last stage is capture mode even on a TTY (bare expressions -/// may inherit the terminal and return an empty string value). +/// inherit the terminal by default and return an empty string value). pub fn eval_pipeline_stdin_to_external_test() { let env = env.new() // `echo` is a builtin; `wc` is external. Count bytes of "hello" (no trailing NL).