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