A structured-data shell in Gleam, inspired by Nushell
0

Configure Feed

Select the types of activity you want to include in your feed.

Fix external TTY sizing and use gleshell's pager for systemctl/git/man.

Script PTYs started at 0×0 under Erlang ports, so tools truncated and
paginated as if the window were tiny. Always wrap PTY children in a
runner that stty-resizes the slave to the host size (from stty size),
and export COLUMNS/LINES.

Keep a long-lived stdin mux for the raw REPL so post-command history
keys are not dropped after PTY relay or interrupt watch.

Prefer capture with nested pagers forced to cat for normal externals;
page long output through the builtin pager in the interactive REPL.
True TTY programs (vim, htop, sudo, ssh, …) still inherit the terminal.

author
nandi
date (Jul 26, 2026, 8:02 AM -0700) commit e0146aa1 parent ea3e054f change-id slwuztut
+470 -85
+9 -2
README.md
··· 182 182 (case-insensitive, finds as you type) and `n`/`N` next/previous match. `^less` 183 183 still runs the external binary. 184 184 185 + External tools that would spawn system `less` (`systemctl`, `git log`, `man`, …) 186 + are captured with nested pagers forced to `cat`, then shown through the **same 187 + builtin pager** when the text does not fit on one screen. Full-screen programs 188 + that need a real TTY (`vim`, `htop`, `sudo`, `ssh`, …) still inherit the 189 + terminal. Use `^less` / a path for the external pager binary. 190 + 185 191 Unknown command names fall through to external executables on `PATH`. 186 192 187 193 ## Layout ··· 205 211 206 212 Input and output are colorized on a TTY (Nu-like shapes on the command line; 207 213 headers bold green, numbers purple, bools cyan, dirs blue, errors red, …). 208 - External tools (`jj`, `git`, `fastfetch`, …) keep their own colors: final-stage 209 - commands inherit the real TTY; captured pipeline stages (e.g. `jj log | less`, 214 + External tools (`jj`, `git`, `fastfetch`, …) keep their own colors via a 215 + throwaway PTY when captured (and live TTY for true interactive programs). 216 + Captured pipeline stages (e.g. `jj log | less`, 210 217 `git log | less`) run under a throwaway PTY when the shell wants color so tools 211 218 that only colorize on a terminal still emit ANSI — without per-tool env hacks. 212 219 Nested pagers are forced to `cat` so the producer cannot hang in its own
+17 -6
src/gleshell.gleam
··· 8 8 import gleshell/display 9 9 import gleshell/env 10 10 import gleshell/eval 11 + import gleshell/pager 11 12 import gleshell/sys 12 13 import gleshell/value.{Nothing} 13 14 import simplifile ··· 54 55 case eval.eval_source(env, code) { 55 56 eval.Quit(code) -> halt(code) 56 57 eval.Continue(_env, value) -> { 57 - print_value(value) 58 + // One-shots never enter the interactive pager (scripts, pipes). 59 + print_value(value, False) 58 60 case value { 59 61 value.Fail(_) -> halt(1) 60 62 _ -> Nil ··· 99 101 } 100 102 } 101 103 eval.Continue(env2, value) -> { 102 - print_value(value) 104 + print_value(value, True) 103 105 repl_loop(env2) 104 106 } 105 107 } ··· 256 258 } 257 259 } 258 260 259 - fn print_value(value: value.Value) -> Nil { 261 + /// Print a pipeline result. When `allow_page` is True (interactive REPL) and 262 + /// the text does not fit on one screen, use the builtin pager instead of 263 + /// dumping. One-shot `-c` always dumps so scripts do not hang in a TUI. 264 + fn print_value(value: value.Value, allow_page: Bool) -> Nil { 260 265 case value { 261 266 Nothing -> Nil 262 267 _ -> 263 268 // External commands that used PTY relay already streamed output to the 264 - // terminal (needed for run0/sudo password prompts). Skip re-printing 265 - // when that flag is still set (final pipeline stage was that external). 269 + // terminal (vim, sudo, …). Skip re-printing when that flag is still set. 270 + // Everything else (including systemctl/git/man with nested pagers forced 271 + // to cat) is captured and shown here — through the builtin pager when 272 + // allowed and the text does not fit on one screen (less -F style). 266 273 case sys.take_output_shown() { 267 274 True -> Nil 268 275 False -> { 269 276 let text = display.render(value) 270 277 case text { 271 278 "" -> Nil 272 - t -> sys.println(t) 279 + t -> 280 + case allow_page && pager.needs_paging(t) { 281 + True -> pager.run(t) 282 + False -> sys.println(t) 283 + } 273 284 } 274 285 } 275 286 }
+55 -4
src/gleshell/eval.gleam
··· 59 59 } 60 60 } 61 61 } 62 - // Bare expression: last external may take the real TTY (less, vim, …). 62 + // Bare expression: last stage may take a live TTY only if it needs one 63 + // (vim, sudo, …). Pager-using tools (systemctl, git, man) are captured 64 + // and shown via gleshell's builtin pager instead of system less. 63 65 parser.Expr(pipeline) -> eval_pipeline(env, pipeline, Nothing, True) 64 66 } 65 67 } 66 68 67 - /// `allow_tty` — when True, the last pipeline stage may run as a foreground 68 - /// interactive process on the real terminal (needed for `less`, `vim`, etc.). 69 + /// `allow_tty` — when True, the last pipeline stage *may* run on a live TTY 70 + /// if `wants_tty` says it needs one (editors, TUIs, auth). Otherwise output 71 + /// is captured (nested pagers → cat) for gleshell's pager. 69 72 fn eval_pipeline( 70 73 env: Env, 71 74 pipeline: Pipeline, ··· 246 249 ) -> EvalResult { 247 250 // Pipeline input becomes the external's stdin (Unix-style `cmd | less`). 248 251 let stdin = stdin_bytes(input) 249 - let result = case interactive { 252 + // Prefer capture so tools that would spawn system `less` (systemctl, git, 253 + // man, …) dump full output with nested pagers disabled. The REPL then pages 254 + // through gleshell's builtin pager. Only true TUI/TTY programs inherit a 255 + // live terminal (vim, htop, sudo, ssh, …). 256 + let result = case interactive && wants_tty(name) { 250 257 True -> sys.run_cmd_tty(name, str_args, stdin) 251 258 False -> sys.run_cmd(name, str_args, stdin) 252 259 } ··· 275 282 other -> value.as_string(other) 276 283 } 277 284 } 285 + 286 + /// True when the external must own a live TTY (full-screen UI, password 287 + /// prompts, REPLs). Everything else is captured so systemctl/git/man use 288 + /// gleshell's pager instead of spawning system `less`. 289 + fn wants_tty(name: String) -> Bool { 290 + let base = command_basename(name) 291 + case base { 292 + // Editors 293 + "vi" | "vim" | "nvim" | "nano" | "emacs" | "emacsclient" | "helix" | "hx" 294 + | "kak" | "micro" | "ed" | "joe" -> True 295 + // Explicit system pagers (builtin `less` is separate; `^less` hits this) 296 + "less" | "more" | "most" -> True 297 + // Process / system monitors 298 + "top" | "htop" | "btop" | "glances" | "iotop" | "nethogs" | "nvtop" 299 + | "gtop" | "watch" -> True 300 + // File managers / TUIs 301 + "ranger" | "mc" | "nnn" | "lf" | "yazi" | "xplr" | "tig" | "gitui" 302 + | "lazygit" | "k9s" -> True 303 + // Interactive filters 304 + "fzf" | "peco" | "sk" -> True 305 + // Shells / REPLs 306 + "sh" | "bash" | "zsh" | "fish" | "nu" | "python" | "python3" | "ipython" 307 + | "node" | "irb" | "pry" | "lua" | "psql" | "mysql" | "redis-cli" 308 + | "sqlite3" | "iex" | "erl" -> True 309 + // Remote / auth (need a controlling TTY) 310 + "ssh" | "sftp" | "scp" | "mosh" | "telnet" | "sudo" | "doas" | "run0" 311 + | "pkexec" | "su" | "login" | "passwd" | "ssh-add" -> True 312 + // Multiplexers / debuggers / network TUI 313 + "tmux" | "screen" | "zellij" | "gdb" | "lldb" | "cgdb" | "nmtui" 314 + | "alsamixer" | "pulsemixer" | "bluetoothctl" -> True 315 + _ -> False 316 + } 317 + } 318 + 319 + fn command_basename(name: String) -> String { 320 + case string.split(name, "/") { 321 + [] -> name 322 + parts -> 323 + case list.last(parts) { 324 + Ok(b) -> b 325 + Error(Nil) -> name 326 + } 327 + } 328 + }
+389 -73
src/gleshell_ffi.erl
··· 423 423 put(gleshell_raw, true), 424 424 load_line_history(), 425 425 configure_line_editor(), 426 + %% One long-lived stdin owner for the raw REPL. External 427 + %% PTY/interrupt helpers retarget it instead of spawning 428 + %% competing get_chars clients (killing those dropped the 429 + %% first post-command key — empty ↑ after nix/sleep/…). 430 + start_stdin_mux(), 426 431 try 427 432 Fun() 428 433 after 434 + stop_stdin_mux(), 429 435 save_line_history() 430 436 end, 431 437 nil; ··· 1727 1733 1728 1734 %% --------------------------------------------------------------------------- 1729 1735 %% Key reading (raw mode — keys arrive as soon as pressed) 1736 + %% 1737 + %% When the stdin mux is running (raw REPL), all key bytes come from that 1738 + %% process so PTY relay / interrupt watch can retarget without spawning a 1739 + %% second get_chars client (see start_stdin_mux/0). 1730 1740 %% --------------------------------------------------------------------------- 1731 1741 1732 1742 read_key() -> 1743 + case read_key_byte() of 1744 + eof -> 1745 + eof; 1746 + {error, Reason} -> 1747 + {error, Reason}; 1748 + C when is_integer(C) -> 1749 + decode_key(C, <<>>) 1750 + end. 1751 + 1752 + %% One logical input unit (codepoint or raw byte) for the line editor / CSI. 1753 + read_key_byte() -> 1754 + case get(gleshell_stdin_mux) of 1755 + Mux when is_pid(Mux) -> 1756 + receive 1757 + {gleshell_stdin, eof} -> 1758 + eof; 1759 + {gleshell_stdin, {error, Reason}} -> 1760 + {error, Reason}; 1761 + {gleshell_stdin, Data} -> 1762 + case key_data_to_codepoint(Data) of 1763 + empty -> 1764 + read_key_byte(); 1765 + C when is_integer(C) -> 1766 + C 1767 + end 1768 + end; 1769 + _ -> 1770 + read_key_byte_direct() 1771 + end. 1772 + 1773 + read_key_byte_direct() -> 1733 1774 case io:get_chars("", 1) of 1734 1775 eof -> 1735 1776 eof; 1736 1777 {error, Reason} -> 1737 1778 {error, Reason}; 1738 - <<C/utf8>> -> 1739 - decode_key(C, <<>>); 1740 - [C] when is_integer(C) -> 1741 - decode_key(C, <<>>); 1742 - Bin when is_binary(Bin), byte_size(Bin) > 0 -> 1779 + Data -> 1780 + case key_data_to_codepoint(Data) of 1781 + empty -> 1782 + read_key_byte_direct(); 1783 + C when is_integer(C) -> 1784 + C 1785 + end 1786 + end. 1787 + 1788 + key_data_to_codepoint(Data) -> 1789 + Bin = io_data_to_bin(Data), 1790 + case Bin of 1791 + <<>> -> 1792 + empty; 1793 + <<C/utf8, _/binary>> -> 1794 + C; 1795 + <<C, _/binary>> when is_integer(C) -> 1796 + C; 1797 + _ -> 1743 1798 case unicode:characters_to_list(Bin) of 1744 - [C | _] -> decode_key(C, <<>>); 1745 - _ -> read_key() 1746 - end; 1747 - List when is_list(List), List =/= [] -> 1748 - decode_key(hd(List), <<>>); 1749 - _ -> 1750 - read_key() 1799 + [C | _] when is_integer(C) -> 1800 + C; 1801 + _ -> 1802 + empty 1803 + end 1751 1804 end. 1752 1805 1753 1806 decode_key($\r, _) -> enter; ··· 1776 1829 other. 1777 1830 1778 1831 read_escape() -> 1779 - case io:get_chars("", 1) of 1832 + case read_key_byte() of 1780 1833 eof -> 1781 1834 %% Lone ESC (no follow-up) — treat as Escape. 1782 1835 esc; 1783 - <<?ESC>> -> 1836 + {error, _} -> 1837 + esc; 1838 + ?ESC -> 1784 1839 %% ESC ESC 1785 1840 esc; 1786 - <<"[">> -> 1841 + $[ -> 1787 1842 read_csi(); 1788 - <<$O>> -> 1843 + $O -> 1789 1844 %% SS3 sequences: OH = home, OF = end, OA/OB/OC/OD arrows 1790 - case io:get_chars("", 1) of 1791 - <<"A">> -> up; 1792 - <<"B">> -> down; 1793 - <<"C">> -> right; 1794 - <<"D">> -> left; 1795 - <<"H">> -> home; 1796 - <<"F">> -> 'end'; 1845 + case read_key_byte() of 1846 + $A -> up; 1847 + $B -> down; 1848 + $C -> right; 1849 + $D -> left; 1850 + $H -> home; 1851 + $F -> 'end'; 1797 1852 _ -> other 1798 1853 end; 1799 1854 %% Alt+letter arrives as ESC then the letter (meta). 1800 - <<$f>> -> 1855 + $f -> 1801 1856 alt_f; 1802 - <<$F>> -> 1857 + $F -> 1803 1858 alt_f; 1804 1859 _ -> 1805 1860 %% Unknown ESC sequence — Escape is the usual cancel key in TUIs. ··· 1810 1865 read_csi_params([]). 1811 1866 1812 1867 read_csi_params(Acc) -> 1813 - case io:get_chars("", 1) of 1868 + case read_key_byte() of 1814 1869 eof -> 1815 1870 other; 1816 - <<C/utf8>> when C >= $0, C =< $9 -> 1871 + {error, _} -> 1872 + other; 1873 + C when is_integer(C), C >= $0, C =< $9 -> 1817 1874 read_csi_params([C | Acc]); 1818 - <<$;>> -> 1875 + $; -> 1819 1876 read_csi_params([$; | Acc]); 1820 - <<$~>> -> 1877 + $~ -> 1821 1878 Params = lists:reverse(Acc), 1822 1879 case Params of 1823 1880 "1" -> home; ··· 1829 1886 "8" -> 'end'; 1830 1887 _ -> other 1831 1888 end; 1832 - <<"A">> -> 1889 + $A -> 1833 1890 up; 1834 - <<"B">> -> 1891 + $B -> 1835 1892 down; 1836 - <<"C">> -> 1893 + $C -> 1837 1894 right; 1838 - <<"D">> -> 1895 + $D -> 1839 1896 left; 1840 - <<"H">> -> 1897 + $H -> 1841 1898 home; 1842 - <<"F">> -> 1899 + $F -> 1843 1900 'end'; 1844 1901 _ -> 1845 1902 other 1846 1903 end. 1847 1904 1848 1905 %% --------------------------------------------------------------------------- 1906 + %% Stdin mux — single get_chars owner for the raw REPL 1907 + %% 1908 + %% Target: 1909 + %% line → forward bytes to the shell process (line editor) 1910 + %% {port, Port} → relay into a child PTY (interactive externals) 1911 + %% {interrupt, Parent} → Ctrl+C → interrupt msg; other keys become typeahead 1912 + %% 1913 + %% Retarget with set_stdin_target/1. The mux applies pending retargets *after* 1914 + %% each get_chars returns and *before* routing, so the first key after a 1915 + %% command is not lost to a dead Port or a killed get_chars client. 1916 + %% --------------------------------------------------------------------------- 1917 + 1918 + start_stdin_mux() -> 1919 + case get(gleshell_stdin_mux) of 1920 + Pid when is_pid(Pid) -> 1921 + Pid; 1922 + _ -> 1923 + Owner = self(), 1924 + GL = group_leader(), 1925 + Mux = spawn(fun() -> 1926 + group_leader(GL, self()), 1927 + stdin_mux_loop(Owner, line, []) 1928 + end), 1929 + put(gleshell_stdin_mux, Mux), 1930 + Mux 1931 + end. 1932 + 1933 + stop_stdin_mux() -> 1934 + case erase(gleshell_stdin_mux) of 1935 + Pid when is_pid(Pid) -> 1936 + Pid ! stop, 1937 + ok; 1938 + _ -> 1939 + ok 1940 + end. 1941 + 1942 + set_stdin_target(Target) -> 1943 + case get(gleshell_stdin_mux) of 1944 + Pid when is_pid(Pid) -> 1945 + Pid ! {set_target, Target}, 1946 + ok; 1947 + _ -> 1948 + ok 1949 + end. 1950 + 1951 + stdin_mux_loop(Owner, Target, Typeahead) -> 1952 + %% Deliver queued typeahead to the line editor before blocking again. 1953 + case {Target, Typeahead} of 1954 + {line, [Bin | Rest]} when is_binary(Bin) -> 1955 + Owner ! {gleshell_stdin, Bin}, 1956 + stdin_mux_loop(Owner, line, Rest); 1957 + _ -> 1958 + case io:get_chars("", 1) of 1959 + eof -> 1960 + {NewT, NewTA} = drain_mux_controls(Target, Typeahead), 1961 + case NewT of 1962 + line -> 1963 + Owner ! {gleshell_stdin, eof}; 1964 + _ -> 1965 + ok 1966 + end, 1967 + stdin_mux_loop(Owner, NewT, NewTA); 1968 + {error, Reason} -> 1969 + {NewT, NewTA} = drain_mux_controls(Target, Typeahead), 1970 + case NewT of 1971 + line -> 1972 + Owner ! {gleshell_stdin, {error, Reason}}; 1973 + _ -> 1974 + ok 1975 + end, 1976 + stdin_mux_loop(Owner, NewT, NewTA); 1977 + Data -> 1978 + Bin0 = io_data_to_bin(Data), 1979 + {NewT, NewTA0} = drain_mux_controls(Target, Typeahead), 1980 + case Bin0 of 1981 + <<>> -> 1982 + stdin_mux_loop(Owner, NewT, NewTA0); 1983 + Bin -> 1984 + case NewT of 1985 + line -> 1986 + %% Typeahead (from interrupt mode) first, then 1987 + %% this key — loop head delivers in order. 1988 + stdin_mux_loop(Owner, line, NewTA0 ++ [Bin]); 1989 + {port, Port} -> 1990 + mux_relay_port(Port, Bin), 1991 + stdin_mux_loop(Owner, NewT, NewTA0); 1992 + {interrupt, Parent} -> 1993 + NewTA1 = mux_interrupt_key(Parent, Bin, NewTA0), 1994 + stdin_mux_loop(Owner, NewT, NewTA1); 1995 + _ -> 1996 + stdin_mux_loop(Owner, NewT, NewTA0) 1997 + end 1998 + end 1999 + end 2000 + end. 2001 + 2002 + %% Apply all pending {set_target, _} / stop messages (non-blocking). 2003 + drain_mux_controls(Target, Typeahead) -> 2004 + receive 2005 + stop -> 2006 + exit(normal); 2007 + {set_target, NewTarget} -> 2008 + drain_mux_controls(NewTarget, Typeahead) 2009 + after 0 -> 2010 + {Target, Typeahead} 2011 + end. 2012 + 2013 + mux_relay_port(Port, <<3>> = Bin) -> 2014 + signal_port_group(Port, int), 2015 + catch port_command(Port, Bin), 2016 + ok; 2017 + mux_relay_port(Port, Bin) -> 2018 + catch port_command(Port, Bin), 2019 + ok. 2020 + 2021 + mux_interrupt_key(Parent, <<3>>, Typeahead) -> 2022 + Parent ! {gleshell_interrupt, mux}, 2023 + Typeahead; 2024 + mux_interrupt_key(_Parent, Bin, Typeahead) -> 2025 + %% Preserve typeahead typed while a capture-mode external runs. 2026 + Typeahead ++ [Bin]. 2027 + 2028 + %% --------------------------------------------------------------------------- 1849 2029 %% History persistence 1850 2030 %% --------------------------------------------------------------------------- 1851 2031 ··· 2438 2618 2439 2619 %% PTY capture: child sees a TTY (colors, auto decorations) but we only collect 2440 2620 %% output — nothing is relayed to the user's terminal. 2621 + %% 2622 + %% Always use a one-shot runner so we can `stty` the slave to the host size. 2623 + %% `script` PTYs start at 0×0 when Erlang's port is not a real TTY, which makes 2624 + %% systemctl/less/… truncate and paginate as if the window were tiny. 2441 2625 run_cmd_capture_pty(Script, Path, PortArgs, <<>>) -> 2442 - run_cmd_capture_pty_argv(Script, [Path | PortArgs]); 2626 + with_exec_runner(Path, PortArgs, undefined, fun(Runner) -> 2627 + run_cmd_capture_pty_argv(Script, [Runner]) 2628 + end); 2443 2629 run_cmd_capture_pty(Script, Path, PortArgs, Stdin) when is_binary(Stdin) -> 2444 2630 with_stdin_file(Stdin, fun(StdinPath) -> 2445 2631 with_exec_runner(Path, PortArgs, StdinPath, fun(Runner) -> ··· 2512 2698 %% `sh -c 'exec "$0" …' path` therefore becomes one mangled shell string and 2513 2699 %% the real binary never runs (fastfetch → empty output, exit 0). 2514 2700 %% 2515 - %% Empty stdin: pass Path/args through as simple tokens (`script -- cmd args`). 2516 - %% Non-empty stdin (pipeline → less): write a one-shot runner script that 2517 - %% redirects and execs, then `script -- /tmp/runner` (single path token). 2701 + %% Always write a one-shot runner (single path token for `script --`): 2702 + %% 1. `stty rows/columns` to the host size (PTY otherwise stays 0×0) 2703 + %% 2. `exec` the real command 2704 + %% Non-empty stdin (pipeline → less): also redirect stdin from the temp file. 2705 + %% Empty stdin: leave stdin on the PTY so key relay still reaches the child. 2518 2706 run_cmd_pty(Script, Path, PortArgs, TtyPath, <<>>) -> 2519 - run_cmd_pty_argv(Script, [Path | PortArgs], TtyPath, Path, PortArgs, <<>>); 2707 + with_exec_runner(Path, PortArgs, undefined, fun(Runner) -> 2708 + run_cmd_pty_argv(Script, [Runner], TtyPath, Path, PortArgs, <<>>) 2709 + end); 2520 2710 run_cmd_pty(Script, Path, PortArgs, TtyPath, Stdin) when is_binary(Stdin) -> 2521 2711 with_stdin_file(Stdin, fun(StdinPath) -> 2522 2712 with_exec_runner(Path, PortArgs, StdinPath, fun(Runner) -> ··· 2540 2730 ), 2541 2731 case file:open(TtyPath, [write, raw, binary]) of 2542 2732 {ok, TtyOut} -> 2543 - GL = group_leader(), 2544 - Reader = spawn(fun() -> 2545 - group_leader(GL, self()), 2546 - io_to_port(Port) 2547 - end), 2548 2733 put(gleshell_output_shown, true), 2549 - try 2550 - collect_output_relay(Port, TtyOut, <<>>) 2551 - after 2552 - exit(Reader, kill), 2553 - catch file:close(TtyOut), 2554 - catch port_close(Port) 2734 + case get(gleshell_stdin_mux) of 2735 + Mux when is_pid(Mux) -> 2736 + %% Prefer the long-lived mux: retarget to Port, never 2737 + %% kill a get_chars client (that stole the next ↑). 2738 + set_stdin_target({port, Port}), 2739 + try 2740 + collect_output_relay(Port, TtyOut, <<>>) 2741 + after 2742 + set_stdin_target(line), 2743 + catch file:close(TtyOut), 2744 + catch port_close(Port) 2745 + end; 2746 + _ -> 2747 + %% Non-raw / no mux: legacy per-command reader. 2748 + GL = group_leader(), 2749 + Reader = spawn(fun() -> 2750 + group_leader(GL, self()), 2751 + io_to_port(Port) 2752 + end), 2753 + try 2754 + collect_output_relay(Port, TtyOut, <<>>) 2755 + after 2756 + stop_io_client(Reader), 2757 + catch file:close(TtyOut), 2758 + catch port_close(Port) 2759 + end 2555 2760 end; 2556 2761 {error, _} -> 2557 2762 catch port_close(Port), ··· 2603 2808 E 2604 2809 end. 2605 2810 2606 - runner_script_body(Path, PortArgs, StdinPath) -> 2811 + %% One-shot runner body. StdinPath = undefined keeps stdin on the PTY (keys); 2812 + %% a filesystem path redirects stdin (pipeline data). Always size the slave 2813 + %% first: script(1) PTYs under Erlang ports report 0×0 without this. 2814 + runner_script_body(CmdPath, PortArgs, StdinPath) -> 2815 + {Rows, Cols} = host_term_dims(), 2816 + Stty = 2817 + case Rows > 0 andalso Cols > 0 of 2818 + true -> 2819 + [ 2820 + "stty rows ", 2821 + integer_to_list(Rows), 2822 + " columns ", 2823 + integer_to_list(Cols), 2824 + " 2>/dev/null\n" 2825 + ]; 2826 + false -> 2827 + [] 2828 + end, 2607 2829 ArgsQ = [[$\s, shell_single_quote(A)] || A <- PortArgs], 2830 + Redirect = 2831 + case StdinPath of 2832 + undefined -> 2833 + []; 2834 + Sp when is_list(Sp) -> 2835 + [" < ", shell_single_quote(Sp)]; 2836 + Sp when is_binary(Sp) -> 2837 + [" < ", shell_single_quote(Sp)] 2838 + end, 2608 2839 [ 2609 2840 "#!/bin/sh\n", 2841 + Stty, 2610 2842 "exec ", 2611 - shell_single_quote(Path), 2843 + shell_single_quote(CmdPath), 2612 2844 ArgsQ, 2613 - " < ", 2614 - shell_single_quote(StdinPath), 2845 + Redirect, 2615 2846 "\n" 2616 2847 ]. 2617 2848 2849 + %% Host terminal dimensions for child PTY `stty` and COLUMNS/LINES. 2850 + %% Prefer `stty size` on the real TTY device (ground truth). Erlang's 2851 + %% io:rows/columns often fail outside raw REPL and term_size/0 then returns 2852 + %% the 24×80 placeholders — which made systemctl truncate wide terminals. 2853 + host_term_dims() -> 2854 + case stty_host_size() of 2855 + {ok, R, C} -> 2856 + {R, C}; 2857 + _ -> 2858 + case term_size() of 2859 + {ok, {R, C}} when is_integer(R), R > 0, is_integer(C), C > 0 -> 2860 + {R, C}; 2861 + _ -> 2862 + {24, 80} 2863 + end 2864 + end. 2865 + 2866 + %% Parse `stty size` → {ok, Rows, Cols}. Uses the same device probe as stty_run 2867 + %% (concrete pts, then /dev/tty) so we work even when stdout is a pipe. 2868 + stty_host_size() -> 2869 + case stty_run(["size"]) of 2870 + {ok, Out} -> 2871 + case string:tokens(string:trim(Out, both, [$\s, $\t, $\n, $\r]), " \t") of 2872 + [Rs, Cs] -> 2873 + try 2874 + R = list_to_integer(Rs), 2875 + C = list_to_integer(Cs), 2876 + case R > 0 andalso C > 0 of 2877 + true -> 2878 + {ok, R, C}; 2879 + false -> 2880 + error 2881 + end 2882 + catch 2883 + _:_ -> 2884 + error 2885 + end; 2886 + _ -> 2887 + error 2888 + end; 2889 + _ -> 2890 + error 2891 + end. 2892 + 2618 2893 %% Safe single-quoted shell token (`foo'bar` → `'foo'\''bar'`). 2619 2894 shell_single_quote(S) when is_list(S) -> 2620 2895 [$' | shell_single_quote_chars(S) ++ "'"]; ··· 2704 2979 end. 2705 2980 2706 2981 %% Relay keypresses from the group leader to the child's PTY (script stdin). 2982 + %% Used only when the stdin mux is unavailable (non-raw fallback). 2707 2983 %% Ctrl+C (ETX / byte 3): SIGINT the child process group, and still write the 2708 2984 %% byte so the PTY line discipline can deliver SIGINT on the slave side too. 2709 2985 io_to_port(Port) -> ··· 2725 3001 io_to_port(Port) 2726 3002 end 2727 3003 end. 3004 + 3005 + %% Kill a short-lived get_chars client and wait until it is gone so the next 3006 + %% REPL read is less likely to race an orphaned I/O request. 3007 + stop_io_client(Pid) when is_pid(Pid) -> 3008 + case is_process_alive(Pid) of 3009 + false -> 3010 + ok; 3011 + true -> 3012 + MRef = erlang:monitor(process, Pid), 3013 + exit(Pid, kill), 3014 + receive 3015 + {'DOWN', MRef, process, Pid, _} -> 3016 + ok 3017 + after 1000 -> 3018 + ok 3019 + end 3020 + end; 3021 + stop_io_client(_) -> 3022 + ok. 2728 3023 2729 3024 io_data_to_bin(Bin) when is_binary(Bin) -> 2730 3025 Bin; ··· 2882 3177 _ = file:write(Tty, Bin), 2883 3178 collect_output_relay(Port, Tty, <<Acc/binary, Bin/binary>>); 2884 3179 {Port, {exit_status, Status}} -> 3180 + %% Retarget stdin immediately so the first post-command key is not 3181 + %% relayed into the dead Port (looked like empty ↑ history). 3182 + set_stdin_target(line), 2885 3183 {ok, {Status, normalize_pty_output(Acc)}}; 2886 3184 {'EXIT', Port, _Reason} -> 3185 + set_stdin_target(line), 2887 3186 {ok, {130, normalize_pty_output(Acc)}}; 2888 3187 {gleshell_interrupt, _} -> 2889 3188 signal_port_group(Port, int), ··· 2904 3203 Port, Tty, <<Acc/binary, Bin/binary>>, GraceMs 2905 3204 ); 2906 3205 {Port, {exit_status, Status}} -> 3206 + set_stdin_target(line), 2907 3207 {ok, {Status, normalize_pty_output(Acc)}}; 2908 3208 {'EXIT', Port, _Reason} -> 3209 + set_stdin_target(line), 2909 3210 {ok, {130, normalize_pty_output(Acc)}}; 2910 3211 {gleshell_interrupt, _} -> 2911 3212 signal_port_group(Port, kill), ··· 2915 3216 catch port_close(Port), 2916 3217 receive 2917 3218 {Port, {exit_status, Status}} -> 3219 + set_stdin_target(line), 2918 3220 {ok, {Status, normalize_pty_output(Acc)}}; 2919 3221 {'EXIT', Port, _} -> 3222 + set_stdin_target(line), 2920 3223 {ok, {130, normalize_pty_output(Acc)}} 2921 3224 after 1000 -> 3225 + set_stdin_target(line), 2922 3226 {ok, {130, normalize_pty_output(Acc)}} 2923 3227 end 2924 3228 end. ··· 2933 3237 2934 3238 with_interrupt_watch(_Port, Fun) when is_function(Fun, 0) -> 2935 3239 Parent = self(), 2936 - GL = group_leader(), 2937 - Watcher = 2938 - case can_watch_interrupt() of 2939 - true -> 3240 + case {can_watch_interrupt(), get(gleshell_stdin_mux)} of 3241 + {true, Mux} when is_pid(Mux) -> 3242 + %% Raw REPL: retarget the mux (no competing get_chars process). 3243 + set_stdin_target({interrupt, Parent}), 3244 + try 3245 + Fun() 3246 + after 3247 + set_stdin_target(line), 3248 + drain_interrupt_msgs() 3249 + end; 3250 + {true, _} -> 3251 + GL = group_leader(), 3252 + Watcher = 2940 3253 spawn(fun() -> 2941 3254 group_leader(GL, self()), 2942 3255 interrupt_watch_loop(Parent) 2943 - end); 2944 - false -> 2945 - undefined 2946 - end, 2947 - try 2948 - Fun() 2949 - after 2950 - case Watcher of 2951 - undefined -> 2952 - ok; 2953 - W -> 2954 - exit(W, kill), 3256 + end), 3257 + try 3258 + Fun() 3259 + after 3260 + stop_io_client(Watcher), 2955 3261 drain_interrupt_msgs() 2956 - end 3262 + end; 3263 + {false, _} -> 3264 + Fun() 2957 3265 end. 2958 3266 2959 3267 %% Collect port output without Ctrl+C watching (stty and other helpers). ··· 3087 3395 disable_nested_pagers(force_color_env(child_env_base())). 3088 3396 3089 3397 child_env_base() -> 3090 - Env0 = [{"SHELL", "/bin/sh"}], 3398 + {Rows, Cols} = host_term_dims(), 3399 + %% SHELL=/bin/sh: script(1) invokes $SHELL -c; nu/fish break that. 3400 + %% COLUMNS/LINES: tools that skip TIOCGWINSZ (or see a 0×0 PTY before 3401 + %% our runner stty) still format to the host width. 3402 + Env0 = [ 3403 + {"SHELL", "/bin/sh"}, 3404 + {"COLUMNS", integer_to_list(Cols)}, 3405 + {"LINES", integer_to_list(Rows)} 3406 + ], 3091 3407 case os:getenv("LESS") of 3092 3408 false -> 3093 3409 [{"LESS", "FRX"} | Env0];