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

Configure Feed

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

Add fish/Nushell-style grey history suggestions.

Show a greyed-out suffix of the newest matching history line while typing;
accept full with →/End/Ctrl+E/Ctrl+F, or one word with Alt+F.

author
nandi
date (Jul 25, 2026, 9:10 PM -0700) commit ff5e6e5e parent 6d8622b4 change-id wrrmruum
+209 -12
+7 -1
README.md
··· 68 68 |-----|--------| 69 69 | ↑ / ↓ | History | 70 70 | **Tab** | Command completion (builtins + `PATH`) at the start of a pipeline stage; filename completion for arguments (common prefix; list matches if ambiguous) | 71 + | **→ / Ctrl+F / End / Ctrl+E** (at end of line) | Accept greyed-out history suggestion (full) | 72 + | **Alt+F** (at end of line) | Accept one word of the history suggestion | 71 73 | **Ctrl+R** | Reverse-i-search through history (Enter accepts onto the line) | 72 - | Ctrl+A / Ctrl+E | Beginning / end of line | 74 + | Ctrl+A / Ctrl+E | Beginning / end of line (Ctrl+E at end also accepts a history hint) | 73 75 | Ctrl+W | Delete previous word | 74 76 | Ctrl+U / Ctrl+K | Kill to start / end of line | 75 77 | Ctrl+L | Clear screen | 76 78 | **Ctrl+C** | Cancel current line at the prompt; while an external command runs, send SIGINT to that process (does not exit the shell) | 77 79 | Ctrl+D | EOF (empty line) or delete under cursor | 80 + 81 + As you type, the **newest matching history line** is shown in grey after the 82 + cursor (Nushell/fish-style hints). Accept with **→**, **End**, **Ctrl+E**, or 83 + **Ctrl+F**; accept one word with **Alt+F**. 78 84 79 85 History is persisted under the user cache as `gleshell-history/lines`. 80 86 Non-TTY input falls back to Erlang’s `edlin`/`get_until` path.
+1 -1
src/gleshell.gleam
··· 65 65 66 66 fn repl(env: env.Env) -> Nil { 67 67 sys.println( 68 - "gleshell 0.1 — structured data shell (type `help`, `exit` to quit; Tab completes commands/paths, Ctrl+R search)", 68 + "gleshell 0.1 — structured data shell (type `help`, `exit` to quit; Tab completes, grey history hints, Ctrl+R search)", 69 69 ) 70 70 repl_loop(env) 71 71 }
+5
src/gleshell/sys.gleam
··· 102 102 @external(erlang, "gleshell_ffi", "complete_word") 103 103 pub fn complete_word(prefix: String, word: String) -> #(List(String), String) 104 104 105 + /// Greyed-out history autosuggestion suffix for `buffer`, given `history` 106 + /// newest-first. Empty string when there is no proper prefix match. 107 + @external(erlang, "gleshell_ffi", "history_hint") 108 + pub fn history_hint(history: List(String), buffer: String) -> String 109 + 105 110 @external(erlang, "gleshell_ffi", "home_dir") 106 111 pub fn home_dir() -> Result(String, String) 107 112
+174 -10
src/gleshell_ffi.erl
··· 25 25 take_output_shown/0, 26 26 clear_output_shown/0, 27 27 complete_word/2, 28 + history_hint/2, 28 29 re_contains/3 29 30 ]). 30 31 ··· 174 175 <<"ctrl_w">>; 175 176 key_to_name(ctrl_r) -> 176 177 <<"ctrl_r">>; 178 + key_to_name(ctrl_f) -> 179 + <<"ctrl_f">>; 180 + key_to_name(alt_f) -> 181 + <<"alt_f">>; 177 182 key_to_name(ctrl_g) -> 178 183 <<"ctrl_g">>; 179 184 key_to_name({char, 32}) -> ··· 503 508 right -> 504 509 case Right of 505 510 [] -> 506 - raw_loop(Prompt, Left, Right, History, HistPos, Saved); 511 + %% At end of line: accept greyed-out history hint (Nu/fish). 512 + accept_history_hint(Prompt, Left, Right, History, HistPos, Saved); 507 513 [C | Rest] -> 508 514 redraw(Prompt, [C | Left], Rest), 509 515 raw_loop(Prompt, [C | Left], Rest, History, HistPos, Saved) ··· 513 519 redraw(Prompt, [], NewRight), 514 520 raw_loop(Prompt, [], NewRight, History, HistPos, Saved); 515 521 'end' -> 516 - NewLeft = lists:reverse(Right) ++ Left, 517 - redraw(Prompt, NewLeft, []), 518 - raw_loop(Prompt, NewLeft, [], History, HistPos, Saved); 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; 519 531 up -> 520 532 hist_nav(Prompt, Left, Right, History, HistPos, Saved, 1); 521 533 down -> ··· 525 537 redraw(Prompt, [], NewRight), 526 538 raw_loop(Prompt, [], NewRight, History, HistPos, Saved); 527 539 ctrl_e -> 528 - NewLeft = lists:reverse(Right) ++ Left, 529 - redraw(Prompt, NewLeft, []), 530 - raw_loop(Prompt, NewLeft, [], History, HistPos, Saved); 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); 531 560 ctrl_u -> 532 561 redraw(Prompt, [], Right), 533 562 raw_loop(Prompt, [], Right, History, 0, <<>>); ··· 1093 1122 _ -> <<>> 1094 1123 end, 1095 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, 1096 1138 %% Clear every physical row the previous render occupied. A longer history 1097 1139 %% entry can soft-wrap; `\e[2K` alone only erases the current row, so a 1098 1140 %% shorter recall (or empty draft) used to leave a blank-looking row and 1099 1141 %% stale wrap residue that felt like an empty ↑ slot. 1100 1142 clear_input_rows(), 1101 - io:put_chars([Prompt, Colored]), 1102 - Rows = count_input_rows(Prompt, FullBin), 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>>), 1103 1146 put(gleshell_input_rows, Rows), 1104 - case length(Right) of 1147 + %% Cursor sits after typed text, before the grey hint (and before Right). 1148 + CursorBack = length(HintChars) + length(Right), 1149 + case CursorBack of 1105 1150 0 -> 1106 1151 ok; 1107 1152 N -> ··· 1109 1154 io:put_chars(["\e[", integer_to_list(N), $D]) 1110 1155 end. 1111 1156 1157 + %% Accept the full greyed-out history suggestion into the buffer. 1158 + accept_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). 1175 + accept_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. 1197 + take_hint_word([]) -> 1198 + []; 1199 + take_hint_word([C | Rest]) when C =:= $\s; C =:= $\t -> 1200 + [C | take_hint_nonspace(Rest)]; 1201 + take_hint_word(Rest) -> 1202 + take_hint_nonspace(Rest). 1203 + 1204 + take_hint_nonspace([]) -> 1205 + []; 1206 + take_hint_nonspace([C | _Rest]) when C =:= $\s; C =:= $\t -> 1207 + []; 1208 + take_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. 1212 + history_hint_suffix([]) -> 1213 + []; 1214 + history_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 + 1222 + history_hint_suffix([], _Buffer) -> 1223 + []; 1224 + %% Empty buffer: never ghost the whole previous command (Nu default). 1225 + history_hint_suffix(_History, []) -> 1226 + []; 1227 + history_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 + 1241 + paint_history_hint([]) -> 1242 + []; 1243 + paint_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(). 1254 + history_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 + 1112 1270 %% Move to the start of the previous input block and erase its rows. 1113 1271 clear_input_rows() -> 1114 1272 Rows0 = ··· 1232 1390 decode_key(23, _) -> ctrl_w; 1233 1391 decode_key(12, _) -> ctrl_l; 1234 1392 decode_key(18, _) -> ctrl_r; 1393 + decode_key(6, _) -> ctrl_f; 1235 1394 decode_key(?ESC, _) -> 1236 1395 read_escape(); 1237 1396 decode_key(C, _) when is_integer(C), C >= 32 -> ··· 1256 1415 <<"F">> -> 'end'; 1257 1416 _ -> other 1258 1417 end; 1418 + %% Alt+letter arrives as ESC then the letter (meta). 1419 + <<$f>> -> 1420 + alt_f; 1421 + <<$F>> -> 1422 + alt_f; 1259 1423 _ -> 1260 1424 other 1261 1425 end.
+22
test/gleshell_test.gleam
··· 1193 1193 Nil 1194 1194 } 1195 1195 1196 + // --- history ghost-text hints (Nu/fish style) --- 1197 + 1198 + pub fn history_hint_newest_prefix_test() { 1199 + // Newest-first: first entry that has the buffer as a proper prefix wins. 1200 + let hist = ["ls | where type == file", "ls | first 3", "echo hi"] 1201 + let assert " | where type == file" = sys.history_hint(hist, "ls") 1202 + let assert " | first 3" = sys.history_hint(["ls | first 3", "ls | where x"], "ls") 1203 + let assert " type == file" = sys.history_hint(hist, "ls | where") 1204 + let assert " hi" = sys.history_hint(hist, "echo") 1205 + Nil 1206 + } 1207 + 1208 + pub fn history_hint_no_match_test() { 1209 + let hist = ["ls", "echo hi"] 1210 + let assert "" = sys.history_hint(hist, "cd") 1211 + // Exact match only — no suffix 1212 + let assert "" = sys.history_hint(hist, "ls") 1213 + // Empty buffer never suggests 1214 + let assert "" = sys.history_hint(hist, "") 1215 + Nil 1216 + } 1217 + 1196 1218 fn list_contains(items: List(String), needle: String) -> Bool { 1197 1219 case items { 1198 1220 [] -> False