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

Configure Feed

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

Improve external TTY, color pass-through, and which -a.

Final pipeline stages inherit the real TTY so tools like less/vim work; capture
mode still forces color when needed. Pre-colored external output is not
re-painted, table widths use visible length, and which -a lists all PATH matches.

author
nandi
date (Jul 25, 2026, 1:07 AM -0700) commit 36a72df9 parent 47ec91a4 change-id kuvwlxtu
+552 -89
+6 -1
README.md
··· 42 42 |-----|--------| 43 43 | ↑ / ↓ | History | 44 44 | **Tab** | Filename completion (common prefix; list matches if ambiguous) | 45 - | **Ctrl+R** | Reverse-i-search through history | 45 + | **Ctrl+R** | Reverse-i-search through history (Enter accepts onto the line) | 46 46 | Ctrl+A / Ctrl+E | Beginning / end of line | 47 47 | Ctrl+W | Delete previous word | 48 48 | Ctrl+U / Ctrl+K | Kill to start / end of line | ··· 82 82 # external programs (stdout captured as a string) 83 83 ^uname -a 84 84 which ls 85 + which -a ls 85 86 ``` 86 87 87 88 ## Language sketch ··· 130 131 131 132 Input and output are colorized on a TTY (Nu-like shapes on the command line; 132 133 headers bold green, numbers purple, bools cyan, dirs blue, errors red, …). 134 + External tools (`jj`, `git`, …) keep their own colors: final-stage commands 135 + inherit the real TTY (or get `FORCE_COLOR` when output is captured), and 136 + pre-colored text is not re-painted by the shell. Paginated output gets 137 + `LESS=FRX` when unset so `less` passes ANSI through. 133 138 Disable with `NO_COLOR=1`; force with `FORCE_COLOR=1`. 134 139 135 140 ## Status
+47 -9
src/gleshell/builtins.gleam
··· 165 165 "env [NAME] — process environment table, or one var (same as `$env` / `$env.NAME`)", 166 166 ), 167 167 #("sys", "sys — host info record"), 168 + #( 169 + "which", 170 + "which [-a|--all] <name> — path of command (builtin or on PATH); -a lists all matches", 171 + ), 168 172 ]) 169 173 } 170 174 ··· 1002 1006 env: Env, 1003 1007 _input: Value, 1004 1008 args: List(Value), 1005 - _flags: dict.Dict(String, Value), 1009 + flags: dict.Dict(String, Value), 1006 1010 ) -> BuiltinResult { 1007 - case args { 1008 - [String(name)] -> 1009 - case dict.has_key(registry(), name) { 1010 - True -> ok(env, String("builtin: " <> name)) 1011 + // `which -a name` is parsed as FlagArg("a", Some("name")) because the 1012 + // generic flag parser attaches the next expression as a flag value. 1013 + // Accept both `which -a name` and `which name -a` / `which --all name`. 1014 + let #(all, name_opt) = case args { 1015 + [String(n)] -> #( 1016 + flag_set(flags, "a") || flag_set(flags, "all"), 1017 + option.Some(n), 1018 + ) 1019 + [] -> 1020 + case dict.get(flags, "a"), dict.get(flags, "all") { 1021 + Ok(String(n)), _ -> #(True, option.Some(n)) 1022 + _, Ok(String(n)) -> #(True, option.Some(n)) 1023 + Ok(Bool(True)), _ | _, Ok(Bool(True)) -> #(True, option.None) 1024 + _, _ -> #(False, option.None) 1025 + } 1026 + _ -> #(False, option.None) 1027 + } 1028 + case name_opt { 1029 + option.Some(name) -> { 1030 + let is_builtin = dict.has_key(registry(), name) 1031 + case all { 1032 + True -> { 1033 + let paths = list.map(sys.which_all(name), String) 1034 + let matches = case is_builtin { 1035 + True -> [String("builtin: " <> name), ..paths] 1036 + False -> paths 1037 + } 1038 + case matches { 1039 + [] -> err(env, "which: " <> name <> " not found") 1040 + [one] -> ok(env, one) 1041 + many -> ok(env, List(many)) 1042 + } 1043 + } 1011 1044 False -> 1012 - case sys.which(name) { 1013 - Ok(path) -> ok(env, String(path)) 1014 - Error(Nil) -> err(env, "which: " <> name <> " not found") 1045 + case is_builtin { 1046 + True -> ok(env, String("builtin: " <> name)) 1047 + False -> 1048 + case sys.which(name) { 1049 + Ok(path) -> ok(env, String(path)) 1050 + Error(Nil) -> err(env, "which: " <> name <> " not found") 1051 + } 1015 1052 } 1016 1053 } 1017 - _ -> err(env, "which: expected name") 1054 + } 1055 + option.None -> err(env, "which: expected name (try `which [-a] <name>`)") 1018 1056 } 1019 1057 } 1020 1058
+6
src/gleshell/color.gleam
··· 250 250 paint(on, garbage, text) 251 251 } 252 252 253 + /// True if `s` already contains ANSI/VT escapes (e.g. external tool output). 254 + /// Such strings should be printed as-is rather than re-colored by the shell. 255 + pub fn contains_ansi(s: String) -> Bool { 256 + string.contains(s, "\u{001b}") 257 + } 258 + 253 259 /// Visible length ignoring ANSI CSI sequences (`ESC [ … final`). 254 260 pub fn visible_length(s: String) -> Int { 255 261 visible_length_loop(string.to_utf_codepoints(s), 0, AnsiNormal)
+24 -4
src/gleshell/display.gleam
··· 17 17 case value { 18 18 Nothing -> "" 19 19 Fail(msg) -> color.error(on, "Error: " <> msg) 20 + // Opaque text from external tools (jj, git, …): keep their ANSI as-is. 21 + // Multi-line output is almost always command text — do not paint it green. 22 + String(s) -> render_string(on, s) 20 23 Table(cols, rows) -> render_table_with(on, cols, rows) 21 24 List(items) -> { 22 25 case list.all(items, is_record) { ··· 30 33 } 31 34 Record(fields) -> render_record(on, fields) 32 35 other -> color_cell(on, "", other, value.cell_string(other)) 36 + } 37 + } 38 + 39 + /// External programs often embed their own colors. Pass those through. 40 + /// Short plain strings still get Nu-style string coloring. 41 + fn render_string(on: Bool, s: String) -> String { 42 + case color.contains_ansi(s) || string.contains(s, "\n") { 43 + True -> s 44 + False -> color.string_(on, s) 33 45 } 34 46 } 35 47 ··· 111 123 let plain_cells: List(List(String)) = 112 124 list.map(rows, fn(row) { list.map(row, value.cell_string) }) 113 125 126 + // Widths use visible length so cells with ANSI (from external tools) 127 + // do not inflate the table. 114 128 let widths = 115 129 list.index_map(columns, fn(col, i) { 116 - let header_w = string.length(col) 130 + let header_w = color.visible_length(col) 117 131 let data_w = 118 132 plain_cells 119 133 |> list.map(fn(row) { 120 134 case list_at(row, i) { 121 - Ok(c) -> string.length(c) 135 + Ok(c) -> color.visible_length(c) 122 136 Error(Nil) -> 0 123 137 } 124 138 }) ··· 183 197 // Color the unpadded text, then add trailing spaces outside ANSI codes 184 198 // so type/name matchers see exact values ("dir", not "dir "). 185 199 let painted = color_cell_for_column(on, col, val, plain, type_hint) 186 - let pad = string.repeat(" ", int.max(0, w - string.length(plain))) 200 + let pad = 201 + string.repeat(" ", int.max(0, w - color.visible_length(plain))) 187 202 " " <> painted <> pad <> " " 188 203 }) 189 204 // Extra empty columns if widths longer than columns (shouldn't happen). ··· 275 290 Bool(_) -> color.bool_(on, plain) 276 291 Int(_) -> color.int_(on, plain) 277 292 Float(_) -> color.float_(on, plain) 278 - String(_) -> color.string_(on, plain) 293 + // Preserve pre-colored external text inside tables/lists/records. 294 + String(_) -> 295 + case color.contains_ansi(plain) { 296 + True -> plain 297 + False -> color.string_(on, plain) 298 + } 279 299 Fail(_) -> color.error(on, plain) 280 300 List(_) | Record(_) | Table(_, _) -> color.string_(on, plain) 281 301 }
+93 -20
src/gleshell/eval.gleam
··· 34 34 35 35 fn eval_statement(env: Env, stmt: Statement) -> EvalResult { 36 36 case stmt { 37 + // Assignments always capture external output into a value. 37 38 Let(name, pipeline) -> 38 - case eval_pipeline(env, pipeline, Nothing) { 39 + case eval_pipeline(env, pipeline, Nothing, False) { 39 40 Quit(code) -> Quit(code) 40 41 Continue(env2, value) -> { 41 42 case value { ··· 45 46 } 46 47 } 47 48 EnvAssign(name, pipeline) -> 48 - case eval_pipeline(env, pipeline, Nothing) { 49 + case eval_pipeline(env, pipeline, Nothing, False) { 49 50 Quit(code) -> Quit(code) 50 51 Continue(env2, value) -> 51 52 case value { ··· 58 59 } 59 60 } 60 61 } 61 - parser.Expr(pipeline) -> eval_pipeline(env, pipeline, Nothing) 62 + // Bare expression: last external may take the real TTY (less, vim, …). 63 + parser.Expr(pipeline) -> eval_pipeline(env, pipeline, Nothing, True) 62 64 } 63 65 } 64 66 65 - fn eval_pipeline(env: Env, pipeline: Pipeline, input: Value) -> EvalResult { 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 + fn eval_pipeline( 70 + env: Env, 71 + pipeline: Pipeline, 72 + input: Value, 73 + allow_tty: Bool, 74 + ) -> EvalResult { 66 75 case pipeline { 67 - parser.Pipeline(commands) -> 68 - list.fold(commands, Continue(env, input), fn(acc, cmd) { 76 + parser.Pipeline(commands) -> { 77 + let total = list.length(commands) 78 + list.index_fold(commands, Continue(env, input), fn(acc, cmd, index) { 69 79 case acc { 70 80 Quit(code) -> Quit(code) 71 81 Continue(env2, value) -> 72 82 case value { 73 83 Fail(_) -> Continue(env2, value) 74 - _ -> eval_command(env2, cmd, value) 84 + _ -> { 85 + let is_last = index + 1 == total 86 + eval_command(env2, cmd, value, allow_tty && is_last) 87 + } 75 88 } 76 89 } 77 90 }) 91 + } 78 92 } 79 93 } 80 94 81 - fn eval_command(env: Env, cmd: Command, input: Value) -> EvalResult { 95 + fn eval_command( 96 + env: Env, 97 + cmd: Command, 98 + input: Value, 99 + interactive: Bool, 100 + ) -> EvalResult { 82 101 case cmd { 83 102 // Bare value stage produced by the parser for `$env`, `$x`, literals, … 84 103 parser.Command("__value__", [ValueArg(expr)], False) -> { ··· 91 110 } 92 111 parser.Command(name, args, external) -> { 93 112 let env = env.set_input(env, input) 94 - case eval_args(env, args) { 95 - Error(msg) -> Continue(env.set_exit(env, 1), Fail(msg)) 96 - Ok(#(pos, flags)) -> { 97 - case external { 98 - True -> run_external(env, name, pos) 99 - False -> { 113 + case external { 114 + // Externals: keep argv order exactly as written (`jj log -n 1`, not 115 + // `-n 1 log`). Flag-first reordering is only for builtins. 116 + True -> 117 + case eval_argv(env, args) { 118 + Error(msg) -> Continue(env.set_exit(env, 1), Fail(msg)) 119 + Ok(str_args) -> run_external(env, name, str_args, interactive) 120 + } 121 + False -> 122 + case eval_args(env, args) { 123 + Error(msg) -> Continue(env.set_exit(env, 1), Fail(msg)) 124 + Ok(#(pos, flags)) -> { 100 125 // Builtins produce a new value that was not streamed to the TTY. 101 126 sys.clear_output_shown() 102 127 case resolve_builtin(name, pos) { ··· 111 136 Continue(env2, value) 112 137 } 113 138 } 114 - // Unknown name → external binary (may set output_shown). 115 - Error(Nil) -> run_external(env, name, pos) 139 + // Unknown name → external binary (preserve order). 140 + Error(Nil) -> 141 + case eval_argv(env, args) { 142 + Error(msg) -> Continue(env.set_exit(env, 1), Fail(msg)) 143 + Ok(str_args) -> 144 + run_external(env, name, str_args, interactive) 145 + } 116 146 } 117 147 } 118 148 } 119 - } 120 149 } 121 150 } 122 151 } ··· 165 194 }) 166 195 } 167 196 197 + /// Flatten command args to an argv for external programs, preserving order. 198 + fn eval_argv(env: Env, args: List(Arg)) -> Result(List(String), String) { 199 + list.try_fold(args, [], fn(acc, arg) { 200 + case arg { 201 + ValueArg(expr) -> 202 + case eval_expr(env, expr) { 203 + Ok(v) -> Ok(list.append(acc, [value.as_string(v)])) 204 + Error(e) -> Error(e) 205 + } 206 + FlagArg(name, parser.None) -> { 207 + let flag = format_flag_name(name) 208 + Ok(list.append(acc, [flag])) 209 + } 210 + FlagArg(name, parser.Some(expr)) -> 211 + case eval_expr(env, expr) { 212 + Ok(v) -> { 213 + let flag = format_flag_name(name) 214 + Ok(list.append(acc, [flag, value.as_string(v)])) 215 + } 216 + Error(e) -> Error(e) 217 + } 218 + } 219 + }) 220 + } 221 + 222 + fn format_flag_name(name: String) -> String { 223 + case string.starts_with(name, "-") { 224 + True -> name 225 + False -> 226 + case string.length(name) { 227 + 1 -> "-" <> name 228 + _ -> "--" <> name 229 + } 230 + } 231 + } 232 + 168 233 fn eval_expr(env: Env, expr: Expr) -> Result(Value, String) { 169 234 case expr { 170 235 Lit(v) -> Ok(v) ··· 192 257 } 193 258 } 194 259 195 - fn run_external(env: Env, name: String, args: List(Value)) -> EvalResult { 196 - let str_args = list.map(args, value.as_string) 197 - case sys.run_cmd(name, str_args) { 260 + fn run_external( 261 + env: Env, 262 + name: String, 263 + str_args: List(String), 264 + interactive: Bool, 265 + ) -> EvalResult { 266 + let result = case interactive { 267 + True -> sys.run_cmd_tty(name, str_args) 268 + False -> sys.run_cmd(name, str_args) 269 + } 270 + case result { 198 271 Error(msg) -> Continue(env.set_exit(env, 127), Fail(msg)) 199 272 Ok(#(status, output)) -> { 200 273 let output = string.trim_end(output)
+4 -2
src/gleshell/highlight.gleam
··· 464 464 465 465 fn is_ident_start(c: String) -> Bool { 466 466 case c { 467 - "_" -> True 467 + // Paths: `.jj`, `..`, `./src`, `/tmp`, `~/code` (must match lexer) 468 + "_" | "." | "/" | "~" -> True 468 469 _ -> { 469 470 let lower = string.lowercase(c) 470 471 case lower { ··· 501 502 } 502 503 503 504 fn is_ident_continue(c: String) -> Bool { 504 - is_ident_start(c) || is_digit(c) || c == "-" || c == "." || c == "/" 505 + // Path-ish chars covered by is_ident_start (`.` `/` `~`); keep `-` mid-token. 506 + is_ident_start(c) || is_digit(c) || c == "-" 505 507 }
+4 -2
src/gleshell/lexer.gleam
··· 243 243 244 244 fn is_ident_start(c: String) -> Bool { 245 245 case c { 246 - "_" -> True 246 + // Paths: `.jj`, `..`, `./src`, `/tmp`, `~/code` 247 + "_" | "." | "/" | "~" -> True 247 248 _ -> { 248 249 let lower = string.lowercase(c) 249 250 case lower { ··· 280 281 } 281 282 282 283 fn is_ident_continue(c: String) -> Bool { 283 - is_ident_start(c) || is_digit(c) || c == "-" || c == "." || c == "/" 284 + // Path-ish chars: letters/digits already covered; keep `.` `/` `-` `~` mid-token. 285 + is_ident_start(c) || is_digit(c) || c == "-" 284 286 }
+14 -1
src/gleshell/sys.gleam
··· 29 29 @external(erlang, "gleshell_ffi", "list_env") 30 30 pub fn list_env() -> List(#(String, String)) 31 31 32 + /// Run an external command capturing stdout/stderr (pipelines, `let`, non-TTY). 32 33 @external(erlang, "gleshell_ffi", "run_cmd") 33 34 pub fn run_cmd( 34 35 command: String, 35 36 args: List(String), 36 37 ) -> Result(#(Int, String), String) 37 38 39 + /// Run an external command in the foreground on the real TTY when possible 40 + /// (`less`, `vim`, …). Falls back to capture when stdout is not a terminal. 41 + @external(erlang, "gleshell_ffi", "run_cmd_tty") 42 + pub fn run_cmd_tty( 43 + command: String, 44 + args: List(String), 45 + ) -> Result(#(Int, String), String) 46 + 38 47 /// True if the last external command already streamed its output to the TTY 39 - /// (PTY relay for interactive tools like `run0`). Consumes the flag. 48 + /// (inherit or PTY relay). Consumes the flag. 40 49 @external(erlang, "gleshell_ffi", "take_output_shown") 41 50 pub fn take_output_shown() -> Bool 42 51 ··· 46 55 47 56 @external(erlang, "gleshell_ffi", "which") 48 57 pub fn which(command: String) -> Result(String, Nil) 58 + 59 + /// All matching executables on `PATH` (or the path itself if it contains `/`). 60 + @external(erlang, "gleshell_ffi", "which_all") 61 + pub fn which_all(command: String) -> List(String) 49 62 50 63 @external(erlang, "gleshell_ffi", "home_dir") 51 64 pub fn home_dir() -> Result(String, String)
+276 -50
src/gleshell_ffi.erl
··· 1 1 %% Erlang FFI for gleshell: REPL I/O, cwd, env, external processes. 2 2 -module(gleshell_ffi). 3 + -include_lib("kernel/include/file.hrl"). 3 4 -export([ 4 5 get_line/1, 5 6 parse_line/2, ··· 11 12 setenv/2, 12 13 list_env/0, 13 14 run_cmd/2, 15 + run_cmd_tty/2, 14 16 which/1, 17 + which_all/1, 15 18 home_dir/0, 16 19 stdout_isatty/0, 17 20 println/1, ··· 652 655 io:put_chars("\r\n"), 653 656 {error, <<"eof">>}; 654 657 enter -> 658 + %% Accept match onto the edit line; do not submit (edit first). 655 659 Line = iolist_to_binary(Match), 656 - io:put_chars("\r\n"), 657 - push_history(Line), 658 - {ok, Line}; 660 + {L, R} = bin_to_buffer(Line), 661 + redraw(Prompt, L, R), 662 + raw_loop(Prompt, L, R, History, 0, <<>>); 659 663 ctrl_c -> 660 664 io:put_chars("\r\n"), 661 665 redraw(Prompt, [], []), ··· 998 1002 999 1003 -spec which(binary()) -> {ok, binary()} | {error, nil}. 1000 1004 which(Command) when is_binary(Command) -> 1001 - case os:find_executable(unicode:characters_to_list(Command)) of 1005 + case which_all(Command) of 1006 + [Path | _] -> 1007 + {ok, Path}; 1008 + [] -> 1009 + {error, nil} 1010 + end. 1011 + 1012 + %% All matching executables on PATH (or the path itself if absolute/relative). 1013 + %% Order matches PATH search; duplicates from the same resolved path are dropped. 1014 + -spec which_all(binary()) -> [binary()]. 1015 + which_all(Command) when is_binary(Command) -> 1016 + Cmd = unicode:characters_to_list(Command), 1017 + case Cmd of 1018 + [] -> 1019 + []; 1020 + _ -> 1021 + case has_path_sep(Cmd) of 1022 + true -> 1023 + case is_executable_file(Cmd) of 1024 + true -> 1025 + [unicode:characters_to_binary(filename:absname(Cmd))]; 1026 + false -> 1027 + [] 1028 + end; 1029 + false -> 1030 + case os:getenv("PATH") of 1031 + false -> 1032 + []; 1033 + PathStr -> 1034 + Dirs = string:tokens(PathStr, path_sep()), 1035 + find_all_in_path(Cmd, Dirs, #{}, []) 1036 + end 1037 + end 1038 + end. 1039 + 1040 + path_sep() -> 1041 + case os:type() of 1042 + {win32, _} -> ";"; 1043 + _ -> ":" 1044 + end. 1045 + 1046 + has_path_sep(Cmd) -> 1047 + lists:member($/, Cmd) orelse lists:member($\\, Cmd). 1048 + 1049 + find_all_in_path(_Cmd, [], _Seen, Acc) -> 1050 + lists:reverse(Acc); 1051 + find_all_in_path(Cmd, [Dir | Rest], Seen, Acc) -> 1052 + File = filename:join(Dir, Cmd), 1053 + case is_executable_file(File) of 1054 + true -> 1055 + Abs = filename:absname(File), 1056 + Bin = unicode:characters_to_binary(Abs), 1057 + case maps:is_key(Abs, Seen) of 1058 + true -> 1059 + find_all_in_path(Cmd, Rest, Seen, Acc); 1060 + false -> 1061 + find_all_in_path(Cmd, Rest, Seen#{Abs => true}, [Bin | Acc]) 1062 + end; 1002 1063 false -> 1003 - {error, nil}; 1004 - Path -> 1005 - {ok, unicode:characters_to_binary(Path)} 1064 + find_all_in_path(Cmd, Rest, Seen, Acc) 1065 + end. 1066 + 1067 + is_executable_file(Path) -> 1068 + case file:read_file_info(Path) of 1069 + {ok, #file_info{type = regular, mode = Mode}} -> 1070 + %% Any execute bit (owner/group/other). 1071 + (Mode band 8#111) =/= 0; 1072 + _ -> 1073 + false 1006 1074 end. 1007 1075 1008 1076 -spec home_dir() -> {ok, binary()} | {error, binary()}. ··· 1034 1102 %% --------------------------------------------------------------------------- 1035 1103 %% External commands 1036 1104 %% 1037 - %% Erlang's erl_child_setup detaches from the controlling TTY, so tools that 1038 - %% need interactive auth (run0, sudo, pkexec, polkit/pkttyagent) fail with: 1039 - %% "interactive authentication has not been enabled by the calling program" 1105 + %% Two modes: 1106 + %% 1107 + %% 1. `run_cmd/2` — capture stdout/stderr into a binary (pipelines, `let x =`, 1108 + %% non-TTY). Uses pipes; the child does NOT get a real terminal. 1109 + %% 1110 + %% 2. `run_cmd_tty/2` — foreground interactive. The child inherits the real 1111 + %% stdio FDs (`nouse_stdio`) so pagers (`less`), editors (`vim`), and most 1112 + %% TUI tools work. BEAM only waits on exit status; keys go straight to the 1113 + %% child (no broken PTY byte-relay). 1040 1114 %% 1041 - %% When a TTY is available and util-linux `script` is on PATH, we allocate a 1042 - %% PTY for the child and relay bytes between that session and the real 1043 - %% terminal so password prompts work. Output is still captured for pipelines. 1115 + %% Auth tools (`sudo`, `run0`, …) need a *controlling* TTY; erl_child_setup 1116 + %% calls setsid, so plain inherit is not enough. For those we wrap with 1117 + %% util-linux `script` to allocate a PTY and relay keys via `io:get_chars` 1118 + %% (same path as the raw line editor — a competing file:read on /dev/pts 1119 + %% never sees keypresses while prim_tty owns the device). 1044 1120 %% --------------------------------------------------------------------------- 1045 1121 1046 1122 -spec run_cmd(binary(), [binary()]) -> {ok, {integer(), binary()}} | {error, binary()}. 1047 1123 run_cmd(Command, Args) when is_binary(Command), is_list(Args) -> 1048 - case os:find_executable(unicode:characters_to_list(Command)) of 1049 - false -> 1050 - {error, <<"command not found: ", Command/binary>>}; 1051 - Path -> 1052 - PortArgs = [unicode:characters_to_list(A) || A <- Args], 1124 + case resolve_cmd(Command, Args) of 1125 + {error, _} = E -> 1126 + E; 1127 + {ok, Path, PortArgs} -> 1053 1128 try 1054 - case {os:find_executable("script"), find_tty_path()} of 1055 - {Script, {ok, Tty}} when is_list(Script) -> 1056 - run_cmd_pty(Script, Path, PortArgs, Tty); 1057 - _ -> 1058 - run_cmd_direct(Path, PortArgs) 1129 + run_cmd_capture(Path, PortArgs) 1130 + catch 1131 + _:Reason -> 1132 + {error, reason_to_bin(Reason)} 1133 + end 1134 + end. 1135 + 1136 + %% Foreground interactive: inherit TTY when possible. 1137 + -spec run_cmd_tty(binary(), [binary()]) -> {ok, {integer(), binary()}} | {error, binary()}. 1138 + run_cmd_tty(Command, Args) when is_binary(Command), is_list(Args) -> 1139 + case resolve_cmd(Command, Args) of 1140 + {error, _} = E -> 1141 + E; 1142 + {ok, Path, PortArgs} -> 1143 + try 1144 + case stdout_isatty() of 1145 + false -> 1146 + run_cmd_capture(Path, PortArgs); 1147 + true -> 1148 + case {needs_controlling_tty(Path), os:find_executable("script"), find_tty_path()} of 1149 + {true, Script, {ok, Tty}} when is_list(Script) -> 1150 + run_cmd_pty(Script, Path, PortArgs, Tty); 1151 + _ -> 1152 + run_cmd_inherit(Path, PortArgs) 1153 + end 1059 1154 end 1060 1155 catch 1061 1156 _:Reason -> ··· 1063 1158 end 1064 1159 end. 1065 1160 1066 - run_cmd_direct(Path, PortArgs) -> 1161 + resolve_cmd(Command, Args) -> 1162 + case os:find_executable(unicode:characters_to_list(Command)) of 1163 + false -> 1164 + {error, <<"command not found: ", Command/binary>>}; 1165 + Path -> 1166 + PortArgs = [unicode:characters_to_list(A) || A <- Args], 1167 + {ok, Path, PortArgs} 1168 + end. 1169 + 1170 + %% Basename check for tools that need a controlling TTY (not just isatty). 1171 + needs_controlling_tty(Path) when is_list(Path) -> 1172 + Base = filename:basename(Path), 1173 + lists:member(Base, ["sudo", "run0", "pkexec", "doas", "su"]). 1174 + 1175 + %% Capture mode: pipes, no TTY. `child_env` forces color when the shell wants 1176 + %% it so tools like `jj` still embed ANSI we can pass through on display. 1177 + %% 1178 + %% Stdin is redirected from /dev/null via `sh -c` so programs that read stdin 1179 + %% (bare `less`, `cat`, `wc`) get EOF immediately instead of hanging on an 1180 + %% open-but-never-written pipe. (Port option `out` alone breaks exit_status 1181 + %% delivery on current OTP.) 1182 + run_cmd_capture(Path, PortArgs) -> 1183 + Sh = 1184 + case os:find_executable("sh") of 1185 + false -> 1186 + "/bin/sh"; 1187 + S -> 1188 + S 1189 + end, 1190 + %% sh -c 'exec "$0" "$@" < /dev/null' path arg1 arg2 ... 1191 + %% $0 = Path; "$@" = remaining args — no shell-quoting of user args. 1067 1192 Port = open_port( 1068 - {spawn_executable, Path}, 1193 + {spawn_executable, Sh}, 1069 1194 [ 1070 1195 binary, 1071 1196 exit_status, 1072 1197 stderr_to_stdout, 1073 1198 use_stdio, 1074 1199 stream, 1200 + {env, child_env()}, 1201 + {args, ["-c", "exec \"$0\" \"$@\" < /dev/null", Path | PortArgs]} 1202 + ] 1203 + ), 1204 + put(gleshell_output_shown, false), 1205 + collect_output(Port, <<>>). 1206 + 1207 + %% Inherit real stdio — pagers/editors talk to the terminal directly. 1208 + %% LESS=FRX (via child_env) lets less pass ANSI colors from jj/git. 1209 + run_cmd_inherit(Path, PortArgs) -> 1210 + Port = open_port( 1211 + {spawn_executable, Path}, 1212 + [ 1213 + exit_status, 1214 + nouse_stdio, 1215 + {env, child_env()}, 1075 1216 {args, PortArgs} 1076 1217 ] 1077 1218 ), 1078 - collect_output(Port, <<>>). 1219 + put(gleshell_output_shown, true), 1220 + %% No timeout: less/vim/top may run for a long time. 1221 + receive 1222 + {Port, {exit_status, Status}} -> 1223 + {ok, {Status, <<>>}} 1224 + end. 1079 1225 1080 - %% Run via `script` so the child gets a controlling TTY (PTY), and relay I/O 1081 - %% with the real terminal for interactive authentication and prompts. 1226 + %% Controlling-TTY + key relay for sudo/run0/etc. 1082 1227 run_cmd_pty(Script, Path, PortArgs, TtyPath) -> 1083 1228 Port = open_port( 1084 1229 {spawn_executable, Script}, ··· 1088 1233 stderr_to_stdout, 1089 1234 use_stdio, 1090 1235 stream, 1091 - %% POSIX shell: caller's $SHELL may be nu/fish and break -c/--. 1092 - {env, [{"SHELL", "/bin/sh"}]}, 1236 + {env, child_env()}, 1093 1237 {args, ["-q", "-e", "/dev/null", "--", Path | PortArgs]} 1094 1238 ] 1095 1239 ), 1096 1240 case file:open(TtyPath, [write, raw, binary]) of 1097 1241 {ok, TtyOut} -> 1098 - %% Raw fds are process-local: reader opens its own handle. 1242 + GL = group_leader(), 1099 1243 Reader = spawn(fun() -> 1100 - case file:open(TtyPath, [read, raw, binary]) of 1101 - {ok, TtyIn} -> 1102 - tty_to_port(TtyIn, Port); 1103 - {error, _} -> 1104 - ok 1105 - end 1244 + group_leader(GL, self()), 1245 + io_to_port(Port) 1106 1246 end), 1107 1247 put(gleshell_output_shown, true), 1108 1248 try ··· 1112 1252 catch file:close(TtyOut) 1113 1253 end; 1114 1254 {error, _} -> 1115 - %% Could not open the TTY for relay; still better than no PTY. 1116 1255 put(gleshell_output_shown, false), 1117 - collect_output(Port, <<>>) 1256 + %% Fall back to inherit rather than a silent capture hang. 1257 + run_cmd_inherit(Path, PortArgs) 1118 1258 end. 1119 1259 1120 - tty_to_port(Tty, Port) -> 1121 - case file:read(Tty, 256) of 1122 - {ok, Data} when is_binary(Data), Data =/= <<>> -> 1123 - catch port_command(Port, Data), 1124 - tty_to_port(Tty, Port); 1125 - {ok, _} -> 1126 - tty_to_port(Tty, Port); 1260 + %% Relay keypresses from the group leader to the child's PTY (script stdin). 1261 + io_to_port(Port) -> 1262 + case io:get_chars("", 1) of 1127 1263 eof -> 1128 1264 ok; 1129 1265 {error, _} -> 1130 - ok 1266 + ok; 1267 + Data -> 1268 + case io_data_to_bin(Data) of 1269 + <<>> -> 1270 + io_to_port(Port); 1271 + Bin -> 1272 + catch port_command(Port, Bin), 1273 + io_to_port(Port) 1274 + end 1131 1275 end. 1132 1276 1277 + io_data_to_bin(Bin) when is_binary(Bin) -> 1278 + Bin; 1279 + io_data_to_bin([C]) when is_integer(C), C >= 0, C =< 16#7F -> 1280 + <<C>>; 1281 + io_data_to_bin(List) when is_list(List) -> 1282 + case unicode:characters_to_binary(List) of 1283 + Bin when is_binary(Bin) -> 1284 + Bin; 1285 + _ -> 1286 + <<>> 1287 + end; 1288 + io_data_to_bin(_) -> 1289 + <<>>. 1290 + 1133 1291 collect_output(Port, Acc) -> 1134 1292 receive 1135 1293 {Port, {data, Data}} when is_binary(Data) -> ··· 1144 1302 {error, <<"command timed out after 120s">>} 1145 1303 end. 1146 1304 1305 + %% PTY auth session: no timeout (password prompts, etc.). 1147 1306 collect_output_relay(Port, Tty, Acc) -> 1148 1307 receive 1149 1308 {Port, {data, Data}} when is_binary(Data) -> ··· 1155 1314 collect_output_relay(Port, Tty, <<Acc/binary, Bin/binary>>); 1156 1315 {Port, {exit_status, Status}} -> 1157 1316 {ok, {Status, normalize_pty_output(Acc)}} 1158 - after 120_000 -> 1159 - catch port_close(Port), 1160 - {error, <<"command timed out after 120s">>} 1161 1317 end. 1162 1318 1163 1319 %% PTY line discipline often emits CR-LF; normalize to LF for structured use. 1164 1320 normalize_pty_output(Bin) when is_binary(Bin) -> 1165 1321 binary:replace(Bin, <<"\r\n">>, <<"\n">>, [global]). 1322 + 1323 + %% Extra env for external commands (merged into the process environment). 1324 + %% 1325 + %% - SHELL=/bin/sh: `script` invokes $SHELL; nu/fish break `script -c`. 1326 + %% - LESS=FRX when unset: pagers (jj/git → less) pass through ANSI colors (-R) 1327 + %% and exit on short output (-F) without clearing the screen (-X). 1328 + %% - FORCE_COLOR / CLICOLOR_FORCE when the shell itself wants color and the 1329 + %% child has no TTY (direct path): tools like jj/git/ripgrep emit ANSI so 1330 + %% we can show their colors when re-printing the captured string. 1331 + child_env() -> 1332 + Env0 = [{"SHELL", "/bin/sh"}], 1333 + Env1 = 1334 + case os:getenv("LESS") of 1335 + false -> 1336 + [{"LESS", "FRX"} | Env0]; 1337 + "" -> 1338 + [{"LESS", "FRX"} | Env0]; 1339 + _ -> 1340 + Env0 1341 + end, 1342 + case want_child_color() of 1343 + false -> 1344 + Env1; 1345 + true -> 1346 + Env2 = 1347 + case os:getenv("FORCE_COLOR") of 1348 + false -> 1349 + [{"FORCE_COLOR", "1"} | Env1]; 1350 + "0" -> 1351 + Env1; 1352 + _ -> 1353 + Env1 1354 + end, 1355 + case os:getenv("CLICOLOR_FORCE") of 1356 + false -> 1357 + [{"CLICOLOR_FORCE", "1"} | Env2]; 1358 + "0" -> 1359 + Env2; 1360 + _ -> 1361 + Env2 1362 + end 1363 + end. 1364 + 1365 + %% Match gleshell color policy: off under NO_COLOR; otherwise on for a TTY 1366 + %% or when the parent already forces color. 1367 + want_child_color() -> 1368 + case os:getenv("NO_COLOR") of 1369 + L when is_list(L), L =/= "" -> 1370 + false; 1371 + _ -> 1372 + case stdout_isatty() of 1373 + true -> 1374 + true; 1375 + false -> 1376 + force_color_set() 1377 + end 1378 + end. 1379 + 1380 + force_color_set() -> 1381 + case os:getenv("FORCE_COLOR") of 1382 + L when is_list(L), L =/= "", L =/= "0" -> 1383 + true; 1384 + _ -> 1385 + case os:getenv("CLICOLOR_FORCE") of 1386 + L when is_list(L), L =/= "", L =/= "0" -> 1387 + true; 1388 + _ -> 1389 + false 1390 + end 1391 + end. 1166 1392 1167 1393 %% True when the last external command already streamed output to the TTY 1168 1394 %% (PTY relay). Cleared after being read so the REPL does not double-print.
+78
test/gleshell_test.gleam
··· 35 35 Nil 36 36 } 37 37 38 + pub fn lexer_path_idents_test() { 39 + // Dotfiles, relative/absolute paths, home — must be bare words for `ls .jj` etc. 40 + let assert Ok(tokens) = lexer.tokenize("ls .jj ./src ../foo /tmp ~ ~/code") 41 + let assert [ 42 + lexer.Ident("ls"), 43 + lexer.Ident(".jj"), 44 + lexer.Ident("./src"), 45 + lexer.Ident("../foo"), 46 + lexer.Ident("/tmp"), 47 + lexer.Ident("~"), 48 + lexer.Ident("~/code"), 49 + lexer.Eof, 50 + ] = tokens 51 + Nil 52 + } 53 + 54 + pub fn parse_ls_dotfile_test() { 55 + let assert Ok(parser.Expr(parser.Pipeline([ 56 + parser.Command("ls", args, False), 57 + ]))) = parser.parse("ls .jj") 58 + let assert [parser.ValueArg(parser.Lit(String(".jj")))] = args 59 + Nil 60 + } 61 + 38 62 // --- parser --- 39 63 40 64 pub fn parse_pipeline_test() { ··· 242 266 Nil 243 267 } 244 268 269 + pub fn eval_which_builtin_test() { 270 + let env = env.new() 271 + let assert eval.Continue(_, String("builtin: ls")) = 272 + eval.eval_source(env, "which ls") 273 + Nil 274 + } 275 + 276 + pub fn eval_which_all_test() { 277 + let env = env.new() 278 + // `-a` includes the builtin first, then any PATH copies of `ls`. 279 + let assert eval.Continue(_, result) = eval.eval_source(env, "which -a ls") 280 + case result { 281 + String("builtin: ls") -> Nil 282 + List([String("builtin: ls"), ..]) -> Nil 283 + other -> { 284 + let _ = other 285 + panic as "which -a ls should start with builtin: ls" 286 + } 287 + } 288 + } 289 + 290 + pub fn eval_which_external_test() { 291 + let env = env.new() 292 + // `sh` is not a gleshell builtin; should resolve via PATH. 293 + let assert eval.Continue(_, String(path)) = eval.eval_source(env, "which sh") 294 + let assert True = string.contains(path, "sh") 295 + Nil 296 + } 297 + 245 298 pub fn value_nothing_falsey_test() { 246 299 let assert False = value.is_truthy(Nothing) 247 300 let assert True = value.is_truthy(Int(1)) ··· 261 314 let text = display.render_with(True, Bool(True)) 262 315 let assert True = string_contains(text, "\u{001b}") 263 316 let assert True = string_contains(text, "true") 317 + Nil 318 + } 319 + 320 + pub fn display_preserves_external_ansi_test() { 321 + // Pre-colored tool output (jj, git, …) must not be re-wrapped in string green. 322 + let app = "\u{001b}[1;31merror\u{001b}[0m: boom" 323 + let text = display.render_with(True, String(app)) 324 + let assert True = text == app 325 + Nil 326 + } 327 + 328 + pub fn display_multiline_string_not_recolored_test() { 329 + let multi = "line1\nline2" 330 + let text = display.render_with(True, String(multi)) 331 + let assert True = text == multi 264 332 Nil 265 333 } 266 334 ··· 335 403 let text = highlight.highlight(True, "range 3 | to json") 336 404 let assert True = string_contains(text, "to json") 337 405 let assert True = string_contains(text, "\u{001b}[1;36m") 406 + Nil 407 + } 408 + 409 + pub fn highlight_path_arg_not_garbage_test() { 410 + // Dotfile paths must not use shape_garbage (white-on-red) 411 + let text = highlight.highlight(True, "ls .jj ./src /tmp ~/code") 412 + let assert True = string_contains(text, ".jj") 413 + let assert False = string_contains(text, "\u{001b}[1;37;41m") 414 + // args use shape_externalarg (bold green) 415 + let assert True = string_contains(text, "\u{001b}[1;32m") 338 416 Nil 339 417 } 340 418