A structured-data shell in Gleam, inspired by Nushell
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.