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

Configure Feed

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

gleshell / src / gleshell_ffi.erl
96 kB 2908 lines
1%% Erlang FFI for gleshell: REPL I/O, cwd, env, external processes. 2-module(gleshell_ffi). 3-include_lib("kernel/include/file.hrl"). 4-export([ 5 get_line/1, 6 parse_line/2, 7 run_as_shell/1, 8 spawn_shell/2, 9 set_cwd/1, 10 get_cwd/0, 11 getenv/1, 12 setenv/2, 13 list_env/0, 14 run_cmd/3, 15 run_cmd_tty/3, 16 which/1, 17 which_all/1, 18 home_dir/0, 19 stdout_isatty/0, 20 println/1, 21 write/1, 22 term_size/0, 23 read_key_name/0, 24 with_key_mode/1, 25 take_output_shown/0, 26 clear_output_shown/0, 27 complete_word/2, 28 history_hint/2, 29 re_contains/3 30]). 31 32-define(ESC, 16#1b). 33-define(CSI_CLEAR_EOL, "\e[K"). 34-define(HISTORY_MAX, 2000). 35 36%% --------------------------------------------------------------------------- 37%% Public: write a line (CRLF in raw TTY mode so multi-line output does not 38%% staircase — raw mode does not map LF → CR+LF the way cooked mode does). 39%% --------------------------------------------------------------------------- 40 41-spec println(binary()) -> nil. 42println(Text) when is_binary(Text) -> 43 case get(gleshell_raw) of 44 true -> 45 io:put_chars([to_crlf(Text), <<"\r\n">>]), 46 nil; 47 _ -> 48 io:put_chars([Text, $\n]), 49 nil 50 end. 51 52%% Write text without an automatic trailing newline. ANSI sequences are passed 53%% through unchanged. In raw TTY mode, bare LFs become CRLF so lines do not 54%% staircase (same as println/1). 55-spec write(binary()) -> nil. 56write(Text) when is_binary(Text) -> 57 case get(gleshell_raw) of 58 true -> 59 io:put_chars(to_crlf(Text)), 60 nil; 61 _ -> 62 io:put_chars(Text), 63 nil 64 end. 65 66%% Terminal size when stdout is a TTY. Defaults to 24×80 if the driver omits a 67%% dimension. Error when stdout is not a terminal (pager should dump instead). 68-spec term_size() -> {ok, {integer(), integer()}} | {error, nil}. 69term_size() -> 70 case stdout_isatty() of 71 false -> 72 {error, nil}; 73 true -> 74 Rows = 75 case io:rows() of 76 {ok, R} when is_integer(R), R > 0 -> 77 R; 78 _ -> 79 24 80 end, 81 Cols = 82 case io:columns() of 83 {ok, C} when is_integer(C), C > 0 -> 84 C; 85 _ -> 86 80 87 end, 88 {ok, {Rows, Cols}} 89 end. 90 91%% Run Fun with the TTY in single-key (non-canonical, no-echo) mode when the 92%% REPL is not already raw. Restores termios afterwards. Prefer wrapping a 93%% whole pager session rather than each keystroke. 94-spec with_key_mode(fun(() -> term())) -> term(). 95with_key_mode(Fun) when is_function(Fun, 0) -> 96 case get(gleshell_raw) of 97 true -> 98 Fun(); 99 _ -> 100 case stdout_isatty() of 101 false -> 102 Fun(); 103 true -> 104 case stty_save() of 105 {ok, Saved} -> 106 _ = stty_run(["-icanon", "-echo", "min", "1", "time", "0"]), 107 try 108 Fun() 109 after 110 stty_restore(Saved) 111 end; 112 _ -> 113 Fun() 114 end 115 end 116 end. 117 118%% Blocking single-key read for the builtin pager. Returns a short name: 119%% "up" | "down" | "left" | "right" | "home" | "end" | "page_up" | "page_down" 120%% | "enter" | "backspace" | "tab" | "ctrl_c" | "ctrl_l" | "space" | "eof" 121%% | single printable grapheme ("q", "j", "G", …) | "other". 122%% 123%% Call inside `with_key_mode/1` when not already in the raw REPL, so cooked 124%% TTYs (e.g. `gleshell -c '… | less'`) still deliver keys one at a time. 125-spec read_key_name() -> {ok, binary()} | {error, binary()}. 126read_key_name() -> 127 try 128 {ok, key_to_name(read_key())} 129 catch 130 _:Reason -> 131 {error, reason_to_bin(Reason)} 132 end. 133 134key_to_name(eof) -> 135 <<"eof">>; 136key_to_name(enter) -> 137 <<"enter">>; 138key_to_name(tab) -> 139 <<"tab">>; 140key_to_name(backspace) -> 141 <<"backspace">>; 142key_to_name(delete) -> 143 <<"delete">>; 144key_to_name(up) -> 145 <<"up">>; 146key_to_name(down) -> 147 <<"down">>; 148key_to_name(left) -> 149 <<"left">>; 150key_to_name(right) -> 151 <<"right">>; 152key_to_name(home) -> 153 <<"home">>; 154key_to_name('end') -> 155 <<"end">>; 156key_to_name(page_up) -> 157 <<"page_up">>; 158key_to_name(page_down) -> 159 <<"page_down">>; 160key_to_name(ctrl_c) -> 161 <<"ctrl_c">>; 162key_to_name(ctrl_l) -> 163 <<"ctrl_l">>; 164key_to_name(ctrl_a) -> 165 <<"ctrl_a">>; 166key_to_name(ctrl_e) -> 167 <<"ctrl_e">>; 168key_to_name(ctrl_d) -> 169 <<"ctrl_d">>; 170key_to_name(ctrl_k) -> 171 <<"ctrl_k">>; 172key_to_name(ctrl_u) -> 173 <<"ctrl_u">>; 174key_to_name(ctrl_w) -> 175 <<"ctrl_w">>; 176key_to_name(ctrl_r) -> 177 <<"ctrl_r">>; 178key_to_name(ctrl_f) -> 179 <<"ctrl_f">>; 180key_to_name(alt_f) -> 181 <<"alt_f">>; 182key_to_name(ctrl_g) -> 183 <<"ctrl_g">>; 184key_to_name({char, 32}) -> 185 <<"space">>; 186key_to_name({char, C}) when is_integer(C), C >= 32 -> 187 unicode:characters_to_binary([C]); 188key_to_name({error, _}) -> 189 <<"eof">>; 190key_to_name(_) -> 191 <<"other">>. 192 193%% Normalize newlines to CRLF without turning existing \r\n into \r\r\n. 194to_crlf(Bin) when is_binary(Bin) -> 195 B1 = binary:replace(Bin, <<"\r\n">>, <<"\n">>, [global]), 196 B2 = binary:replace(B1, <<"\r">>, <<"\n">>, [global]), 197 binary:replace(B2, <<"\n">>, <<"\r\n">>, [global]). 198 199%% --------------------------------------------------------------------------- 200%% Public: read a line (syntax-highlighted when raw TTY mode is active) 201%% --------------------------------------------------------------------------- 202 203-spec get_line(binary()) -> {ok, binary()} | {error, binary()}. 204get_line(Prompt) when is_binary(Prompt) -> 205 case get(gleshell_raw) of 206 true -> 207 raw_get_line(Prompt); 208 _ -> 209 classic_get_line(Prompt) 210 end. 211 212%% Edlin / get_until path (non-TTY or when raw mode unavailable). 213classic_get_line(Prompt) -> 214 PromptChars = unicode:characters_to_list(Prompt), 215 case io:request( 216 standard_io, 217 {get_until, unicode, PromptChars, ?MODULE, parse_line, []} 218 ) of 219 eof -> 220 {error, <<"eof">>}; 221 {error, interrupted} -> 222 %% Ctrl+C while reading: cancel line, keep the REPL alive. 223 {error, <<"interrupted">>}; 224 {error, _} -> 225 {error, <<"io_error">>}; 226 Line when is_list(Line); is_binary(Line) -> 227 Bin = unicode:characters_to_binary(Line), 228 Stripped = string:trim(Bin, trailing, [$\n, $\r]), 229 {ok, Stripped}; 230 Other -> 231 try 232 Bin = unicode:characters_to_binary(Other), 233 Stripped = string:trim(Bin, trailing, [$\n, $\r]), 234 {ok, Stripped} 235 catch 236 _:_ -> 237 {error, <<"io_error">>} 238 end 239 end. 240 241%% get_until callback: edlin already gathers a full line. 242-spec parse_line(term(), term()) -> 243 {done, eof | string(), list()} | {more, term()}. 244parse_line(_Cont, eof) -> 245 {done, eof, []}; 246parse_line(_Cont, Chars) when is_list(Chars) -> 247 {done, Chars, []}. 248 249%% --------------------------------------------------------------------------- 250%% Shell bootstrap: prefer OTP raw mode for live syntax highlighting. 251%% Falls back to edlin interactive shell when raw is unavailable. 252%% 253%% Ctrl+C: the BEAM default opens the BREAK menu (a = abort → process exit). 254%% Re-exec once with +Bc so Ctrl+C is delivered as a character to our editor 255%% (cancel line) instead of killing the shell. See erl(1) +B. 256%% --------------------------------------------------------------------------- 257 258-spec run_as_shell(fun(() -> term())) -> nil. 259run_as_shell(Fun) when is_function(Fun, 0) -> 260 case ensure_plus_bc() of 261 {parent, Port} -> 262 %% Child owns the TTY; we only wait for its exit status. 263 receive 264 {Port, {exit_status, Status}} -> 265 erlang:halt(Status) 266 end; 267 child -> 268 enable_shell_history(), 269 case try_start_raw() of 270 true -> 271 put(gleshell_raw, true), 272 load_line_history(), 273 configure_line_editor(), 274 try 275 Fun() 276 after 277 save_line_history() 278 end, 279 nil; 280 false -> 281 erase(gleshell_raw), 282 Parent = self(), 283 case try_start_interactive(Parent, Fun) of 284 {ok, started} -> 285 receive 286 {gleshell_shell_done, ok} -> 287 nil; 288 {gleshell_shell_done, {error, Class, Reason, Stack}} -> 289 erlang:raise(Class, Reason, Stack) 290 end; 291 {ok, direct} -> 292 configure_line_editor(), 293 Fun(), 294 nil 295 end 296 end 297 end. 298 299%% Ensure the emulator was started with +Bc (Ctrl+C → char, not BREAK/abort). 300%% Returns `child` when this process should run the REPL, or `{parent, Port}` 301%% when we re-exec'd and should wait on the child. 302-spec ensure_plus_bc() -> child | {parent, port()}. 303ensure_plus_bc() -> 304 case os:getenv("GLESHELL_PLUS_BC") of 305 "1" -> 306 child; 307 _ -> 308 case already_has_plus_bc() of 309 true -> 310 os:putenv("GLESHELL_PLUS_BC", "1"), 311 child; 312 false -> 313 reexec_with_plus_bc() 314 end 315 end. 316 317already_has_plus_bc() -> 318 lists:any( 319 fun(Var) -> 320 case os:getenv(Var) of 321 false -> 322 false; 323 Flags -> 324 string:find(Flags, "+Bc") =/= nomatch 325 end 326 end, 327 ["ERL_AFLAGS", "ERL_FLAGS", "ERL_ZFLAGS"] 328 ). 329 330reexec_with_plus_bc() -> 331 os:putenv("GLESHELL_PLUS_BC", "1"), 332 case os:getenv("ERL_AFLAGS") of 333 false -> 334 os:putenv("ERL_AFLAGS", "+Bc"); 335 Flags -> 336 case string:find(Flags, "+Bc") of 337 nomatch -> 338 os:putenv("ERL_AFLAGS", "+Bc " ++ Flags); 339 _ -> 340 ok 341 end 342 end, 343 case os:find_executable("erl") of 344 false -> 345 %% No erl on PATH — continue without +Bc (BREAK menu may appear). 346 child; 347 Erl -> 348 Pa = lists:flatmap(fun(D) -> ["-pa", D] end, code:get_path()), 349 Extra = init:get_plain_arguments(), 350 Args = 351 ["+Bc", "-noshell"] ++ 352 Pa ++ 353 ["-eval", "gleshell@@main:run(gleshell)", "-extra" | Extra], 354 try 355 Port = open_port( 356 {spawn_executable, Erl}, 357 [exit_status, nouse_stdio, {args, Args}] 358 ), 359 {parent, Port} 360 catch 361 _:_ -> 362 child 363 end 364 end. 365 366try_start_raw() -> 367 case stdout_isatty() of 368 false -> 369 false; 370 true -> 371 case catch shell:start_interactive({noshell, raw}) of 372 ok -> 373 true; 374 {error, already_started} -> 375 %% Cannot switch an existing interactive shell into raw. 376 false; 377 _ -> 378 false 379 end 380 end. 381 382try_start_interactive(Parent, Fun) -> 383 _ = application:set_env(stdlib, shell_slogan, "", [{persistent, true}]), 384 case shell:start_interactive({gleshell_ffi, spawn_shell, [Parent, Fun]}) of 385 ok -> 386 {ok, started}; 387 {error, already_started} -> 388 {ok, direct}; 389 {error, _} -> 390 {ok, direct} 391 end. 392 393-spec spawn_shell(pid(), fun(() -> term())) -> pid(). 394spawn_shell(Parent, Fun) when is_pid(Parent), is_function(Fun, 0) -> 395 spawn(fun() -> 396 try 397 configure_line_editor(), 398 Fun() 399 of 400 _ -> 401 Parent ! {gleshell_shell_done, ok}, 402 exit(die) 403 catch 404 Class:Reason:Stack -> 405 Parent ! {gleshell_shell_done, {error, Class, Reason, Stack}}, 406 erlang:raise(Class, Reason, Stack) 407 end 408 end). 409 410configure_line_editor() -> 411 _ = io:setopts([{encoding, unicode}, binary]), 412 try 413 io:setopts([{line_history, true}]) 414 catch 415 _:_ -> 416 ok 417 end, 418 ok. 419 420enable_shell_history() -> 421 case application:get_env(kernel, shell_history_path) of 422 {ok, _} -> 423 ok; 424 undefined -> 425 Path = filename:basedir(user_cache, "gleshell-history"), 426 _ = application:set_env( 427 kernel, shell_history_path, Path, [{persistent, true}] 428 ), 429 ok 430 end, 431 case application:get_env(kernel, shell_history) of 432 {ok, _} -> 433 ok; 434 undefined -> 435 _ = application:set_env( 436 kernel, shell_history, enabled, [{persistent, true}] 437 ), 438 ok 439 end. 440 441%% --------------------------------------------------------------------------- 442%% Raw-mode line editor with Nushell-style syntax highlighting 443%% --------------------------------------------------------------------------- 444%% 445%% Buffer model: Left is graphemes before the cursor (reversed), 446%% Right is graphemes after the cursor (normal order). 447%% History is a list of binaries (newest first). 448 449raw_get_line(Prompt) when is_binary(Prompt) -> 450 PromptList = unicode:characters_to_list(Prompt), 451 %% Densify every prompt: drop blanks so ↑ never lands on an empty slot 452 %% even if an older session or bug left one in the process dict. 453 History = sanitize_history( 454 case get(gleshell_history) of 455 L when is_list(L) -> L; 456 _ -> [] 457 end 458 ), 459 put(gleshell_history, History), 460 put(gleshell_input_rows, 1), 461 redraw(PromptList, [], []), 462 raw_loop(PromptList, [], [], History, 0, <<>>). 463 464%% HistPos: 0 = editing current buffer; N>0 = viewing Nth history entry. 465%% Saved: buffer saved when first entering history navigation. 466raw_loop(Prompt, Left, Right, History, HistPos, Saved) -> 467 case read_key() of 468 eof -> 469 io:put_chars("\r\n"), 470 {error, <<"eof">>}; 471 {error, _} -> 472 io:put_chars("\r\n"), 473 {error, <<"io_error">>}; 474 enter -> 475 Line = buffer_to_bin(Left, Right), 476 io:put_chars("\r\n"), 477 push_history(Line), 478 {ok, Line}; 479 {char, C} when is_integer(C), C >= 32, C =/= 127 -> 480 %% Printable Unicode codepoint 481 NewLeft = [C | Left], 482 redraw(Prompt, NewLeft, Right), 483 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 484 backspace -> 485 case Left of 486 [] -> 487 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 488 [_ | Rest] -> 489 redraw(Prompt, Rest, Right), 490 raw_loop(Prompt, Rest, Right, History, 0, <<>>) 491 end; 492 delete -> 493 case Right of 494 [] -> 495 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 496 [_ | Rest] -> 497 redraw(Prompt, Left, Rest), 498 raw_loop(Prompt, Left, Rest, History, 0, <<>>) 499 end; 500 left -> 501 case Left of 502 [] -> 503 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 504 [C | Rest] -> 505 redraw(Prompt, Rest, [C | Right]), 506 raw_loop(Prompt, Rest, [C | Right], History, HistPos, Saved) 507 end; 508 right -> 509 case Right of 510 [] -> 511 %% At end of line: accept greyed-out history hint (Nu/fish). 512 accept_history_hint(Prompt, Left, Right, History, HistPos, Saved); 513 [C | Rest] -> 514 redraw(Prompt, [C | Left], Rest), 515 raw_loop(Prompt, [C | Left], Rest, History, HistPos, Saved) 516 end; 517 home -> 518 NewRight = lists:reverse(Left) ++ Right, 519 redraw(Prompt, [], NewRight), 520 raw_loop(Prompt, [], NewRight, History, HistPos, Saved); 521 'end' -> 522 case Right of 523 [] -> 524 %% Already at end: accept full history hint if present. 525 accept_history_hint(Prompt, Left, Right, History, HistPos, Saved); 526 _ -> 527 NewLeft = lists:reverse(Right) ++ Left, 528 redraw(Prompt, NewLeft, []), 529 raw_loop(Prompt, NewLeft, [], History, HistPos, Saved) 530 end; 531 up -> 532 hist_nav(Prompt, Left, Right, History, HistPos, Saved, 1); 533 down -> 534 hist_nav(Prompt, Left, Right, History, HistPos, Saved, -1); 535 ctrl_a -> 536 NewRight = lists:reverse(Left) ++ Right, 537 redraw(Prompt, [], NewRight), 538 raw_loop(Prompt, [], NewRight, History, HistPos, Saved); 539 ctrl_e -> 540 case Right of 541 [] -> 542 accept_history_hint(Prompt, Left, Right, History, HistPos, Saved); 543 _ -> 544 NewLeft = lists:reverse(Right) ++ Left, 545 redraw(Prompt, NewLeft, []), 546 raw_loop(Prompt, NewLeft, [], History, HistPos, Saved) 547 end; 548 ctrl_f -> 549 %% Emacs-style forward-char; at EOL accepts history hint (like Nu). 550 case Right of 551 [] -> 552 accept_history_hint(Prompt, Left, Right, History, HistPos, Saved); 553 [C | Rest] -> 554 redraw(Prompt, [C | Left], Rest), 555 raw_loop(Prompt, [C | Left], Rest, History, HistPos, Saved) 556 end; 557 alt_f -> 558 %% Accept one word of the history hint (Nu Alt+F). 559 accept_history_hint_word(Prompt, Left, Right, History, HistPos, Saved); 560 ctrl_u -> 561 redraw(Prompt, [], Right), 562 raw_loop(Prompt, [], Right, History, 0, <<>>); 563 ctrl_k -> 564 redraw(Prompt, Left, []), 565 raw_loop(Prompt, Left, [], History, 0, <<>>); 566 ctrl_w -> 567 {NewLeft, _} = kill_word(Left), 568 redraw(Prompt, NewLeft, Right), 569 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 570 ctrl_d -> 571 case {Left, Right} of 572 {[], []} -> 573 io:put_chars("\r\n"), 574 {error, <<"eof">>}; 575 {_, []} -> 576 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 577 {_, [_ | Rest]} -> 578 redraw(Prompt, Left, Rest), 579 raw_loop(Prompt, Left, Rest, History, 0, <<>>) 580 end; 581 ctrl_c -> 582 %% Cancel current line (like bash) and return empty. 583 io:put_chars("^C\r\n"), 584 {ok, <<>>}; 585 ctrl_l -> 586 io:put_chars("\e[H\e[2J"), 587 redraw(Prompt, Left, Right), 588 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 589 ctrl_r -> 590 reverse_search(Prompt, History); 591 tab -> 592 tab_complete(Prompt, Left, Right, History, HistPos, Saved); 593 _Other -> 594 raw_loop(Prompt, Left, Right, History, HistPos, Saved) 595 end. 596 597%% --------------------------------------------------------------------------- 598%% Tab: command + filename completion (token under cursor) 599%% --------------------------------------------------------------------------- 600%% 601%% Command position (start of line / after | ; & =): complete builtins and 602%% PATH executables. Path-like command words (./foo, /bin/ls, ~/x) still use 603%% filename completion. Elsewhere: filename completion as before. 604%% 605%% One match → insert it (commands get a trailing space; dirs get /). 606%% Several matches → extend the longest common prefix; if that does not 607%% advance the buffer, list candidates under the line and redraw. 608 609tab_complete(Prompt, Left, Right, History, HistPos, Saved) -> 610 {PrefixRev, Word} = word_before_cursor(Left), 611 {Matches, Kind} = completions_for(PrefixRev, Word), 612 case Matches of 613 [] -> 614 beep(), 615 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 616 [Only] -> 617 Insert = finalize_completion(Only, Kind), 618 NewLeft = apply_completed_word(PrefixRev, Insert), 619 redraw(Prompt, NewLeft, Right), 620 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 621 _ -> 622 Common = longest_common_prefix(Matches), 623 case Common =/= Word andalso length(Common) >= length(Word) of 624 true -> 625 NewLeft = apply_completed_word(PrefixRev, Common), 626 redraw(Prompt, NewLeft, Right), 627 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 628 false -> 629 show_completions(Matches), 630 redraw(Prompt, Left, Right), 631 raw_loop(Prompt, Left, Right, History, HistPos, Saved) 632 end 633 end. 634 635%% Test/helper: return {Matches, Kind} for a buffer prefix and word. 636%% Prefix is the text *before* the word being completed (not reversed). 637%% Kind is <<"command">> | <<"path">>. 638-spec complete_word(binary(), binary()) -> {list(binary()), binary()}. 639complete_word(PrefixBin, WordBin) when is_binary(PrefixBin), is_binary(WordBin) -> 640 Prefix = unicode:characters_to_list(PrefixBin), 641 Word = unicode:characters_to_list(WordBin), 642 PrefixRev = lists:reverse(Prefix), 643 {Matches, Kind} = completions_for(PrefixRev, Word), 644 KindBin = 645 case Kind of 646 command -> <<"command">>; 647 path -> <<"path">> 648 end, 649 { 650 [unicode:characters_to_binary(M) || M <- Matches], 651 KindBin 652 }. 653 654%% Trailing space after a unique command so the user can type args next. 655finalize_completion(Word, command) -> 656 case lists:last(Word) of 657 $/ -> Word; 658 $\s -> Word; 659 _ -> Word ++ " " 660 end; 661finalize_completion(Word, path) -> 662 Word. 663 664completions_for(PrefixRev, Word) -> 665 case is_command_position(PrefixRev) andalso not is_path_like_word(Word) of 666 true -> 667 {command_completions(Word), command}; 668 false -> 669 {filename_completions(Word), path} 670 end. 671 672%% Command position: empty prefix, or last non-space before the word is a 673%% pipeline/statement separator or assignment (`let x = …`). 674is_command_position(PrefixRev) -> 675 Before = string:trim(lists:reverse(PrefixRev), trailing), 676 case Before of 677 [] -> 678 true; 679 _ -> 680 case lists:last(Before) of 681 $| -> true; 682 $; -> true; 683 $& -> true; 684 $= -> true; 685 _ -> false 686 end 687 end. 688 689%% ./script, ../bin/x, /usr/bin/ls, ~/bin/foo — complete as paths even as cmds. 690is_path_like_word([]) -> 691 false; 692is_path_like_word(Word) -> 693 lists:member($/, Word) orelse lists:member($\\, Word) orelse hd(Word) =:= $~. 694 695%% Builtins + keywords + PATH executables matching Word as a prefix. 696command_completions(Word) -> 697 Builtins = [ 698 N 699 || N <- builtin_command_names(), 700 lists:prefix(Word, N) 701 ], 702 Keywords = [ 703 N 704 || N <- ["let"], 705 lists:prefix(Word, N) 706 ], 707 PathCmds = 708 case Word of 709 %% Empty prefix: skip PATH dump (can be thousands of names). 710 [] -> 711 []; 712 _ -> 713 path_command_completions(Word) 714 end, 715 lists:usort(Builtins ++ Keywords ++ PathCmds). 716 717%% Prefer live Gleam registry; fall back if the module is not loaded yet. 718builtin_command_names() -> 719 try 720 Names = 'gleshell@builtins':names(), 721 [to_charlist(N) || N <- Names] 722 catch 723 _:_ -> 724 fallback_builtin_names() 725 end. 726 727to_charlist(B) when is_binary(B) -> 728 unicode:characters_to_list(B); 729to_charlist(L) when is_list(L) -> 730 L. 731 732fallback_builtin_names() -> 733 [ 734 "about", "append", "cat", "cd", "columns", "count", "describe", "echo", 735 "env", "exit", "filter", "find", "first", "flatten", "from", "get", 736 "help", "identity", "ignore", "is-empty", "is_empty", "keys", "last", 737 "length", "less", "lines", "ls", "open", "prepend", "print", "pwd", 738 "quit", "range", "reverse", "save", "select", "skip", "sort-by", 739 "sort_by", "sys", "table", "take", "to", "type", "typeof", "uniq", 740 "unwrap", "values", "where", "which", "wrap" 741 ]. 742 743%% Executable basenames on PATH that match Prefix (deduped, sorted). 744path_command_completions(Prefix) -> 745 case os:getenv("PATH") of 746 false -> 747 []; 748 PathStr -> 749 Dirs = string:tokens(PathStr, path_sep()), 750 Acc = lists:foldl( 751 fun(Dir, Seen) -> 752 collect_path_cmds(Dir, Prefix, Seen) 753 end, 754 #{}, 755 Dirs 756 ), 757 lists:sort(maps:keys(Acc)) 758 end. 759 760collect_path_cmds(Dir, Prefix, Seen) -> 761 case file:list_dir(Dir) of 762 {ok, Names} -> 763 lists:foldl( 764 fun(Name, Acc) -> 765 case 766 lists:prefix(Prefix, Name) 767 andalso show_dotfile(Prefix, Name) 768 andalso not maps:is_key(Name, Acc) 769 andalso is_executable_file(filename:join(Dir, Name)) 770 of 771 true -> 772 Acc#{Name => true}; 773 false -> 774 Acc 775 end 776 end, 777 Seen, 778 Names 779 ); 780 {error, _} -> 781 Seen 782 end. 783 784beep() -> 785 io:put_chars([7]). 786 787%% Left is graphemes before the cursor in reverse order. 788%% Returns {PrefixRev, WordForward} where Word is the path token. 789word_before_cursor(Left) -> 790 take_completion_word(Left, []). 791 792%% Acc: walking Left (reversed buffer) with [C|Acc] rebuilds the word forward. 793take_completion_word([], Acc) -> 794 {[], Acc}; 795take_completion_word([C | Rest], Acc) -> 796 case is_completion_break(C) of 797 true -> 798 {[C | Rest], Acc}; 799 false -> 800 take_completion_word(Rest, [C | Acc]) 801 end. 802 803is_completion_break(C) when C =:= $\s; C =:= $\t -> 804 true; 805is_completion_break(C) when C =:= $|; C =:= $;; C =:= $& -> 806 true; 807is_completion_break(C) when C =:= $(; C =:= $); C =:= $[; C =:= $] -> 808 true; 809is_completion_break(C) when C =:= ${; C =:= $}; C =:= $<; C =:= $> -> 810 true; 811is_completion_break(C) when C =:= $'; C =:= $" -> 812 true; 813is_completion_break(_) -> 814 false. 815 816apply_completed_word(PrefixRev, Word) -> 817 lists:reverse(Word) ++ PrefixRev. 818 819%% Return sorted completion strings (as typed, with ~ preserved; dirs end in /). 820filename_completions(Word) -> 821 {ListDirTyped, Base, InsertPrefix} = split_completion_word(Word), 822 ListDir = expand_home_path(ListDirTyped), 823 case file:list_dir(ListDir) of 824 {ok, Names0} -> 825 Names = lists:sort(Names0), 826 [ 827 InsertPrefix ++ maybe_dir_slash(ListDir, Name) 828 || Name <- Names, 829 lists:prefix(Base, Name), 830 show_dotfile(Base, Name) 831 ]; 832 {error, _} -> 833 [] 834 end. 835 836%% Hide dotfiles unless the partial name already starts with '.'. 837show_dotfile([$. | _], _) -> 838 true; 839show_dotfile(_, [$. | _]) -> 840 false; 841show_dotfile(_, _) -> 842 true. 843 844maybe_dir_slash(ListDir, Name) -> 845 case filelib:is_dir(filename:join(ListDir, Name)) of 846 true -> Name ++ "/"; 847 false -> Name 848 end. 849 850%% Split a path word into {dir_to_list, basename_prefix, insert_prefix}. 851%% insert_prefix is the directory part as the user typed it (incl. trailing /). 852split_completion_word(Word) -> 853 case rsplit_path(Word) of 854 {none, Base} -> 855 {".", Base, ""}; 856 {Dir, Base} -> 857 ListDir = 858 case Dir of 859 "" -> "/"; 860 _ -> Dir 861 end, 862 InsertPrefix = 863 case Dir of 864 "" -> "/"; 865 _ -> Dir ++ "/" 866 end, 867 {ListDir, Base, InsertPrefix} 868 end. 869 870%% Rightmost / splits directory from the partial basename. 871rsplit_path(Word) -> 872 rsplit_path(lists:reverse(Word), []). 873 874rsplit_path([], Acc) -> 875 {none, Acc}; 876rsplit_path([$/ | Rest], Acc) -> 877 {lists:reverse(Rest), Acc}; 878rsplit_path([C | Rest], Acc) -> 879 rsplit_path(Rest, [C | Acc]). 880 881expand_home_path(Path) -> 882 case Path of 883 "~" -> 884 home_path_string(); 885 [$~, $/ | More] -> 886 home_path_string() ++ "/" ++ More; 887 _ -> 888 Path 889 end. 890 891home_path_string() -> 892 case os:getenv("HOME") of 893 false -> "."; 894 Home when is_list(Home) -> Home; 895 Home when is_binary(Home) -> unicode:characters_to_list(Home) 896 end. 897 898longest_common_prefix([]) -> 899 ""; 900longest_common_prefix([H | T]) -> 901 lists:foldl(fun lcp2/2, H, T). 902 903lcp2(A, B) -> 904 lcp2(A, B, []). 905 906lcp2([X | As], [X | Bs], Acc) -> 907 lcp2(As, Bs, [X | Acc]); 908lcp2(_, _, Acc) -> 909 lists:reverse(Acc). 910 911show_completions(Matches) -> 912 io:put_chars("\r\n"), 913 case io:columns() of 914 {ok, Cols} when is_integer(Cols), Cols > 8 -> 915 print_completion_columns(Matches, Cols); 916 _ -> 917 io:put_chars(lists:join(" ", Matches)), 918 io:put_chars("\r\n") 919 end. 920 921print_completion_columns(Matches, Cols) -> 922 MaxLen = lists:max([0 | [length(M) || M <- Matches]]), 923 Width = MaxLen + 2, 924 PerRow = max(1, Cols div Width), 925 print_rows(Matches, PerRow, Width). 926 927print_rows([], _PerRow, _Width) -> 928 ok; 929print_rows(Matches, PerRow, Width) -> 930 {Row, Rest} = take_n(Matches, PerRow, []), 931 Line = [ 932 pad_cell(M, Width) 933 || M <- Row 934 ], 935 io:put_chars([Line, "\r\n"]), 936 print_rows(Rest, PerRow, Width). 937 938take_n(List, 0, Acc) -> 939 {lists:reverse(Acc), List}; 940take_n([], _N, Acc) -> 941 {lists:reverse(Acc), []}; 942take_n([H | T], N, Acc) -> 943 take_n(T, N - 1, [H | Acc]). 944 945pad_cell(S, Width) -> 946 Pad = Width - length(S), 947 case Pad > 0 of 948 true -> S ++ lists:duplicate(Pad, $\s); 949 false -> S ++ " " 950 end. 951 952hist_nav(Prompt, Left, Right, History, HistPos, Saved, Delta) -> 953 case hist_seek(History, HistPos, Delta) of 954 stay -> 955 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 956 draft -> 957 %% Restore the buffer from before history navigation. 958 {L, R} = bin_to_buffer(Saved), 959 redraw(Prompt, L, R), 960 raw_loop(Prompt, L, R, History, 0, <<>>); 961 {NewPos, Entry} -> 962 NewSaved = case HistPos of 963 0 -> buffer_to_bin(Left, Right); 964 _ -> Saved 965 end, 966 %% Belt-and-suspenders: never paint a blank recall (dense History 967 %% should already exclude these). 968 case history_blank(Entry) of 969 true -> 970 hist_nav(Prompt, Left, Right, History, NewPos, NewSaved, Delta); 971 false -> 972 {L, R} = bin_to_buffer(Entry), 973 redraw(Prompt, L, R), 974 raw_loop(Prompt, L, R, History, NewPos, NewSaved) 975 end 976 end. 977 978%% Walk history in `Delta` direction, skipping blank/whitespace-only entries. 979%% Delta > 0 = older (↑); Delta < 0 = newer (↓). Position 0 is the live draft. 980%% History is expected newest-first and already densified (no blanks), but we 981%% still skip blanks so a stale list cannot surface an empty ↑ recall. 982hist_seek(_History, 0, Delta) when Delta < 0 -> 983 stay; 984hist_seek(History, HistPos, Delta) when Delta > 0 -> 985 hist_seek_loop(History, HistPos + 1, 1, length(History)); 986hist_seek(History, HistPos, Delta) when Delta < 0 -> 987 hist_seek_loop(History, HistPos - 1, -1, length(History)). 988 989hist_seek_loop(_History, Pos, Step, _Len) when Step < 0, Pos =< 0 -> 990 draft; 991hist_seek_loop(_History, Pos, Step, Len) when Step > 0, Pos > Len -> 992 stay; 993hist_seek_loop(History, Pos, Step, Len) when Pos >= 1, Pos =< Len -> 994 Entry = lists:nth(Pos, History), 995 case history_blank(Entry) of 996 true -> 997 hist_seek_loop(History, Pos + Step, Step, Len); 998 false -> 999 {Pos, Entry} 1000 end; 1001hist_seek_loop(_History, _Pos, _Step, _Len) -> 1002 stay. 1003 1004%% Minimal Ctrl+R reverse-i-search over history. 1005reverse_search(Prompt, History) -> 1006 reverse_search_loop(Prompt, History, [], match_history(History, [])). 1007 1008reverse_search_loop(Prompt, History, Query, Match) -> 1009 Hint = unicode:characters_to_list( 1010 ["(reverse-i-search)`", Query, "': ", Match] 1011 ), 1012 io:put_chars([$\r, Hint, ?CSI_CLEAR_EOL]), 1013 case read_key() of 1014 eof -> 1015 io:put_chars("\r\n"), 1016 {error, <<"eof">>}; 1017 enter -> 1018 %% Accept match onto the edit line; do not submit (edit first). 1019 %% Empty match (no query / no hit) — return to an empty draft 1020 %% rather than "accepting" a blank line that later looks like a 1021 %% phantom history entry when browsing with ↑. 1022 Line = iolist_to_binary(Match), 1023 case history_blank(Line) of 1024 true -> 1025 redraw(Prompt, [], []), 1026 raw_loop(Prompt, [], [], History, 0, <<>>); 1027 false -> 1028 {L, R} = bin_to_buffer(Line), 1029 redraw(Prompt, L, R), 1030 raw_loop(Prompt, L, R, History, 0, <<>>) 1031 end; 1032 ctrl_c -> 1033 io:put_chars("\r\n"), 1034 redraw(Prompt, [], []), 1035 raw_loop(Prompt, [], [], History, 0, <<>>); 1036 ctrl_g -> 1037 redraw(Prompt, [], []), 1038 raw_loop(Prompt, [], [], History, 0, <<>>); 1039 ctrl_r -> 1040 %% Find older match 1041 NewMatch = match_history_after(History, Query, Match), 1042 reverse_search_loop(Prompt, History, Query, NewMatch); 1043 backspace -> 1044 NewQuery = case Query of 1045 [] -> []; 1046 [_ | _] -> lists:droplast(Query) 1047 end, 1048 reverse_search_loop( 1049 Prompt, History, NewQuery, match_history(History, NewQuery) 1050 ); 1051 {char, C} when is_integer(C), C >= 32, C =/= 127 -> 1052 NewQuery = Query ++ [C], 1053 reverse_search_loop( 1054 Prompt, History, NewQuery, match_history(History, NewQuery) 1055 ); 1056 _ -> 1057 reverse_search_loop(Prompt, History, Query, Match) 1058 end. 1059 1060match_history(_History, []) -> 1061 ""; 1062match_history(History, Query) -> 1063 QBin = unicode:characters_to_binary(Query), 1064 case first_match(History, QBin) of 1065 undefined -> ""; 1066 Bin -> unicode:characters_to_list(Bin) 1067 end. 1068 1069match_history_after(History, Query, CurrentMatch) -> 1070 QBin = unicode:characters_to_binary(Query), 1071 CurBin = unicode:characters_to_binary(CurrentMatch), 1072 case skip_until_then_match(History, CurBin, QBin, false) of 1073 undefined -> CurrentMatch; 1074 Bin -> unicode:characters_to_list(Bin) 1075 end. 1076 1077first_match([], _) -> 1078 undefined; 1079first_match([H | T], Q) -> 1080 case binary:match(H, Q) of 1081 nomatch -> first_match(T, Q); 1082 _ -> H 1083 end. 1084 1085skip_until_then_match([], _Cur, _Q, _Seen) -> 1086 undefined; 1087skip_until_then_match([H | T], Cur, Q, false) -> 1088 case H =:= Cur of 1089 true -> skip_until_then_match(T, Cur, Q, true); 1090 false -> skip_until_then_match(T, Cur, Q, false) 1091 end; 1092skip_until_then_match([H | T], Cur, Q, true) -> 1093 case binary:match(H, Q) of 1094 nomatch -> skip_until_then_match(T, Cur, Q, true); 1095 _ -> H 1096 end. 1097 1098kill_word([]) -> 1099 {[], []}; 1100kill_word(Left) -> 1101 %% Left is reversed: strip trailing spaces then a word. 1102 L1 = drop_while_space(Left), 1103 drop_while_word(L1). 1104 1105drop_while_space([C | Rest]) when C =:= $\s; C =:= $\t -> 1106 drop_while_space(Rest); 1107drop_while_space(L) -> 1108 L. 1109 1110drop_while_word([]) -> 1111 {[], []}; 1112drop_while_word([C | Rest]) when C =:= $\s; C =:= $\t -> 1113 {[C | Rest], []}; 1114drop_while_word([_ | Rest]) -> 1115 drop_while_word(Rest). 1116 1117redraw(Prompt, Left, Right) -> 1118 Full = lists:reverse(Left) ++ Right, 1119 FullBin = 1120 case unicode:characters_to_binary(Full) of 1121 Bin when is_binary(Bin) -> Bin; 1122 _ -> <<>> 1123 end, 1124 Colored = highlight_line(FullBin), 1125 %% Fish/Nu-style ghost text: grey suffix of the newest history match. 1126 %% Only when the cursor is at end of the buffer (Right == []). 1127 HintChars = 1128 case Right of 1129 [] -> history_hint_suffix(Full); 1130 _ -> [] 1131 end, 1132 HintPainted = paint_history_hint(HintChars), 1133 HintBin = 1134 case unicode:characters_to_binary(HintChars) of 1135 HB when is_binary(HB) -> HB; 1136 _ -> <<>> 1137 end, 1138 %% Clear every physical row the previous render occupied. A longer history 1139 %% entry can soft-wrap; `\e[2K` alone only erases the current row, so a 1140 %% shorter recall (or empty draft) used to leave a blank-looking row and 1141 %% stale wrap residue that felt like an empty ↑ slot. 1142 clear_input_rows(), 1143 io:put_chars([Prompt, Colored, HintPainted]), 1144 %% Include hint width so multi-line wrap clears correctly on next redraw. 1145 Rows = count_input_rows(Prompt, <<FullBin/binary, HintBin/binary>>), 1146 put(gleshell_input_rows, Rows), 1147 %% Cursor sits after typed text, before the grey hint (and before Right). 1148 CursorBack = length(HintChars) + length(Right), 1149 case CursorBack of 1150 0 -> 1151 ok; 1152 N -> 1153 %% Best-effort: codepoint count ≈ columns for ASCII-heavy input. 1154 io:put_chars(["\e[", integer_to_list(N), $D]) 1155 end. 1156 1157%% Accept the full greyed-out history suggestion into the buffer. 1158accept_history_hint(Prompt, Left, Right, History, HistPos, Saved) -> 1159 case Right of 1160 [] -> 1161 case history_hint_suffix(lists:reverse(Left)) of 1162 [] -> 1163 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 1164 Suffix -> 1165 %% Left is reversed; append Suffix at end of buffer. 1166 NewLeft = lists:reverse(Suffix) ++ Left, 1167 redraw(Prompt, NewLeft, []), 1168 raw_loop(Prompt, NewLeft, [], History, 0, <<>>) 1169 end; 1170 _ -> 1171 raw_loop(Prompt, Left, Right, History, HistPos, Saved) 1172 end. 1173 1174%% Accept the next word of the history suggestion (leading space + word). 1175accept_history_hint_word(Prompt, Left, Right, History, HistPos, Saved) -> 1176 case Right of 1177 [] -> 1178 case history_hint_suffix(lists:reverse(Left)) of 1179 [] -> 1180 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 1181 Suffix -> 1182 Word = take_hint_word(Suffix), 1183 case Word of 1184 [] -> 1185 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 1186 _ -> 1187 NewLeft = lists:reverse(Word) ++ Left, 1188 redraw(Prompt, NewLeft, []), 1189 raw_loop(Prompt, NewLeft, [], History, 0, <<>>) 1190 end 1191 end; 1192 _ -> 1193 raw_loop(Prompt, Left, Right, History, HistPos, Saved) 1194 end. 1195 1196%% First token of a hint: optional leading whitespace + following non-space run. 1197take_hint_word([]) -> 1198 []; 1199take_hint_word([C | Rest]) when C =:= $\s; C =:= $\t -> 1200 [C | take_hint_nonspace(Rest)]; 1201take_hint_word(Rest) -> 1202 take_hint_nonspace(Rest). 1203 1204take_hint_nonspace([]) -> 1205 []; 1206take_hint_nonspace([C | _Rest]) when C =:= $\s; C =:= $\t -> 1207 []; 1208take_hint_nonspace([C | Rest]) -> 1209 [C | take_hint_nonspace(Rest)]. 1210 1211%% Newest-first history: suffix of the first entry that has Buffer as a proper prefix. 1212history_hint_suffix([]) -> 1213 []; 1214history_hint_suffix(Buffer) when is_list(Buffer) -> 1215 Hist = 1216 case get(gleshell_history) of 1217 L when is_list(L) -> L; 1218 _ -> [] 1219 end, 1220 history_hint_suffix(Hist, Buffer). 1221 1222history_hint_suffix([], _Buffer) -> 1223 []; 1224%% Empty buffer: never ghost the whole previous command (Nu default). 1225history_hint_suffix(_History, []) -> 1226 []; 1227history_hint_suffix([H | T], Buffer) -> 1228 HList = 1229 case H of 1230 Bin when is_binary(Bin) -> unicode:characters_to_list(Bin); 1231 List when is_list(List) -> List; 1232 _ -> [] 1233 end, 1234 case is_list(HList) andalso lists:prefix(Buffer, HList) of 1235 true when length(HList) > length(Buffer) -> 1236 lists:nthtail(length(Buffer), HList); 1237 _ -> 1238 history_hint_suffix(T, Buffer) 1239 end. 1240 1241paint_history_hint([]) -> 1242 []; 1243paint_history_hint(Chars) -> 1244 case get(gleshell_color) of 1245 false -> 1246 Chars; 1247 _ -> 1248 %% Dark gray — same as Nu `color_config.hints` / shape_nothing. 1249 ["\e[90m", Chars, "\e[0m"] 1250 end. 1251 1252%% Test helper: given history (newest first) and buffer, return grey suffix binary. 1253-spec history_hint(list(binary()), binary()) -> binary(). 1254history_hint(History, Buffer) when is_list(History), is_binary(Buffer) -> 1255 BufList = 1256 case unicode:characters_to_list(Buffer) of 1257 L when is_list(L) -> L; 1258 _ -> [] 1259 end, 1260 case history_hint_suffix(History, BufList) of 1261 [] -> 1262 <<>>; 1263 Suffix -> 1264 case unicode:characters_to_binary(Suffix) of 1265 Bin when is_binary(Bin) -> Bin; 1266 _ -> <<>> 1267 end 1268 end. 1269 1270%% Move to the start of the previous input block and erase its rows. 1271clear_input_rows() -> 1272 Rows0 = 1273 case get(gleshell_input_rows) of 1274 N when is_integer(N), N > 0 -> N; 1275 _ -> 1 1276 end, 1277 %% Cap so a bad columns() estimate cannot wipe the path line above the prompt. 1278 Rows = min(Rows0, 16), 1279 %% Cursor sits on the last physical row of the previous draw when Right=[]. 1280 case Rows of 1281 1 -> 1282 io:put_chars([$\r, "\e[2K"]); 1283 _ -> 1284 Up = Rows - 1, 1285 io:put_chars([$\r, "\e[", integer_to_list(Up), $A, "\e[J"]) 1286 end. 1287 1288%% How many terminal rows does prompt+line occupy (ANSI-aware, 1-col glyphs). 1289count_input_rows(Prompt, FullBin) -> 1290 Cols = 1291 case io:columns() of 1292 {ok, C} when is_integer(C), C >= 8 -> C; 1293 {ok, C} when is_integer(C), C > 0 -> 8; 1294 _ -> 80 1295 end, 1296 PW = visible_width(Prompt), 1297 FW = visible_width(FullBin), 1298 Total = PW + FW, 1299 case Total =< 0 of 1300 true -> 1; 1301 false -> min(16, (Total + Cols - 1) div Cols) 1302 end. 1303 1304%% Visible column count ignoring CSI (ESC [ … final). Wide glyphs count as 1 1305%% (same approximation as the pager); good enough to clear wrap residue. 1306visible_width(Data) when is_binary(Data) -> 1307 visible_width(unicode:characters_to_list(Data)); 1308visible_width(List) when is_list(List) -> 1309 visible_width_loop(List, 0, normal); 1310visible_width(_) -> 1311 0. 1312 1313visible_width_loop([], Acc, _) -> 1314 Acc; 1315visible_width_loop([16#1b | Rest], Acc, normal) -> 1316 visible_width_loop(Rest, Acc, esc); 1317visible_width_loop([_C | Rest], Acc, normal) -> 1318 visible_width_loop(Rest, Acc + 1, normal); 1319visible_width_loop([$[ | Rest], Acc, esc) -> 1320 visible_width_loop(Rest, Acc, csi); 1321visible_width_loop([_ | Rest], Acc, esc) -> 1322 visible_width_loop(Rest, Acc, normal); 1323visible_width_loop([C | Rest], Acc, csi) when C >= 16#40, C =< 16#7e -> 1324 visible_width_loop(Rest, Acc, normal); 1325visible_width_loop([_ | Rest], Acc, csi) -> 1326 visible_width_loop(Rest, Acc, csi). 1327 1328highlight_line(Bin) when is_binary(Bin) -> 1329 case get(gleshell_color) of 1330 false -> 1331 Bin; 1332 _ -> 1333 try 1334 case 'gleshell@highlight':line(Bin) of 1335 Out when is_binary(Out) -> Out; 1336 Out when is_list(Out) -> unicode:characters_to_binary(Out); 1337 _ -> Bin 1338 end 1339 catch 1340 _:_ -> 1341 Bin 1342 end 1343 end. 1344 1345buffer_to_bin(Left, Right) -> 1346 unicode:characters_to_binary(lists:reverse(Left) ++ Right). 1347 1348bin_to_buffer(Bin) when is_binary(Bin) -> 1349 Chars = unicode:characters_to_list(Bin), 1350 {lists:reverse(Chars), []}; 1351bin_to_buffer(List) when is_list(List) -> 1352 {lists:reverse(List), []}. 1353 1354%% --------------------------------------------------------------------------- 1355%% Key reading (raw mode — keys arrive as soon as pressed) 1356%% --------------------------------------------------------------------------- 1357 1358read_key() -> 1359 case io:get_chars("", 1) of 1360 eof -> 1361 eof; 1362 {error, Reason} -> 1363 {error, Reason}; 1364 <<C/utf8>> -> 1365 decode_key(C, <<>>); 1366 [C] when is_integer(C) -> 1367 decode_key(C, <<>>); 1368 Bin when is_binary(Bin), byte_size(Bin) > 0 -> 1369 case unicode:characters_to_list(Bin) of 1370 [C | _] -> decode_key(C, <<>>); 1371 _ -> read_key() 1372 end; 1373 List when is_list(List), List =/= [] -> 1374 decode_key(hd(List), <<>>); 1375 _ -> 1376 read_key() 1377 end. 1378 1379decode_key($\r, _) -> enter; 1380decode_key($\n, _) -> enter; 1381decode_key($\t, _) -> tab; 1382decode_key(127, _) -> backspace; 1383decode_key($\b, _) -> backspace; 1384decode_key(1, _) -> ctrl_a; 1385decode_key(5, _) -> ctrl_e; 1386decode_key(4, _) -> ctrl_d; 1387decode_key(3, _) -> ctrl_c; 1388decode_key(11, _) -> ctrl_k; 1389decode_key(21, _) -> ctrl_u; 1390decode_key(23, _) -> ctrl_w; 1391decode_key(12, _) -> ctrl_l; 1392decode_key(18, _) -> ctrl_r; 1393decode_key(6, _) -> ctrl_f; 1394decode_key(?ESC, _) -> 1395 read_escape(); 1396decode_key(C, _) when is_integer(C), C >= 32 -> 1397 {char, C}; 1398decode_key(_, _) -> 1399 other. 1400 1401read_escape() -> 1402 case io:get_chars("", 1) of 1403 eof -> 1404 other; 1405 <<"[">> -> 1406 read_csi(); 1407 <<$O>> -> 1408 %% SS3 sequences: OH = home, OF = end, OA/OB/OC/OD arrows 1409 case io:get_chars("", 1) of 1410 <<"A">> -> up; 1411 <<"B">> -> down; 1412 <<"C">> -> right; 1413 <<"D">> -> left; 1414 <<"H">> -> home; 1415 <<"F">> -> 'end'; 1416 _ -> other 1417 end; 1418 %% Alt+letter arrives as ESC then the letter (meta). 1419 <<$f>> -> 1420 alt_f; 1421 <<$F>> -> 1422 alt_f; 1423 _ -> 1424 other 1425 end. 1426 1427read_csi() -> 1428 read_csi_params([]). 1429 1430read_csi_params(Acc) -> 1431 case io:get_chars("", 1) of 1432 eof -> 1433 other; 1434 <<C/utf8>> when C >= $0, C =< $9 -> 1435 read_csi_params([C | Acc]); 1436 <<$;>> -> 1437 read_csi_params([$; | Acc]); 1438 <<$~>> -> 1439 Params = lists:reverse(Acc), 1440 case Params of 1441 "1" -> home; 1442 "3" -> delete; 1443 "4" -> 'end'; 1444 "5" -> page_up; 1445 "6" -> page_down; 1446 "7" -> home; 1447 "8" -> 'end'; 1448 _ -> other 1449 end; 1450 <<"A">> -> 1451 up; 1452 <<"B">> -> 1453 down; 1454 <<"C">> -> 1455 right; 1456 <<"D">> -> 1457 left; 1458 <<"H">> -> 1459 home; 1460 <<"F">> -> 1461 'end'; 1462 _ -> 1463 other 1464 end. 1465 1466%% --------------------------------------------------------------------------- 1467%% History persistence 1468%% --------------------------------------------------------------------------- 1469 1470history_file() -> 1471 case application:get_env(kernel, shell_history_path) of 1472 {ok, Path} when is_list(Path) -> 1473 filename:join(Path, "lines"); 1474 {ok, Path} when is_binary(Path) -> 1475 filename:join(unicode:characters_to_list(Path), "lines"); 1476 _ -> 1477 filename:join( 1478 filename:basedir(user_cache, "gleshell-history"), "lines" 1479 ) 1480 end. 1481 1482load_line_history() -> 1483 File = history_file(), 1484 case file:read_file(File) of 1485 {ok, Bin} -> 1486 Lines0 = [ 1487 string:trim(L, trailing, [$\r]) 1488 || L <- binary:split(Bin, <<"\n">>, [global]) 1489 ], 1490 %% Newest first; sanitize drops blanks / ANSI-only / ZWSP-only. 1491 put(gleshell_history, sanitize_history(lists:reverse(Lines0))); 1492 _ -> 1493 put(gleshell_history, []) 1494 end, 1495 put(gleshell_color, true), 1496 ok. 1497 1498save_line_history() -> 1499 case get(gleshell_history) of 1500 Hist when is_list(Hist) -> 1501 File = history_file(), 1502 _ = filelib:ensure_dir(File), 1503 %% Store oldest-first for human readability; drop blanks. 1504 Kept = lists:sublist(sanitize_history(Hist), ?HISTORY_MAX), 1505 Body = [[L, $\n] || L <- lists:reverse(Kept)], 1506 _ = file:write_file(File, Body), 1507 ok; 1508 _ -> 1509 ok 1510 end. 1511 1512%% Drop blank entries; keep order (newest first). 1513sanitize_history(Hist) when is_list(Hist) -> 1514 [L || L <- Hist, not history_blank(L)]; 1515sanitize_history(_) -> 1516 []. 1517 1518%% Empty / whitespace-only / ANSI-only / zero-width-only lines look blank when 1519%% recalled with ↑ — never store or navigate to them. 1520history_blank(Bin) when is_binary(Bin) -> 1521 history_blank_visible(string:trim(Bin)); 1522history_blank(List) when is_list(List) -> 1523 case unicode:characters_to_binary(List) of 1524 Bin when is_binary(Bin) -> 1525 history_blank_visible(string:trim(Bin)); 1526 _ -> 1527 true 1528 end; 1529history_blank(_) -> 1530 true. 1531 1532history_blank_visible(Bin) when is_binary(Bin) -> 1533 case strip_ansi_bin(Bin) of 1534 <<>> -> 1535 true; 1536 Visible -> 1537 case unicode:characters_to_list(Visible) of 1538 List when is_list(List), List =/= [] -> 1539 %% ZWSP / ZWNJ / ZWJ / BOM / soft hyphen — render as empty. 1540 lists:all( 1541 fun(C) -> 1542 C =:= 16#200B orelse C =:= 16#200C orelse 1543 C =:= 16#200D orelse C =:= 16#FEFF orelse 1544 C =:= 16#00AD 1545 end, 1546 List 1547 ); 1548 _ -> 1549 false 1550 end 1551 end. 1552 1553%% Strip CSI sequences (ESC [ … final) for blank detection. 1554strip_ansi_bin(Bin) when is_binary(Bin) -> 1555 case unicode:characters_to_list(Bin) of 1556 List when is_list(List) -> 1557 unicode:characters_to_binary(strip_ansi_list(List, normal, [])); 1558 _ -> 1559 Bin 1560 end. 1561 1562strip_ansi_list([], _State, Acc) -> 1563 lists:reverse(Acc); 1564strip_ansi_list([16#1b | Rest], normal, Acc) -> 1565 strip_ansi_list(Rest, esc, Acc); 1566strip_ansi_list([C | Rest], normal, Acc) -> 1567 strip_ansi_list(Rest, normal, [C | Acc]); 1568strip_ansi_list([$[ | Rest], esc, Acc) -> 1569 strip_ansi_list(Rest, csi, Acc); 1570strip_ansi_list([_ | Rest], esc, Acc) -> 1571 strip_ansi_list(Rest, normal, Acc); 1572strip_ansi_list([C | Rest], csi, Acc) when C >= 16#40, C =< 16#7e -> 1573 strip_ansi_list(Rest, normal, Acc); 1574strip_ansi_list([_ | Rest], csi, Acc) -> 1575 strip_ansi_list(Rest, csi, Acc). 1576 1577push_history(Line) when is_binary(Line) -> 1578 %% Trim so " ls " does not become a near-blank distinct from "ls". 1579 Trimmed = string:trim(Line), 1580 case history_blank(Trimmed) of 1581 true -> 1582 ok; 1583 false -> 1584 Hist = sanitize_history( 1585 case get(gleshell_history) of 1586 L when is_list(L) -> L; 1587 _ -> [] 1588 end 1589 ), 1590 New = case Hist of 1591 [Trimmed | _] -> Hist; 1592 _ -> [Trimmed | Hist] 1593 end, 1594 put(gleshell_history, lists:sublist(New, ?HISTORY_MAX)), 1595 ok 1596 end; 1597push_history(_) -> 1598 ok. 1599 1600%% --------------------------------------------------------------------------- 1601%% OS / process helpers 1602%% --------------------------------------------------------------------------- 1603 1604-spec set_cwd(binary()) -> {ok, nil} | {error, binary()}. 1605set_cwd(Path) when is_binary(Path) -> 1606 case file:set_cwd(unicode:characters_to_list(Path)) of 1607 ok -> 1608 {ok, nil}; 1609 {error, Reason} -> 1610 {error, reason_to_bin(Reason)} 1611 end. 1612 1613-spec get_cwd() -> {ok, binary()} | {error, binary()}. 1614get_cwd() -> 1615 case file:get_cwd() of 1616 {ok, Dir} -> 1617 {ok, unicode:characters_to_binary(Dir)}; 1618 {error, Reason} -> 1619 {error, reason_to_bin(Reason)} 1620 end. 1621 1622-spec getenv(binary()) -> {ok, binary()} | {error, nil}. 1623getenv(Name) when is_binary(Name) -> 1624 case os:getenv(unicode:characters_to_list(Name)) of 1625 false -> 1626 {error, nil}; 1627 Value -> 1628 {ok, unicode:characters_to_binary(Value)} 1629 end. 1630 1631-spec setenv(binary(), binary()) -> {ok, nil}. 1632setenv(Name, Value) when is_binary(Name), is_binary(Value) -> 1633 os:putenv(unicode:characters_to_list(Name), unicode:characters_to_list(Value)), 1634 {ok, nil}. 1635 1636%% All process environment variables as a list of {Name, Value} binaries. 1637-spec list_env() -> list({binary(), binary()}). 1638list_env() -> 1639 lists:map( 1640 fun(Entry) -> 1641 case string:split(Entry, "=", leading) of 1642 [K, V] -> 1643 {unicode:characters_to_binary(K), unicode:characters_to_binary(V)}; 1644 [K] -> 1645 {unicode:characters_to_binary(K), <<>>}; 1646 _ -> 1647 {<<>>, <<>>} 1648 end 1649 end, 1650 os:getenv() 1651 ). 1652 1653%% Substring/regex search helper for the `find` builtin. 1654%% Returns {ok, true|false} or {error, Message} on invalid pattern. 1655-spec re_contains(binary(), binary(), boolean()) -> {ok, boolean()} | {error, binary()}. 1656re_contains(Text, Pattern, IgnoreCase) 1657 when is_binary(Text), is_binary(Pattern), is_boolean(IgnoreCase) -> 1658 Opts0 = [unicode], 1659 Opts = 1660 case IgnoreCase of 1661 true -> 1662 [caseless | Opts0]; 1663 false -> 1664 Opts0 1665 end, 1666 case re:compile(Pattern, Opts) of 1667 {ok, Re} -> 1668 case re:run(Text, Re, [{capture, none}]) of 1669 match -> 1670 {ok, true}; 1671 nomatch -> 1672 {ok, false} 1673 end; 1674 {error, {Reason, _}} -> 1675 {error, iolist_to_binary(io_lib:format("~p", [Reason]))}; 1676 {error, Reason} -> 1677 {error, iolist_to_binary(io_lib:format("~p", [Reason]))} 1678 end. 1679 1680-spec which(binary()) -> {ok, binary()} | {error, nil}. 1681which(Command) when is_binary(Command) -> 1682 case which_all(Command) of 1683 [Path | _] -> 1684 {ok, Path}; 1685 [] -> 1686 {error, nil} 1687 end. 1688 1689%% All matching executables on PATH (or the path itself if absolute/relative). 1690%% Order matches PATH search; duplicates from the same resolved path are dropped. 1691-spec which_all(binary()) -> [binary()]. 1692which_all(Command) when is_binary(Command) -> 1693 Cmd = unicode:characters_to_list(Command), 1694 case Cmd of 1695 [] -> 1696 []; 1697 _ -> 1698 case has_path_sep(Cmd) of 1699 true -> 1700 case is_executable_file(Cmd) of 1701 true -> 1702 [unicode:characters_to_binary(filename:absname(Cmd))]; 1703 false -> 1704 [] 1705 end; 1706 false -> 1707 case os:getenv("PATH") of 1708 false -> 1709 []; 1710 PathStr -> 1711 Dirs = string:tokens(PathStr, path_sep()), 1712 find_all_in_path(Cmd, Dirs, #{}, []) 1713 end 1714 end 1715 end. 1716 1717path_sep() -> 1718 case os:type() of 1719 {win32, _} -> ";"; 1720 _ -> ":" 1721 end. 1722 1723has_path_sep(Cmd) -> 1724 lists:member($/, Cmd) orelse lists:member($\\, Cmd). 1725 1726find_all_in_path(_Cmd, [], _Seen, Acc) -> 1727 lists:reverse(Acc); 1728find_all_in_path(Cmd, [Dir | Rest], Seen, Acc) -> 1729 File = filename:join(Dir, Cmd), 1730 case is_executable_file(File) of 1731 true -> 1732 Abs = filename:absname(File), 1733 Bin = unicode:characters_to_binary(Abs), 1734 case maps:is_key(Abs, Seen) of 1735 true -> 1736 find_all_in_path(Cmd, Rest, Seen, Acc); 1737 false -> 1738 find_all_in_path(Cmd, Rest, Seen#{Abs => true}, [Bin | Acc]) 1739 end; 1740 false -> 1741 find_all_in_path(Cmd, Rest, Seen, Acc) 1742 end. 1743 1744is_executable_file(Path) -> 1745 case file:read_file_info(Path) of 1746 {ok, #file_info{type = regular, mode = Mode}} -> 1747 %% Any execute bit (owner/group/other). 1748 (Mode band 8#111) =/= 0; 1749 _ -> 1750 false 1751 end. 1752 1753-spec home_dir() -> {ok, binary()} | {error, binary()}. 1754home_dir() -> 1755 case os:getenv("HOME") of 1756 false -> 1757 {error, <<"HOME not set">>}; 1758 Home -> 1759 {ok, unicode:characters_to_binary(Home)} 1760 end. 1761 1762-spec stdout_isatty() -> boolean(). 1763stdout_isatty() -> 1764 case io:columns() of 1765 {ok, _} -> 1766 true; 1767 _ -> 1768 try 1769 case prim_tty:isatty(stdout) of 1770 true -> true; 1771 _ -> false 1772 end 1773 catch 1774 _:_ -> 1775 false 1776 end 1777 end. 1778 1779%% --------------------------------------------------------------------------- 1780%% External commands 1781%% 1782%% Two modes: 1783%% 1784%% 1. `run_cmd/2` — capture stdout/stderr into a binary (pipelines, `let x =`, 1785%% non-TTY display). Prefers a throwaway PTY when color is wanted so tools 1786%% emit ANSI for `cmd | less`; falls back to pipes. 1787%% 1788%% 2. `run_cmd_tty/2` — foreground interactive. Prefer util-linux `script` 1789%% (PTY + key relay via `io:get_chars`) so Ctrl+C can SIGINT the child. 1790%% `erl_child_setup` calls setsid, so the child is never in the terminal's 1791%% foreground process group — kernel SIGINT goes to BEAM, not the child. 1792%% Fallback: inherit real stdio (`nouse_stdio`) when script/TTY is missing. 1793%% 1794%% Auth tools (`sudo`, `run0`, …) also need a controlling TTY; the PTY path 1795%% covers that. Host termios during children: cooked for OPOST/ONLCR (no 1796%% staircase) but ISIG off so Ctrl+C is readable as byte 3 instead of opening 1797%% the Erlang BREAK menu. 1798%% --------------------------------------------------------------------------- 1799 1800-spec run_cmd(binary(), [binary()], binary()) -> 1801 {ok, {integer(), binary()}} | {error, binary()}. 1802run_cmd(Command, Args, Stdin) when is_binary(Command), is_list(Args), is_binary(Stdin) -> 1803 case resolve_cmd(Command, Args) of 1804 {error, _} = E -> 1805 E; 1806 {ok, Path, PortArgs} -> 1807 try 1808 run_cmd_capture(Path, PortArgs, Stdin) 1809 catch 1810 _:Reason -> 1811 {error, reason_to_bin(Reason)} 1812 end 1813 end. 1814 1815%% Foreground interactive: inherit TTY when possible. 1816%% Non-empty Stdin is still fed (temp file + redirect) so `cat f | less` works. 1817%% 1818%% While the REPL uses OTP `{noshell, raw}`, prim_tty leaves termios with 1819%% OPOST/ONLCR off so a bare LF does not return the cursor to column 0. 1820%% Tools that write LF-only lines (fastfetch, many TUIs) look staircased if 1821%% they inherit that TTY. Wrap inherit/PTY runs in cooked mode and restore 1822%% raw afterwards (same idea as println/1 converting to CRLF for shell text). 1823-spec run_cmd_tty(binary(), [binary()], binary()) -> 1824 {ok, {integer(), binary()}} | {error, binary()}. 1825run_cmd_tty(Command, Args, Stdin) when is_binary(Command), is_list(Args), is_binary(Stdin) -> 1826 case resolve_cmd(Command, Args) of 1827 {error, _} = E -> 1828 E; 1829 {ok, Path, PortArgs} -> 1830 try 1831 case stdout_isatty() of 1832 false -> 1833 run_cmd_capture(Path, PortArgs, Stdin); 1834 true -> 1835 with_cooked_tty(fun() -> 1836 %% PTY for all interactive when possible: key relay 1837 %% sees Ctrl+C and can SIGINT the child process group. 1838 case {os:find_executable("script"), find_tty_path()} of 1839 {Script, {ok, Tty}} when is_list(Script) -> 1840 run_cmd_pty(Script, Path, PortArgs, Tty, Stdin); 1841 _ -> 1842 run_cmd_inherit(Path, PortArgs, Stdin) 1843 end 1844 end) 1845 end 1846 catch 1847 _:Reason -> 1848 {error, reason_to_bin(Reason)} 1849 end 1850 end. 1851 1852%% Temporarily put the controlling TTY into cooked output + non-canonical 1853%% input for an external child, then restore previous termios (raw REPL). 1854%% 1855%% Applied whenever stdout is a TTY (not only raw REPL): -c under a terminal 1856%% still needs -isig/-icanon so Ctrl+C is a readable byte for the interrupt 1857%% path. No-op when stty/TTY is unavailable. 1858with_cooked_tty(Fun) when is_function(Fun, 0) -> 1859 case stdout_isatty() of 1860 false -> 1861 Fun(); 1862 true -> 1863 case stty_save() of 1864 undefined -> 1865 %% Still try to apply host flags; restore is best-effort. 1866 stty_sane(), 1867 try 1868 Fun() 1869 after 1870 ok 1871 end; 1872 Saved -> 1873 stty_sane(), 1874 try 1875 Fun() 1876 after 1877 stty_restore(Saved) 1878 end 1879 end 1880 end. 1881 1882stty_save() -> 1883 case stty_run(["-g"]) of 1884 {ok, Out} -> 1885 case string:trim(Out, both, [$\s, $\t, $\n, $\r]) of 1886 "" -> 1887 undefined; 1888 Settings -> 1889 %% stty -g is a single token of colon-separated hex flags. 1890 Settings 1891 end; 1892 _ -> 1893 undefined 1894 end. 1895 1896stty_sane() -> 1897 %% Host TTY while an external runs under the raw REPL: 1898 %% - sane / opost / onlcr: LF→CRLF so children don't staircase 1899 %% - -isig: Ctrl+C is byte 3 (not kernel SIGINT → BEAM BREAK menu) 1900 %% - -icanon min 1 time 0: deliver each byte immediately — with ICANON 1901 %% left on, Ctrl+C sits in the line buffer until Enter and our key 1902 %% relay never sees it (external freezes; second ^C looks wedged) 1903 %% - -echo: host must not echo keys we relay into the child PTY 1904 _ = stty_run([ 1905 "sane", 1906 "-isig", 1907 "-icanon", 1908 "min", 1909 "1", 1910 "time", 1911 "0", 1912 "-echo" 1913 ]), 1914 ok. 1915 1916stty_restore(Settings) when is_list(Settings) -> 1917 _ = stty_run([Settings]), 1918 ok; 1919stty_restore(_) -> 1920 ok. 1921 1922%% Run stty against the real terminal device (not a pipe). Prefer the pts 1923%% path from /proc (same as sudo/PTY path), then `/dev/tty`. 1924%% 1925%% NOTE: do not use filelib:is_file/1 for `/dev/tty` — it is a device node, 1926%% so is_file returns false and would skip stty entirely (fastfetch staircase). 1927stty_run(Args) when is_list(Args) -> 1928 case os:find_executable("stty") of 1929 false -> 1930 {error, no_stty}; 1931 Stty when is_list(Stty) -> 1932 stty_run_on(Stty, Args, stty_devices()) 1933 end. 1934 1935stty_devices() -> 1936 case find_tty_path() of 1937 {ok, Path} -> 1938 %% Prefer the concrete pts; /dev/tty is a fallback alias. 1939 [Path, "/dev/tty"]; 1940 _ -> 1941 ["/dev/tty"] 1942 end. 1943 1944stty_run_on(_Stty, _Args, []) -> 1945 {error, no_tty}; 1946stty_run_on(Stty, Args, [Dev | Rest]) -> 1947 case stty_on_device(Stty, Dev, Args) of 1948 {ok, _} = Ok -> 1949 Ok; 1950 _ -> 1951 stty_run_on(Stty, Args, Rest) 1952 end. 1953 1954stty_on_device(Stty, Dev, Args) when is_list(Stty), is_list(Dev), is_list(Args) -> 1955 try 1956 Port = open_port( 1957 {spawn_executable, Stty}, 1958 [ 1959 binary, 1960 exit_status, 1961 use_stdio, 1962 stderr_to_stdout, 1963 {args, ["-F", Dev | Args]} 1964 ] 1965 ), 1966 %% No interrupt watch — internal helper, must not steal TTY input. 1967 case collect_output_quiet(Port, <<>>, 5000) of 1968 {ok, {0, Bin}} -> 1969 {ok, unicode:characters_to_list(Bin)}; 1970 {ok, {Status, Bin}} -> 1971 {error, {Status, Bin}}; 1972 {error, _} = E -> 1973 E 1974 end 1975 catch 1976 _:_ -> 1977 {error, stty_failed} 1978 end. 1979 1980resolve_cmd(Command, Args) -> 1981 case os:find_executable(unicode:characters_to_list(Command)) of 1982 false -> 1983 {error, <<"command not found: ", Command/binary>>}; 1984 Path -> 1985 PortArgs = [unicode:characters_to_list(A) || A <- Args], 1986 {ok, Path, PortArgs} 1987 end. 1988 1989%% Capture mode: collect stdout/stderr into a binary (pipelines, `let x =`). 1990%% 1991%% When the shell wants color, prefer a throwaway PTY (`script`) so tools that 1992%% only colorize on a TTY (`jj`, `git`, …) still emit ANSI for `cmd | less` — 1993%% without tool-specific env hacks. Nested pagers are forced to `cat` so the 1994%% child cannot hang waiting for interactive `less`. Falls back to plain pipes 1995%% (+ FORCE_COLOR / git overlays) when `script` is missing. 1996%% 1997%% Stdin is redirected via temp file / `$GLESHELL_STDIN` so programs never hang 1998%% on an open-but-never-written Erlang port pipe. 1999run_cmd_capture(Path, PortArgs, Stdin) when is_binary(Stdin) -> 2000 case want_child_color() of 2001 true -> 2002 case os:find_executable("script") of 2003 Script when is_list(Script) -> 2004 run_cmd_capture_pty(Script, Path, PortArgs, Stdin); 2005 _ -> 2006 run_cmd_capture_pipe(Path, PortArgs, Stdin) 2007 end; 2008 false -> 2009 run_cmd_capture_pipe(Path, PortArgs, Stdin) 2010 end. 2011 2012run_cmd_capture_pipe(Path, PortArgs, Stdin) when is_binary(Stdin) -> 2013 with_stdin_file(Stdin, fun(StdinPath) -> 2014 sh_exec(Path, PortArgs, StdinPath, capture) 2015 end). 2016 2017%% PTY capture: child sees a TTY (colors, auto decorations) but we only collect 2018%% output — nothing is relayed to the user's terminal. 2019run_cmd_capture_pty(Script, Path, PortArgs, <<>>) -> 2020 run_cmd_capture_pty_argv(Script, [Path | PortArgs]); 2021run_cmd_capture_pty(Script, Path, PortArgs, Stdin) when is_binary(Stdin) -> 2022 with_stdin_file(Stdin, fun(StdinPath) -> 2023 with_exec_runner(Path, PortArgs, StdinPath, fun(Runner) -> 2024 run_cmd_capture_pty_argv(Script, [Runner]) 2025 end) 2026 end). 2027 2028run_cmd_capture_pty_argv(Script, Argv) when is_list(Argv) -> 2029 with_trapped_exits(fun() -> 2030 Port = open_port( 2031 {spawn_executable, Script}, 2032 [ 2033 binary, 2034 exit_status, 2035 stderr_to_stdout, 2036 use_stdio, 2037 stream, 2038 {env, child_env_capture_pty()}, 2039 {args, ["-q", "-e", "/dev/null", "--" | Argv]} 2040 ] 2041 ), 2042 put(gleshell_output_shown, false), 2043 try 2044 case collect_output(Port, <<>>) of 2045 {ok, {Status, Acc}} -> 2046 {ok, {Status, normalize_pty_output(Acc)}}; 2047 Other -> 2048 Other 2049 end 2050 after 2051 catch port_close(Port) 2052 end 2053 end). 2054 2055%% Inherit real stdio — pagers/editors talk to the terminal directly. 2056%% LESS=FRX (via child_env) lets less pass ANSI colors from jj/git. 2057%% 2058%% Empty stdin: pure inherit (bare `less` reads the TTY). 2059%% Non-empty stdin: still inherit stdout/stderr TTY, but redirect stdin from 2060%% a temp file so `cat file | less` pages the pipeline data. 2061%% 2062%% Ctrl+C: prefer the PTY path (key relay). Inherit is a fallback when 2063%% `script` is missing — host is -isig/-icanon so Ctrl+C is byte 3; a 2064%% watcher SIGINTs the child's process group (setsid means kernel SIGINT 2065%% never reaches the child even with ISIG on). 2066run_cmd_inherit(Path, PortArgs, Stdin) when is_binary(Stdin) -> 2067 case Stdin of 2068 <<>> -> 2069 Port = open_port( 2070 {spawn_executable, Path}, 2071 [ 2072 exit_status, 2073 nouse_stdio, 2074 {env, child_env()}, 2075 {args, PortArgs} 2076 ] 2077 ), 2078 put(gleshell_output_shown, true), 2079 await_port_exit_interruptible(Port); 2080 _ -> 2081 with_stdin_file(Stdin, fun(StdinPath) -> 2082 sh_exec(Path, PortArgs, StdinPath, inherit) 2083 end) 2084 end. 2085 2086%% PTY + key relay (interactive TTY, sudo/run0, …). 2087%% 2088%% util-linux `script` does NOT exec the argv after `--` directly. It runs 2089%% `$SHELL -c "<joined args>"` (see script(1)). Nested 2090%% `sh -c 'exec "$0" …' path` therefore becomes one mangled shell string and 2091%% the real binary never runs (fastfetch → empty output, exit 0). 2092%% 2093%% Empty stdin: pass Path/args through as simple tokens (`script -- cmd args`). 2094%% Non-empty stdin (pipeline → less): write a one-shot runner script that 2095%% redirects and execs, then `script -- /tmp/runner` (single path token). 2096run_cmd_pty(Script, Path, PortArgs, TtyPath, <<>>) -> 2097 run_cmd_pty_argv(Script, [Path | PortArgs], TtyPath, Path, PortArgs, <<>>); 2098run_cmd_pty(Script, Path, PortArgs, TtyPath, Stdin) when is_binary(Stdin) -> 2099 with_stdin_file(Stdin, fun(StdinPath) -> 2100 with_exec_runner(Path, PortArgs, StdinPath, fun(Runner) -> 2101 run_cmd_pty_argv(Script, [Runner], TtyPath, Path, PortArgs, Stdin) 2102 end) 2103 end). 2104 2105run_cmd_pty_argv(Script, Argv, TtyPath, Path, PortArgs, Stdin) when is_list(Argv) -> 2106 with_trapped_exits(fun() -> 2107 Port = open_port( 2108 {spawn_executable, Script}, 2109 [ 2110 binary, 2111 exit_status, 2112 stderr_to_stdout, 2113 use_stdio, 2114 stream, 2115 {env, child_env()}, 2116 {args, ["-q", "-e", "/dev/null", "--" | Argv]} 2117 ] 2118 ), 2119 case file:open(TtyPath, [write, raw, binary]) of 2120 {ok, TtyOut} -> 2121 GL = group_leader(), 2122 Reader = spawn(fun() -> 2123 group_leader(GL, self()), 2124 io_to_port(Port) 2125 end), 2126 put(gleshell_output_shown, true), 2127 try 2128 collect_output_relay(Port, TtyOut, <<>>) 2129 after 2130 exit(Reader, kill), 2131 catch file:close(TtyOut), 2132 catch port_close(Port) 2133 end; 2134 {error, _} -> 2135 catch port_close(Port), 2136 put(gleshell_output_shown, false), 2137 run_cmd_inherit(Path, PortArgs, Stdin) 2138 end 2139 end). 2140 2141%% One-shot `#!/bin/sh` runner: exec Path with PortArgs, stdin from StdinPath. 2142%% Needed because `script` flattens argv into `$SHELL -c` (no real multi-arg exec). 2143with_exec_runner(Path, PortArgs, StdinPath, Fun) when is_function(Fun, 1) -> 2144 case write_runner_script(Path, PortArgs, StdinPath) of 2145 {ok, Runner} -> 2146 try 2147 Fun(Runner) 2148 after 2149 _ = file:delete(Runner) 2150 end; 2151 {error, Reason} -> 2152 {error, reason_to_bin({runner_script, Reason})} 2153 end. 2154 2155write_runner_script(Path, PortArgs, StdinPath) -> 2156 Dir = 2157 case os:getenv("TMPDIR") of 2158 false -> 2159 "/tmp"; 2160 "" -> 2161 "/tmp"; 2162 D -> 2163 D 2164 end, 2165 Name = 2166 filename:join( 2167 Dir, 2168 "gleshell-run-" ++ integer_to_list(erlang:unique_integer([positive])) 2169 ), 2170 Body = runner_script_body(Path, PortArgs, StdinPath), 2171 case file:write_file(Name, Body) of 2172 ok -> 2173 case file:change_mode(Name, 8#755) of 2174 ok -> 2175 {ok, Name}; 2176 {error, _} = E -> 2177 _ = file:delete(Name), 2178 E 2179 end; 2180 {error, _} = E -> 2181 E 2182 end. 2183 2184runner_script_body(Path, PortArgs, StdinPath) -> 2185 ArgsQ = [[$\s, shell_single_quote(A)] || A <- PortArgs], 2186 [ 2187 "#!/bin/sh\n", 2188 "exec ", 2189 shell_single_quote(Path), 2190 ArgsQ, 2191 " < ", 2192 shell_single_quote(StdinPath), 2193 "\n" 2194 ]. 2195 2196%% Safe single-quoted shell token (`foo'bar` → `'foo'\''bar'`). 2197shell_single_quote(S) when is_list(S) -> 2198 [$' | shell_single_quote_chars(S) ++ "'"]; 2199shell_single_quote(B) when is_binary(B) -> 2200 shell_single_quote(unicode:characters_to_list(B)). 2201 2202shell_single_quote_chars([]) -> 2203 []; 2204shell_single_quote_chars([$' | Rest]) -> 2205 "'\\''" ++ shell_single_quote_chars(Rest); 2206shell_single_quote_chars([C | Rest]) -> 2207 [C | shell_single_quote_chars(Rest)]. 2208 2209find_sh() -> 2210 case os:find_executable("sh") of 2211 false -> 2212 "/bin/sh"; 2213 S -> 2214 S 2215 end. 2216 2217%% Run Path with stdin redirected from StdinPath. 2218%% Mode `capture` uses pipes; `inherit` uses nouse_stdio (real TTY for out/err). 2219sh_exec(Path, PortArgs, StdinPath, capture) -> 2220 Port = open_port( 2221 {spawn_executable, find_sh()}, 2222 [ 2223 binary, 2224 exit_status, 2225 stderr_to_stdout, 2226 use_stdio, 2227 stream, 2228 {env, [{"GLESHELL_STDIN", StdinPath} | child_env()]}, 2229 {args, ["-c", "exec \"$0\" \"$@\" < \"$GLESHELL_STDIN\"", Path | PortArgs]} 2230 ] 2231 ), 2232 put(gleshell_output_shown, false), 2233 collect_output(Port, <<>>); 2234sh_exec(Path, PortArgs, StdinPath, inherit) -> 2235 Port = open_port( 2236 {spawn_executable, find_sh()}, 2237 [ 2238 exit_status, 2239 nouse_stdio, 2240 {env, [{"GLESHELL_STDIN", StdinPath} | child_env()]}, 2241 {args, ["-c", "exec \"$0\" \"$@\" < \"$GLESHELL_STDIN\"", Path | PortArgs]} 2242 ] 2243 ), 2244 put(gleshell_output_shown, true), 2245 await_port_exit_interruptible(Port). 2246 2247%% Provide a filesystem path for stdin bytes; clean up temp files afterwards. 2248with_stdin_file(<<>>, Fun) when is_function(Fun, 1) -> 2249 Fun("/dev/null"); 2250with_stdin_file(Data, Fun) when is_binary(Data), is_function(Fun, 1) -> 2251 case write_stdin_tmp(Data) of 2252 {ok, Path} -> 2253 try 2254 Fun(Path) 2255 after 2256 _ = file:delete(Path) 2257 end; 2258 {error, Reason} -> 2259 {error, reason_to_bin({stdin_tmp, Reason})} 2260 end. 2261 2262write_stdin_tmp(Data) when is_binary(Data) -> 2263 Dir = 2264 case os:getenv("TMPDIR") of 2265 false -> 2266 "/tmp"; 2267 "" -> 2268 "/tmp"; 2269 D -> 2270 D 2271 end, 2272 Name = 2273 filename:join( 2274 Dir, 2275 "gleshell-stdin-" ++ integer_to_list(erlang:unique_integer([positive])) 2276 ), 2277 case file:write_file(Name, Data) of 2278 ok -> 2279 {ok, Name}; 2280 {error, _} = E -> 2281 E 2282 end. 2283 2284%% Relay keypresses from the group leader to the child's PTY (script stdin). 2285%% Ctrl+C (ETX / byte 3): SIGINT the child process group, and still write the 2286%% byte so the PTY line discipline can deliver SIGINT on the slave side too. 2287io_to_port(Port) -> 2288 case io:get_chars("", 1) of 2289 eof -> 2290 ok; 2291 {error, _} -> 2292 ok; 2293 Data -> 2294 case io_data_to_bin(Data) of 2295 <<>> -> 2296 io_to_port(Port); 2297 <<3>> = Bin -> 2298 signal_port_group(Port, int), 2299 catch port_command(Port, Bin), 2300 io_to_port(Port); 2301 Bin -> 2302 catch port_command(Port, Bin), 2303 io_to_port(Port) 2304 end 2305 end. 2306 2307io_data_to_bin(Bin) when is_binary(Bin) -> 2308 Bin; 2309io_data_to_bin([C]) when is_integer(C), C >= 0, C =< 16#7F -> 2310 <<C>>; 2311io_data_to_bin(List) when is_list(List) -> 2312 case unicode:characters_to_binary(List) of 2313 Bin when is_binary(Bin) -> 2314 Bin; 2315 _ -> 2316 <<>> 2317 end; 2318io_data_to_bin(_) -> 2319 <<>>. 2320 2321%% When a port's OS process is killed (Ctrl+C → SIGINT), the linked port may 2322%% exit with `epipe` / signal reasons. Without trap_exit the shell process 2323%% dies with "Erlang exit: Epipe" instead of returning to the prompt. 2324with_trapped_exits(Fun) when is_function(Fun, 0) -> 2325 Old = process_flag(trap_exit, true), 2326 try 2327 Fun() 2328 after 2329 process_flag(trap_exit, Old), 2330 drain_exit_msgs() 2331 end. 2332 2333drain_exit_msgs() -> 2334 receive 2335 {'EXIT', _, _} -> 2336 drain_exit_msgs() 2337 after 0 -> 2338 ok 2339 end. 2340 2341%% Inherit path: watch for Ctrl+C (byte 3) and SIGINT the child group. 2342%% Used when `script`/PTY is unavailable; same kill path as the PTY relay. 2343await_port_exit_interruptible(Port) -> 2344 with_trapped_exits(fun() -> 2345 with_interrupt_watch(Port, fun() -> 2346 await_port_exit_interruptible_loop(Port) 2347 end) 2348 end). 2349 2350await_port_exit_interruptible_loop(Port) -> 2351 receive 2352 {Port, {exit_status, Status}} -> 2353 {ok, {Status, <<>>}}; 2354 {'EXIT', Port, _Reason} -> 2355 {ok, {130, <<>>}}; 2356 {gleshell_interrupt, _} -> 2357 signal_port_group(Port, int), 2358 await_port_exit_after_interrupt(Port, 2000) 2359 end. 2360 2361await_port_exit_after_interrupt(Port, GraceMs) -> 2362 receive 2363 {Port, {exit_status, Status}} -> 2364 {ok, {Status, <<>>}}; 2365 {'EXIT', Port, _Reason} -> 2366 {ok, {130, <<>>}}; 2367 {gleshell_interrupt, _} -> 2368 signal_port_group(Port, kill), 2369 await_port_exit_after_interrupt(Port, 1000) 2370 after GraceMs -> 2371 signal_port_group(Port, kill), 2372 catch port_close(Port), 2373 receive 2374 {Port, {exit_status, Status}} -> 2375 {ok, {Status, <<>>}}; 2376 {'EXIT', Port, _} -> 2377 {ok, {130, <<>>}} 2378 after 1000 -> 2379 {ok, {130, <<>>}} 2380 end 2381 end. 2382 2383collect_output(Port, Acc) -> 2384 with_trapped_exits(fun() -> 2385 with_interrupt_watch(Port, fun() -> 2386 collect_output_loop(Port, Acc, 120_000) 2387 end) 2388 end). 2389 2390collect_output_loop(Port, Acc, Timeout) -> 2391 receive 2392 {Port, {data, Data}} when is_binary(Data) -> 2393 collect_output_loop(Port, <<Acc/binary, Data/binary>>, Timeout); 2394 {Port, {data, Data}} when is_list(Data) -> 2395 Bin = unicode:characters_to_binary(Data), 2396 collect_output_loop(Port, <<Acc/binary, Bin/binary>>, Timeout); 2397 {Port, {exit_status, Status}} -> 2398 {ok, {Status, Acc}}; 2399 {'EXIT', Port, _Reason} -> 2400 {ok, {130, Acc}}; 2401 {gleshell_interrupt, _} -> 2402 signal_port_group(Port, int), 2403 collect_output_after_interrupt(Port, Acc, 2000) 2404 after Timeout -> 2405 signal_port_group(Port, term), 2406 catch port_close(Port), 2407 {error, <<"command timed out after 120s">>} 2408 end. 2409 2410collect_output_after_interrupt(Port, Acc, GraceMs) -> 2411 receive 2412 {Port, {data, Data}} when is_binary(Data) -> 2413 collect_output_after_interrupt( 2414 Port, <<Acc/binary, Data/binary>>, GraceMs 2415 ); 2416 {Port, {data, Data}} when is_list(Data) -> 2417 Bin = unicode:characters_to_binary(Data), 2418 collect_output_after_interrupt( 2419 Port, <<Acc/binary, Bin/binary>>, GraceMs 2420 ); 2421 {Port, {exit_status, Status}} -> 2422 {ok, {Status, Acc}}; 2423 {'EXIT', Port, _Reason} -> 2424 {ok, {130, Acc}}; 2425 {gleshell_interrupt, _} -> 2426 signal_port_group(Port, kill), 2427 collect_output_after_interrupt(Port, Acc, 1000) 2428 after GraceMs -> 2429 signal_port_group(Port, kill), 2430 catch port_close(Port), 2431 receive 2432 {Port, {data, Data}} when is_binary(Data) -> 2433 collect_output_after_interrupt( 2434 Port, <<Acc/binary, Data/binary>>, 500 2435 ); 2436 {Port, {data, Data}} when is_list(Data) -> 2437 Bin = unicode:characters_to_binary(Data), 2438 collect_output_after_interrupt( 2439 Port, <<Acc/binary, Bin/binary>>, 500 2440 ); 2441 {Port, {exit_status, Status}} -> 2442 {ok, {Status, Acc}}; 2443 {'EXIT', Port, _} -> 2444 {ok, {130, Acc}} 2445 after 1000 -> 2446 {ok, {130, Acc}} 2447 end 2448 end. 2449 2450%% PTY session: no timeout (password prompts, long pagers, etc.). 2451%% Ctrl+C is handled in io_to_port/1 (SIGINT); also accept interrupt msgs. 2452%% Caller must run under with_trapped_exits/1 so port epipe is not fatal. 2453collect_output_relay(Port, Tty, Acc) -> 2454 receive 2455 {Port, {data, Data}} when is_binary(Data) -> 2456 _ = file:write(Tty, Data), 2457 collect_output_relay(Port, Tty, <<Acc/binary, Data/binary>>); 2458 {Port, {data, Data}} when is_list(Data) -> 2459 Bin = unicode:characters_to_binary(Data), 2460 _ = file:write(Tty, Bin), 2461 collect_output_relay(Port, Tty, <<Acc/binary, Bin/binary>>); 2462 {Port, {exit_status, Status}} -> 2463 {ok, {Status, normalize_pty_output(Acc)}}; 2464 {'EXIT', Port, _Reason} -> 2465 {ok, {130, normalize_pty_output(Acc)}}; 2466 {gleshell_interrupt, _} -> 2467 signal_port_group(Port, int), 2468 collect_output_relay_after_interrupt(Port, Tty, Acc, 2000) 2469 end. 2470 2471collect_output_relay_after_interrupt(Port, Tty, Acc, GraceMs) -> 2472 receive 2473 {Port, {data, Data}} when is_binary(Data) -> 2474 _ = file:write(Tty, Data), 2475 collect_output_relay_after_interrupt( 2476 Port, Tty, <<Acc/binary, Data/binary>>, GraceMs 2477 ); 2478 {Port, {data, Data}} when is_list(Data) -> 2479 Bin = unicode:characters_to_binary(Data), 2480 _ = file:write(Tty, Bin), 2481 collect_output_relay_after_interrupt( 2482 Port, Tty, <<Acc/binary, Bin/binary>>, GraceMs 2483 ); 2484 {Port, {exit_status, Status}} -> 2485 {ok, {Status, normalize_pty_output(Acc)}}; 2486 {'EXIT', Port, _Reason} -> 2487 {ok, {130, normalize_pty_output(Acc)}}; 2488 {gleshell_interrupt, _} -> 2489 signal_port_group(Port, kill), 2490 collect_output_relay_after_interrupt(Port, Tty, Acc, 1000) 2491 after GraceMs -> 2492 signal_port_group(Port, kill), 2493 catch port_close(Port), 2494 receive 2495 {Port, {exit_status, Status}} -> 2496 {ok, {Status, normalize_pty_output(Acc)}}; 2497 {'EXIT', Port, _} -> 2498 {ok, {130, normalize_pty_output(Acc)}} 2499 after 1000 -> 2500 {ok, {130, normalize_pty_output(Acc)}} 2501 end 2502 end. 2503 2504%% --------------------------------------------------------------------------- 2505%% Ctrl+C / SIGINT forwarding 2506%% 2507%% BEAM's open_port → erl_child_setup → setsid, so the child is not in the 2508%% terminal foreground group. Host ISIG is left off while a child runs; we 2509%% watch for byte 3 (ETX) and kill(-pid, SIGINT) on the child's process group. 2510%% --------------------------------------------------------------------------- 2511 2512with_interrupt_watch(_Port, Fun) when is_function(Fun, 0) -> 2513 Parent = self(), 2514 GL = group_leader(), 2515 Watcher = 2516 case can_watch_interrupt() of 2517 true -> 2518 spawn(fun() -> 2519 group_leader(GL, self()), 2520 interrupt_watch_loop(Parent) 2521 end); 2522 false -> 2523 undefined 2524 end, 2525 try 2526 Fun() 2527 after 2528 case Watcher of 2529 undefined -> 2530 ok; 2531 W -> 2532 exit(W, kill), 2533 drain_interrupt_msgs() 2534 end 2535 end. 2536 2537%% Collect port output without Ctrl+C watching (stty and other helpers). 2538collect_output_quiet(Port, Acc, Timeout) -> 2539 receive 2540 {Port, {data, Data}} when is_binary(Data) -> 2541 collect_output_quiet(Port, <<Acc/binary, Data/binary>>, Timeout); 2542 {Port, {data, Data}} when is_list(Data) -> 2543 Bin = unicode:characters_to_binary(Data), 2544 collect_output_quiet(Port, <<Acc/binary, Bin/binary>>, Timeout); 2545 {Port, {exit_status, Status}} -> 2546 {ok, {Status, Acc}} 2547 after Timeout -> 2548 catch port_close(Port), 2549 {error, <<"command timed out">>} 2550 end. 2551 2552%% Watch when the REPL owns the TTY (raw mode) or stdin is a terminal. 2553can_watch_interrupt() -> 2554 case get(gleshell_raw) of 2555 true -> 2556 true; 2557 _ -> 2558 stdout_isatty() 2559 end. 2560 2561interrupt_watch_loop(Parent) when is_pid(Parent) -> 2562 case catch io:get_chars("", 1) of 2563 eof -> 2564 ok; 2565 {error, _} -> 2566 ok; 2567 {'EXIT', _} -> 2568 ok; 2569 Data -> 2570 case io_data_to_bin(Data) of 2571 <<3>> -> 2572 Parent ! {gleshell_interrupt, self()}, 2573 interrupt_watch_loop(Parent); 2574 _ -> 2575 %% Non-Ctrl+C: discard here (capture mode). Interactive 2576 %% PTY uses io_to_port instead; inherit prefers PTY. 2577 interrupt_watch_loop(Parent) 2578 end 2579 end. 2580 2581drain_interrupt_msgs() -> 2582 receive 2583 {gleshell_interrupt, _} -> 2584 drain_interrupt_msgs() 2585 after 0 -> 2586 ok 2587 end. 2588 2589%% SIGINT/SIGTERM/SIGKILL the port's OS process group (setsid → pgid = pid). 2590signal_port_group(Port, Sig) when is_port(Port) -> 2591 case erlang:port_info(Port, os_pid) of 2592 {os_pid, Pid} when is_integer(Pid), Pid > 0 -> 2593 kill_os_group(Pid, Sig); 2594 _ -> 2595 ok 2596 end. 2597 2598kill_os_group(Pid, Sig) when is_integer(Pid) -> 2599 Kill = 2600 case os:find_executable("kill") of 2601 false -> 2602 "kill"; 2603 K -> 2604 K 2605 end, 2606 SigArg = 2607 case Sig of 2608 int -> 2609 "-INT"; 2610 term -> 2611 "-TERM"; 2612 kill -> 2613 "-KILL" 2614 end, 2615 %% Negative pid → process group (child is session/group leader after setsid). 2616 Pg = "-" ++ integer_to_list(Pid), 2617 Single = integer_to_list(Pid), 2618 _ = kill_once(Kill, [SigArg, Pg]), 2619 _ = kill_once(Kill, [SigArg, Single]), 2620 ok. 2621 2622kill_once(Kill, Args) -> 2623 try 2624 Port = open_port( 2625 {spawn_executable, Kill}, 2626 [exit_status, nouse_stdio, {args, Args}] 2627 ), 2628 receive 2629 {Port, {exit_status, _}} -> 2630 ok 2631 after 1000 -> 2632 catch port_close(Port), 2633 ok 2634 end 2635 catch 2636 _:_ -> 2637 ok 2638 end. 2639 2640%% PTY line discipline often emits CR-LF; normalize to LF for structured use. 2641normalize_pty_output(Bin) when is_binary(Bin) -> 2642 binary:replace(Bin, <<"\r\n">>, <<"\n">>, [global]). 2643 2644%% Extra env for external commands (merged into the process environment). 2645%% 2646%% - SHELL=/bin/sh: `script` invokes $SHELL; nu/fish break `script -c`. 2647%% - LESS=FRX when unset: external pagers pass through ANSI (-R) and exit on 2648%% short output (-F) without clearing the screen (-X). 2649%% - When the shell wants color and the child has no real TTY (pipe capture 2650%% fallback): FORCE_COLOR / CLICOLOR_FORCE, plus git GIT_CONFIG_* overlays 2651%% (git ignores FORCE_COLOR; decorate=auto drops ref names on pipes). 2652%% Prefer PTY capture (`child_env_capture_pty`) so jj/git/etc. colorize 2653%% naturally for `cmd | less` without per-tool config. 2654child_env() -> 2655 Env0 = child_env_base(), 2656 case want_child_color() of 2657 false -> 2658 Env0; 2659 true -> 2660 force_git_tty_env(force_color_env(Env0)) 2661 end. 2662 2663%% PTY capture env: color via TTY detection; never nest an interactive pager. 2664child_env_capture_pty() -> 2665 disable_nested_pagers(force_color_env(child_env_base())). 2666 2667child_env_base() -> 2668 Env0 = [{"SHELL", "/bin/sh"}], 2669 case os:getenv("LESS") of 2670 false -> 2671 [{"LESS", "FRX"} | Env0]; 2672 "" -> 2673 [{"LESS", "FRX"} | Env0]; 2674 _ -> 2675 Env0 2676 end. 2677 2678%% FORCE_COLOR / CLICOLOR_FORCE for children that honor them. 2679force_color_env(Env) -> 2680 Env1 = 2681 case os:getenv("FORCE_COLOR") of 2682 false -> 2683 [{"FORCE_COLOR", "1"} | Env]; 2684 "0" -> 2685 Env; 2686 _ -> 2687 Env 2688 end, 2689 case os:getenv("CLICOLOR_FORCE") of 2690 false -> 2691 [{"CLICOLOR_FORCE", "1"} | Env1]; 2692 "0" -> 2693 Env1; 2694 _ -> 2695 Env1 2696 end. 2697 2698%% Capture must not hang inside the child's own pager (git/jj → less). 2699%% Always override for PTY capture; interactive `run_cmd_tty` uses child_env/0. 2700disable_nested_pagers(Env) -> 2701 [ 2702 {"PAGER", "cat"}, 2703 {"GIT_PAGER", "cat"}, 2704 {"JJ_PAGER", "cat"}, 2705 {"SYSTEMD_PAGER", "cat"}, 2706 {"MANPAGER", "cat"} 2707 | Env 2708 ]. 2709 2710%% Pipe-capture fallback only (git ≥ 2.31 GIT_CONFIG_*). Skipped when the 2711%% caller already set GIT_CONFIG_COUNT so we do not clobber. 2712%% 2713%% - color.ui=always: git ignores FORCE_COLOR 2714%% - log.decorate=short: default auto hides decorations on non-TTY stdout 2715force_git_tty_env(Env) -> 2716 case os:getenv("GIT_CONFIG_COUNT") of 2717 false -> 2718 [ 2719 {"GIT_CONFIG_COUNT", "2"}, 2720 {"GIT_CONFIG_KEY_0", "color.ui"}, 2721 {"GIT_CONFIG_VALUE_0", "always"}, 2722 {"GIT_CONFIG_KEY_1", "log.decorate"}, 2723 {"GIT_CONFIG_VALUE_1", "short"} 2724 | Env 2725 ]; 2726 _ -> 2727 Env 2728 end. 2729 2730%% Match gleshell color policy: off under NO_COLOR; otherwise on for a TTY 2731%% or when the parent already forces color. 2732want_child_color() -> 2733 case os:getenv("NO_COLOR") of 2734 L when is_list(L), L =/= "" -> 2735 false; 2736 _ -> 2737 case stdout_isatty() of 2738 true -> 2739 true; 2740 false -> 2741 force_color_set() 2742 end 2743 end. 2744 2745force_color_set() -> 2746 case os:getenv("FORCE_COLOR") of 2747 L when is_list(L), L =/= "", L =/= "0" -> 2748 true; 2749 _ -> 2750 case os:getenv("CLICOLOR_FORCE") of 2751 L when is_list(L), L =/= "", L =/= "0" -> 2752 true; 2753 _ -> 2754 false 2755 end 2756 end. 2757 2758%% True when the last external command already streamed output to the TTY 2759%% (PTY relay). Cleared after being read so the REPL does not double-print. 2760-spec take_output_shown() -> boolean(). 2761take_output_shown() -> 2762 case erase(gleshell_output_shown) of 2763 true -> true; 2764 _ -> false 2765 end. 2766 2767-spec clear_output_shown() -> nil. 2768clear_output_shown() -> 2769 erase(gleshell_output_shown), 2770 nil. 2771 2772%% Find a terminal device attached to this BEAM (or an ancestor). 2773%% Note: os:getpid() returns a string, not an integer. 2774find_tty_path() -> 2775 case catch list_to_integer(os:getpid()) of 2776 Pid when is_integer(Pid) -> 2777 case tty_path_for_pid(Pid) of 2778 {ok, _} = Ok -> 2779 Ok; 2780 error -> 2781 walk_parent_tty(Pid, 12) 2782 end; 2783 _ -> 2784 error 2785 end. 2786 2787walk_parent_tty(_Pid, 0) -> 2788 error; 2789walk_parent_tty(Pid, N) when is_integer(Pid), Pid > 1 -> 2790 case parent_pid(Pid) of 2791 {ok, Parent} when Parent > 1, Parent =/= Pid -> 2792 case tty_path_for_pid(Parent) of 2793 {ok, _} = Ok -> 2794 Ok; 2795 error -> 2796 walk_parent_tty(Parent, N - 1) 2797 end; 2798 _ -> 2799 error 2800 end; 2801walk_parent_tty(_, _) -> 2802 error. 2803 2804tty_path_for_pid(Pid) when is_integer(Pid) -> 2805 case read_tty_nr(Pid) of 2806 {ok, 0} -> 2807 error; 2808 {ok, TtyNr} -> 2809 tty_nr_to_path(TtyNr); 2810 error -> 2811 error 2812 end. 2813 2814read_tty_nr(Pid) when is_integer(Pid) -> 2815 case file:read_file("/proc/" ++ integer_to_list(Pid) ++ "/stat") of 2816 {ok, Bin} -> 2817 case parse_stat_tty_nr(binary_to_list(Bin)) of 2818 {ok, N} -> {ok, N}; 2819 error -> error 2820 end; 2821 _ -> 2822 error 2823 end. 2824%% /proc/pid/stat: "pid (comm) state ppid pgrp session tty_nr ..." 2825parse_stat_tty_nr(List) -> 2826 case lists:splitwith(fun(C) -> C =/= $) end, List) of 2827 {_, [$) | Rest0]} -> 2828 Rest = string:trim(Rest0, leading), 2829 Fields = string:tokens(Rest, " "), 2830 %% After ')': state, ppid, pgrp, session, tty_nr → index 5 2831 case length(Fields) >= 5 of 2832 true -> 2833 try 2834 {ok, list_to_integer(lists:nth(5, Fields))} 2835 catch 2836 _:_ -> error 2837 end; 2838 false -> 2839 error 2840 end; 2841 _ -> 2842 error 2843 end. 2844 2845parent_pid(Pid) -> 2846 case file:read_file("/proc/" ++ integer_to_list(Pid) ++ "/stat") of 2847 {ok, Bin} -> 2848 case parse_stat_ppid(binary_to_list(Bin)) of 2849 {ok, P} -> {ok, P}; 2850 error -> error 2851 end; 2852 _ -> 2853 error 2854 end. 2855 2856parse_stat_ppid(List) -> 2857 case lists:splitwith(fun(C) -> C =/= $) end, List) of 2858 {_, [$) | Rest0]} -> 2859 Rest = string:trim(Rest0, leading), 2860 Fields = string:tokens(Rest, " "), 2861 %% After ')': state, ppid → index 2 2862 case length(Fields) >= 2 of 2863 true -> 2864 try 2865 {ok, list_to_integer(lists:nth(2, Fields))} 2866 catch 2867 _:_ -> error 2868 end; 2869 false -> 2870 error 2871 end; 2872 _ -> 2873 error 2874 end. 2875 2876%% Decode Linux tty_nr (see drivers/tty/tty_io.c / procfs) to a device path. 2877tty_nr_to_path(0) -> 2878 error; 2879tty_nr_to_path(TtyNr) when is_integer(TtyNr) -> 2880 Major = (TtyNr bsr 8) band 16#ff, 2881 Minor = (TtyNr band 16#ff) bor (((TtyNr bsr 20) band 16#fff) bsl 8), 2882 Path = 2883 case Major of 2884 136 -> 2885 "/dev/pts/" ++ integer_to_list(Minor); 2886 4 when Minor >= 64 -> 2887 "/dev/ttyS" ++ integer_to_list(Minor - 64); 2888 4 -> 2889 "/dev/tty" ++ integer_to_list(Minor); 2890 _ -> 2891 undefined 2892 end, 2893 case Path of 2894 undefined -> 2895 error; 2896 _ -> 2897 case file:read_file_info(Path) of 2898 {ok, _} -> {ok, Path}; 2899 _ -> error 2900 end 2901 end. 2902 2903reason_to_bin(Reason) when is_atom(Reason) -> 2904 atom_to_binary(Reason, utf8); 2905reason_to_bin(Reason) when is_binary(Reason) -> 2906 Reason; 2907reason_to_bin(Reason) -> 2908 iolist_to_binary(io_lib:format("~p", [Reason])).