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
154 kB 4602 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 read_user_input/1, 7 parse_line/2, 8 run_as_shell/1, 9 spawn_shell/2, 10 set_cwd/1, 11 get_cwd/0, 12 getenv/1, 13 setenv/2, 14 list_env/0, 15 run_cmd/3, 16 run_cmd_tty/3, 17 which/1, 18 which_all/1, 19 realpath/1, 20 home_dir/0, 21 stdout_isatty/0, 22 println/1, 23 write/1, 24 term_size/0, 25 read_key_name/0, 26 with_key_mode/1, 27 take_output_shown/0, 28 clear_output_shown/0, 29 complete_word/2, 30 history_hint/2, 31 history_search/2, 32 re_contains/3, 33 format_unix_local/1, 34 unix_now/0, 35 list_processes/0, 36 list_port_sockets/1 37]). 38 39-define(ESC, 16#1b). 40-define(CSI_CLEAR_EOL, "\e[K"). 41-define(HISTORY_MAX, 2000). 42%% Max rows of history matches shown in the Ctrl+R picker (stinkpot-style). 43-define(SEARCH_MAX_ROWS, 12). 44 45%% --------------------------------------------------------------------------- 46%% Public: write a line (CRLF in raw TTY mode so multi-line output does not 47%% staircase — raw mode does not map LF → CR+LF the way cooked mode does). 48%% --------------------------------------------------------------------------- 49 50-spec println(binary()) -> nil. 51println(Text) when is_binary(Text) -> 52 case get(gleshell_raw) of 53 true -> 54 io:put_chars([to_crlf(Text), <<"\r\n">>]), 55 nil; 56 _ -> 57 io:put_chars([Text, $\n]), 58 nil 59 end. 60 61%% Write text without an automatic trailing newline. ANSI sequences are passed 62%% through unchanged. In raw TTY mode, bare LFs become CRLF so lines do not 63%% staircase (same as println/1). 64-spec write(binary()) -> nil. 65write(Text) when is_binary(Text) -> 66 case get(gleshell_raw) of 67 true -> 68 io:put_chars(to_crlf(Text)), 69 nil; 70 _ -> 71 io:put_chars(Text), 72 nil 73 end. 74 75%% Terminal size when stdout is a TTY. Defaults to 24×80 if the driver omits a 76%% dimension. Error when stdout is not a terminal (pager should dump instead). 77-spec term_size() -> {ok, {integer(), integer()}} | {error, nil}. 78term_size() -> 79 case stdout_isatty() of 80 false -> 81 {error, nil}; 82 true -> 83 Rows = 84 case io:rows() of 85 {ok, R} when is_integer(R), R > 0 -> 86 R; 87 _ -> 88 24 89 end, 90 Cols = 91 case io:columns() of 92 {ok, C} when is_integer(C), C > 0 -> 93 C; 94 _ -> 95 80 96 end, 97 {ok, {Rows, Cols}} 98 end. 99 100%% Run Fun with the TTY in single-key (non-canonical, no-echo) mode when the 101%% REPL is not already raw. Restores termios afterwards. Prefer wrapping a 102%% whole pager session rather than each keystroke. 103-spec with_key_mode(fun(() -> term())) -> term(). 104with_key_mode(Fun) when is_function(Fun, 0) -> 105 case get(gleshell_raw) of 106 true -> 107 Fun(); 108 _ -> 109 case stdout_isatty() of 110 false -> 111 Fun(); 112 true -> 113 case stty_save() of 114 {ok, Saved} -> 115 _ = stty_run(["-icanon", "-echo", "min", "1", "time", "0"]), 116 try 117 Fun() 118 after 119 stty_restore(Saved) 120 end; 121 _ -> 122 Fun() 123 end 124 end 125 end. 126 127%% Blocking single-key read for the builtin pager. Returns a short name: 128%% "up" | "down" | "left" | "right" | "home" | "end" | "page_up" | "page_down" 129%% | "enter" | "backspace" | "tab" | "ctrl_c" | "ctrl_l" | "space" | "eof" 130%% | single printable grapheme ("q", "j", "G", …) | "other". 131%% 132%% Call inside `with_key_mode/1` when not already in the raw REPL, so cooked 133%% TTYs (e.g. `gleshell -c '… | less'`) still deliver keys one at a time. 134-spec read_key_name() -> {ok, binary()} | {error, binary()}. 135read_key_name() -> 136 try 137 {ok, key_to_name(read_key())} 138 catch 139 _:Reason -> 140 {error, reason_to_bin(Reason)} 141 end. 142 143key_to_name(eof) -> 144 <<"eof">>; 145key_to_name(enter) -> 146 <<"enter">>; 147key_to_name(tab) -> 148 <<"tab">>; 149key_to_name(backspace) -> 150 <<"backspace">>; 151key_to_name(delete) -> 152 <<"delete">>; 153key_to_name(up) -> 154 <<"up">>; 155key_to_name(down) -> 156 <<"down">>; 157key_to_name(left) -> 158 <<"left">>; 159key_to_name(right) -> 160 <<"right">>; 161key_to_name(home) -> 162 <<"home">>; 163key_to_name('end') -> 164 <<"end">>; 165key_to_name(page_up) -> 166 <<"page_up">>; 167key_to_name(page_down) -> 168 <<"page_down">>; 169key_to_name(ctrl_c) -> 170 <<"ctrl_c">>; 171key_to_name(ctrl_l) -> 172 <<"ctrl_l">>; 173key_to_name(ctrl_a) -> 174 <<"ctrl_a">>; 175key_to_name(ctrl_e) -> 176 <<"ctrl_e">>; 177key_to_name(ctrl_d) -> 178 <<"ctrl_d">>; 179key_to_name(ctrl_k) -> 180 <<"ctrl_k">>; 181key_to_name(ctrl_u) -> 182 <<"ctrl_u">>; 183key_to_name(ctrl_w) -> 184 <<"ctrl_w">>; 185key_to_name(ctrl_r) -> 186 <<"ctrl_r">>; 187key_to_name(ctrl_f) -> 188 <<"ctrl_f">>; 189key_to_name(ctrl_p) -> 190 <<"ctrl_p">>; 191key_to_name(ctrl_n) -> 192 <<"ctrl_n">>; 193key_to_name(alt_f) -> 194 <<"alt_f">>; 195key_to_name(ctrl_g) -> 196 <<"ctrl_g">>; 197key_to_name(esc) -> 198 <<"esc">>; 199key_to_name({char, 32}) -> 200 <<"space">>; 201key_to_name({char, C}) when is_integer(C), C >= 32 -> 202 unicode:characters_to_binary([C]); 203key_to_name({error, _}) -> 204 <<"eof">>; 205key_to_name(_) -> 206 <<"other">>. 207 208%% Normalize newlines to CRLF without turning existing \r\n into \r\r\n. 209to_crlf(Bin) when is_binary(Bin) -> 210 B1 = binary:replace(Bin, <<"\r\n">>, <<"\n">>, [global]), 211 B2 = binary:replace(B1, <<"\r">>, <<"\n">>, [global]), 212 binary:replace(B2, <<"\n">>, <<"\r\n">>, [global]). 213 214%% --------------------------------------------------------------------------- 215%% Public: read a line (syntax-highlighted when raw TTY mode is active) 216%% --------------------------------------------------------------------------- 217 218-spec get_line(binary()) -> {ok, binary()} | {error, binary()}. 219get_line(Prompt) when is_binary(Prompt) -> 220 case get(gleshell_raw) of 221 true -> 222 raw_get_line(Prompt); 223 _ -> 224 classic_get_line(Prompt) 225 end. 226 227%% Edlin / get_until path (non-TTY or when raw mode unavailable). 228classic_get_line(Prompt) -> 229 PromptChars = unicode:characters_to_list(Prompt), 230 case io:request( 231 standard_io, 232 {get_until, unicode, PromptChars, ?MODULE, parse_line, []} 233 ) of 234 eof -> 235 {error, <<"eof">>}; 236 {error, interrupted} -> 237 %% Ctrl+C while reading: cancel line, keep the REPL alive. 238 {error, <<"interrupted">>}; 239 {error, _} -> 240 {error, <<"io_error">>}; 241 Line when is_list(Line); is_binary(Line) -> 242 Bin = unicode:characters_to_binary(Line), 243 Stripped = string:trim(Bin, trailing, [$\n, $\r]), 244 {ok, Stripped}; 245 Other -> 246 try 247 Bin = unicode:characters_to_binary(Other), 248 Stripped = string:trim(Bin, trailing, [$\n, $\r]), 249 {ok, Stripped} 250 catch 251 _:_ -> 252 {error, <<"io_error">>} 253 end 254 end. 255 256%% get_until callback: edlin already gathers a full line. 257-spec parse_line(term(), term()) -> 258 {done, eof | string(), list()} | {more, term()}. 259parse_line(_Cont, eof) -> 260 {done, eof, []}; 261parse_line(_Cont, Chars) when is_list(Chars) -> 262 {done, Chars, []}. 263 264%% --------------------------------------------------------------------------- 265%% Public: multi-line user input for the `input` builtin 266%% 267%% Interactive (raw REPL or TTY): read until Ctrl+D / EOF so the user can 268%% paste a blob and end with Ctrl+D, e.g. `input | from json`. 269%% Non-TTY (piped stdin): read the whole stream — `printf '…' | gle -c 'input | …'`. 270%% Optional prompt is printed first (empty prompt = silent). 271%% --------------------------------------------------------------------------- 272 273-spec read_user_input(binary()) -> {ok, binary()} | {error, binary()}. 274read_user_input(Prompt) when is_binary(Prompt) -> 275 case Prompt of 276 <<>> -> 277 ok; 278 _ -> 279 %% Prompt on its own line so paste starts cleanly below it. 280 case get(gleshell_raw) of 281 true -> 282 io:put_chars(to_crlf(<<Prompt/binary, "\n">>)); 283 _ -> 284 io:put_chars(<<Prompt/binary, "\n">>) 285 end 286 end, 287 try 288 case get(gleshell_raw) of 289 true -> 290 read_input_raw([]); 291 _ -> 292 case stdin_isatty() of 293 true -> 294 read_input_lines([]); 295 false -> 296 read_input_stream([]) 297 end 298 end 299 catch 300 _:Reason -> 301 {error, reason_to_bin(Reason)} 302 end. 303 304%% Raw-mode multi-line: echo printable chars, Enter → newline, Ctrl+D ends. 305read_input_raw(Acc) -> 306 case read_key() of 307 eof -> 308 io:put_chars("\r\n"), 309 {ok, codepoints_to_bin(lists:reverse(Acc))}; 310 ctrl_d -> 311 io:put_chars("\r\n"), 312 {ok, codepoints_to_bin(lists:reverse(Acc))}; 313 ctrl_c -> 314 io:put_chars("^C\r\n"), 315 {error, <<"interrupted">>}; 316 enter -> 317 io:put_chars("\r\n"), 318 read_input_raw([$\n | Acc]); 319 backspace -> 320 case Acc of 321 [] -> 322 read_input_raw(Acc); 323 [$\n | _] -> 324 %% Do not erase previous lines with a simple \b. 325 read_input_raw(Acc); 326 [_ | Rest] -> 327 io:put_chars("\b \b"), 328 read_input_raw(Rest) 329 end; 330 {char, C} when is_integer(C), C >= 32 -> 331 io:put_chars(unicode:characters_to_binary([C])), 332 read_input_raw([C | Acc]); 333 {error, _} -> 334 io:put_chars("\r\n"), 335 {error, <<"io_error">>}; 336 _Other -> 337 read_input_raw(Acc) 338 end. 339 340codepoints_to_bin(Cs) -> 341 unicode:characters_to_binary(Cs). 342 343%% Cooked/edlin TTY: line-at-a-time until EOF (Ctrl+D on empty line). 344read_input_lines(Acc) -> 345 case io:get_line("") of 346 eof -> 347 {ok, iolist_to_binary(lists:reverse(Acc))}; 348 {error, interrupted} -> 349 {error, <<"interrupted">>}; 350 {error, _} -> 351 {error, <<"io_error">>}; 352 Line when is_list(Line); is_binary(Line) -> 353 Bin = unicode:characters_to_binary(Line), 354 read_input_lines([Bin | Acc]); 355 Other -> 356 try 357 Bin = unicode:characters_to_binary(Other), 358 read_input_lines([Bin | Acc]) 359 catch 360 _:_ -> 361 {error, <<"io_error">>} 362 end 363 end. 364 365%% Piped / non-TTY stdin: drain the whole stream. 366read_input_stream(Acc) -> 367 case io:get_chars("", 8192) of 368 eof -> 369 {ok, iolist_to_binary(lists:reverse(Acc))}; 370 {error, Reason} -> 371 {error, reason_to_bin(Reason)}; 372 Data when is_binary(Data) -> 373 read_input_stream([Data | Acc]); 374 Data when is_list(Data) -> 375 read_input_stream([unicode:characters_to_binary(Data) | Acc]); 376 Other -> 377 try 378 Bin = unicode:characters_to_binary(Other), 379 read_input_stream([Bin | Acc]) 380 catch 381 _:_ -> 382 {error, <<"io_error">>} 383 end 384 end. 385 386-spec stdin_isatty() -> boolean(). 387stdin_isatty() -> 388 try 389 case prim_tty:isatty(stdin) of 390 true -> 391 true; 392 _ -> 393 false 394 end 395 catch 396 _:_ -> 397 %% Fallback: if we cannot tell, prefer stream read so pipes work. 398 false 399 end. 400 401%% --------------------------------------------------------------------------- 402%% Shell bootstrap: prefer OTP raw mode for live syntax highlighting. 403%% Falls back to edlin interactive shell when raw is unavailable. 404%% 405%% Ctrl+C: the BEAM default opens the BREAK menu (a = abort → process exit). 406%% Re-exec once with +Bc so Ctrl+C is delivered as a character to our editor 407%% (cancel line) instead of killing the shell. See erl(1) +B. 408%% --------------------------------------------------------------------------- 409 410-spec run_as_shell(fun(() -> term())) -> nil. 411run_as_shell(Fun) when is_function(Fun, 0) -> 412 case ensure_plus_bc() of 413 {parent, Port} -> 414 %% Child owns the TTY; we only wait for its exit status. 415 receive 416 {Port, {exit_status, Status}} -> 417 erlang:halt(Status) 418 end; 419 child -> 420 enable_shell_history(), 421 case try_start_raw() of 422 true -> 423 put(gleshell_raw, true), 424 load_line_history(), 425 configure_line_editor(), 426 %% One long-lived stdin owner for the raw REPL. External 427 %% PTY/interrupt helpers retarget it instead of spawning 428 %% competing get_chars clients (killing those dropped the 429 %% first post-command key — empty ↑ after nix/sleep/…). 430 start_stdin_mux(), 431 enable_bracketed_paste(), 432 try 433 Fun() 434 after 435 disable_bracketed_paste(), 436 stop_stdin_mux(), 437 save_line_history() 438 end, 439 nil; 440 false -> 441 erase(gleshell_raw), 442 Parent = self(), 443 case try_start_interactive(Parent, Fun) of 444 {ok, started} -> 445 receive 446 {gleshell_shell_done, ok} -> 447 nil; 448 {gleshell_shell_done, {error, Class, Reason, Stack}} -> 449 erlang:raise(Class, Reason, Stack) 450 end; 451 {ok, direct} -> 452 configure_line_editor(), 453 Fun(), 454 nil 455 end 456 end 457 end. 458 459%% Ensure the emulator was started with +Bc (Ctrl+C → char, not BREAK/abort). 460%% Returns `child` when this process should run the REPL, or `{parent, Port}` 461%% when we re-exec'd and should wait on the child. 462-spec ensure_plus_bc() -> child | {parent, port()}. 463ensure_plus_bc() -> 464 case os:getenv("GLESHELL_PLUS_BC") of 465 "1" -> 466 child; 467 _ -> 468 case already_has_plus_bc() of 469 true -> 470 os:putenv("GLESHELL_PLUS_BC", "1"), 471 child; 472 false -> 473 reexec_with_plus_bc() 474 end 475 end. 476 477already_has_plus_bc() -> 478 lists:any( 479 fun(Var) -> 480 case os:getenv(Var) of 481 false -> 482 false; 483 Flags -> 484 string:find(Flags, "+Bc") =/= nomatch 485 end 486 end, 487 ["ERL_AFLAGS", "ERL_FLAGS", "ERL_ZFLAGS"] 488 ). 489 490reexec_with_plus_bc() -> 491 os:putenv("GLESHELL_PLUS_BC", "1"), 492 case os:getenv("ERL_AFLAGS") of 493 false -> 494 os:putenv("ERL_AFLAGS", "+Bc"); 495 Flags -> 496 case string:find(Flags, "+Bc") of 497 nomatch -> 498 os:putenv("ERL_AFLAGS", "+Bc " ++ Flags); 499 _ -> 500 ok 501 end 502 end, 503 case os:find_executable("erl") of 504 false -> 505 %% No erl on PATH — continue without +Bc (BREAK menu may appear). 506 child; 507 Erl -> 508 Pa = lists:flatmap(fun(D) -> ["-pa", D] end, code:get_path()), 509 Extra = init:get_plain_arguments(), 510 Args = 511 ["+Bc", "-noshell"] ++ 512 Pa ++ 513 ["-eval", "gleshell@@main:run(gleshell)", "-extra" | Extra], 514 try 515 Port = open_port( 516 {spawn_executable, Erl}, 517 [exit_status, nouse_stdio, {args, Args}] 518 ), 519 {parent, Port} 520 catch 521 _:_ -> 522 child 523 end 524 end. 525 526try_start_raw() -> 527 case stdout_isatty() of 528 false -> 529 false; 530 true -> 531 case catch shell:start_interactive({noshell, raw}) of 532 ok -> 533 true; 534 {error, already_started} -> 535 %% Cannot switch an existing interactive shell into raw. 536 false; 537 _ -> 538 false 539 end 540 end. 541 542try_start_interactive(Parent, Fun) -> 543 _ = application:set_env(stdlib, shell_slogan, "", [{persistent, true}]), 544 case shell:start_interactive({gleshell_ffi, spawn_shell, [Parent, Fun]}) of 545 ok -> 546 {ok, started}; 547 {error, already_started} -> 548 {ok, direct}; 549 {error, _} -> 550 {ok, direct} 551 end. 552 553-spec spawn_shell(pid(), fun(() -> term())) -> pid(). 554spawn_shell(Parent, Fun) when is_pid(Parent), is_function(Fun, 0) -> 555 spawn(fun() -> 556 try 557 configure_line_editor(), 558 Fun() 559 of 560 _ -> 561 Parent ! {gleshell_shell_done, ok}, 562 exit(die) 563 catch 564 Class:Reason:Stack -> 565 Parent ! {gleshell_shell_done, {error, Class, Reason, Stack}}, 566 erlang:raise(Class, Reason, Stack) 567 end 568 end). 569 570configure_line_editor() -> 571 _ = io:setopts([{encoding, unicode}, binary]), 572 try 573 io:setopts([{line_history, true}]) 574 catch 575 _:_ -> 576 ok 577 end, 578 ok. 579 580enable_shell_history() -> 581 case application:get_env(kernel, shell_history_path) of 582 {ok, _} -> 583 ok; 584 undefined -> 585 Path = filename:basedir(user_cache, "gleshell-history"), 586 _ = application:set_env( 587 kernel, shell_history_path, Path, [{persistent, true}] 588 ), 589 ok 590 end, 591 case application:get_env(kernel, shell_history) of 592 {ok, _} -> 593 ok; 594 undefined -> 595 _ = application:set_env( 596 kernel, shell_history, enabled, [{persistent, true}] 597 ), 598 ok 599 end. 600 601%% --------------------------------------------------------------------------- 602%% Raw-mode line editor with Nushell-style syntax highlighting 603%% --------------------------------------------------------------------------- 604%% 605%% Buffer model: Left is graphemes before the cursor (reversed), 606%% Right is graphemes after the cursor (normal order). 607%% History is a list of binaries (newest first). 608 609raw_get_line(Prompt) when is_binary(Prompt) -> 610 PromptList = unicode:characters_to_list(Prompt), 611 %% Densify every prompt: drop blanks so ↑ never lands on an empty slot 612 %% even if an older session or bug left one in the process dict. 613 History = sanitize_history( 614 case get(gleshell_history) of 615 L when is_list(L) -> L; 616 _ -> [] 617 end 618 ), 619 put(gleshell_history, History), 620 put(gleshell_input_rows, 1), 621 put(gleshell_key_q, []), 622 put(gleshell_key_unread, []), 623 redraw(PromptList, [], []), 624 raw_loop(PromptList, [], [], History, 0, <<>>). 625 626%% HistPos: 0 = editing current buffer; N>0 = viewing Nth history entry. 627%% Saved: buffer saved when first entering history navigation. 628raw_loop(Prompt, Left, Right, History, HistPos, Saved) -> 629 case read_key() of 630 eof -> 631 io:put_chars("\r\n"), 632 {error, <<"eof">>}; 633 {error, _} -> 634 io:put_chars("\r\n"), 635 {error, <<"io_error">>}; 636 enter -> 637 Line = buffer_to_bin(Left, Right), 638 io:put_chars("\r\n"), 639 push_history(Line), 640 {ok, Line}; 641 paste_start -> 642 %% Bracketed paste: insert literally (no key bindings), one redraw. 643 NewLeft = read_bracketed_paste(Left), 644 redraw(Prompt, NewLeft, Right), 645 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 646 paste_end -> 647 %% Stray end marker (paste not open) — ignore. 648 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 649 {char, C} when is_integer(C), C >= 32, C =/= 127 -> 650 %% Printable Unicode codepoint. Drain any already-queued paste burst 651 %% so we insert every letter and only redraw once (avoids drops when 652 %% a multi-char read or ESC fragment would otherwise lose bytes). 653 Burst = drain_printable_burst(C), 654 NewLeft = lists:reverse(Burst) ++ Left, 655 redraw(Prompt, NewLeft, Right), 656 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 657 backspace -> 658 case Left of 659 [] -> 660 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 661 [_ | Rest] -> 662 redraw(Prompt, Rest, Right), 663 raw_loop(Prompt, Rest, Right, History, 0, <<>>) 664 end; 665 delete -> 666 case Right of 667 [] -> 668 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 669 [_ | Rest] -> 670 redraw(Prompt, Left, Rest), 671 raw_loop(Prompt, Left, Rest, History, 0, <<>>) 672 end; 673 left -> 674 case Left of 675 [] -> 676 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 677 [C | Rest] -> 678 redraw(Prompt, Rest, [C | Right]), 679 raw_loop(Prompt, Rest, [C | Right], History, HistPos, Saved) 680 end; 681 right -> 682 case Right of 683 [] -> 684 %% At end of line: accept greyed-out history hint (Nu/fish). 685 accept_history_hint(Prompt, Left, Right, History, HistPos, Saved); 686 [C | Rest] -> 687 redraw(Prompt, [C | Left], Rest), 688 raw_loop(Prompt, [C | Left], Rest, History, HistPos, Saved) 689 end; 690 home -> 691 NewRight = lists:reverse(Left) ++ Right, 692 redraw(Prompt, [], NewRight), 693 raw_loop(Prompt, [], NewRight, History, HistPos, Saved); 694 'end' -> 695 case Right of 696 [] -> 697 %% Already at end: accept full history hint if present. 698 accept_history_hint(Prompt, Left, Right, History, HistPos, Saved); 699 _ -> 700 NewLeft = lists:reverse(Right) ++ Left, 701 redraw(Prompt, NewLeft, []), 702 raw_loop(Prompt, NewLeft, [], History, HistPos, Saved) 703 end; 704 up -> 705 hist_nav(Prompt, Left, Right, History, HistPos, Saved, 1); 706 down -> 707 hist_nav(Prompt, Left, Right, History, HistPos, Saved, -1); 708 ctrl_a -> 709 NewRight = lists:reverse(Left) ++ Right, 710 redraw(Prompt, [], NewRight), 711 raw_loop(Prompt, [], NewRight, History, HistPos, Saved); 712 ctrl_e -> 713 case Right of 714 [] -> 715 accept_history_hint(Prompt, Left, Right, History, HistPos, Saved); 716 _ -> 717 NewLeft = lists:reverse(Right) ++ Left, 718 redraw(Prompt, NewLeft, []), 719 raw_loop(Prompt, NewLeft, [], History, HistPos, Saved) 720 end; 721 ctrl_f -> 722 %% Emacs-style forward-char; at EOL accepts history hint (like Nu). 723 case Right of 724 [] -> 725 accept_history_hint(Prompt, Left, Right, History, HistPos, Saved); 726 [C | Rest] -> 727 redraw(Prompt, [C | Left], Rest), 728 raw_loop(Prompt, [C | Left], Rest, History, HistPos, Saved) 729 end; 730 alt_f -> 731 %% Accept one word of the history hint (Nu Alt+F). 732 accept_history_hint_word(Prompt, Left, Right, History, HistPos, Saved); 733 ctrl_u -> 734 redraw(Prompt, [], Right), 735 raw_loop(Prompt, [], Right, History, 0, <<>>); 736 ctrl_k -> 737 redraw(Prompt, Left, []), 738 raw_loop(Prompt, Left, [], History, 0, <<>>); 739 ctrl_w -> 740 {NewLeft, _} = kill_word(Left), 741 redraw(Prompt, NewLeft, Right), 742 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 743 ctrl_d -> 744 case {Left, Right} of 745 {[], []} -> 746 io:put_chars("\r\n"), 747 {error, <<"eof">>}; 748 {_, []} -> 749 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 750 {_, [_ | Rest]} -> 751 redraw(Prompt, Left, Rest), 752 raw_loop(Prompt, Left, Rest, History, 0, <<>>) 753 end; 754 ctrl_c -> 755 %% Cancel current line (like bash) and return empty. 756 io:put_chars("^C\r\n"), 757 {ok, <<>>}; 758 ctrl_l -> 759 io:put_chars("\e[H\e[2J"), 760 redraw(Prompt, Left, Right), 761 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 762 ctrl_r -> 763 reverse_search(Prompt, Left, Right, History); 764 tab -> 765 tab_complete(Prompt, Left, Right, History, HistPos, Saved); 766 _Other -> 767 raw_loop(Prompt, Left, Right, History, HistPos, Saved) 768 end. 769 770%% --------------------------------------------------------------------------- 771%% Tab: command + filename completion (token under cursor) 772%% --------------------------------------------------------------------------- 773%% 774%% Command position (start of line / after | ; & =): complete builtins and 775%% PATH executables. Path-like command words (./foo, /bin/ls, ~/x) still use 776%% filename completion. Elsewhere: filename completion as before. 777%% 778%% One match → insert it (commands get a trailing space; dirs get /). 779%% Several matches → extend the longest common prefix; if that does not 780%% advance the buffer, list candidates under the line and redraw. 781 782tab_complete(Prompt, Left, Right, History, HistPos, Saved) -> 783 {PrefixRev, Word} = word_before_cursor(Left), 784 {Matches, Kind} = completions_for(PrefixRev, Word), 785 case Matches of 786 [] -> 787 beep(), 788 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 789 [Only] -> 790 Insert = finalize_completion(Only, Kind), 791 NewLeft = apply_completed_word(PrefixRev, Insert), 792 redraw(Prompt, NewLeft, Right), 793 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 794 _ -> 795 Common = longest_common_prefix(Matches), 796 case Common =/= Word andalso length(Common) >= length(Word) of 797 true -> 798 NewLeft = apply_completed_word(PrefixRev, Common), 799 redraw(Prompt, NewLeft, Right), 800 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 801 false -> 802 show_completions(Matches), 803 redraw(Prompt, Left, Right), 804 raw_loop(Prompt, Left, Right, History, HistPos, Saved) 805 end 806 end. 807 808%% Test/helper: return {Matches, Kind} for a buffer prefix and word. 809%% Prefix is the text *before* the word being completed (not reversed). 810%% Kind is <<"command">> | <<"path">>. 811-spec complete_word(binary(), binary()) -> {list(binary()), binary()}. 812complete_word(PrefixBin, WordBin) when is_binary(PrefixBin), is_binary(WordBin) -> 813 Prefix = unicode:characters_to_list(PrefixBin), 814 Word = unicode:characters_to_list(WordBin), 815 PrefixRev = lists:reverse(Prefix), 816 {Matches, Kind} = completions_for(PrefixRev, Word), 817 KindBin = 818 case Kind of 819 command -> <<"command">>; 820 path -> <<"path">> 821 end, 822 { 823 [unicode:characters_to_binary(M) || M <- Matches], 824 KindBin 825 }. 826 827%% Trailing space after a unique command so the user can type args next. 828finalize_completion(Word, command) -> 829 case lists:last(Word) of 830 $/ -> Word; 831 $\s -> Word; 832 _ -> Word ++ " " 833 end; 834finalize_completion(Word, path) -> 835 Word. 836 837completions_for(PrefixRev, Word) -> 838 case is_command_position(PrefixRev) andalso not is_path_like_word(Word) of 839 true -> 840 {command_completions(Word), command}; 841 false -> 842 {filename_completions(Word), path} 843 end. 844 845%% Command position: empty prefix, or last non-space before the word is a 846%% pipeline/statement separator or assignment (`let x = …`). 847is_command_position(PrefixRev) -> 848 Before = string:trim(lists:reverse(PrefixRev), trailing), 849 case Before of 850 [] -> 851 true; 852 _ -> 853 case lists:last(Before) of 854 $| -> true; 855 $; -> true; 856 $& -> true; 857 $= -> true; 858 _ -> false 859 end 860 end. 861 862%% ./script, ../bin/x, /usr/bin/ls, ~/bin/foo — complete as paths even as cmds. 863is_path_like_word([]) -> 864 false; 865is_path_like_word(Word) -> 866 lists:member($/, Word) orelse lists:member($\\, Word) orelse hd(Word) =:= $~. 867 868%% Builtins + keywords + PATH executables matching Word as a prefix. 869command_completions(Word) -> 870 Builtins = [ 871 N 872 || N <- builtin_command_names(), 873 lists:prefix(Word, N) 874 ], 875 Keywords = [ 876 N 877 || N <- ["let"], 878 lists:prefix(Word, N) 879 ], 880 PathCmds = 881 case Word of 882 %% Empty prefix: skip PATH dump (can be thousands of names). 883 [] -> 884 []; 885 _ -> 886 path_command_completions(Word) 887 end, 888 lists:usort(Builtins ++ Keywords ++ PathCmds). 889 890%% Prefer live Gleam registry; fall back if the module is not loaded yet. 891builtin_command_names() -> 892 try 893 Names = 'gleshell@builtins':names(), 894 [to_charlist(N) || N <- Names] 895 catch 896 _:_ -> 897 fallback_builtin_names() 898 end. 899 900to_charlist(B) when is_binary(B) -> 901 unicode:characters_to_list(B); 902to_charlist(L) when is_list(L) -> 903 L. 904 905fallback_builtin_names() -> 906 [ 907 "about", "append", "cat", "cd", "columns", "count", "describe", "echo", 908 "env", "exit", "filter", "find", "first", "flatten", "from", "get", 909 "help", "identity", "ignore", "input", "is-empty", "is_empty", "keys", 910 "last", "length", "less", "lines", "ls", "now", "open", "prepend", 911 "print", "ps", "pwd", "quit", "range", "reverse", "save", "select", 912 "skip", "sort-by", "sort_by", "sys", "table", "take", "to", "type", 913 "typeof", "uniq", "unwrap", "values", "where", "which", "whyport", 914 "wrap" 915 ]. 916 917%% Executable basenames on PATH that match Prefix (deduped, sorted). 918path_command_completions(Prefix) -> 919 case os:getenv("PATH") of 920 false -> 921 []; 922 PathStr -> 923 Dirs = string:tokens(PathStr, path_sep()), 924 Acc = lists:foldl( 925 fun(Dir, Seen) -> 926 collect_path_cmds(Dir, Prefix, Seen) 927 end, 928 #{}, 929 Dirs 930 ), 931 lists:sort(maps:keys(Acc)) 932 end. 933 934collect_path_cmds(Dir, Prefix, Seen) -> 935 case file:list_dir(Dir) of 936 {ok, Names} -> 937 lists:foldl( 938 fun(Name, Acc) -> 939 case 940 lists:prefix(Prefix, Name) 941 andalso show_dotfile(Prefix, Name) 942 andalso not maps:is_key(Name, Acc) 943 andalso is_executable_file(filename:join(Dir, Name)) 944 of 945 true -> 946 Acc#{Name => true}; 947 false -> 948 Acc 949 end 950 end, 951 Seen, 952 Names 953 ); 954 {error, _} -> 955 Seen 956 end. 957 958beep() -> 959 io:put_chars([7]). 960 961%% Left is graphemes before the cursor in reverse order. 962%% Returns {PrefixRev, WordForward} where Word is the path token. 963word_before_cursor(Left) -> 964 take_completion_word(Left, []). 965 966%% Acc: walking Left (reversed buffer) with [C|Acc] rebuilds the word forward. 967take_completion_word([], Acc) -> 968 {[], Acc}; 969take_completion_word([C | Rest], Acc) -> 970 case is_completion_break(C) of 971 true -> 972 {[C | Rest], Acc}; 973 false -> 974 take_completion_word(Rest, [C | Acc]) 975 end. 976 977is_completion_break(C) when C =:= $\s; C =:= $\t -> 978 true; 979is_completion_break(C) when C =:= $|; C =:= $;; C =:= $& -> 980 true; 981is_completion_break(C) when C =:= $(; C =:= $); C =:= $[; C =:= $] -> 982 true; 983is_completion_break(C) when C =:= ${; C =:= $}; C =:= $<; C =:= $> -> 984 true; 985is_completion_break(C) when C =:= $'; C =:= $" -> 986 true; 987is_completion_break(_) -> 988 false. 989 990apply_completed_word(PrefixRev, Word) -> 991 lists:reverse(Word) ++ PrefixRev. 992 993%% Return sorted completion strings (as typed, with ~ preserved; dirs end in /). 994filename_completions(Word) -> 995 {ListDirTyped, Base, InsertPrefix} = split_completion_word(Word), 996 ListDir = expand_home_path(ListDirTyped), 997 case file:list_dir(ListDir) of 998 {ok, Names0} -> 999 Names = lists:sort(Names0), 1000 [ 1001 InsertPrefix ++ maybe_dir_slash(ListDir, Name) 1002 || Name <- Names, 1003 lists:prefix(Base, Name), 1004 show_dotfile(Base, Name) 1005 ]; 1006 {error, _} -> 1007 [] 1008 end. 1009 1010%% Hide dotfiles unless the partial name already starts with '.'. 1011show_dotfile([$. | _], _) -> 1012 true; 1013show_dotfile(_, [$. | _]) -> 1014 false; 1015show_dotfile(_, _) -> 1016 true. 1017 1018maybe_dir_slash(ListDir, Name) -> 1019 case filelib:is_dir(filename:join(ListDir, Name)) of 1020 true -> Name ++ "/"; 1021 false -> Name 1022 end. 1023 1024%% Split a path word into {dir_to_list, basename_prefix, insert_prefix}. 1025%% insert_prefix is the directory part as the user typed it (incl. trailing /). 1026split_completion_word(Word) -> 1027 case rsplit_path(Word) of 1028 {none, Base} -> 1029 {".", Base, ""}; 1030 {Dir, Base} -> 1031 ListDir = 1032 case Dir of 1033 "" -> "/"; 1034 _ -> Dir 1035 end, 1036 InsertPrefix = 1037 case Dir of 1038 "" -> "/"; 1039 _ -> Dir ++ "/" 1040 end, 1041 {ListDir, Base, InsertPrefix} 1042 end. 1043 1044%% Rightmost / splits directory from the partial basename. 1045rsplit_path(Word) -> 1046 rsplit_path(lists:reverse(Word), []). 1047 1048rsplit_path([], Acc) -> 1049 {none, Acc}; 1050rsplit_path([$/ | Rest], Acc) -> 1051 {lists:reverse(Rest), Acc}; 1052rsplit_path([C | Rest], Acc) -> 1053 rsplit_path(Rest, [C | Acc]). 1054 1055expand_home_path(Path) -> 1056 case Path of 1057 "~" -> 1058 home_path_string(); 1059 [$~, $/ | More] -> 1060 home_path_string() ++ "/" ++ More; 1061 _ -> 1062 Path 1063 end. 1064 1065home_path_string() -> 1066 case os:getenv("HOME") of 1067 false -> "."; 1068 Home when is_list(Home) -> Home; 1069 Home when is_binary(Home) -> unicode:characters_to_list(Home) 1070 end. 1071 1072longest_common_prefix([]) -> 1073 ""; 1074longest_common_prefix([H | T]) -> 1075 lists:foldl(fun lcp2/2, H, T). 1076 1077lcp2(A, B) -> 1078 lcp2(A, B, []). 1079 1080lcp2([X | As], [X | Bs], Acc) -> 1081 lcp2(As, Bs, [X | Acc]); 1082lcp2(_, _, Acc) -> 1083 lists:reverse(Acc). 1084 1085show_completions(Matches) -> 1086 io:put_chars("\r\n"), 1087 case io:columns() of 1088 {ok, Cols} when is_integer(Cols), Cols > 8 -> 1089 print_completion_columns(Matches, Cols); 1090 _ -> 1091 io:put_chars(lists:join(" ", Matches)), 1092 io:put_chars("\r\n") 1093 end. 1094 1095print_completion_columns(Matches, Cols) -> 1096 MaxLen = lists:max([0 | [length(M) || M <- Matches]]), 1097 Width = MaxLen + 2, 1098 PerRow = max(1, Cols div Width), 1099 print_rows(Matches, PerRow, Width). 1100 1101print_rows([], _PerRow, _Width) -> 1102 ok; 1103print_rows(Matches, PerRow, Width) -> 1104 {Row, Rest} = take_n(Matches, PerRow, []), 1105 Line = [ 1106 pad_cell(M, Width) 1107 || M <- Row 1108 ], 1109 io:put_chars([Line, "\r\n"]), 1110 print_rows(Rest, PerRow, Width). 1111 1112take_n(List, 0, Acc) -> 1113 {lists:reverse(Acc), List}; 1114take_n([], _N, Acc) -> 1115 {lists:reverse(Acc), []}; 1116take_n([H | T], N, Acc) -> 1117 take_n(T, N - 1, [H | Acc]). 1118 1119pad_cell(S, Width) -> 1120 Pad = Width - length(S), 1121 case Pad > 0 of 1122 true -> S ++ lists:duplicate(Pad, $\s); 1123 false -> S ++ " " 1124 end. 1125 1126hist_nav(Prompt, Left, Right, History, HistPos, Saved, Delta) -> 1127 case hist_seek(History, HistPos, Delta) of 1128 stay -> 1129 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 1130 draft -> 1131 %% Restore the buffer from before history navigation. 1132 {L, R} = bin_to_buffer(Saved), 1133 redraw(Prompt, L, R), 1134 raw_loop(Prompt, L, R, History, 0, <<>>); 1135 {NewPos, Entry} -> 1136 NewSaved = case HistPos of 1137 0 -> buffer_to_bin(Left, Right); 1138 _ -> Saved 1139 end, 1140 %% Belt-and-suspenders: never paint a blank recall (dense History 1141 %% should already exclude these). 1142 case history_blank(Entry) of 1143 true -> 1144 hist_nav(Prompt, Left, Right, History, NewPos, NewSaved, Delta); 1145 false -> 1146 {L, R} = bin_to_buffer(Entry), 1147 redraw(Prompt, L, R), 1148 raw_loop(Prompt, L, R, History, NewPos, NewSaved) 1149 end 1150 end. 1151 1152%% Walk history in `Delta` direction, skipping blank/whitespace-only entries. 1153%% Delta > 0 = older (↑); Delta < 0 = newer (↓). Position 0 is the live draft. 1154%% History is expected newest-first and already densified (no blanks), but we 1155%% still skip blanks so a stale list cannot surface an empty ↑ recall. 1156hist_seek(_History, 0, Delta) when Delta < 0 -> 1157 stay; 1158hist_seek(History, HistPos, Delta) when Delta > 0 -> 1159 hist_seek_loop(History, HistPos + 1, 1, length(History)); 1160hist_seek(History, HistPos, Delta) when Delta < 0 -> 1161 hist_seek_loop(History, HistPos - 1, -1, length(History)). 1162 1163hist_seek_loop(_History, Pos, Step, _Len) when Step < 0, Pos =< 0 -> 1164 draft; 1165hist_seek_loop(_History, Pos, Step, Len) when Step > 0, Pos > Len -> 1166 stay; 1167hist_seek_loop(History, Pos, Step, Len) when Pos >= 1, Pos =< Len -> 1168 Entry = lists:nth(Pos, History), 1169 case history_blank(Entry) of 1170 true -> 1171 hist_seek_loop(History, Pos + Step, Step, Len); 1172 false -> 1173 {Pos, Entry} 1174 end; 1175hist_seek_loop(_History, _Pos, _Step, _Len) -> 1176 stay. 1177 1178%% --------------------------------------------------------------------------- 1179%% Ctrl+R reverse history search — stinkpot-style multi-match TUI 1180%% (https://tangled.org/oppi.li/stinkpot) 1181%% 1182%% Fuzzy filter over newest-first history, list up to 12 rows, ↑/↓ move, 1183%% Enter/Tab accept onto the line (does not execute), Esc/Ctrl+C/Ctrl+G cancel. 1184%% Runs on the alternate screen so the REPL is restored cleanly. 1185%% --------------------------------------------------------------------------- 1186 1187reverse_search(Prompt, Left, Right, History) -> 1188 %% Seed query from the current buffer (like stinkpot's READLINE_LINE). 1189 QueryChars = 1190 case unicode:characters_to_list(lists:reverse(Left) ++ Right) of 1191 L when is_list(L) -> L; 1192 _ -> [] 1193 end, 1194 Candidates = uniq_history(History), 1195 Filtered = history_search_filter(Candidates, QueryChars), 1196 enter_alt_screen(), 1197 Result = reverse_search_loop(Candidates, QueryChars, Filtered, 0), 1198 leave_alt_screen(), 1199 put(gleshell_input_rows, 1), 1200 case Result of 1201 {ok, Selected} when is_binary(Selected) -> 1202 reverse_search_accept(Prompt, History, Selected); 1203 cancel -> 1204 redraw(Prompt, Left, Right), 1205 raw_loop(Prompt, Left, Right, History, 0, <<>>); 1206 eof -> 1207 io:put_chars("\r\n"), 1208 {error, <<"eof">>}; 1209 _ -> 1210 redraw(Prompt, Left, Right), 1211 raw_loop(Prompt, Left, Right, History, 0, <<>>) 1212 end. 1213 1214reverse_search_accept(Prompt, History, Selected) -> 1215 case history_blank(Selected) of 1216 true -> 1217 redraw(Prompt, [], []), 1218 raw_loop(Prompt, [], [], History, 0, <<>>); 1219 false -> 1220 {L, R} = bin_to_buffer(Selected), 1221 redraw(Prompt, L, R), 1222 raw_loop(Prompt, L, R, History, 0, <<>>) 1223 end. 1224 1225reverse_search_loop(All, Query, Filtered, Cursor0) -> 1226 Cursor = clamp_cursor(Cursor0, length(Filtered)), 1227 draw_search_ui(Query, Filtered, Cursor), 1228 case read_key() of 1229 eof -> 1230 eof; 1231 {error, _} -> 1232 eof; 1233 enter -> 1234 pick_filtered(Filtered, Cursor); 1235 tab -> 1236 pick_filtered(Filtered, Cursor); 1237 esc -> 1238 cancel; 1239 ctrl_c -> 1240 cancel; 1241 ctrl_g -> 1242 cancel; 1243 up -> 1244 reverse_search_loop(All, Query, Filtered, max(0, Cursor - 1)); 1245 ctrl_p -> 1246 reverse_search_loop(All, Query, Filtered, max(0, Cursor - 1)); 1247 down -> 1248 reverse_search_loop( 1249 All, Query, Filtered, min(length(Filtered) - 1, Cursor + 1) 1250 ); 1251 ctrl_n -> 1252 reverse_search_loop( 1253 All, Query, Filtered, min(length(Filtered) - 1, Cursor + 1) 1254 ); 1255 backspace -> 1256 NewQuery = 1257 case Query of 1258 [] -> []; 1259 [_ | _] -> lists:droplast(Query) 1260 end, 1261 NewFiltered = history_search_filter(All, NewQuery), 1262 reverse_search_loop(All, NewQuery, NewFiltered, 0); 1263 delete -> 1264 reverse_search_loop(All, Query, Filtered, Cursor); 1265 {char, C} when is_integer(C), C >= 32, C =/= 127 -> 1266 NewQuery = Query ++ [C], 1267 NewFiltered = history_search_filter(All, NewQuery), 1268 reverse_search_loop(All, NewQuery, NewFiltered, 0); 1269 _ -> 1270 reverse_search_loop(All, Query, Filtered, Cursor) 1271 end. 1272 1273pick_filtered([], _Cursor) -> 1274 cancel; 1275pick_filtered(Filtered, Cursor) when Cursor >= 0, Cursor < length(Filtered) -> 1276 Cmd = lists:nth(Cursor + 1, Filtered), 1277 Bin = 1278 case Cmd of 1279 B when is_binary(B) -> B; 1280 L when is_list(L) -> unicode:characters_to_binary(L); 1281 _ -> <<>> 1282 end, 1283 {ok, Bin}; 1284pick_filtered(_, _) -> 1285 cancel. 1286 1287clamp_cursor(_C, 0) -> 1288 0; 1289clamp_cursor(C, _Len) when C < 0 -> 1290 0; 1291clamp_cursor(C, Len) when C >= Len -> 1292 Len - 1; 1293clamp_cursor(C, _Len) -> 1294 C. 1295 1296enter_alt_screen() -> 1297 io:put_chars("\e[?1049h\e[H\e[2J"). 1298 1299leave_alt_screen() -> 1300 io:put_chars("\e[?1049l"). 1301 1302draw_search_ui(Query, Filtered, Cursor) -> 1303 %% Full repaint from home — alternate screen, so wipe + rewrite is fine. 1304 io:put_chars("\e[H\e[J"), 1305 QueryLine = search_query_line(Query), 1306 io:put_chars([QueryLine, "\r\n"]), 1307 Len = length(Filtered), 1308 Start = 1309 case Cursor >= ?SEARCH_MAX_ROWS of 1310 true -> Cursor - ?SEARCH_MAX_ROWS + 1; 1311 false -> 0 1312 end, 1313 End = min(Start + ?SEARCH_MAX_ROWS, Len), 1314 draw_search_rows(Filtered, Start, End, Cursor), 1315 Footer = search_footer(Len), 1316 io:put_chars(Footer), 1317 %% Park the cursor at the end of the query line. 1318 Col = 2 + length(Query), 1319 io:put_chars(["\e[H", "\e[", integer_to_list(Col + 1), $G]). 1320 1321search_query_line([]) -> 1322 case get(gleshell_color) of 1323 false -> 1324 "> search history..."; 1325 _ -> 1326 ["> ", "\e[2m", "search history...", "\e[0m"] 1327 end; 1328search_query_line(Query) when is_list(Query) -> 1329 ["> ", Query]. 1330 1331draw_search_rows(_Filtered, Start, End, _Cursor) when Start >= End -> 1332 ok; 1333draw_search_rows(Filtered, Start, End, Cursor) -> 1334 draw_search_rows_loop(Filtered, Start, End, Cursor). 1335 1336draw_search_rows_loop(_Filtered, I, End, _Cursor) when I >= End -> 1337 ok; 1338draw_search_rows_loop(Filtered, I, End, Cursor) -> 1339 Cmd0 = lists:nth(I + 1, Filtered), 1340 CmdList = 1341 case Cmd0 of 1342 Bin when is_binary(Bin) -> 1343 case unicode:characters_to_list(Bin) of 1344 L when is_list(L) -> L; 1345 _ -> [] 1346 end; 1347 L when is_list(L) -> L; 1348 _ -> [] 1349 end, 1350 %% Flatten newlines so a multi-line history entry stays one row. 1351 Line = [case C of $\n -> $\s; $\r -> $\s; _ -> C end || C <- CmdList], 1352 case I =:= Cursor of 1353 true -> 1354 case get(gleshell_color) of 1355 false -> 1356 io:put_chars([" ", Line, "\r\n"]); 1357 _ -> 1358 %% Bold blue — stinkpot styleCursor (lipgloss color "4"). 1359 io:put_chars([" ", "\e[1;34m", Line, "\e[0m", "\r\n"]) 1360 end; 1361 false -> 1362 io:put_chars([" ", Line, "\r\n"]) 1363 end, 1364 draw_search_rows_loop(Filtered, I + 1, End, Cursor). 1365 1366search_footer(N) -> 1367 %% Unicode arrows match stinkpot's footer copy. 1368 Text = [ 1369 " ", 1370 integer_to_list(N), 1371 " matches · ↑/↓ move · enter accept · esc cancel" 1372 ], 1373 case get(gleshell_color) of 1374 false -> 1375 Text; 1376 _ -> 1377 ["\e[2m", Text, "\e[0m"] 1378 end. 1379 1380%% Dedupe history preserving newest-first order (stinkpot stores unique cmds). 1381uniq_history(Hist) when is_list(Hist) -> 1382 uniq_history(Hist, #{}, []); 1383uniq_history(_) -> 1384 []. 1385 1386uniq_history([], _Seen, Acc) -> 1387 lists:reverse(Acc); 1388uniq_history([H | T], Seen, Acc) -> 1389 Key = 1390 case H of 1391 B when is_binary(B) -> B; 1392 L when is_list(L) -> unicode:characters_to_binary(L); 1393 _ -> <<>> 1394 end, 1395 case history_blank(Key) orelse maps:is_key(Key, Seen) of 1396 true -> 1397 uniq_history(T, Seen, Acc); 1398 false -> 1399 uniq_history(T, Seen#{Key => true}, [Key | Acc]) 1400 end. 1401 1402%% Test helper / filter: history (newest first binaries) + query → filtered list. 1403-spec history_search(list(binary()), binary()) -> list(binary()). 1404history_search(History, Query) when is_list(History), is_binary(Query) -> 1405 QList = 1406 case unicode:characters_to_list(Query) of 1407 L when is_list(L) -> L; 1408 _ -> [] 1409 end, 1410 history_search_filter(uniq_history(History), QList); 1411history_search(_, _) -> 1412 []. 1413 1414%% Empty / whitespace-only query → all candidates (newest first). 1415%% Otherwise fuzzy subsequence match (case-insensitive), best scores first; 1416%% ties keep newest-first order via stable index. 1417history_search_filter(Candidates, Query) when is_list(Candidates), is_list(Query) -> 1418 case string:trim(Query) of 1419 [] -> 1420 Candidates; 1421 QTrim -> 1422 QLower = to_lower_chars(QTrim), 1423 Scored = score_candidates(Candidates, QLower, 0, []), 1424 Sorted = lists:sort( 1425 fun({Sa, Ia, _}, {Sb, Ib, _}) -> 1426 case Sa =:= Sb of 1427 true -> Ia =< Ib; 1428 false -> Sa > Sb 1429 end 1430 end, 1431 Scored 1432 ), 1433 [Cmd || {_S, _I, Cmd} <- Sorted] 1434 end; 1435history_search_filter(_, _) -> 1436 []. 1437 1438score_candidates([], _Q, _I, Acc) -> 1439 Acc; 1440score_candidates([Cmd | Rest], Q, I, Acc) -> 1441 CmdList = 1442 case Cmd of 1443 Bin when is_binary(Bin) -> 1444 case unicode:characters_to_list(Bin) of 1445 L when is_list(L) -> L; 1446 _ -> [] 1447 end; 1448 L when is_list(L) -> L; 1449 _ -> [] 1450 end, 1451 case fuzzy_score(Q, to_lower_chars(CmdList)) of 1452 nomatch -> 1453 score_candidates(Rest, Q, I + 1, Acc); 1454 Score when is_integer(Score) -> 1455 score_candidates(Rest, Q, I + 1, [{Score, I, Cmd} | Acc]) 1456 end. 1457 1458%% Case-insensitive subsequence fuzzy score (inspired by sahilm/fuzzy used by 1459%% stinkpot). Higher is better. Consecutive matches and early matches score up. 1460fuzzy_score([], _Cand) -> 1461 0; 1462fuzzy_score(Query, Cand) -> 1463 fuzzy_score(Query, Cand, 0, 0, -2, 0). 1464 1465fuzzy_score([], _Cand, Score, _Idx, _Last, _Run) -> 1466 Score; 1467fuzzy_score([_ | _], [], _Score, _Idx, _Last, _Run) -> 1468 nomatch; 1469fuzzy_score([Q | Qs], [C | Cs], Score, Idx, Last, Run) when Q =:= C -> 1470 Consecutive = 1471 case Idx =:= Last + 1 of 1472 true -> Run + 1; 1473 false -> 0 1474 end, 1475 %% Base hit + bonus for runs; slight preference for earlier positions. 1476 Bonus = Consecutive * 4, 1477 Early = max(0, 32 - Idx), 1478 fuzzy_score(Qs, Cs, Score + 16 + Bonus + Early, Idx + 1, Idx, Consecutive); 1479fuzzy_score(Q, [_ | Cs], Score, Idx, Last, _Run) -> 1480 fuzzy_score(Q, Cs, Score, Idx + 1, Last, 0). 1481 1482to_lower_chars(List) when is_list(List) -> 1483 [ 1484 case C of 1485 X when is_integer(X), X >= $A, X =< $Z -> X + 32; 1486 X -> X 1487 end 1488 || C <- List 1489 ]; 1490to_lower_chars(_) -> 1491 []. 1492 1493kill_word([]) -> 1494 {[], []}; 1495kill_word(Left) -> 1496 %% Left is reversed: strip trailing spaces then a word. 1497 L1 = drop_while_space(Left), 1498 drop_while_word(L1). 1499 1500drop_while_space([C | Rest]) when C =:= $\s; C =:= $\t -> 1501 drop_while_space(Rest); 1502drop_while_space(L) -> 1503 L. 1504 1505drop_while_word([]) -> 1506 {[], []}; 1507drop_while_word([C | Rest]) when C =:= $\s; C =:= $\t -> 1508 {[C | Rest], []}; 1509drop_while_word([_ | Rest]) -> 1510 drop_while_word(Rest). 1511 1512redraw(Prompt, Left, Right) -> 1513 Full = lists:reverse(Left) ++ Right, 1514 FullBin = 1515 case unicode:characters_to_binary(Full) of 1516 Bin when is_binary(Bin) -> Bin; 1517 _ -> <<>> 1518 end, 1519 Colored = highlight_line(FullBin), 1520 %% Fish/Nu-style ghost text: grey suffix of the newest history match. 1521 %% Only when the cursor is at end of the buffer (Right == []). 1522 HintChars = 1523 case Right of 1524 [] -> history_hint_suffix(Full); 1525 _ -> [] 1526 end, 1527 HintPainted = paint_history_hint(HintChars), 1528 HintBin = 1529 case unicode:characters_to_binary(HintChars) of 1530 HB when is_binary(HB) -> HB; 1531 _ -> <<>> 1532 end, 1533 %% Clear every physical row the previous render occupied. A longer history 1534 %% entry can soft-wrap; `\e[2K` alone only erases the current row, so a 1535 %% shorter recall (or empty draft) used to leave a blank-looking row and 1536 %% stale wrap residue that felt like an empty ↑ slot. 1537 clear_input_rows(), 1538 io:put_chars([Prompt, Colored, HintPainted]), 1539 %% Include hint width so multi-line wrap clears correctly on next redraw. 1540 Rows = count_input_rows(Prompt, <<FullBin/binary, HintBin/binary>>), 1541 put(gleshell_input_rows, Rows), 1542 %% Cursor sits after typed text, before the grey hint (and before Right). 1543 CursorBack = length(HintChars) + length(Right), 1544 case CursorBack of 1545 0 -> 1546 ok; 1547 N -> 1548 %% Best-effort: codepoint count ≈ columns for ASCII-heavy input. 1549 io:put_chars(["\e[", integer_to_list(N), $D]) 1550 end. 1551 1552%% Accept the full greyed-out history suggestion into the buffer. 1553accept_history_hint(Prompt, Left, Right, History, HistPos, Saved) -> 1554 case Right of 1555 [] -> 1556 case history_hint_suffix(lists:reverse(Left)) of 1557 [] -> 1558 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 1559 Suffix -> 1560 %% Left is reversed; append Suffix at end of buffer. 1561 NewLeft = lists:reverse(Suffix) ++ Left, 1562 redraw(Prompt, NewLeft, []), 1563 raw_loop(Prompt, NewLeft, [], History, 0, <<>>) 1564 end; 1565 _ -> 1566 raw_loop(Prompt, Left, Right, History, HistPos, Saved) 1567 end. 1568 1569%% Accept the next word of the history suggestion (leading space + word). 1570accept_history_hint_word(Prompt, Left, Right, History, HistPos, Saved) -> 1571 case Right of 1572 [] -> 1573 case history_hint_suffix(lists:reverse(Left)) of 1574 [] -> 1575 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 1576 Suffix -> 1577 Word = take_hint_word(Suffix), 1578 case Word of 1579 [] -> 1580 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 1581 _ -> 1582 NewLeft = lists:reverse(Word) ++ Left, 1583 redraw(Prompt, NewLeft, []), 1584 raw_loop(Prompt, NewLeft, [], History, 0, <<>>) 1585 end 1586 end; 1587 _ -> 1588 raw_loop(Prompt, Left, Right, History, HistPos, Saved) 1589 end. 1590 1591%% First token of a hint: optional leading whitespace + following non-space run. 1592take_hint_word([]) -> 1593 []; 1594take_hint_word([C | Rest]) when C =:= $\s; C =:= $\t -> 1595 [C | take_hint_nonspace(Rest)]; 1596take_hint_word(Rest) -> 1597 take_hint_nonspace(Rest). 1598 1599take_hint_nonspace([]) -> 1600 []; 1601take_hint_nonspace([C | _Rest]) when C =:= $\s; C =:= $\t -> 1602 []; 1603take_hint_nonspace([C | Rest]) -> 1604 [C | take_hint_nonspace(Rest)]. 1605 1606%% Newest-first history: suffix of the first entry that has Buffer as a proper prefix. 1607history_hint_suffix([]) -> 1608 []; 1609history_hint_suffix(Buffer) when is_list(Buffer) -> 1610 Hist = 1611 case get(gleshell_history) of 1612 L when is_list(L) -> L; 1613 _ -> [] 1614 end, 1615 history_hint_suffix(Hist, Buffer). 1616 1617history_hint_suffix([], _Buffer) -> 1618 []; 1619%% Empty buffer: never ghost the whole previous command (Nu default). 1620history_hint_suffix(_History, []) -> 1621 []; 1622history_hint_suffix([H | T], Buffer) -> 1623 HList = 1624 case H of 1625 Bin when is_binary(Bin) -> unicode:characters_to_list(Bin); 1626 List when is_list(List) -> List; 1627 _ -> [] 1628 end, 1629 case is_list(HList) andalso lists:prefix(Buffer, HList) of 1630 true when length(HList) > length(Buffer) -> 1631 lists:nthtail(length(Buffer), HList); 1632 _ -> 1633 history_hint_suffix(T, Buffer) 1634 end. 1635 1636paint_history_hint([]) -> 1637 []; 1638paint_history_hint(Chars) -> 1639 case get(gleshell_color) of 1640 false -> 1641 Chars; 1642 _ -> 1643 %% Dark gray — same as Nu `color_config.hints` / shape_nothing. 1644 ["\e[90m", Chars, "\e[0m"] 1645 end. 1646 1647%% Test helper: given history (newest first) and buffer, return grey suffix binary. 1648-spec history_hint(list(binary()), binary()) -> binary(). 1649history_hint(History, Buffer) when is_list(History), is_binary(Buffer) -> 1650 BufList = 1651 case unicode:characters_to_list(Buffer) of 1652 L when is_list(L) -> L; 1653 _ -> [] 1654 end, 1655 case history_hint_suffix(History, BufList) of 1656 [] -> 1657 <<>>; 1658 Suffix -> 1659 case unicode:characters_to_binary(Suffix) of 1660 Bin when is_binary(Bin) -> Bin; 1661 _ -> <<>> 1662 end 1663 end. 1664 1665%% Move to the start of the previous input block and erase its rows. 1666clear_input_rows() -> 1667 Rows0 = 1668 case get(gleshell_input_rows) of 1669 N when is_integer(N), N > 0 -> N; 1670 _ -> 1 1671 end, 1672 %% Cap so a bad columns() estimate cannot wipe the path line above the prompt. 1673 Rows = min(Rows0, 16), 1674 %% Cursor sits on the last physical row of the previous draw when Right=[]. 1675 case Rows of 1676 1 -> 1677 io:put_chars([$\r, "\e[2K"]); 1678 _ -> 1679 Up = Rows - 1, 1680 io:put_chars([$\r, "\e[", integer_to_list(Up), $A, "\e[J"]) 1681 end. 1682 1683%% How many terminal rows does prompt+line occupy (ANSI-aware, 1-col glyphs). 1684count_input_rows(Prompt, FullBin) -> 1685 Cols = 1686 case io:columns() of 1687 {ok, C} when is_integer(C), C >= 8 -> C; 1688 {ok, C} when is_integer(C), C > 0 -> 8; 1689 _ -> 80 1690 end, 1691 PW = visible_width(Prompt), 1692 FW = visible_width(FullBin), 1693 Total = PW + FW, 1694 case Total =< 0 of 1695 true -> 1; 1696 false -> min(16, (Total + Cols - 1) div Cols) 1697 end. 1698 1699%% Visible column count ignoring CSI (ESC [ … final). Wide glyphs count as 1 1700%% (same approximation as the pager); good enough to clear wrap residue. 1701visible_width(Data) when is_binary(Data) -> 1702 visible_width(unicode:characters_to_list(Data)); 1703visible_width(List) when is_list(List) -> 1704 visible_width_loop(List, 0, normal); 1705visible_width(_) -> 1706 0. 1707 1708visible_width_loop([], Acc, _) -> 1709 Acc; 1710visible_width_loop([16#1b | Rest], Acc, normal) -> 1711 visible_width_loop(Rest, Acc, esc); 1712visible_width_loop([_C | Rest], Acc, normal) -> 1713 visible_width_loop(Rest, Acc + 1, normal); 1714visible_width_loop([$[ | Rest], Acc, esc) -> 1715 visible_width_loop(Rest, Acc, csi); 1716visible_width_loop([_ | Rest], Acc, esc) -> 1717 visible_width_loop(Rest, Acc, normal); 1718visible_width_loop([C | Rest], Acc, csi) when C >= 16#40, C =< 16#7e -> 1719 visible_width_loop(Rest, Acc, normal); 1720visible_width_loop([_ | Rest], Acc, csi) -> 1721 visible_width_loop(Rest, Acc, csi). 1722 1723highlight_line(Bin) when is_binary(Bin) -> 1724 case get(gleshell_color) of 1725 false -> 1726 Bin; 1727 _ -> 1728 try 1729 case 'gleshell@highlight':line(Bin) of 1730 Out when is_binary(Out) -> Out; 1731 Out when is_list(Out) -> unicode:characters_to_binary(Out); 1732 _ -> Bin 1733 end 1734 catch 1735 _:_ -> 1736 Bin 1737 end 1738 end. 1739 1740buffer_to_bin(Left, Right) -> 1741 unicode:characters_to_binary(lists:reverse(Left) ++ Right). 1742 1743bin_to_buffer(Bin) when is_binary(Bin) -> 1744 Chars = unicode:characters_to_list(Bin), 1745 {lists:reverse(Chars), []}; 1746bin_to_buffer(List) when is_list(List) -> 1747 {lists:reverse(List), []}. 1748 1749%% --------------------------------------------------------------------------- 1750%% Key reading (raw mode — keys arrive as soon as pressed) 1751%% 1752%% When the stdin mux is running (raw REPL), all key bytes come from that 1753%% process so PTY relay / interrupt watch can retarget without spawning a 1754%% second get_chars client (see start_stdin_mux/0). 1755%% 1756%% Push-back queue (`gleshell_key_q`): codepoints waiting to be consumed. 1757%% Critical when a single io payload decodes to multiple characters — the old 1758%% `<<C/utf8, _/binary>>` path kept only the first and dropped the rest, which 1759%% turned paste bursts like "gleam" into "geam" / "gem" / etc. 1760%% --------------------------------------------------------------------------- 1761 1762read_key() -> 1763 case pop_unread_key() of 1764 {ok, K} -> 1765 K; 1766 empty -> 1767 case read_key_byte() of 1768 eof -> 1769 eof; 1770 {error, Reason} -> 1771 {error, Reason}; 1772 C when is_integer(C) -> 1773 decode_key(C) 1774 end 1775 end. 1776 1777%% Non-blocking: next already-queued codepoint or mux message, else `none`. 1778try_read_key_byte() -> 1779 case pop_key_q() of 1780 {ok, C} -> 1781 C; 1782 empty -> 1783 case get(gleshell_stdin_mux) of 1784 Mux when is_pid(Mux) -> 1785 receive 1786 {gleshell_stdin, eof} -> 1787 eof; 1788 {gleshell_stdin, {error, Reason}} -> 1789 {error, Reason}; 1790 {gleshell_stdin, Data} -> 1791 case enqueue_key_data(Data) of 1792 empty -> 1793 try_read_key_byte(); 1794 C when is_integer(C) -> 1795 C 1796 end 1797 after 0 -> 1798 none 1799 end; 1800 _ -> 1801 none 1802 end 1803 end. 1804 1805%% One logical input unit (codepoint or raw byte) for the line editor / CSI. 1806read_key_byte() -> 1807 case pop_key_q() of 1808 {ok, C} -> 1809 C; 1810 empty -> 1811 case get(gleshell_stdin_mux) of 1812 Mux when is_pid(Mux) -> 1813 receive 1814 {gleshell_stdin, eof} -> 1815 eof; 1816 {gleshell_stdin, {error, Reason}} -> 1817 {error, Reason}; 1818 {gleshell_stdin, Data} -> 1819 case enqueue_key_data(Data) of 1820 empty -> 1821 read_key_byte(); 1822 C when is_integer(C) -> 1823 C 1824 end 1825 end; 1826 _ -> 1827 read_key_byte_direct() 1828 end 1829 end. 1830 1831read_key_byte_direct() -> 1832 case io:get_chars("", 1) of 1833 eof -> 1834 eof; 1835 {error, Reason} -> 1836 {error, Reason}; 1837 Data -> 1838 case enqueue_key_data(Data) of 1839 empty -> 1840 read_key_byte_direct(); 1841 C when is_integer(C) -> 1842 C 1843 end 1844 end. 1845 1846%% Decode IO data into codepoints: return the first, queue the rest. 1847enqueue_key_data(Data) -> 1848 Bin = io_data_to_bin(Data), 1849 case Bin of 1850 <<>> -> 1851 empty; 1852 _ -> 1853 case unicode:characters_to_list(Bin) of 1854 [C | Rest] when is_integer(C) -> 1855 unread_codepoints(Rest), 1856 C; 1857 [] -> 1858 empty; 1859 {incomplete, Done, _Rest} when is_list(Done) -> 1860 case Done of 1861 [C | Rest] when is_integer(C) -> 1862 unread_codepoints(Rest), 1863 C; 1864 [] -> 1865 %% Incomplete UTF-8 lead — keep raw bytes so we do 1866 %% not invent latin1 garbage; wait for more data. 1867 unread_codepoints(binary_to_byte_list(Bin)), 1868 case pop_key_q() of 1869 {ok, C} -> C; 1870 empty -> empty 1871 end; 1872 _ -> 1873 empty 1874 end; 1875 {error, Done, _Rest} when is_list(Done) -> 1876 case Done of 1877 [C | Rest] when is_integer(C) -> 1878 unread_codepoints(Rest), 1879 C; 1880 _ -> 1881 fallback_first_byte(Bin) 1882 end; 1883 _ -> 1884 fallback_first_byte(Bin) 1885 end 1886 end. 1887 1888fallback_first_byte(<<C, Rest/binary>>) when is_integer(C) -> 1889 unread_codepoints(binary_to_byte_list(Rest)), 1890 C; 1891fallback_first_byte(_) -> 1892 empty. 1893 1894binary_to_byte_list(Bin) when is_binary(Bin) -> 1895 binary_to_list(Bin). 1896 1897pop_key_q() -> 1898 case get(gleshell_key_q) of 1899 [C | Rest] when is_integer(C) -> 1900 put(gleshell_key_q, Rest), 1901 {ok, C}; 1902 _ -> 1903 put(gleshell_key_q, []), 1904 empty 1905 end. 1906 1907%% Prepend so the next pop returns these in order (Cs is chronological). 1908unread_codepoints([]) -> 1909 ok; 1910unread_codepoints(Cs) when is_list(Cs) -> 1911 Q = 1912 case get(gleshell_key_q) of 1913 Q0 when is_list(Q0) -> Q0; 1914 _ -> [] 1915 end, 1916 put(gleshell_key_q, Cs ++ Q), 1917 ok. 1918 1919unread_key(Key) -> 1920 case get(gleshell_key_unread) of 1921 L when is_list(L) -> 1922 put(gleshell_key_unread, [Key | L]); 1923 _ -> 1924 put(gleshell_key_unread, [Key]) 1925 end. 1926 1927pop_unread_key() -> 1928 case get(gleshell_key_unread) of 1929 [K | Rest] -> 1930 put(gleshell_key_unread, Rest), 1931 {ok, K}; 1932 _ -> 1933 put(gleshell_key_unread, []), 1934 empty 1935 end. 1936 1937%% Drain a paste/typeahead burst of printable chars already in the queue/mailbox. 1938%% Acc starts with the first char; returns chronological list of codepoints. 1939drain_printable_burst(First) when is_integer(First) -> 1940 drain_printable_burst_loop([First]). 1941 1942drain_printable_burst_loop(AccRev) -> 1943 case try_next_printable() of 1944 {char, C} -> 1945 drain_printable_burst_loop([C | AccRev]); 1946 none -> 1947 %% Brief yield: mux may still be forwarding the rest of a paste. 1948 receive 1949 after 2 -> 1950 case try_next_printable() of 1951 {char, C} -> 1952 drain_printable_burst_loop([C | AccRev]); 1953 none -> 1954 lists:reverse(AccRev) 1955 end 1956 end 1957 end. 1958 1959try_next_printable() -> 1960 case pop_unread_key() of 1961 {ok, {char, C}} when is_integer(C), C >= 32, C =/= 127 -> 1962 {char, C}; 1963 {ok, Other} -> 1964 unread_key(Other), 1965 none; 1966 empty -> 1967 case try_read_key_byte() of 1968 none -> 1969 none; 1970 eof -> 1971 unread_key(eof), 1972 none; 1973 {error, Reason} -> 1974 unread_key({error, Reason}), 1975 none; 1976 C when is_integer(C) -> 1977 case decode_key(C) of 1978 {char, Ch} when is_integer(Ch), Ch >= 32, Ch =/= 127 -> 1979 {char, Ch}; 1980 Other -> 1981 unread_key(Other), 1982 none 1983 end 1984 end 1985 end. 1986 1987%% Bracketed paste body until paste_end. Newlines → space (single-line editor). 1988read_bracketed_paste(Left) -> 1989 case read_key() of 1990 paste_end -> 1991 Left; 1992 paste_start -> 1993 read_bracketed_paste(Left); 1994 {char, C} when is_integer(C), C >= 32, C =/= 127 -> 1995 read_bracketed_paste([C | Left]); 1996 enter -> 1997 read_bracketed_paste([$\s | Left]); 1998 eof -> 1999 Left; 2000 {error, _} -> 2001 Left; 2002 ctrl_c -> 2003 Left; 2004 _Other -> 2005 read_bracketed_paste(Left) 2006 end. 2007 2008enable_bracketed_paste() -> 2009 %% Ask the terminal to wrap pastes in ESC[200~ … ESC[201~. 2010 catch io:put_chars("\e[?2004h"), 2011 ok. 2012 2013disable_bracketed_paste() -> 2014 catch io:put_chars("\e[?2004l"), 2015 ok. 2016 2017decode_key($\r) -> enter; 2018decode_key($\n) -> enter; 2019decode_key($\t) -> tab; 2020decode_key(127) -> backspace; 2021decode_key($\b) -> backspace; 2022decode_key(1) -> ctrl_a; 2023decode_key(5) -> ctrl_e; 2024decode_key(4) -> ctrl_d; 2025decode_key(3) -> ctrl_c; 2026decode_key(11) -> ctrl_k; 2027decode_key(21) -> ctrl_u; 2028decode_key(23) -> ctrl_w; 2029decode_key(12) -> ctrl_l; 2030decode_key(18) -> ctrl_r; 2031decode_key(6) -> ctrl_f; 2032decode_key(16) -> ctrl_p; 2033decode_key(14) -> ctrl_n; 2034decode_key(7) -> ctrl_g; 2035decode_key(?ESC) -> 2036 read_escape(); 2037decode_key(C) when is_integer(C), C >= 32 -> 2038 {char, C}; 2039decode_key(_) -> 2040 other. 2041 2042read_escape() -> 2043 case read_key_byte() of 2044 eof -> 2045 %% Lone ESC (no follow-up) — treat as Escape. 2046 esc; 2047 {error, _} -> 2048 esc; 2049 ?ESC -> 2050 %% ESC ESC 2051 esc; 2052 $[ -> 2053 read_csi(); 2054 $O -> 2055 %% SS3 sequences: OH = home, OF = end, OA/OB/OC/OD arrows 2056 case read_key_byte() of 2057 $A -> up; 2058 $B -> down; 2059 $C -> right; 2060 $D -> left; 2061 $H -> home; 2062 $F -> 'end'; 2063 Other when is_integer(Other) -> 2064 %% Do not drop the follow-up byte (paste / unbound meta). 2065 unread_codepoints([Other]), 2066 other; 2067 _ -> 2068 other 2069 end; 2070 %% Alt+letter arrives as ESC then the letter (meta). 2071 $f -> 2072 alt_f; 2073 $F -> 2074 alt_f; 2075 C when is_integer(C), C >= 32 -> 2076 %% Unbound Alt+key or ESC fragment mid-paste: keep the letter. 2077 %% (Old path returned `esc` and *discarded* C — "gleam" → "geam".) 2078 {char, C}; 2079 _ -> 2080 esc 2081 end. 2082 2083read_csi() -> 2084 read_csi_params([]). 2085 2086read_csi_params(Acc) -> 2087 case read_key_byte() of 2088 eof -> 2089 other; 2090 {error, _} -> 2091 other; 2092 C when is_integer(C), C >= $0, C =< $9 -> 2093 read_csi_params([C | Acc]); 2094 $; -> 2095 read_csi_params([$; | Acc]); 2096 $~ -> 2097 Params = lists:reverse(Acc), 2098 case Params of 2099 "1" -> home; 2100 "3" -> delete; 2101 "4" -> 'end'; 2102 "5" -> page_up; 2103 "6" -> page_down; 2104 "7" -> home; 2105 "8" -> 'end'; 2106 "200" -> paste_start; 2107 "201" -> paste_end; 2108 _ -> other 2109 end; 2110 $A -> 2111 up; 2112 $B -> 2113 down; 2114 $C -> 2115 right; 2116 $D -> 2117 left; 2118 $H -> 2119 home; 2120 $F -> 2121 'end'; 2122 _ -> 2123 other 2124 end. 2125 2126%% --------------------------------------------------------------------------- 2127%% Stdin mux — single get_chars owner for the raw REPL 2128%% 2129%% Target: 2130%% line → forward bytes to the shell process (line editor) 2131%% {port, Port} → relay into a child PTY (interactive externals) 2132%% {interrupt, Parent} → Ctrl+C → interrupt msg; other keys become typeahead 2133%% 2134%% Retarget with set_stdin_target/1. The mux applies pending retargets *after* 2135%% each get_chars returns and *before* routing, so the first key after a 2136%% command is not lost to a dead Port or a killed get_chars client. 2137%% --------------------------------------------------------------------------- 2138 2139start_stdin_mux() -> 2140 case get(gleshell_stdin_mux) of 2141 Pid when is_pid(Pid) -> 2142 Pid; 2143 _ -> 2144 Owner = self(), 2145 GL = group_leader(), 2146 Mux = spawn(fun() -> 2147 group_leader(GL, self()), 2148 stdin_mux_loop(Owner, line, []) 2149 end), 2150 put(gleshell_stdin_mux, Mux), 2151 Mux 2152 end. 2153 2154stop_stdin_mux() -> 2155 case erase(gleshell_stdin_mux) of 2156 Pid when is_pid(Pid) -> 2157 Pid ! stop, 2158 ok; 2159 _ -> 2160 ok 2161 end. 2162 2163set_stdin_target(Target) -> 2164 case get(gleshell_stdin_mux) of 2165 Pid when is_pid(Pid) -> 2166 Pid ! {set_target, Target}, 2167 ok; 2168 _ -> 2169 ok 2170 end. 2171 2172stdin_mux_loop(Owner, Target, Typeahead) -> 2173 %% Deliver queued typeahead to the line editor before blocking again. 2174 case {Target, Typeahead} of 2175 {line, [Bin | Rest]} when is_binary(Bin) -> 2176 Owner ! {gleshell_stdin, Bin}, 2177 stdin_mux_loop(Owner, line, Rest); 2178 _ -> 2179 case io:get_chars("", 1) of 2180 eof -> 2181 {NewT, NewTA} = drain_mux_controls(Target, Typeahead), 2182 case NewT of 2183 line -> 2184 Owner ! {gleshell_stdin, eof}; 2185 _ -> 2186 ok 2187 end, 2188 stdin_mux_loop(Owner, NewT, NewTA); 2189 {error, Reason} -> 2190 {NewT, NewTA} = drain_mux_controls(Target, Typeahead), 2191 case NewT of 2192 line -> 2193 Owner ! {gleshell_stdin, {error, Reason}}; 2194 _ -> 2195 ok 2196 end, 2197 stdin_mux_loop(Owner, NewT, NewTA); 2198 Data -> 2199 Bin0 = io_data_to_bin(Data), 2200 {NewT, NewTA0} = drain_mux_controls(Target, Typeahead), 2201 case Bin0 of 2202 <<>> -> 2203 stdin_mux_loop(Owner, NewT, NewTA0); 2204 Bin -> 2205 case NewT of 2206 line -> 2207 %% Typeahead (from interrupt mode) first, then 2208 %% this key — loop head delivers in order. 2209 stdin_mux_loop(Owner, line, NewTA0 ++ [Bin]); 2210 {port, Port} -> 2211 mux_relay_port(Port, Bin), 2212 stdin_mux_loop(Owner, NewT, NewTA0); 2213 {interrupt, Parent} -> 2214 NewTA1 = mux_interrupt_key(Parent, Bin, NewTA0), 2215 stdin_mux_loop(Owner, NewT, NewTA1); 2216 _ -> 2217 stdin_mux_loop(Owner, NewT, NewTA0) 2218 end 2219 end 2220 end 2221 end. 2222 2223%% Apply all pending {set_target, _} / stop messages (non-blocking). 2224drain_mux_controls(Target, Typeahead) -> 2225 receive 2226 stop -> 2227 exit(normal); 2228 {set_target, NewTarget} -> 2229 drain_mux_controls(NewTarget, Typeahead) 2230 after 0 -> 2231 {Target, Typeahead} 2232 end. 2233 2234mux_relay_port(Port, <<3>> = Bin) -> 2235 signal_port_group(Port, int), 2236 catch port_command(Port, Bin), 2237 ok; 2238mux_relay_port(Port, Bin) -> 2239 catch port_command(Port, Bin), 2240 ok. 2241 2242mux_interrupt_key(Parent, <<3>>, Typeahead) -> 2243 Parent ! {gleshell_interrupt, mux}, 2244 Typeahead; 2245mux_interrupt_key(_Parent, Bin, Typeahead) -> 2246 %% Preserve typeahead typed while a capture-mode external runs. 2247 Typeahead ++ [Bin]. 2248 2249%% --------------------------------------------------------------------------- 2250%% History persistence 2251%% --------------------------------------------------------------------------- 2252 2253history_file() -> 2254 case application:get_env(kernel, shell_history_path) of 2255 {ok, Path} when is_list(Path) -> 2256 filename:join(Path, "lines"); 2257 {ok, Path} when is_binary(Path) -> 2258 filename:join(unicode:characters_to_list(Path), "lines"); 2259 _ -> 2260 filename:join( 2261 filename:basedir(user_cache, "gleshell-history"), "lines" 2262 ) 2263 end. 2264 2265load_line_history() -> 2266 File = history_file(), 2267 case file:read_file(File) of 2268 {ok, Bin} -> 2269 Lines0 = [ 2270 string:trim(L, trailing, [$\r]) 2271 || L <- binary:split(Bin, <<"\n">>, [global]) 2272 ], 2273 %% Newest first; sanitize drops blanks / ANSI-only / ZWSP-only. 2274 put(gleshell_history, sanitize_history(lists:reverse(Lines0))); 2275 _ -> 2276 put(gleshell_history, []) 2277 end, 2278 put(gleshell_color, true), 2279 ok. 2280 2281save_line_history() -> 2282 case get(gleshell_history) of 2283 Hist when is_list(Hist) -> 2284 File = history_file(), 2285 _ = filelib:ensure_dir(File), 2286 %% Store oldest-first for human readability; drop blanks. 2287 Kept = lists:sublist(sanitize_history(Hist), ?HISTORY_MAX), 2288 Body = [[L, $\n] || L <- lists:reverse(Kept)], 2289 _ = file:write_file(File, Body), 2290 ok; 2291 _ -> 2292 ok 2293 end. 2294 2295%% Drop blank entries; keep order (newest first). 2296sanitize_history(Hist) when is_list(Hist) -> 2297 [L || L <- Hist, not history_blank(L)]; 2298sanitize_history(_) -> 2299 []. 2300 2301%% Empty / whitespace-only / ANSI-only / zero-width-only lines look blank when 2302%% recalled with ↑ — never store or navigate to them. 2303history_blank(Bin) when is_binary(Bin) -> 2304 history_blank_visible(string:trim(Bin)); 2305history_blank(List) when is_list(List) -> 2306 case unicode:characters_to_binary(List) of 2307 Bin when is_binary(Bin) -> 2308 history_blank_visible(string:trim(Bin)); 2309 _ -> 2310 true 2311 end; 2312history_blank(_) -> 2313 true. 2314 2315history_blank_visible(Bin) when is_binary(Bin) -> 2316 case strip_ansi_bin(Bin) of 2317 <<>> -> 2318 true; 2319 Visible -> 2320 case unicode:characters_to_list(Visible) of 2321 List when is_list(List), List =/= [] -> 2322 %% ZWSP / ZWNJ / ZWJ / BOM / soft hyphen — render as empty. 2323 lists:all( 2324 fun(C) -> 2325 C =:= 16#200B orelse C =:= 16#200C orelse 2326 C =:= 16#200D orelse C =:= 16#FEFF orelse 2327 C =:= 16#00AD 2328 end, 2329 List 2330 ); 2331 _ -> 2332 false 2333 end 2334 end. 2335 2336%% Strip CSI sequences (ESC [ … final) for blank detection. 2337strip_ansi_bin(Bin) when is_binary(Bin) -> 2338 case unicode:characters_to_list(Bin) of 2339 List when is_list(List) -> 2340 unicode:characters_to_binary(strip_ansi_list(List, normal, [])); 2341 _ -> 2342 Bin 2343 end. 2344 2345strip_ansi_list([], _State, Acc) -> 2346 lists:reverse(Acc); 2347strip_ansi_list([16#1b | Rest], normal, Acc) -> 2348 strip_ansi_list(Rest, esc, Acc); 2349strip_ansi_list([C | Rest], normal, Acc) -> 2350 strip_ansi_list(Rest, normal, [C | Acc]); 2351strip_ansi_list([$[ | Rest], esc, Acc) -> 2352 strip_ansi_list(Rest, csi, Acc); 2353strip_ansi_list([_ | Rest], esc, Acc) -> 2354 strip_ansi_list(Rest, normal, Acc); 2355strip_ansi_list([C | Rest], csi, Acc) when C >= 16#40, C =< 16#7e -> 2356 strip_ansi_list(Rest, normal, Acc); 2357strip_ansi_list([_ | Rest], csi, Acc) -> 2358 strip_ansi_list(Rest, csi, Acc). 2359 2360push_history(Line) when is_binary(Line) -> 2361 %% Trim so " ls " does not become a near-blank distinct from "ls". 2362 Trimmed = string:trim(Line), 2363 case history_blank(Trimmed) of 2364 true -> 2365 ok; 2366 false -> 2367 Hist = sanitize_history( 2368 case get(gleshell_history) of 2369 L when is_list(L) -> L; 2370 _ -> [] 2371 end 2372 ), 2373 New = case Hist of 2374 [Trimmed | _] -> Hist; 2375 _ -> [Trimmed | Hist] 2376 end, 2377 put(gleshell_history, lists:sublist(New, ?HISTORY_MAX)), 2378 ok 2379 end; 2380push_history(_) -> 2381 ok. 2382 2383%% --------------------------------------------------------------------------- 2384%% OS / process helpers 2385%% --------------------------------------------------------------------------- 2386 2387-spec set_cwd(binary()) -> {ok, nil} | {error, binary()}. 2388set_cwd(Path) when is_binary(Path) -> 2389 case file:set_cwd(unicode:characters_to_list(Path)) of 2390 ok -> 2391 {ok, nil}; 2392 {error, Reason} -> 2393 {error, reason_to_bin(Reason)} 2394 end. 2395 2396-spec get_cwd() -> {ok, binary()} | {error, binary()}. 2397get_cwd() -> 2398 case file:get_cwd() of 2399 {ok, Dir} -> 2400 {ok, unicode:characters_to_binary(Dir)}; 2401 {error, Reason} -> 2402 {error, reason_to_bin(Reason)} 2403 end. 2404 2405-spec getenv(binary()) -> {ok, binary()} | {error, nil}. 2406getenv(Name) when is_binary(Name) -> 2407 case os:getenv(unicode:characters_to_list(Name)) of 2408 false -> 2409 {error, nil}; 2410 Value -> 2411 {ok, unicode:characters_to_binary(Value)} 2412 end. 2413 2414-spec setenv(binary(), binary()) -> {ok, nil}. 2415setenv(Name, Value) when is_binary(Name), is_binary(Value) -> 2416 os:putenv(unicode:characters_to_list(Name), unicode:characters_to_list(Value)), 2417 {ok, nil}. 2418 2419%% All process environment variables as a list of {Name, Value} binaries. 2420-spec list_env() -> list({binary(), binary()}). 2421list_env() -> 2422 lists:map( 2423 fun(Entry) -> 2424 case string:split(Entry, "=", leading) of 2425 [K, V] -> 2426 {unicode:characters_to_binary(K), unicode:characters_to_binary(V)}; 2427 [K] -> 2428 {unicode:characters_to_binary(K), <<>>}; 2429 _ -> 2430 {<<>>, <<>>} 2431 end 2432 end, 2433 os:getenv() 2434 ). 2435 2436%% Substring/regex search helper for the `find` builtin. 2437%% Returns {ok, true|false} or {error, Message} on invalid pattern. 2438-spec re_contains(binary(), binary(), boolean()) -> {ok, boolean()} | {error, binary()}. 2439re_contains(Text, Pattern, IgnoreCase) 2440 when is_binary(Text), is_binary(Pattern), is_boolean(IgnoreCase) -> 2441 Opts0 = [unicode], 2442 Opts = 2443 case IgnoreCase of 2444 true -> 2445 [caseless | Opts0]; 2446 false -> 2447 Opts0 2448 end, 2449 case re:compile(Pattern, Opts) of 2450 {ok, Re} -> 2451 case re:run(Text, Re, [{capture, none}]) of 2452 match -> 2453 {ok, true}; 2454 nomatch -> 2455 {ok, false} 2456 end; 2457 {error, {Reason, _}} -> 2458 {error, iolist_to_binary(io_lib:format("~p", [Reason]))}; 2459 {error, Reason} -> 2460 {error, iolist_to_binary(io_lib:format("~p", [Reason]))} 2461 end. 2462 2463-spec which(binary()) -> {ok, binary()} | {error, nil}. 2464which(Command) when is_binary(Command) -> 2465 case which_all(Command) of 2466 [Path | _] -> 2467 {ok, Path}; 2468 [] -> 2469 {error, nil} 2470 end. 2471 2472%% All matching executables on PATH (or the path itself if absolute/relative). 2473%% Order matches PATH search; duplicates from the same resolved path are dropped. 2474-spec which_all(binary()) -> [binary()]. 2475which_all(Command) when is_binary(Command) -> 2476 Cmd = unicode:characters_to_list(Command), 2477 case Cmd of 2478 [] -> 2479 []; 2480 _ -> 2481 case has_path_sep(Cmd) of 2482 true -> 2483 case is_executable_file(Cmd) of 2484 true -> 2485 [unicode:characters_to_binary(filename:absname(Cmd))]; 2486 false -> 2487 [] 2488 end; 2489 false -> 2490 case os:getenv("PATH") of 2491 false -> 2492 []; 2493 PathStr -> 2494 Dirs = string:tokens(PathStr, path_sep()), 2495 find_all_in_path(Cmd, Dirs, #{}, []) 2496 end 2497 end 2498 end. 2499 2500path_sep() -> 2501 case os:type() of 2502 {win32, _} -> ";"; 2503 _ -> ":" 2504 end. 2505 2506has_path_sep(Cmd) -> 2507 lists:member($/, Cmd) orelse lists:member($\\, Cmd). 2508 2509find_all_in_path(_Cmd, [], _Seen, Acc) -> 2510 lists:reverse(Acc); 2511find_all_in_path(Cmd, [Dir | Rest], Seen, Acc) -> 2512 File = filename:join(Dir, Cmd), 2513 case is_executable_file(File) of 2514 true -> 2515 Abs = filename:absname(File), 2516 Bin = unicode:characters_to_binary(Abs), 2517 case maps:is_key(Abs, Seen) of 2518 true -> 2519 find_all_in_path(Cmd, Rest, Seen, Acc); 2520 false -> 2521 find_all_in_path(Cmd, Rest, Seen#{Abs => true}, [Bin | Acc]) 2522 end; 2523 false -> 2524 find_all_in_path(Cmd, Rest, Seen, Acc) 2525 end. 2526 2527is_executable_file(Path) -> 2528 case file:read_file_info(Path) of 2529 {ok, #file_info{type = regular, mode = Mode}} -> 2530 %% Any execute bit (owner/group/other). 2531 (Mode band 8#111) =/= 0; 2532 _ -> 2533 false 2534 end. 2535 2536%% Canonical absolute path: resolve `.`/`..` and follow symlinks (like realpath(3)). 2537%% On failure (missing path, loop, etc.) returns {error, nil}. 2538-spec realpath(binary()) -> {ok, binary()} | {error, nil}. 2539realpath(Path) when is_binary(Path) -> 2540 case realpath_loop(unicode:characters_to_list(Path), #{}) of 2541 {ok, Resolved} -> 2542 {ok, unicode:characters_to_binary(Resolved)}; 2543 {error, _} -> 2544 {error, nil} 2545 end. 2546 2547realpath_loop(Path, Seen) -> 2548 Abs = filename:absname(Path), 2549 case maps:is_key(Abs, Seen) of 2550 true -> 2551 {error, eloop}; 2552 false -> 2553 case file:read_link(Abs) of 2554 {ok, Target} -> 2555 Next = 2556 case filename:pathtype(Target) of 2557 absolute -> 2558 Target; 2559 _ -> 2560 filename:absname(filename:join(filename:dirname(Abs), Target)) 2561 end, 2562 realpath_loop(Next, Seen#{Abs => true}); 2563 {error, einval} -> 2564 %% Not a symlink — Abs is the final path. 2565 case file:read_file_info(Abs) of 2566 {ok, _} -> 2567 {ok, Abs}; 2568 {error, Reason} -> 2569 {error, Reason} 2570 end; 2571 {error, Reason} -> 2572 {error, Reason} 2573 end 2574 end. 2575 2576-spec home_dir() -> {ok, binary()} | {error, binary()}. 2577home_dir() -> 2578 case os:getenv("HOME") of 2579 false -> 2580 {error, <<"HOME not set">>}; 2581 Home -> 2582 {ok, unicode:characters_to_binary(Home)} 2583 end. 2584 2585-spec stdout_isatty() -> boolean(). 2586stdout_isatty() -> 2587 case io:columns() of 2588 {ok, _} -> 2589 true; 2590 _ -> 2591 try 2592 case prim_tty:isatty(stdout) of 2593 true -> true; 2594 _ -> false 2595 end 2596 catch 2597 _:_ -> 2598 false 2599 end 2600 end. 2601 2602%% --------------------------------------------------------------------------- 2603%% External commands 2604%% 2605%% Two modes: 2606%% 2607%% 1. `run_cmd/2` — capture stdout/stderr into a binary (pipelines, `let x =`, 2608%% non-TTY display). Prefers a throwaway PTY when color is wanted so tools 2609%% emit ANSI for `cmd | less`; falls back to pipes. 2610%% 2611%% 2. `run_cmd_tty/2` — foreground interactive. Prefer util-linux `script` 2612%% (PTY + key relay via `io:get_chars`) so Ctrl+C can SIGINT the child. 2613%% `erl_child_setup` calls setsid, so the child is never in the terminal's 2614%% foreground process group — kernel SIGINT goes to BEAM, not the child. 2615%% Fallback: inherit real stdio (`nouse_stdio`) when script/TTY is missing. 2616%% 2617%% Auth tools (`sudo`, `run0`, …) also need a controlling TTY; the PTY path 2618%% covers that. Host termios during children: cooked for OPOST/ONLCR (no 2619%% staircase) but ISIG off so Ctrl+C is readable as byte 3 instead of opening 2620%% the Erlang BREAK menu. 2621%% --------------------------------------------------------------------------- 2622 2623-spec run_cmd(binary(), [binary()], binary()) -> 2624 {ok, {integer(), binary()}} | {error, binary()}. 2625run_cmd(Command, Args, Stdin) when is_binary(Command), is_list(Args), is_binary(Stdin) -> 2626 case resolve_cmd(Command, Args) of 2627 {error, _} = E -> 2628 E; 2629 {ok, Path, PortArgs} -> 2630 try 2631 run_cmd_capture(Path, PortArgs, Stdin) 2632 catch 2633 _:Reason -> 2634 {error, reason_to_bin(Reason)} 2635 end 2636 end. 2637 2638%% Foreground interactive: inherit TTY when possible. 2639%% Non-empty Stdin is still fed (temp file + redirect) so `cat f | less` works. 2640%% 2641%% While the REPL uses OTP `{noshell, raw}`, prim_tty leaves termios with 2642%% OPOST/ONLCR off so a bare LF does not return the cursor to column 0. 2643%% Tools that write LF-only lines (fastfetch, many TUIs) look staircased if 2644%% they inherit that TTY. Wrap inherit/PTY runs in cooked mode and restore 2645%% raw afterwards (same idea as println/1 converting to CRLF for shell text). 2646-spec run_cmd_tty(binary(), [binary()], binary()) -> 2647 {ok, {integer(), binary()}} | {error, binary()}. 2648run_cmd_tty(Command, Args, Stdin) when is_binary(Command), is_list(Args), is_binary(Stdin) -> 2649 case resolve_cmd(Command, Args) of 2650 {error, _} = E -> 2651 E; 2652 {ok, Path, PortArgs} -> 2653 try 2654 case stdout_isatty() of 2655 false -> 2656 run_cmd_capture(Path, PortArgs, Stdin); 2657 true -> 2658 with_cooked_tty(fun() -> 2659 %% PTY for all interactive when possible: key relay 2660 %% sees Ctrl+C and can SIGINT the child process group. 2661 case {os:find_executable("script"), find_tty_path()} of 2662 {Script, {ok, Tty}} when is_list(Script) -> 2663 run_cmd_pty(Script, Path, PortArgs, Tty, Stdin); 2664 _ -> 2665 run_cmd_inherit(Path, PortArgs, Stdin) 2666 end 2667 end) 2668 end 2669 catch 2670 _:Reason -> 2671 {error, reason_to_bin(Reason)} 2672 end 2673 end. 2674 2675%% Temporarily put the controlling TTY into cooked output + non-canonical 2676%% input for an external child, then restore previous termios (raw REPL). 2677%% 2678%% Applied whenever stdout is a TTY (not only raw REPL): -c under a terminal 2679%% still needs -isig/-icanon so Ctrl+C is a readable byte for the interrupt 2680%% path. No-op when stty/TTY is unavailable. 2681with_cooked_tty(Fun) when is_function(Fun, 0) -> 2682 case stdout_isatty() of 2683 false -> 2684 Fun(); 2685 true -> 2686 case stty_save() of 2687 undefined -> 2688 %% Still try to apply host flags; restore is best-effort. 2689 stty_sane(), 2690 try 2691 Fun() 2692 after 2693 ok 2694 end; 2695 Saved -> 2696 stty_sane(), 2697 try 2698 Fun() 2699 after 2700 stty_restore(Saved) 2701 end 2702 end 2703 end. 2704 2705stty_save() -> 2706 case stty_run(["-g"]) of 2707 {ok, Out} -> 2708 case string:trim(Out, both, [$\s, $\t, $\n, $\r]) of 2709 "" -> 2710 undefined; 2711 Settings -> 2712 %% stty -g is a single token of colon-separated hex flags. 2713 Settings 2714 end; 2715 _ -> 2716 undefined 2717 end. 2718 2719stty_sane() -> 2720 %% Host TTY while an external runs under the raw REPL: 2721 %% - sane / opost / onlcr: LF→CRLF so children don't staircase 2722 %% - -isig: Ctrl+C is byte 3 (not kernel SIGINT → BEAM BREAK menu) 2723 %% - -icanon min 1 time 0: deliver each byte immediately — with ICANON 2724 %% left on, Ctrl+C sits in the line buffer until Enter and our key 2725 %% relay never sees it (external freezes; second ^C looks wedged) 2726 %% - -echo: host must not echo keys we relay into the child PTY 2727 %% - -icrnl: keep Enter as CR (0x0D). `sane` enables ICRNL, which turns 2728 %% Enter into NL (0x0A). Relayed into a raw-mode child (hx/vim/…), 2729 %% crossterm treats 0x0A as Ctrl+J → Char('j'), so `:wq` + Enter 2730 %% inserts `j` instead of submitting the command. 2731 _ = stty_run([ 2732 "sane", 2733 "-isig", 2734 "-icanon", 2735 "min", 2736 "1", 2737 "time", 2738 "0", 2739 "-echo", 2740 "-icrnl" 2741 ]), 2742 ok. 2743 2744stty_restore(Settings) when is_list(Settings) -> 2745 _ = stty_run([Settings]), 2746 ok; 2747stty_restore(_) -> 2748 ok. 2749 2750%% Run stty against the real terminal device (not a pipe). Prefer the pts 2751%% path from /proc (same as sudo/PTY path), then `/dev/tty`. 2752%% 2753%% NOTE: do not use filelib:is_file/1 for `/dev/tty` — it is a device node, 2754%% so is_file returns false and would skip stty entirely (fastfetch staircase). 2755stty_run(Args) when is_list(Args) -> 2756 case os:find_executable("stty") of 2757 false -> 2758 {error, no_stty}; 2759 Stty when is_list(Stty) -> 2760 stty_run_on(Stty, Args, stty_devices()) 2761 end. 2762 2763stty_devices() -> 2764 case find_tty_path() of 2765 {ok, Path} -> 2766 %% Prefer the concrete pts; /dev/tty is a fallback alias. 2767 [Path, "/dev/tty"]; 2768 _ -> 2769 ["/dev/tty"] 2770 end. 2771 2772stty_run_on(_Stty, _Args, []) -> 2773 {error, no_tty}; 2774stty_run_on(Stty, Args, [Dev | Rest]) -> 2775 case stty_on_device(Stty, Dev, Args) of 2776 {ok, _} = Ok -> 2777 Ok; 2778 _ -> 2779 stty_run_on(Stty, Args, Rest) 2780 end. 2781 2782stty_on_device(Stty, Dev, Args) when is_list(Stty), is_list(Dev), is_list(Args) -> 2783 try 2784 Port = open_port( 2785 {spawn_executable, Stty}, 2786 [ 2787 binary, 2788 exit_status, 2789 use_stdio, 2790 stderr_to_stdout, 2791 {args, ["-F", Dev | Args]} 2792 ] 2793 ), 2794 %% No interrupt watch — internal helper, must not steal TTY input. 2795 case collect_output_quiet(Port, <<>>, 5000) of 2796 {ok, {0, Bin}} -> 2797 {ok, unicode:characters_to_list(Bin)}; 2798 {ok, {Status, Bin}} -> 2799 {error, {Status, Bin}}; 2800 {error, _} = E -> 2801 E 2802 end 2803 catch 2804 _:_ -> 2805 {error, stty_failed} 2806 end. 2807 2808resolve_cmd(Command, Args) -> 2809 case os:find_executable(unicode:characters_to_list(Command)) of 2810 false -> 2811 {error, <<"command not found: ", Command/binary>>}; 2812 Path -> 2813 PortArgs = [unicode:characters_to_list(A) || A <- Args], 2814 {ok, Path, PortArgs} 2815 end. 2816 2817%% Capture mode: collect stdout/stderr into a binary (pipelines, `let x =`). 2818%% 2819%% When the shell wants color, prefer a throwaway PTY (`script`) so tools that 2820%% only colorize on a TTY (`jj`, `git`, …) still emit ANSI for `cmd | less` — 2821%% without tool-specific env hacks. Nested pagers are forced to `cat` so the 2822%% child cannot hang waiting for interactive `less`. Falls back to plain pipes 2823%% (+ FORCE_COLOR / git overlays) when `script` is missing. 2824%% 2825%% Stdin is redirected via temp file / `$GLESHELL_STDIN` so programs never hang 2826%% on an open-but-never-written Erlang port pipe. 2827run_cmd_capture(Path, PortArgs, Stdin) when is_binary(Stdin) -> 2828 case want_child_color() of 2829 true -> 2830 case os:find_executable("script") of 2831 Script when is_list(Script) -> 2832 run_cmd_capture_pty(Script, Path, PortArgs, Stdin); 2833 _ -> 2834 run_cmd_capture_pipe(Path, PortArgs, Stdin) 2835 end; 2836 false -> 2837 run_cmd_capture_pipe(Path, PortArgs, Stdin) 2838 end. 2839 2840run_cmd_capture_pipe(Path, PortArgs, Stdin) when is_binary(Stdin) -> 2841 with_stdin_file(Stdin, fun(StdinPath) -> 2842 sh_exec(Path, PortArgs, StdinPath, capture) 2843 end). 2844 2845%% PTY capture: child sees a TTY (colors, auto decorations) but we only collect 2846%% output — nothing is relayed to the user's terminal. 2847%% 2848%% Always use a one-shot runner so we can `stty` the slave to the host size. 2849%% `script` PTYs start at 0×0 when Erlang's port is not a real TTY, which makes 2850%% systemctl/less/… truncate and paginate as if the window were tiny. 2851run_cmd_capture_pty(Script, Path, PortArgs, <<>>) -> 2852 with_exec_runner(Path, PortArgs, undefined, fun(Runner) -> 2853 run_cmd_capture_pty_argv(Script, [Runner]) 2854 end); 2855run_cmd_capture_pty(Script, Path, PortArgs, Stdin) when is_binary(Stdin) -> 2856 with_stdin_file(Stdin, fun(StdinPath) -> 2857 with_exec_runner(Path, PortArgs, StdinPath, fun(Runner) -> 2858 run_cmd_capture_pty_argv(Script, [Runner]) 2859 end) 2860 end). 2861 2862run_cmd_capture_pty_argv(Script, Argv) when is_list(Argv) -> 2863 with_trapped_exits(fun() -> 2864 Port = open_port( 2865 {spawn_executable, Script}, 2866 [ 2867 binary, 2868 exit_status, 2869 stderr_to_stdout, 2870 use_stdio, 2871 stream, 2872 {env, child_env_capture_pty()}, 2873 {args, ["-q", "-e", "/dev/null", "--" | Argv]} 2874 ] 2875 ), 2876 put(gleshell_output_shown, false), 2877 try 2878 case collect_output(Port, <<>>) of 2879 {ok, {Status, Acc}} -> 2880 {ok, {Status, normalize_pty_output(Acc)}}; 2881 Other -> 2882 Other 2883 end 2884 after 2885 catch port_close(Port) 2886 end 2887 end). 2888 2889%% Inherit real stdio — pagers/editors talk to the terminal directly. 2890%% LESS=FRX (via child_env) lets less pass ANSI colors from jj/git. 2891%% 2892%% Empty stdin: pure inherit (bare `less` reads the TTY). 2893%% Non-empty stdin: still inherit stdout/stderr TTY, but redirect stdin from 2894%% a temp file so `cat file | less` pages the pipeline data. 2895%% 2896%% Ctrl+C: prefer the PTY path (key relay). Inherit is a fallback when 2897%% `script` is missing — host is -isig/-icanon so Ctrl+C is byte 3; a 2898%% watcher SIGINTs the child's process group (setsid means kernel SIGINT 2899%% never reaches the child even with ISIG on). 2900run_cmd_inherit(Path, PortArgs, Stdin) when is_binary(Stdin) -> 2901 case Stdin of 2902 <<>> -> 2903 Port = open_port( 2904 {spawn_executable, Path}, 2905 [ 2906 exit_status, 2907 nouse_stdio, 2908 {env, child_env()}, 2909 {args, PortArgs} 2910 ] 2911 ), 2912 put(gleshell_output_shown, true), 2913 await_port_exit_interruptible(Port); 2914 _ -> 2915 with_stdin_file(Stdin, fun(StdinPath) -> 2916 sh_exec(Path, PortArgs, StdinPath, inherit) 2917 end) 2918 end. 2919 2920%% PTY + key relay (interactive TTY, sudo/run0, …). 2921%% 2922%% util-linux `script` does NOT exec the argv after `--` directly. It runs 2923%% `$SHELL -c "<joined args>"` (see script(1)). Nested 2924%% `sh -c 'exec "$0" …' path` therefore becomes one mangled shell string and 2925%% the real binary never runs (fastfetch → empty output, exit 0). 2926%% 2927%% Always write a one-shot runner (single path token for `script --`): 2928%% 1. `stty rows/columns` to the host size (PTY otherwise stays 0×0) 2929%% 2. `exec` the real command 2930%% Non-empty stdin (pipeline → less): also redirect stdin from the temp file. 2931%% Empty stdin: leave stdin on the PTY so key relay still reaches the child. 2932run_cmd_pty(Script, Path, PortArgs, TtyPath, <<>>) -> 2933 with_exec_runner(Path, PortArgs, undefined, fun(Runner) -> 2934 run_cmd_pty_argv(Script, [Runner], TtyPath, Path, PortArgs, <<>>) 2935 end); 2936run_cmd_pty(Script, Path, PortArgs, TtyPath, Stdin) when is_binary(Stdin) -> 2937 with_stdin_file(Stdin, fun(StdinPath) -> 2938 with_exec_runner(Path, PortArgs, StdinPath, fun(Runner) -> 2939 run_cmd_pty_argv(Script, [Runner], TtyPath, Path, PortArgs, Stdin) 2940 end) 2941 end). 2942 2943run_cmd_pty_argv(Script, Argv, TtyPath, Path, PortArgs, Stdin) when is_list(Argv) -> 2944 with_trapped_exits(fun() -> 2945 Port = open_port( 2946 {spawn_executable, Script}, 2947 [ 2948 binary, 2949 exit_status, 2950 stderr_to_stdout, 2951 use_stdio, 2952 stream, 2953 {env, child_env()}, 2954 {args, ["-q", "-e", "/dev/null", "--" | Argv]} 2955 ] 2956 ), 2957 case file:open(TtyPath, [write, raw, binary]) of 2958 {ok, TtyOut} -> 2959 put(gleshell_output_shown, true), 2960 case get(gleshell_stdin_mux) of 2961 Mux when is_pid(Mux) -> 2962 %% Prefer the long-lived mux: retarget to Port, never 2963 %% kill a get_chars client (that stole the next ↑). 2964 set_stdin_target({port, Port}), 2965 try 2966 collect_output_relay(Port, TtyOut, <<>>) 2967 after 2968 set_stdin_target(line), 2969 catch file:close(TtyOut), 2970 catch port_close(Port) 2971 end; 2972 _ -> 2973 %% Non-raw / no mux: legacy per-command reader. 2974 GL = group_leader(), 2975 Reader = spawn(fun() -> 2976 group_leader(GL, self()), 2977 io_to_port(Port) 2978 end), 2979 try 2980 collect_output_relay(Port, TtyOut, <<>>) 2981 after 2982 stop_io_client(Reader), 2983 catch file:close(TtyOut), 2984 catch port_close(Port) 2985 end 2986 end; 2987 {error, _} -> 2988 catch port_close(Port), 2989 put(gleshell_output_shown, false), 2990 run_cmd_inherit(Path, PortArgs, Stdin) 2991 end 2992 end). 2993 2994%% One-shot `#!/bin/sh` runner: exec Path with PortArgs, stdin from StdinPath. 2995%% Needed because `script` flattens argv into `$SHELL -c` (no real multi-arg exec). 2996with_exec_runner(Path, PortArgs, StdinPath, Fun) when is_function(Fun, 1) -> 2997 case write_runner_script(Path, PortArgs, StdinPath) of 2998 {ok, Runner} -> 2999 try 3000 Fun(Runner) 3001 after 3002 _ = file:delete(Runner) 3003 end; 3004 {error, Reason} -> 3005 {error, reason_to_bin({runner_script, Reason})} 3006 end. 3007 3008write_runner_script(Path, PortArgs, StdinPath) -> 3009 Dir = 3010 case os:getenv("TMPDIR") of 3011 false -> 3012 "/tmp"; 3013 "" -> 3014 "/tmp"; 3015 D -> 3016 D 3017 end, 3018 Name = 3019 filename:join( 3020 Dir, 3021 "gleshell-run-" ++ integer_to_list(erlang:unique_integer([positive])) 3022 ), 3023 Body = runner_script_body(Path, PortArgs, StdinPath), 3024 case file:write_file(Name, Body) of 3025 ok -> 3026 case file:change_mode(Name, 8#755) of 3027 ok -> 3028 {ok, Name}; 3029 {error, _} = E -> 3030 _ = file:delete(Name), 3031 E 3032 end; 3033 {error, _} = E -> 3034 E 3035 end. 3036 3037%% One-shot runner body. StdinPath = undefined keeps stdin on the PTY (keys); 3038%% a filesystem path redirects stdin (pipeline data). Always size the slave 3039%% first: script(1) PTYs under Erlang ports report 0×0 without this. 3040runner_script_body(CmdPath, PortArgs, StdinPath) -> 3041 {Rows, Cols} = host_term_dims(), 3042 Stty = 3043 case Rows > 0 andalso Cols > 0 of 3044 true -> 3045 [ 3046 "stty rows ", 3047 integer_to_list(Rows), 3048 " columns ", 3049 integer_to_list(Cols), 3050 " 2>/dev/null\n" 3051 ]; 3052 false -> 3053 [] 3054 end, 3055 ArgsQ = [[$\s, shell_single_quote(A)] || A <- PortArgs], 3056 Redirect = 3057 case StdinPath of 3058 undefined -> 3059 []; 3060 Sp when is_list(Sp) -> 3061 [" < ", shell_single_quote(Sp)]; 3062 Sp when is_binary(Sp) -> 3063 [" < ", shell_single_quote(Sp)] 3064 end, 3065 [ 3066 "#!/bin/sh\n", 3067 Stty, 3068 "exec ", 3069 shell_single_quote(CmdPath), 3070 ArgsQ, 3071 Redirect, 3072 "\n" 3073 ]. 3074 3075%% Host terminal dimensions for child PTY `stty` and COLUMNS/LINES. 3076%% Prefer `stty size` on the real TTY device (ground truth). Erlang's 3077%% io:rows/columns often fail outside raw REPL and term_size/0 then returns 3078%% the 24×80 placeholders — which made systemctl truncate wide terminals. 3079host_term_dims() -> 3080 case stty_host_size() of 3081 {ok, R, C} -> 3082 {R, C}; 3083 _ -> 3084 case term_size() of 3085 {ok, {R, C}} when is_integer(R), R > 0, is_integer(C), C > 0 -> 3086 {R, C}; 3087 _ -> 3088 {24, 80} 3089 end 3090 end. 3091 3092%% Parse `stty size` → {ok, Rows, Cols}. Uses the same device probe as stty_run 3093%% (concrete pts, then /dev/tty) so we work even when stdout is a pipe. 3094stty_host_size() -> 3095 case stty_run(["size"]) of 3096 {ok, Out} -> 3097 case string:tokens(string:trim(Out, both, [$\s, $\t, $\n, $\r]), " \t") of 3098 [Rs, Cs] -> 3099 try 3100 R = list_to_integer(Rs), 3101 C = list_to_integer(Cs), 3102 case R > 0 andalso C > 0 of 3103 true -> 3104 {ok, R, C}; 3105 false -> 3106 error 3107 end 3108 catch 3109 _:_ -> 3110 error 3111 end; 3112 _ -> 3113 error 3114 end; 3115 _ -> 3116 error 3117 end. 3118 3119%% Safe single-quoted shell token (`foo'bar` → `'foo'\''bar'`). 3120shell_single_quote(S) when is_list(S) -> 3121 [$' | shell_single_quote_chars(S) ++ "'"]; 3122shell_single_quote(B) when is_binary(B) -> 3123 shell_single_quote(unicode:characters_to_list(B)). 3124 3125shell_single_quote_chars([]) -> 3126 []; 3127shell_single_quote_chars([$' | Rest]) -> 3128 "'\\''" ++ shell_single_quote_chars(Rest); 3129shell_single_quote_chars([C | Rest]) -> 3130 [C | shell_single_quote_chars(Rest)]. 3131 3132find_sh() -> 3133 case os:find_executable("sh") of 3134 false -> 3135 "/bin/sh"; 3136 S -> 3137 S 3138 end. 3139 3140%% Run Path with stdin redirected from StdinPath. 3141%% Mode `capture` uses pipes; `inherit` uses nouse_stdio (real TTY for out/err). 3142sh_exec(Path, PortArgs, StdinPath, capture) -> 3143 Port = open_port( 3144 {spawn_executable, find_sh()}, 3145 [ 3146 binary, 3147 exit_status, 3148 stderr_to_stdout, 3149 use_stdio, 3150 stream, 3151 {env, [{"GLESHELL_STDIN", StdinPath} | child_env()]}, 3152 {args, ["-c", "exec \"$0\" \"$@\" < \"$GLESHELL_STDIN\"", Path | PortArgs]} 3153 ] 3154 ), 3155 put(gleshell_output_shown, false), 3156 collect_output(Port, <<>>); 3157sh_exec(Path, PortArgs, StdinPath, inherit) -> 3158 Port = open_port( 3159 {spawn_executable, find_sh()}, 3160 [ 3161 exit_status, 3162 nouse_stdio, 3163 {env, [{"GLESHELL_STDIN", StdinPath} | child_env()]}, 3164 {args, ["-c", "exec \"$0\" \"$@\" < \"$GLESHELL_STDIN\"", Path | PortArgs]} 3165 ] 3166 ), 3167 put(gleshell_output_shown, true), 3168 await_port_exit_interruptible(Port). 3169 3170%% Provide a filesystem path for stdin bytes; clean up temp files afterwards. 3171with_stdin_file(<<>>, Fun) when is_function(Fun, 1) -> 3172 Fun("/dev/null"); 3173with_stdin_file(Data, Fun) when is_binary(Data), is_function(Fun, 1) -> 3174 case write_stdin_tmp(Data) of 3175 {ok, Path} -> 3176 try 3177 Fun(Path) 3178 after 3179 _ = file:delete(Path) 3180 end; 3181 {error, Reason} -> 3182 {error, reason_to_bin({stdin_tmp, Reason})} 3183 end. 3184 3185write_stdin_tmp(Data) when is_binary(Data) -> 3186 Dir = 3187 case os:getenv("TMPDIR") of 3188 false -> 3189 "/tmp"; 3190 "" -> 3191 "/tmp"; 3192 D -> 3193 D 3194 end, 3195 Name = 3196 filename:join( 3197 Dir, 3198 "gleshell-stdin-" ++ integer_to_list(erlang:unique_integer([positive])) 3199 ), 3200 case file:write_file(Name, Data) of 3201 ok -> 3202 {ok, Name}; 3203 {error, _} = E -> 3204 E 3205 end. 3206 3207%% Relay keypresses from the group leader to the child's PTY (script stdin). 3208%% Used only when the stdin mux is unavailable (non-raw fallback). 3209%% Ctrl+C (ETX / byte 3): SIGINT the child process group, and still write the 3210%% byte so the PTY line discipline can deliver SIGINT on the slave side too. 3211io_to_port(Port) -> 3212 case io:get_chars("", 1) of 3213 eof -> 3214 ok; 3215 {error, _} -> 3216 ok; 3217 Data -> 3218 case io_data_to_bin(Data) of 3219 <<>> -> 3220 io_to_port(Port); 3221 <<3>> = Bin -> 3222 signal_port_group(Port, int), 3223 catch port_command(Port, Bin), 3224 io_to_port(Port); 3225 Bin -> 3226 catch port_command(Port, Bin), 3227 io_to_port(Port) 3228 end 3229 end. 3230 3231%% Kill a short-lived get_chars client and wait until it is gone so the next 3232%% REPL read is less likely to race an orphaned I/O request. 3233stop_io_client(Pid) when is_pid(Pid) -> 3234 case is_process_alive(Pid) of 3235 false -> 3236 ok; 3237 true -> 3238 MRef = erlang:monitor(process, Pid), 3239 exit(Pid, kill), 3240 receive 3241 {'DOWN', MRef, process, Pid, _} -> 3242 ok 3243 after 1000 -> 3244 ok 3245 end 3246 end; 3247stop_io_client(_) -> 3248 ok. 3249 3250io_data_to_bin(Bin) when is_binary(Bin) -> 3251 Bin; 3252io_data_to_bin([C]) when is_integer(C), C >= 0, C =< 16#7F -> 3253 <<C>>; 3254io_data_to_bin(List) when is_list(List) -> 3255 case unicode:characters_to_binary(List) of 3256 Bin when is_binary(Bin) -> 3257 Bin; 3258 _ -> 3259 <<>> 3260 end; 3261io_data_to_bin(_) -> 3262 <<>>. 3263 3264%% When a port's OS process is killed (Ctrl+C → SIGINT), the linked port may 3265%% exit with `epipe` / signal reasons. Without trap_exit the shell process 3266%% dies with "Erlang exit: Epipe" instead of returning to the prompt. 3267with_trapped_exits(Fun) when is_function(Fun, 0) -> 3268 Old = process_flag(trap_exit, true), 3269 try 3270 Fun() 3271 after 3272 process_flag(trap_exit, Old), 3273 drain_exit_msgs() 3274 end. 3275 3276drain_exit_msgs() -> 3277 receive 3278 {'EXIT', _, _} -> 3279 drain_exit_msgs() 3280 after 0 -> 3281 ok 3282 end. 3283 3284%% Inherit path: watch for Ctrl+C (byte 3) and SIGINT the child group. 3285%% Used when `script`/PTY is unavailable; same kill path as the PTY relay. 3286await_port_exit_interruptible(Port) -> 3287 with_trapped_exits(fun() -> 3288 with_interrupt_watch(Port, fun() -> 3289 await_port_exit_interruptible_loop(Port) 3290 end) 3291 end). 3292 3293await_port_exit_interruptible_loop(Port) -> 3294 receive 3295 {Port, {exit_status, Status}} -> 3296 {ok, {Status, <<>>}}; 3297 {'EXIT', Port, _Reason} -> 3298 {ok, {130, <<>>}}; 3299 {gleshell_interrupt, _} -> 3300 signal_port_group(Port, int), 3301 await_port_exit_after_interrupt(Port, 2000) 3302 end. 3303 3304await_port_exit_after_interrupt(Port, GraceMs) -> 3305 receive 3306 {Port, {exit_status, Status}} -> 3307 {ok, {Status, <<>>}}; 3308 {'EXIT', Port, _Reason} -> 3309 {ok, {130, <<>>}}; 3310 {gleshell_interrupt, _} -> 3311 signal_port_group(Port, kill), 3312 await_port_exit_after_interrupt(Port, 1000) 3313 after GraceMs -> 3314 signal_port_group(Port, kill), 3315 catch port_close(Port), 3316 receive 3317 {Port, {exit_status, Status}} -> 3318 {ok, {Status, <<>>}}; 3319 {'EXIT', Port, _} -> 3320 {ok, {130, <<>>}} 3321 after 1000 -> 3322 {ok, {130, <<>>}} 3323 end 3324 end. 3325 3326collect_output(Port, Acc) -> 3327 with_trapped_exits(fun() -> 3328 with_interrupt_watch(Port, fun() -> 3329 collect_output_loop(Port, Acc, 120_000) 3330 end) 3331 end). 3332 3333collect_output_loop(Port, Acc, Timeout) -> 3334 receive 3335 {Port, {data, Data}} when is_binary(Data) -> 3336 collect_output_loop(Port, <<Acc/binary, Data/binary>>, Timeout); 3337 {Port, {data, Data}} when is_list(Data) -> 3338 Bin = unicode:characters_to_binary(Data), 3339 collect_output_loop(Port, <<Acc/binary, Bin/binary>>, Timeout); 3340 {Port, {exit_status, Status}} -> 3341 {ok, {Status, Acc}}; 3342 {'EXIT', Port, _Reason} -> 3343 {ok, {130, Acc}}; 3344 {gleshell_interrupt, _} -> 3345 signal_port_group(Port, int), 3346 collect_output_after_interrupt(Port, Acc, 2000) 3347 after Timeout -> 3348 signal_port_group(Port, term), 3349 catch port_close(Port), 3350 {error, <<"command timed out after 120s">>} 3351 end. 3352 3353collect_output_after_interrupt(Port, Acc, GraceMs) -> 3354 receive 3355 {Port, {data, Data}} when is_binary(Data) -> 3356 collect_output_after_interrupt( 3357 Port, <<Acc/binary, Data/binary>>, GraceMs 3358 ); 3359 {Port, {data, Data}} when is_list(Data) -> 3360 Bin = unicode:characters_to_binary(Data), 3361 collect_output_after_interrupt( 3362 Port, <<Acc/binary, Bin/binary>>, GraceMs 3363 ); 3364 {Port, {exit_status, Status}} -> 3365 {ok, {Status, Acc}}; 3366 {'EXIT', Port, _Reason} -> 3367 {ok, {130, Acc}}; 3368 {gleshell_interrupt, _} -> 3369 signal_port_group(Port, kill), 3370 collect_output_after_interrupt(Port, Acc, 1000) 3371 after GraceMs -> 3372 signal_port_group(Port, kill), 3373 catch port_close(Port), 3374 receive 3375 {Port, {data, Data}} when is_binary(Data) -> 3376 collect_output_after_interrupt( 3377 Port, <<Acc/binary, Data/binary>>, 500 3378 ); 3379 {Port, {data, Data}} when is_list(Data) -> 3380 Bin = unicode:characters_to_binary(Data), 3381 collect_output_after_interrupt( 3382 Port, <<Acc/binary, Bin/binary>>, 500 3383 ); 3384 {Port, {exit_status, Status}} -> 3385 {ok, {Status, Acc}}; 3386 {'EXIT', Port, _} -> 3387 {ok, {130, Acc}} 3388 after 1000 -> 3389 {ok, {130, Acc}} 3390 end 3391 end. 3392 3393%% PTY session: no timeout (password prompts, long pagers, etc.). 3394%% Ctrl+C is handled in io_to_port/1 (SIGINT); also accept interrupt msgs. 3395%% Caller must run under with_trapped_exits/1 so port epipe is not fatal. 3396collect_output_relay(Port, Tty, Acc) -> 3397 receive 3398 {Port, {data, Data}} when is_binary(Data) -> 3399 _ = file:write(Tty, Data), 3400 collect_output_relay(Port, Tty, <<Acc/binary, Data/binary>>); 3401 {Port, {data, Data}} when is_list(Data) -> 3402 Bin = unicode:characters_to_binary(Data), 3403 _ = file:write(Tty, Bin), 3404 collect_output_relay(Port, Tty, <<Acc/binary, Bin/binary>>); 3405 {Port, {exit_status, Status}} -> 3406 %% Retarget stdin immediately so the first post-command key is not 3407 %% relayed into the dead Port (looked like empty ↑ history). 3408 set_stdin_target(line), 3409 {ok, {Status, normalize_pty_output(Acc)}}; 3410 {'EXIT', Port, _Reason} -> 3411 set_stdin_target(line), 3412 {ok, {130, normalize_pty_output(Acc)}}; 3413 {gleshell_interrupt, _} -> 3414 signal_port_group(Port, int), 3415 collect_output_relay_after_interrupt(Port, Tty, Acc, 2000) 3416 end. 3417 3418collect_output_relay_after_interrupt(Port, Tty, Acc, GraceMs) -> 3419 receive 3420 {Port, {data, Data}} when is_binary(Data) -> 3421 _ = file:write(Tty, Data), 3422 collect_output_relay_after_interrupt( 3423 Port, Tty, <<Acc/binary, Data/binary>>, GraceMs 3424 ); 3425 {Port, {data, Data}} when is_list(Data) -> 3426 Bin = unicode:characters_to_binary(Data), 3427 _ = file:write(Tty, Bin), 3428 collect_output_relay_after_interrupt( 3429 Port, Tty, <<Acc/binary, Bin/binary>>, GraceMs 3430 ); 3431 {Port, {exit_status, Status}} -> 3432 set_stdin_target(line), 3433 {ok, {Status, normalize_pty_output(Acc)}}; 3434 {'EXIT', Port, _Reason} -> 3435 set_stdin_target(line), 3436 {ok, {130, normalize_pty_output(Acc)}}; 3437 {gleshell_interrupt, _} -> 3438 signal_port_group(Port, kill), 3439 collect_output_relay_after_interrupt(Port, Tty, Acc, 1000) 3440 after GraceMs -> 3441 signal_port_group(Port, kill), 3442 catch port_close(Port), 3443 receive 3444 {Port, {exit_status, Status}} -> 3445 set_stdin_target(line), 3446 {ok, {Status, normalize_pty_output(Acc)}}; 3447 {'EXIT', Port, _} -> 3448 set_stdin_target(line), 3449 {ok, {130, normalize_pty_output(Acc)}} 3450 after 1000 -> 3451 set_stdin_target(line), 3452 {ok, {130, normalize_pty_output(Acc)}} 3453 end 3454 end. 3455 3456%% --------------------------------------------------------------------------- 3457%% Ctrl+C / SIGINT forwarding 3458%% 3459%% BEAM's open_port → erl_child_setup → setsid, so the child is not in the 3460%% terminal foreground group. Host ISIG is left off while a child runs; we 3461%% watch for byte 3 (ETX) and kill(-pid, SIGINT) on the child's process group. 3462%% --------------------------------------------------------------------------- 3463 3464with_interrupt_watch(_Port, Fun) when is_function(Fun, 0) -> 3465 Parent = self(), 3466 case {can_watch_interrupt(), get(gleshell_stdin_mux)} of 3467 {true, Mux} when is_pid(Mux) -> 3468 %% Raw REPL: retarget the mux (no competing get_chars process). 3469 set_stdin_target({interrupt, Parent}), 3470 try 3471 Fun() 3472 after 3473 set_stdin_target(line), 3474 drain_interrupt_msgs() 3475 end; 3476 {true, _} -> 3477 GL = group_leader(), 3478 Watcher = 3479 spawn(fun() -> 3480 group_leader(GL, self()), 3481 interrupt_watch_loop(Parent) 3482 end), 3483 try 3484 Fun() 3485 after 3486 stop_io_client(Watcher), 3487 drain_interrupt_msgs() 3488 end; 3489 {false, _} -> 3490 Fun() 3491 end. 3492 3493%% Collect port output without Ctrl+C watching (stty and other helpers). 3494collect_output_quiet(Port, Acc, Timeout) -> 3495 receive 3496 {Port, {data, Data}} when is_binary(Data) -> 3497 collect_output_quiet(Port, <<Acc/binary, Data/binary>>, Timeout); 3498 {Port, {data, Data}} when is_list(Data) -> 3499 Bin = unicode:characters_to_binary(Data), 3500 collect_output_quiet(Port, <<Acc/binary, Bin/binary>>, Timeout); 3501 {Port, {exit_status, Status}} -> 3502 {ok, {Status, Acc}} 3503 after Timeout -> 3504 catch port_close(Port), 3505 {error, <<"command timed out">>} 3506 end. 3507 3508%% Watch when the REPL owns the TTY (raw mode) or stdin is a terminal. 3509can_watch_interrupt() -> 3510 case get(gleshell_raw) of 3511 true -> 3512 true; 3513 _ -> 3514 stdout_isatty() 3515 end. 3516 3517interrupt_watch_loop(Parent) when is_pid(Parent) -> 3518 case catch io:get_chars("", 1) of 3519 eof -> 3520 ok; 3521 {error, _} -> 3522 ok; 3523 {'EXIT', _} -> 3524 ok; 3525 Data -> 3526 case io_data_to_bin(Data) of 3527 <<3>> -> 3528 Parent ! {gleshell_interrupt, self()}, 3529 interrupt_watch_loop(Parent); 3530 _ -> 3531 %% Non-Ctrl+C: discard here (capture mode). Interactive 3532 %% PTY uses io_to_port instead; inherit prefers PTY. 3533 interrupt_watch_loop(Parent) 3534 end 3535 end. 3536 3537drain_interrupt_msgs() -> 3538 receive 3539 {gleshell_interrupt, _} -> 3540 drain_interrupt_msgs() 3541 after 0 -> 3542 ok 3543 end. 3544 3545%% SIGINT/SIGTERM/SIGKILL the port's OS process group (setsid → pgid = pid). 3546signal_port_group(Port, Sig) when is_port(Port) -> 3547 case erlang:port_info(Port, os_pid) of 3548 {os_pid, Pid} when is_integer(Pid), Pid > 0 -> 3549 kill_os_group(Pid, Sig); 3550 _ -> 3551 ok 3552 end. 3553 3554kill_os_group(Pid, Sig) when is_integer(Pid) -> 3555 Kill = 3556 case os:find_executable("kill") of 3557 false -> 3558 "kill"; 3559 K -> 3560 K 3561 end, 3562 SigArg = 3563 case Sig of 3564 int -> 3565 "-INT"; 3566 term -> 3567 "-TERM"; 3568 kill -> 3569 "-KILL" 3570 end, 3571 %% Negative pid → process group (child is session/group leader after setsid). 3572 Pg = "-" ++ integer_to_list(Pid), 3573 Single = integer_to_list(Pid), 3574 _ = kill_once(Kill, [SigArg, Pg]), 3575 _ = kill_once(Kill, [SigArg, Single]), 3576 ok. 3577 3578kill_once(Kill, Args) -> 3579 try 3580 Port = open_port( 3581 {spawn_executable, Kill}, 3582 [exit_status, nouse_stdio, {args, Args}] 3583 ), 3584 receive 3585 {Port, {exit_status, _}} -> 3586 ok 3587 after 1000 -> 3588 catch port_close(Port), 3589 ok 3590 end 3591 catch 3592 _:_ -> 3593 ok 3594 end. 3595 3596%% PTY line discipline often emits CR-LF; normalize to LF for structured use. 3597normalize_pty_output(Bin) when is_binary(Bin) -> 3598 binary:replace(Bin, <<"\r\n">>, <<"\n">>, [global]). 3599 3600%% Extra env for external commands (merged into the process environment). 3601%% 3602%% - SHELL=/bin/sh: `script` invokes $SHELL; nu/fish break `script -c`. 3603%% - LESS=FRX when unset: external pagers pass through ANSI (-R) and exit on 3604%% short output (-F) without clearing the screen (-X). 3605%% - When the shell wants color and the child has no real TTY (pipe capture 3606%% fallback): FORCE_COLOR / CLICOLOR_FORCE, plus git GIT_CONFIG_* overlays 3607%% (git ignores FORCE_COLOR; decorate=auto drops ref names on pipes). 3608%% Prefer PTY capture (`child_env_capture_pty`) so jj/git/etc. colorize 3609%% naturally for `cmd | less` without per-tool config. 3610child_env() -> 3611 Env0 = child_env_base(), 3612 case want_child_color() of 3613 false -> 3614 Env0; 3615 true -> 3616 force_git_tty_env(force_color_env(Env0)) 3617 end. 3618 3619%% PTY capture env: color via TTY detection; never nest an interactive pager. 3620child_env_capture_pty() -> 3621 disable_nested_pagers(force_color_env(child_env_base())). 3622 3623child_env_base() -> 3624 {Rows, Cols} = host_term_dims(), 3625 %% SHELL=/bin/sh: script(1) invokes $SHELL -c; nu/fish break that. 3626 %% COLUMNS/LINES: tools that skip TIOCGWINSZ (or see a 0×0 PTY before 3627 %% our runner stty) still format to the host width. 3628 Env0 = [ 3629 {"SHELL", "/bin/sh"}, 3630 {"COLUMNS", integer_to_list(Cols)}, 3631 {"LINES", integer_to_list(Rows)} 3632 ], 3633 case os:getenv("LESS") of 3634 false -> 3635 [{"LESS", "FRX"} | Env0]; 3636 "" -> 3637 [{"LESS", "FRX"} | Env0]; 3638 _ -> 3639 Env0 3640 end. 3641 3642%% FORCE_COLOR / CLICOLOR_FORCE for children that honor them. 3643force_color_env(Env) -> 3644 Env1 = 3645 case os:getenv("FORCE_COLOR") of 3646 false -> 3647 [{"FORCE_COLOR", "1"} | Env]; 3648 "0" -> 3649 Env; 3650 _ -> 3651 Env 3652 end, 3653 case os:getenv("CLICOLOR_FORCE") of 3654 false -> 3655 [{"CLICOLOR_FORCE", "1"} | Env1]; 3656 "0" -> 3657 Env1; 3658 _ -> 3659 Env1 3660 end. 3661 3662%% Capture must not hang inside the child's own pager (git/jj → less). 3663%% Always override for PTY capture; interactive `run_cmd_tty` uses child_env/0. 3664disable_nested_pagers(Env) -> 3665 [ 3666 {"PAGER", "cat"}, 3667 {"GIT_PAGER", "cat"}, 3668 {"JJ_PAGER", "cat"}, 3669 {"SYSTEMD_PAGER", "cat"}, 3670 {"MANPAGER", "cat"} 3671 | Env 3672 ]. 3673 3674%% Pipe-capture fallback only (git ≥ 2.31 GIT_CONFIG_*). Skipped when the 3675%% caller already set GIT_CONFIG_COUNT so we do not clobber. 3676%% 3677%% - color.ui=always: git ignores FORCE_COLOR 3678%% - log.decorate=short: default auto hides decorations on non-TTY stdout 3679force_git_tty_env(Env) -> 3680 case os:getenv("GIT_CONFIG_COUNT") of 3681 false -> 3682 [ 3683 {"GIT_CONFIG_COUNT", "2"}, 3684 {"GIT_CONFIG_KEY_0", "color.ui"}, 3685 {"GIT_CONFIG_VALUE_0", "always"}, 3686 {"GIT_CONFIG_KEY_1", "log.decorate"}, 3687 {"GIT_CONFIG_VALUE_1", "short"} 3688 | Env 3689 ]; 3690 _ -> 3691 Env 3692 end. 3693 3694%% Match gleshell color policy: off under NO_COLOR; otherwise on for a TTY 3695%% or when the parent already forces color. 3696want_child_color() -> 3697 case os:getenv("NO_COLOR") of 3698 L when is_list(L), L =/= "" -> 3699 false; 3700 _ -> 3701 case stdout_isatty() of 3702 true -> 3703 true; 3704 false -> 3705 force_color_set() 3706 end 3707 end. 3708 3709force_color_set() -> 3710 case os:getenv("FORCE_COLOR") of 3711 L when is_list(L), L =/= "", L =/= "0" -> 3712 true; 3713 _ -> 3714 case os:getenv("CLICOLOR_FORCE") of 3715 L when is_list(L), L =/= "", L =/= "0" -> 3716 true; 3717 _ -> 3718 false 3719 end 3720 end. 3721 3722%% True when the last external command already streamed output to the TTY 3723%% (PTY relay). Cleared after being read so the REPL does not double-print. 3724-spec take_output_shown() -> boolean(). 3725take_output_shown() -> 3726 case erase(gleshell_output_shown) of 3727 true -> true; 3728 _ -> false 3729 end. 3730 3731-spec clear_output_shown() -> nil. 3732clear_output_shown() -> 3733 erase(gleshell_output_shown), 3734 nil. 3735 3736%% Find a terminal device attached to this BEAM (or an ancestor). 3737%% Note: os:getpid() returns a string, not an integer. 3738find_tty_path() -> 3739 case catch list_to_integer(os:getpid()) of 3740 Pid when is_integer(Pid) -> 3741 case tty_path_for_pid(Pid) of 3742 {ok, _} = Ok -> 3743 Ok; 3744 error -> 3745 walk_parent_tty(Pid, 12) 3746 end; 3747 _ -> 3748 error 3749 end. 3750 3751walk_parent_tty(_Pid, 0) -> 3752 error; 3753walk_parent_tty(Pid, N) when is_integer(Pid), Pid > 1 -> 3754 case parent_pid(Pid) of 3755 {ok, Parent} when Parent > 1, Parent =/= Pid -> 3756 case tty_path_for_pid(Parent) of 3757 {ok, _} = Ok -> 3758 Ok; 3759 error -> 3760 walk_parent_tty(Parent, N - 1) 3761 end; 3762 _ -> 3763 error 3764 end; 3765walk_parent_tty(_, _) -> 3766 error. 3767 3768tty_path_for_pid(Pid) when is_integer(Pid) -> 3769 case read_tty_nr(Pid) of 3770 {ok, 0} -> 3771 error; 3772 {ok, TtyNr} -> 3773 tty_nr_to_path(TtyNr); 3774 error -> 3775 error 3776 end. 3777 3778read_tty_nr(Pid) when is_integer(Pid) -> 3779 case file:read_file("/proc/" ++ integer_to_list(Pid) ++ "/stat") of 3780 {ok, Bin} -> 3781 case parse_stat_tty_nr(binary_to_list(Bin)) of 3782 {ok, N} -> {ok, N}; 3783 error -> error 3784 end; 3785 _ -> 3786 error 3787 end. 3788%% /proc/pid/stat: "pid (comm) state ppid pgrp session tty_nr ..." 3789parse_stat_tty_nr(List) -> 3790 case lists:splitwith(fun(C) -> C =/= $) end, List) of 3791 {_, [$) | Rest0]} -> 3792 Rest = string:trim(Rest0, leading), 3793 Fields = string:tokens(Rest, " "), 3794 %% After ')': state, ppid, pgrp, session, tty_nr → index 5 3795 case length(Fields) >= 5 of 3796 true -> 3797 try 3798 {ok, list_to_integer(lists:nth(5, Fields))} 3799 catch 3800 _:_ -> error 3801 end; 3802 false -> 3803 error 3804 end; 3805 _ -> 3806 error 3807 end. 3808 3809parent_pid(Pid) -> 3810 case file:read_file("/proc/" ++ integer_to_list(Pid) ++ "/stat") of 3811 {ok, Bin} -> 3812 case parse_stat_ppid(binary_to_list(Bin)) of 3813 {ok, P} -> {ok, P}; 3814 error -> error 3815 end; 3816 _ -> 3817 error 3818 end. 3819 3820parse_stat_ppid(List) -> 3821 case lists:splitwith(fun(C) -> C =/= $) end, List) of 3822 {_, [$) | Rest0]} -> 3823 Rest = string:trim(Rest0, leading), 3824 Fields = string:tokens(Rest, " "), 3825 %% After ')': state, ppid → index 2 3826 case length(Fields) >= 2 of 3827 true -> 3828 try 3829 {ok, list_to_integer(lists:nth(2, Fields))} 3830 catch 3831 _:_ -> error 3832 end; 3833 false -> 3834 error 3835 end; 3836 _ -> 3837 error 3838 end. 3839 3840%% Decode Linux tty_nr (see drivers/tty/tty_io.c / procfs) to a device path. 3841tty_nr_to_path(0) -> 3842 error; 3843tty_nr_to_path(TtyNr) when is_integer(TtyNr) -> 3844 Major = (TtyNr bsr 8) band 16#ff, 3845 Minor = (TtyNr band 16#ff) bor (((TtyNr bsr 20) band 16#fff) bsl 8), 3846 Path = 3847 case Major of 3848 136 -> 3849 "/dev/pts/" ++ integer_to_list(Minor); 3850 4 when Minor >= 64 -> 3851 "/dev/ttyS" ++ integer_to_list(Minor - 64); 3852 4 -> 3853 "/dev/tty" ++ integer_to_list(Minor); 3854 _ -> 3855 undefined 3856 end, 3857 case Path of 3858 undefined -> 3859 error; 3860 _ -> 3861 case file:read_file_info(Path) of 3862 {ok, _} -> {ok, Path}; 3863 _ -> error 3864 end 3865 end. 3866 3867reason_to_bin(Reason) when is_atom(Reason) -> 3868 atom_to_binary(Reason, utf8); 3869reason_to_bin(Reason) when is_binary(Reason) -> 3870 Reason; 3871reason_to_bin(Reason) -> 3872 iolist_to_binary(io_lib:format("~p", [Reason])). 3873 3874%% --------------------------------------------------------------------------- 3875%% Current Unix epoch seconds (UTC). Used by the `now` builtin. 3876%% --------------------------------------------------------------------------- 3877 3878-spec unix_now() -> integer(). 3879unix_now() -> 3880 os:system_time(second). 3881 3882%% --------------------------------------------------------------------------- 3883%% Format Unix epoch seconds as local calendar time: 3884%% "Jul 3 2026 9:39:40 PM" (abbreviated month, 12-hour clock). 3885%% Used for `ls` modified column display (data stays as raw Int). 3886%% --------------------------------------------------------------------------- 3887 3888-spec format_unix_local(integer()) -> binary(). 3889format_unix_local(Seconds) when is_integer(Seconds) -> 3890 try 3891 {{Y, Mo, D}, {H, Mi, S}} = 3892 calendar:system_time_to_local_time(Seconds, second), 3893 {H12, AmPm} = to_12h(H), 3894 iolist_to_binary( 3895 io_lib:format( 3896 "~s ~B ~4..0B ~B:~2..0B:~2..0B ~s", 3897 [month_abbr(Mo), D, Y, H12, Mi, S, AmPm] 3898 ) 3899 ) 3900 catch 3901 _:_ -> 3902 integer_to_binary(Seconds) 3903 end; 3904format_unix_local(_) -> 3905 <<"0">>. 3906 3907%% 0 → 12 AM, 1–11 → AM, 12 → 12 PM, 13–23 → 1–11 PM 3908to_12h(0) -> {12, "AM"}; 3909to_12h(H) when H < 12 -> {H, "AM"}; 3910to_12h(12) -> {12, "PM"}; 3911to_12h(H) -> {H - 12, "PM"}. 3912 3913month_abbr(1) -> "Jan"; 3914month_abbr(2) -> "Feb"; 3915month_abbr(3) -> "Mar"; 3916month_abbr(4) -> "Apr"; 3917month_abbr(5) -> "May"; 3918month_abbr(6) -> "Jun"; 3919month_abbr(7) -> "Jul"; 3920month_abbr(8) -> "Aug"; 3921month_abbr(9) -> "Sep"; 3922month_abbr(10) -> "Oct"; 3923month_abbr(11) -> "Nov"; 3924month_abbr(12) -> "Dec"; 3925month_abbr(_) -> "???". 3926 3927%% --------------------------------------------------------------------------- 3928%% ps: list system processes (Nushell-compatible columns, Linux /proc) 3929%% --------------------------------------------------------------------------- 3930%% 3931%% Returns a list of 17-tuples, one per process (numeric /proc entries that 3932%% can still be read after a short CPU sample interval): 3933%% 3934%% {Pid, Ppid, Name, Status, Cpu, Mem, Virtual, Command, StartTime, 3935%% UserId, ProcessGroupId, SessionId, Priority, ProcessThreads, 3936%% Working, Paged, Cwd} 3937%% 3938%% Integers are bytes for mem/virtual/working/paged; StartTime is Unix 3939%% seconds (0 if unknown). Cpu is percent of one core over ~100ms, like Nu. 3940%% 3941-spec list_processes() -> 3942 list({ 3943 integer(), 3944 integer(), 3945 binary(), 3946 binary(), 3947 float(), 3948 integer(), 3949 integer(), 3950 binary(), 3951 integer(), 3952 integer(), 3953 integer(), 3954 integer(), 3955 integer(), 3956 integer(), 3957 integer(), 3958 integer(), 3959 binary() 3960 }). 3961list_processes() -> 3962 case os:type() of 3963 {unix, linux} -> 3964 list_processes_linux(); 3965 _ -> 3966 %% Other OSes: empty table rather than crash; caller still gets 3967 %% a valid table shape from the Gleam side when needed. 3968 [] 3969 end. 3970 3971list_processes_linux() -> 3972 Ticks = clk_tck(), 3973 PageSize = page_size(), 3974 Boot = boot_time_seconds(), 3975 Base = snapshot_cpu_times(), 3976 timer:sleep(100), 3977 IntervalMs = 100.0, 3978 case file:list_dir("/proc") of 3979 {ok, Entries} -> 3980 lists:filtermap( 3981 fun(Entry) -> 3982 case is_pid_name(Entry) of 3983 false -> 3984 false; 3985 true -> 3986 Pid = list_to_integer(Entry), 3987 case read_process(Pid, Ticks, PageSize, Boot, Base, IntervalMs) of 3988 {ok, Row} -> 3989 {true, Row}; 3990 error -> 3991 false 3992 end 3993 end 3994 end, 3995 Entries 3996 ); 3997 {error, _} -> 3998 [] 3999 end. 4000 4001is_pid_name([]) -> 4002 false; 4003is_pid_name(Name) -> 4004 lists:all(fun(C) -> C >= $0 andalso C =< $9 end, Name). 4005 4006%% Map pid -> total jiffies (utime+stime) from first snapshot. 4007snapshot_cpu_times() -> 4008 case file:list_dir("/proc") of 4009 {ok, Entries} -> 4010 lists:foldl( 4011 fun(Entry, Acc) -> 4012 case is_pid_name(Entry) of 4013 false -> 4014 Acc; 4015 true -> 4016 Pid = list_to_integer(Entry), 4017 case read_stat_cpu(Pid) of 4018 {ok, Total} -> 4019 Acc#{Pid => Total}; 4020 error -> 4021 Acc 4022 end 4023 end 4024 end, 4025 #{}, 4026 Entries 4027 ); 4028 {error, _} -> 4029 #{} 4030 end. 4031 4032read_stat_cpu(Pid) -> 4033 case read_stat_fields(Pid) of 4034 {ok, Fields} -> 4035 U = maps:get(utime, Fields, 0), 4036 S = maps:get(stime, Fields, 0), 4037 {ok, U + S}; 4038 error -> 4039 error 4040 end. 4041 4042read_process(Pid, Ticks, PageSize, Boot, Base, IntervalMs) -> 4043 case read_stat_fields(Pid) of 4044 {ok, Fields} -> 4045 U = maps:get(utime, Fields, 0), 4046 S = maps:get(stime, Fields, 0), 4047 Total = U + S, 4048 Prev = maps:get(Pid, Base, Total), 4049 Delta = max(0, Total - Prev), 4050 %% usage_ms = delta_jiffies * 1000 / ticks; percent of one core 4051 UsageMs = 4052 case Ticks > 0 of 4053 true -> 4054 Delta * 1000 / Ticks; 4055 false -> 4056 0.0 4057 end, 4058 Cpu = 4059 case IntervalMs > 0.0 of 4060 true -> 4061 UsageMs * 100.0 / IntervalMs; 4062 false -> 4063 0.0 4064 end, 4065 Status = status_name(maps:get(state, Fields, $?)), 4066 Name = maps:get(comm, Fields, <<>>), 4067 Ppid = maps:get(ppid, Fields, 0), 4068 Pgrp = maps:get(pgrp, Fields, 0), 4069 Session = maps:get(session, Fields, 0), 4070 Priority = maps:get(priority, Fields, 0), 4071 Threads = maps:get(num_threads, Fields, 0), 4072 Vsize = maps:get(vsize, Fields, 0), 4073 RssPages = maps:get(rss, Fields, 0), 4074 MemFromStat = RssPages * PageSize, 4075 StartJiffies = maps:get(starttime, Fields, 0), 4076 StartTime = 4077 case Boot > 0 andalso Ticks > 0 of 4078 true -> 4079 Boot + StartJiffies div Ticks; 4080 false -> 4081 0 4082 end, 4083 {Mem, Working, Paged, Virtual, UserId} = read_status_mem(Pid, MemFromStat, Vsize), 4084 Command = read_cmdline(Pid, Name), 4085 Cwd = read_cwd(Pid), 4086 %% Gleam `ProcessInfo` constructor → Erlang `{process_info, ...}`. 4087 {ok, 4088 {process_info, Pid, Ppid, Name, Status, float(Cpu), Mem, Virtual, 4089 Command, StartTime, UserId, Pgrp, Session, Priority, Threads, 4090 Working, Paged, Cwd}}; 4091 error -> 4092 error 4093 end. 4094 4095%% Parse /proc/<pid>/stat. Comm is between the first '(' and the matching ") ". 4096read_stat_fields(Pid) -> 4097 Path = "/proc/" ++ integer_to_list(Pid) ++ "/stat", 4098 case file:read_file(Path) of 4099 {ok, Bin0} -> 4100 Bin = string:trim(Bin0, trailing, [$\n]), 4101 case binary:split(Bin, <<"(">>) of 4102 [_PidBin, Rest] -> 4103 case binary:match(Rest, <<") ">>) of 4104 {Pos, 2} -> 4105 Comm = binary:part(Rest, 0, Pos), 4106 After = binary:part(Rest, Pos + 2, byte_size(Rest) - Pos - 2), 4107 Fs = binary:split(After, <<" ">>, [global]), 4108 %% Indices after state (0-based): see proc(5) 4109 %% 0 state, 1 ppid, 2 pgrp, 3 session, 4110 %% 11 utime, 12 stime, 15 priority, 17 num_threads, 4111 %% 19 starttime, 20 vsize, 21 rss 4112 try 4113 StateBin = nth_bin(Fs, 1), 4114 State = 4115 case StateBin of 4116 <<C, _/binary>> -> 4117 C; 4118 <<>> -> 4119 $?; 4120 _ -> 4121 $? 4122 end, 4123 {ok, #{ 4124 comm => Comm, 4125 state => State, 4126 ppid => nth_int(Fs, 2), 4127 pgrp => nth_int(Fs, 3), 4128 session => nth_int(Fs, 4), 4129 utime => nth_int(Fs, 12), 4130 stime => nth_int(Fs, 13), 4131 priority => nth_int(Fs, 16), 4132 num_threads => nth_int(Fs, 18), 4133 starttime => nth_int(Fs, 20), 4134 vsize => nth_int(Fs, 21), 4135 rss => nth_int(Fs, 22) 4136 }} 4137 catch 4138 _:_ -> 4139 error 4140 end; 4141 nomatch -> 4142 error 4143 end; 4144 _ -> 4145 error 4146 end; 4147 {error, _} -> 4148 error 4149 end. 4150 4151nth_bin(List, N) when N >= 1 -> 4152 case length(List) >= N of 4153 true -> 4154 lists:nth(N, List); 4155 false -> 4156 <<>> 4157 end. 4158 4159nth_int(List, N) -> 4160 case nth_bin(List, N) of 4161 <<>> -> 4162 0; 4163 Bin -> 4164 try 4165 binary_to_integer(Bin) 4166 catch 4167 _:_ -> 4168 0 4169 end 4170 end. 4171 4172status_name($S) -> <<"Sleeping">>; 4173status_name($R) -> <<"Running">>; 4174status_name($D) -> <<"Disk sleep">>; 4175status_name($Z) -> <<"Zombie">>; 4176status_name($T) -> <<"Stopped">>; 4177status_name($t) -> <<"Tracing">>; 4178status_name($X) -> <<"Dead">>; 4179status_name($x) -> <<"Dead">>; 4180status_name($K) -> <<"Wakekill">>; 4181status_name($W) -> <<"Waking">>; 4182status_name($P) -> <<"Parked">>; 4183status_name($I) -> <<"Idle">>; 4184status_name(_) -> <<"Unknown">>. 4185 4186%% Prefer VmRSS / VmSize / VmSwap (kB) and Uid from status; fall back to stat. 4187read_status_mem(Pid, MemFromStat, VsizeFromStat) -> 4188 Path = "/proc/" ++ integer_to_list(Pid) ++ "/status", 4189 case file:read_file(Path) of 4190 {ok, Bin} -> 4191 Lines = binary:split(Bin, <<"\n">>, [global]), 4192 Mem = kb_field(Lines, <<"VmRSS:">>, MemFromStat), 4193 Virtual = kb_field(Lines, <<"VmSize:">>, VsizeFromStat), 4194 Paged = kb_field(Lines, <<"VmSwap:">>, 0), 4195 UserId = uid_field(Lines), 4196 {Mem, Mem, Paged, Virtual, UserId}; 4197 {error, _} -> 4198 {MemFromStat, MemFromStat, 0, VsizeFromStat, 0} 4199 end. 4200 4201kb_field(Lines, Key, Default) -> 4202 case find_status_line(Lines, Key) of 4203 {ok, Rest} -> 4204 %% " 1234 kB" 4205 case re:run(Rest, <<"([0-9]+)">>, [{capture, all_but_first, binary}]) of 4206 {match, [Num]} -> 4207 binary_to_integer(Num) * 1024; 4208 _ -> 4209 Default 4210 end; 4211 error -> 4212 Default 4213 end. 4214 4215uid_field(Lines) -> 4216 case find_status_line(Lines, <<"Uid:">>) of 4217 {ok, Rest} -> 4218 case re:run(Rest, <<"([0-9]+)">>, [{capture, all_but_first, binary}]) of 4219 {match, [Num]} -> 4220 binary_to_integer(Num); 4221 _ -> 4222 0 4223 end; 4224 error -> 4225 0 4226 end. 4227 4228find_status_line([], _Key) -> 4229 error; 4230find_status_line([Line | Rest], Key) -> 4231 Klen = byte_size(Key), 4232 case Line of 4233 <<Key:Klen/binary, RestLine/binary>> -> 4234 {ok, RestLine}; 4235 _ -> 4236 find_status_line(Rest, Key) 4237 end. 4238 4239read_cmdline(Pid, FallbackName) -> 4240 Path = "/proc/" ++ integer_to_list(Pid) ++ "/cmdline", 4241 case file:read_file(Path) of 4242 {ok, <<>>} -> 4243 FallbackName; 4244 {ok, Bin} -> 4245 Parts = [P || P <- binary:split(Bin, <<0>>, [global]), P =/= <<>>], 4246 case Parts of 4247 [] -> 4248 FallbackName; 4249 _ -> 4250 Joined = iolist_to_binary(lists:join(<<" ">>, Parts)), 4251 %% Nu collapses newlines/tabs in the display command. 4252 re:replace(Joined, <<"[\n\t]">>, <<" ">>, [global, {return, binary}]) 4253 end; 4254 {error, _} -> 4255 FallbackName 4256 end. 4257 4258read_cwd(Pid) -> 4259 Path = "/proc/" ++ integer_to_list(Pid) ++ "/cwd", 4260 case file:read_link(Path) of 4261 {ok, Target} -> 4262 unicode:characters_to_binary(Target); 4263 {error, _} -> 4264 <<>> 4265 end. 4266 4267clk_tck() -> 4268 case getconf_int("CLK_TCK") of 4269 {ok, N} when N > 0 -> 4270 N; 4271 _ -> 4272 100 4273 end. 4274 4275page_size() -> 4276 case getconf_int("PAGE_SIZE") of 4277 {ok, N} when N > 0 -> 4278 N; 4279 _ -> 4280 4096 4281 end. 4282 4283getconf_int(Name) -> 4284 Cmd = "getconf " ++ Name, 4285 try 4286 Out = string:trim(os:cmd(Cmd)), 4287 {ok, list_to_integer(Out)} 4288 catch 4289 _:_ -> 4290 error 4291 end. 4292 4293boot_time_seconds() -> 4294 case file:read_file("/proc/stat") of 4295 {ok, Bin} -> 4296 case re:run(Bin, <<"btime ([0-9]+)">>, [{capture, all_but_first, binary}]) of 4297 {match, [Num]} -> 4298 binary_to_integer(Num); 4299 _ -> 4300 0 4301 end; 4302 {error, _} -> 4303 0 4304 end. 4305 4306%% --------------------------------------------------------------------------- 4307%% whyport: sockets using a local/remote port (like `lsof -i :<port>`) 4308%% --------------------------------------------------------------------------- 4309%% 4310%% Returns a list of Gleam `PortSocket` records (Erlang tagged tuples): 4311%% 4312%% {port_socket, Protocol, Family, LocalAddress, LocalPort, 4313%% RemoteAddress, RemotePort, State, Pid, Name, Command, UserId, Fd} 4314%% 4315%% Protocol: <<"tcp">> | <<"udp">> 4316%% Family: <<"ipv4">> | <<"ipv6">> 4317%% State: LISTEN / ESTABLISHED / … (TCP) or empty for UDP 4318%% Pid/Fd: 0 when the owning process is unknown (permissions / TIME_WAIT) 4319%% 4320%% Linux only via /proc/net/{tcp,tcp6,udp,udp6} + /proc/*/fd socket inodes. 4321%% 4322-spec list_port_sockets(integer()) -> 4323 list({ 4324 port_socket, 4325 binary(), 4326 binary(), 4327 binary(), 4328 integer(), 4329 binary(), 4330 integer(), 4331 binary(), 4332 integer(), 4333 binary(), 4334 binary(), 4335 integer(), 4336 integer() 4337 }). 4338list_port_sockets(Port) when is_integer(Port), Port >= 0, Port =< 65535 -> 4339 case os:type() of 4340 {unix, linux} -> 4341 list_port_sockets_linux(Port); 4342 _ -> 4343 [] 4344 end; 4345list_port_sockets(_) -> 4346 []. 4347 4348list_port_sockets_linux(Port) -> 4349 SockMap = socket_inode_map(), 4350 Sources = [ 4351 {"/proc/net/tcp", <<"tcp">>, ipv4}, 4352 {"/proc/net/tcp6", <<"tcp">>, ipv6}, 4353 {"/proc/net/udp", <<"udp">>, ipv4}, 4354 {"/proc/net/udp6", <<"udp">>, ipv6} 4355 ], 4356 lists:flatmap( 4357 fun({Path, Proto, Family}) -> 4358 parse_net_table(Path, Proto, Family, Port, SockMap) 4359 end, 4360 Sources 4361 ). 4362 4363%% inode => list of {Pid, Fd, Name, Command} 4364socket_inode_map() -> 4365 case file:list_dir("/proc") of 4366 {ok, Entries} -> 4367 lists:foldl( 4368 fun(Entry, Acc) -> 4369 case is_pid_name(Entry) of 4370 false -> 4371 Acc; 4372 true -> 4373 Pid = list_to_integer(Entry), 4374 merge_pid_sockets(Pid, Acc) 4375 end 4376 end, 4377 #{}, 4378 Entries 4379 ); 4380 {error, _} -> 4381 #{} 4382 end. 4383 4384merge_pid_sockets(Pid, Acc) -> 4385 FdDir = "/proc/" ++ integer_to_list(Pid) ++ "/fd", 4386 case file:list_dir(FdDir) of 4387 {ok, Fds} -> 4388 {Name, Command} = pid_name_command(Pid), 4389 lists:foldl( 4390 fun(FdName, Acc1) -> 4391 case is_pid_name(FdName) of 4392 false -> 4393 Acc1; 4394 true -> 4395 Fd = list_to_integer(FdName), 4396 Link = FdDir ++ "/" ++ FdName, 4397 case file:read_link(Link) of 4398 {ok, Target} -> 4399 case socket_inode_from_link(Target) of 4400 {ok, Inode} -> 4401 Owner = {Pid, Fd, Name, Command}, 4402 Prev = maps:get(Inode, Acc1, []), 4403 Acc1#{Inode => [Owner | Prev]}; 4404 error -> 4405 Acc1 4406 end; 4407 {error, _} -> 4408 Acc1 4409 end 4410 end 4411 end, 4412 Acc, 4413 Fds 4414 ); 4415 {error, _} -> 4416 Acc 4417 end. 4418 4419%% "socket:[12345]" or "socket:[12345]\n" 4420socket_inode_from_link(Target) when is_list(Target) -> 4421 socket_inode_from_link(unicode:characters_to_binary(Target)); 4422socket_inode_from_link(Target) when is_binary(Target) -> 4423 case re:run(Target, <<"^socket:\\[([0-9]+)\\]">>, [{capture, all_but_first, binary}]) of 4424 {match, [Num]} -> 4425 {ok, binary_to_integer(Num)}; 4426 _ -> 4427 error 4428 end; 4429socket_inode_from_link(_) -> 4430 error. 4431 4432pid_name_command(Pid) -> 4433 case read_stat_fields(Pid) of 4434 {ok, Fields} -> 4435 Name = maps:get(comm, Fields, <<>>), 4436 {Name, read_cmdline(Pid, Name)}; 4437 error -> 4438 {<<>>, <<>>} 4439 end. 4440 4441parse_net_table(Path, Proto, Family, Port, SockMap) -> 4442 case file:read_file(Path) of 4443 {ok, Bin} -> 4444 Lines = binary:split(Bin, <<"\n">>, [global]), 4445 %% First line is the header. 4446 case Lines of 4447 [_Header | Rows] -> 4448 lists:flatmap( 4449 fun(Line) -> 4450 parse_net_row(Line, Proto, Family, Port, SockMap) 4451 end, 4452 Rows 4453 ); 4454 [] -> 4455 [] 4456 end; 4457 {error, _} -> 4458 [] 4459 end. 4460 4461parse_net_row(<<>>, _Proto, _Family, _Port, _SockMap) -> 4462 []; 4463parse_net_row(Line, Proto, Family, Port, SockMap) -> 4464 %% /proc/net/tcp columns (whitespace-separated after optional "sl:" index): 4465 %% sl local_address rem_address st … uid timeout inode 4466 Parts = [P || P <- binary:split(string:trim(Line), <<" ">>, [global]), P =/= <<>>], 4467 case Parts of 4468 %% drop "0:" style index 4469 [_Sl, Local, Remote, St | Rest] when length(Rest) >= 6 -> 4470 %% uid is 7th field after sl (index 7 in 0-based after splitting with sl), 4471 %% inode is field 9 (0-based: parts after drop of sl: local=0 rem=1 st=2 4472 %% tx=3 rx=4 tr=5 tm=6 retrnsmt=7 uid=8 timeout=9 inode=10 — wait. 4473 %% With sl kept: [sl, local, rem, st, tx_rx, tr_tm, retrnsmt, uid, timeout, inode] 4474 %% Actually tx_queue:rx_queue is one token, tr:tm->when is one. 4475 %% Parts after split: sl, local, rem, st, tx:rx, tr:tm, retrnsmt, uid, timeout, inode, … 4476 case Rest of 4477 [_TxRx, _TrTm, _Retr, UidBin, _Timeout, InodeBin | _] -> 4478 case {parse_addr_port(Local, Family), parse_addr_port(Remote, Family)} of 4479 {{ok, LAddr, LPort}, {ok, RAddr, RPort}} -> 4480 case LPort =:= Port orelse RPort =:= Port of 4481 false -> 4482 []; 4483 true -> 4484 State = tcp_state_name(Proto, St), 4485 Uid = 4486 try 4487 binary_to_integer(UidBin) 4488 catch 4489 _:_ -> 4490 0 4491 end, 4492 Inode = 4493 try 4494 binary_to_integer(InodeBin) 4495 catch 4496 _:_ -> 4497 0 4498 end, 4499 Owners = maps:get(Inode, SockMap, []), 4500 case Owners of 4501 [] -> 4502 [ 4503 {port_socket, Proto, family_bin(Family), LAddr, LPort, 4504 RAddr, RPort, State, 0, <<>>, <<>>, Uid, 0} 4505 ]; 4506 _ -> 4507 [ 4508 {port_socket, Proto, family_bin(Family), LAddr, LPort, 4509 RAddr, RPort, State, Pid, Name, Command, Uid, Fd} 4510 || {Pid, Fd, Name, Command} <- lists:reverse(Owners) 4511 ] 4512 end 4513 end; 4514 _ -> 4515 [] 4516 end; 4517 _ -> 4518 [] 4519 end; 4520 _ -> 4521 [] 4522 end. 4523 4524family_bin(ipv4) -> <<"ipv4">>; 4525family_bin(ipv6) -> <<"ipv6">>. 4526 4527%% Local/remote address in /proc/net is HEXIP:HEXPORT (host byte order for port; 4528%% IP is little-endian 32-bit words). 4529parse_addr_port(Bin, Family) -> 4530 case binary:split(Bin, <<":">>) of 4531 [IpHex, PortHex] -> 4532 try 4533 Port = binary_to_integer(PortHex, 16), 4534 Addr = decode_ip(IpHex, Family), 4535 {ok, Addr, Port} 4536 catch 4537 _:_ -> 4538 error 4539 end; 4540 _ -> 4541 error 4542 end. 4543 4544decode_ip(Hex, ipv4) -> 4545 <<A, B, C, D>> = <<(binary_to_integer(Hex, 16)):32/little>>, 4546 iolist_to_binary(io_lib:format("~b.~b.~b.~b", [A, B, C, D])); 4547decode_ip(Hex, ipv6) -> 4548 %% 32 hex chars = 4 little-endian 32-bit words → 16 network-order bytes 4549 Int = binary_to_integer(Hex, 16), 4550 <<W0:32, W1:32, W2:32, W3:32>> = <<Int:128/big>>, 4551 <<B0:8, B1:8, B2:8, B3:8>> = <<W0:32/little>>, 4552 <<B4:8, B5:8, B6:8, B7:8>> = <<W1:32/little>>, 4553 <<B8:8, B9:8, B10:8, B11:8>> = <<W2:32/little>>, 4554 <<B12:8, B13:8, B14:8, B15:8>> = <<W3:32/little>>, 4555 Bytes = <<B0, B1, B2, B3, B4, B5, B6, B7, B8, B9, B10, B11, B12, B13, B14, B15>>, 4556 format_ipv6(Bytes). 4557 4558%% Compact-ish IPv6 text (not full RFC 5952, but readable). 4559format_ipv6(<<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>>) -> 4560 <<"::">>; 4561format_ipv6(<<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, A, B, C, D>>) -> 4562 %% IPv4-mapped 4563 iolist_to_binary(io_lib:format("::ffff:~b.~b.~b.~b", [A, B, C, D])); 4564format_ipv6(<<B0, B1, B2, B3, B4, B5, B6, B7, B8, B9, B10, B11, B12, B13, B14, B15>>) -> 4565 Groups = [ 4566 (B0 bsl 8) bor B1, 4567 (B2 bsl 8) bor B3, 4568 (B4 bsl 8) bor B5, 4569 (B6 bsl 8) bor B7, 4570 (B8 bsl 8) bor B9, 4571 (B10 bsl 8) bor B11, 4572 (B12 bsl 8) bor B13, 4573 (B14 bsl 8) bor B15 4574 ], 4575 Parts = [iolist_to_binary(io_lib:format("~.16b", [G])) || G <- Groups], 4576 iolist_to_binary(lists:join(<<":">>, Parts)). 4577 4578tcp_state_name(<<"udp">>, _) -> 4579 <<"">>; 4580tcp_state_name(<<"tcp">>, StHex) -> 4581 try 4582 case binary_to_integer(StHex, 16) of 4583 1 -> <<"ESTABLISHED">>; 4584 2 -> <<"SYN_SENT">>; 4585 3 -> <<"SYN_RECV">>; 4586 4 -> <<"FIN_WAIT1">>; 4587 5 -> <<"FIN_WAIT2">>; 4588 6 -> <<"TIME_WAIT">>; 4589 7 -> <<"CLOSE">>; 4590 8 -> <<"CLOSE_WAIT">>; 4591 9 -> <<"LAST_ACK">>; 4592 10 -> <<"LISTEN">>; 4593 11 -> <<"CLOSING">>; 4594 12 -> <<"NEW_SYN_RECV">>; 4595 N -> iolist_to_binary(io_lib:format("UNKNOWN(~b)", [N])) 4596 end 4597 catch 4598 _:_ -> 4599 StHex 4600 end; 4601tcp_state_name(_, St) -> 4602 St.