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 color-aware builtin less pager, about command, and prompt Nerd Fonts.

Ship an interactive `less` that keeps ANSI through alternate-screen paging,
plus `about`, bare `--` as a literal arg, git color/decoration for pipelines,
and symbols-only Nerd Fonts in devenv/flake for the REPL glyphs.

author
nandi
date (Jul 25, 2026, 5:33 PM -0700) commit af16c86d parent 731128ff change-id xlyxosmp
+895 -41
+39 -10
README.md
··· 6 6 7 7 ```text 8 8 ~/code/gleshell on  main 9 - ❯ ls | where type == file | select name size | first 5 9 +  ❯ ls | where type == file | select name size | first 5 10 10 ╭──────┬──────╮ 11 11 │ name │ size │ 12 12 ├──────┼──────┤ ··· 15 15 ``` 16 16 17 17 The interactive prompt is zero-config and Starship-inspired: full path with `~`, 18 - optional git branch (read from `.git`, no external tools), and a `❯` character 19 - that turns red after a non-zero exit. 18 + optional git branch (read from `.git`, no external tools), and a Nerd Font shell 19 + icon (``) before the `❯` character (which turns red after a non-zero exit). 20 + Install a Nerd Font so the glyphs render (flake: `nix profile install 21 + .#nerd-fonts`, or put `nerd-fonts.symbols-only` in your system/fonts packages; 22 + the devenv shell already includes it). 20 23 21 24 ## Quick start 22 25 ··· 31 34 nix run . -- -c 'ls | first 3' 32 35 33 36 # dev shell (source tree; gleam, erlang, rebar3) 34 - # --no-pure-eval lets devenv use the real project dir (not /nix/store) 37 + # Preferred: direnv auto-loads the flake shell (see .envrc). Once: 38 + # direnv allow 39 + # then cd into the repo (nushell needs a direnv pre_prompt hook; see below). 40 + # Manual alternative — bare `nix develop` fails under pure flake eval: 35 41 nix develop --no-pure-eval 36 42 gleam run # interactive REPL 37 43 gleam run -- -c 'ls | first 3' 38 44 gleam test 39 45 ``` 40 46 47 + ### direnv 48 + 49 + This repo ships a [`.envrc`](.envrc) that runs `use flake . --no-pure-eval` 50 + so devenv can see the real project directory (pure flake eval cannot). 51 + 52 + 1. Install [direnv](https://direnv.net/) and hook it into your shell 53 + ([nushell](https://github.com/direnv/direnv/wiki/Nushell), bash, zsh, …). 54 + 2. From the repo root: `direnv allow` 55 + 3. Open a new shell (or `cd .`) — you should see the devenv enter message and 56 + have `gleam` on `PATH` without running `nix develop`. 57 + 58 + Bare `nix develop` still needs `--no-pure-eval` for the same reason; with 59 + direnv you usually do not need `nix develop` at all. 60 + 41 61 ### REPL editing 42 62 43 63 On a TTY the interactive REPL uses a **raw-mode line editor** with ··· 116 136 117 137 Table/list: `where`/`filter`, `find`, `select`, `get`, `first`, `last`, `take`, `skip`, `sort-by`, `reverse`, `length`, `columns`, `table`, `flatten`, `uniq`, `wrap`, `unwrap`, `keys`, `values` 118 138 119 - Data: `echo`, `range`, `lines`, `to`/`from` (subcommand `json`), `type`, `describe`, `env`, `sys`, `which`, `help`, `exit` 139 + Data: `echo`, `range`, `lines`, `to`/`from` (subcommand `json`), `type`, `describe`, `env`, `sys`, `which`, `help`, `about`, `exit` 140 + 141 + Pager: `less` — builtin color-aware pager for pipeline input or files (`ls | less`, 142 + `less README.md`). ANSI from tables and external tools is kept; short output is 143 + printed without an interactive session. `^less` still runs the external binary. 120 144 121 145 Unknown command names fall through to external executables on `PATH`. 122 146 ··· 131 155 lexer.gleam / parser.gleam 132 156 eval.gleam # pipeline evaluator 133 157 builtins.gleam # Nu-inspired commands 158 + pager.gleam # color-aware builtin less 134 159 display.gleam # table pretty-printer 135 160 color.gleam # Nushell-style ANSI colors 136 161 highlight.gleam # live input syntax highlighting ··· 140 165 Input and output are colorized on a TTY (Nu-like shapes on the command line; 141 166 headers bold green, numbers purple, bools cyan, dirs blue, errors red, …). 142 167 External tools (`jj`, `git`, `fastfetch`, …) keep their own colors: final-stage 143 - commands inherit the real TTY (or get `FORCE_COLOR` when output is captured), and 144 - pre-colored text is not re-painted by the shell. While the raw line editor is 145 - active, the TTY is switched to cooked mode for those children so LF-only 146 - output does not staircase. Paginated output gets `LESS=FRX` when unset so 147 - `less` passes ANSI through. 168 + commands inherit the real TTY; captured pipeline stages get `FORCE_COLOR` / 169 + `CLICOLOR_FORCE`, and git gets `color.ui=always` plus `log.decorate=short` via 170 + `GIT_CONFIG_*` (git ignores `FORCE_COLOR`, and `decorate=auto` drops 171 + `(HEAD, origin/main, …)` when stdout is a pipe — so bare `git log | less` 172 + would otherwise lose both color and ref decorations). Pre-colored text is not 173 + re-painted by the shell. While the raw line editor is active, the TTY is 174 + switched to cooked mode for those children so LF-only output does not 175 + staircase. The builtin `less` keeps ANSI (like `less -R`); external pagers 176 + still get `LESS=FRX` when unset so they pass colors through. 148 177 Disable with `NO_COLOR=1`; force with `FORCE_COLOR=1`. 149 178 150 179 ## Status
+2
devenv.nix
··· 5 5 gleam 6 6 beamPackages.erlang 7 7 rebar3 8 + # Icons for the REPL prompt (``, ``, …). Symbols-only is small. 9 + nerd-fonts.symbols-only 8 10 ]; 9 11 10 12 enterShell = ''
+6 -1
flake.nix
··· 136 136 license = lib.licenses.asl20; 137 137 }; 138 138 }; 139 + 140 + # Prompt icons (``, ``, …). Install so the REPL glyph set renders: 141 + # nix profile install .#nerd-fonts 142 + # On NixOS/home-manager prefer fonts.packages instead. 143 + nerd-fonts = pkgs.nerd-fonts.symbols-only; 139 144 in 140 145 { 141 146 default = gleshell; 142 - inherit gleshell; 147 + inherit gleshell nerd-fonts; 143 148 } 144 149 ); 145 150
+8 -2
src/gleshell.gleam
··· 109 109 } 110 110 111 111 /// Zero-config prompt inspired by Starship: 112 - /// blank line, directory (+ optional git branch), then a green/red `❯`. 112 + /// blank line, directory (+ optional git branch), then Nerd Font shell icon 113 + /// + green/red `❯`. 113 114 /// 114 115 /// Status lines are printed once; only the character is the line-editor prompt 115 116 /// (the raw editor redraws a single line). 117 + /// 118 + /// Icon `` is nf-oct-terminal (Nerd Fonts). Install `nerd-fonts.symbols-only` 119 + /// (or any Nerd Font) so it renders — see the flake packages / devenv. 116 120 fn prompt_for(env: env.Env) -> String { 117 121 let on = color.enabled() 118 122 let path = color.prompt_path(on, display_cwd(env.cwd)) ··· 129 133 130 134 fn prompt_character(last_exit: Int) -> String { 131 135 let on = color.enabled() 136 + // Cute Nerd Font marker so you always know you're in gleshell. 137 + let icon = color.prompt_name(on, "") 132 138 let mark = case last_exit { 133 139 0 -> color.prompt_character_ok(on, "❯") 134 140 _ -> color.prompt_character_err(on, "❯") 135 141 } 136 - mark <> " " 142 + icon <> " " <> mark <> " " 137 143 } 138 144 139 145 /// Full cwd for the prompt, with `$HOME` shown as `~`.
+134
src/gleshell/builtins.gleam
··· 10 10 import gleam/option 11 11 import gleam/order 12 12 import gleam/string 13 + import gleshell/display 13 14 import gleshell/env.{type Env} 15 + import gleshell/pager 14 16 import gleshell/sys 15 17 import gleshell/value.{ 16 18 type Value, Bool, Fail, Float, Int, List, Nothing, Record, String, Table, ··· 77 79 #("values", cmd_values), 78 80 #("keys", cmd_keys), 79 81 #("sys", cmd_sys), 82 + #("about", cmd_about), 83 + #("less", cmd_less), 80 84 ]) 81 85 } 82 86 ··· 214 218 ], 215 219 "\n", 216 220 )) 221 + "less" -> 222 + Ok(string.join( 223 + [ 224 + "less [file]… — page pipeline input or files (ANSI colors kept)", 225 + "", 226 + "Builtin pager inspired by less -FRX: colors from tools and gleshell", 227 + "tables pass through; if the text fits on one screen (or stdout is not", 228 + "a TTY), it is printed and the pager exits. Use `^less` for the", 229 + "external binary on PATH.", 230 + "", 231 + "Keys (interactive):", 232 + " j / ↓ / Enter line down k / ↑ line up", 233 + " space / f / PgDn page down b / PgUp page up", 234 + " g / Home top G / End bottom", 235 + " h / ? help q / Ctrl+C quit", 236 + "", 237 + "Examples:", 238 + " ls | less", 239 + " cat README.md | less", 240 + " less README.md", 241 + " ^jj log | less", 242 + ], 243 + "\n", 244 + )) 217 245 _ -> help_line(name) 218 246 } 219 247 } ··· 293 321 #("values", "values — list of values from a record"), 294 322 #("keys", "keys — list of keys from a record"), 295 323 #("sys", "sys — host info record (cwd, home, shell, last_exit)"), 324 + #("about", "about — authorship, ATProto handle, and a little sparkle"), 325 + #( 326 + "less", 327 + "less [file]… — page pipeline input or files (ANSI colors kept)", 328 + ), 296 329 ]) 297 330 } 298 331 ··· 1717 1750 #("shell", String("gleshell")), 1718 1751 #("last_exit", Int(env.last_exit)), 1719 1752 ]), 1753 + ) 1754 + } 1755 + 1756 + // --- less (color-aware pager) --- 1757 + 1758 + fn cmd_less( 1759 + env: Env, 1760 + input: Value, 1761 + args: List(Value), 1762 + _flags: dict.Dict(String, Value), 1763 + ) -> BuiltinResult { 1764 + case less_content(env, input, args) { 1765 + Error(msg) -> err(env, msg) 1766 + Ok(text) -> 1767 + case pager.needs_paging(text) { 1768 + True -> { 1769 + pager.run(text) 1770 + ok(env, Nothing) 1771 + } 1772 + // Fits on one screen or not a TTY: emit the text so the REPL / -c 1773 + // path prints it once (ANSI preserved by display.render for strings). 1774 + False -> ok(env, String(text)) 1775 + } 1776 + } 1777 + } 1778 + 1779 + /// Build the text to page: file args win (like external less); otherwise 1780 + /// pipeline input. Raw strings (e.g. `git log` output) are kept byte-for-byte 1781 + /// so embedded ANSI is not re-painted; structured values are pretty-printed 1782 + /// with colors via `display.render`. 1783 + fn less_content( 1784 + env: Env, 1785 + input: Value, 1786 + args: List(Value), 1787 + ) -> Result(String, String) { 1788 + case args { 1789 + [] -> 1790 + case input { 1791 + Nothing -> Error("less: no input (pipe data or pass a file path)") 1792 + // Do not run display.render on external text: short plain lines would 1793 + // get string-green, and multi-line ANSI must stay exactly as emitted. 1794 + String(s) -> Ok(s) 1795 + other -> Ok(display.render(other)) 1796 + } 1797 + paths -> { 1798 + case list.try_map(paths, fn(a) { 1799 + case a { 1800 + String(path) -> read_less_file(env, path) 1801 + _ -> Error("less: expected file path") 1802 + } 1803 + }) { 1804 + Error(e) -> Error(e) 1805 + Ok(parts) -> Ok(string.join(parts, "\n")) 1806 + } 1807 + } 1808 + } 1809 + } 1810 + 1811 + fn read_less_file(env: Env, path: String) -> Result(String, String) { 1812 + let path = resolve_path(env, path) 1813 + case simplifile.read(path) { 1814 + Ok(content) -> Ok(content) 1815 + Error(e) -> Error("less: " <> simplifile.describe_error(e)) 1816 + } 1817 + } 1818 + 1819 + // --- about --- 1820 + 1821 + fn cmd_about( 1822 + env: Env, 1823 + _input: Value, 1824 + _args: List(Value), 1825 + _flags: dict.Dict(String, Value), 1826 + ) -> BuiltinResult { 1827 + ok(env, String(about_text())) 1828 + } 1829 + 1830 + fn about_text() -> String { 1831 + string.join( 1832 + [ 1833 + " ✨ gleshell ✨", 1834 + " a structured-data shell in Gleam", 1835 + " inspired by Nushell · pipelines with types", 1836 + "", 1837 + " ╱|、", 1838 + " (˚ˎ 。7", 1839 + " |、˜〵", 1840 + " じしˍ,)ノ meow · you found the about page", 1841 + "", 1842 + " author NaNdi", 1843 + " handle @nandi.uk", 1844 + " atproto did:plc:ngokl2gnmpbvuvrfckja3g7p", 1845 + " web https://latha.org", 1846 + " licence Apache-2.0", 1847 + "", 1848 + " \"a category is a quiver under the free functor\"", 1849 + "", 1850 + " 🐚 type `help` to explore · `^cmd` for externals", 1851 + " 💜 made for people who pipe records, not just text", 1852 + ], 1853 + "\n", 1720 1854 ) 1721 1855 } 1722 1856
+3 -1
src/gleshell/lexer.gleam
··· 88 88 ["-", "-", ..rest] -> { 89 89 let #(name, after, new_pos) = read_ident_body(rest, pos + 2, "") 90 90 case name { 91 - "" -> Error(LexError("expected flag name after --", pos)) 91 + // Bare `--` is the POSIX end-of-options marker (`nix run . -- args`). 92 + // Empty flag name is parsed as a literal `"--"` argument. 93 + "" -> do_tokenize(after, new_pos, [Flag(""), ..acc]) 92 94 n -> do_tokenize(after, new_pos, [Flag(n), ..acc]) 93 95 } 94 96 }
+309
src/gleshell/pager.gleam
··· 1 + //// Color-aware pager (builtin `less`). 2 + //// 3 + //// Passes ANSI through unchanged, sizes pages with `color.visible_length`, 4 + //// and uses the alternate screen while interactive so the REPL is restored 5 + //// on quit. When stdout is not a TTY, or the text fits on one screen, the 6 + //// caller should print the text itself (see `needs_paging`). 7 + 8 + import gleam/int 9 + import gleam/list 10 + import gleam/string 11 + import gleshell/color 12 + import gleshell/sys 13 + 14 + /// True when stdout is a TTY and wrapping `text` to the terminal width yields 15 + /// more lines than fit on one screen (minus the status row). 16 + pub fn needs_paging(text: String) -> Bool { 17 + case sys.term_size() { 18 + Error(Nil) -> False 19 + Ok(#(rows, cols)) -> { 20 + let height = page_height(rows) 21 + let lines = display_lines(text, cols) 22 + list.length(lines) > height 23 + } 24 + } 25 + } 26 + 27 + /// Interactive page session. Call only when `needs_paging` is True. 28 + /// Leaves the alternate screen on exit; does not print the text afterwards. 29 + pub fn run(text: String) -> Nil { 30 + case sys.term_size() { 31 + Error(Nil) -> Nil 32 + Ok(#(rows, cols)) -> { 33 + let height = page_height(rows) 34 + let lines = display_lines(text, cols) 35 + let total = list.length(lines) 36 + sys.with_key_mode(fn() { 37 + enter_alt_screen() 38 + page_loop(lines, total, 0, height, cols) 39 + leave_alt_screen() 40 + }) 41 + } 42 + } 43 + } 44 + 45 + fn page_height(rows: Int) -> Int { 46 + int.max(1, rows - 1) 47 + } 48 + 49 + /// Split on newlines, then soft-wrap each logical line to `cols` using 50 + /// ANSI-aware visible width so color codes do not throw off wrapping. 51 + pub fn display_lines(text: String, cols: Int) -> List(String) { 52 + let cols = int.max(1, cols) 53 + text 54 + |> string.replace("\r\n", "\n") 55 + |> string.replace("\r", "\n") 56 + |> string.split("\n") 57 + |> list.flat_map(fn(line) { wrap_line(line, cols) }) 58 + } 59 + 60 + fn page_loop( 61 + lines: List(String), 62 + total: Int, 63 + offset: Int, 64 + height: Int, 65 + cols: Int, 66 + ) -> Nil { 67 + let max_off = int.max(0, total - height) 68 + let offset = int.clamp(offset, 0, max_off) 69 + redraw(lines, total, offset, height, cols) 70 + case sys.read_key_name() { 71 + Error(_) -> Nil 72 + Ok("eof") -> Nil 73 + Ok("q") | Ok("Q") | Ok("ctrl_c") | Ok("ctrl_d") -> Nil 74 + Ok("down") | Ok("j") | Ok("enter") -> 75 + page_loop(lines, total, offset + 1, height, cols) 76 + Ok("up") | Ok("k") -> page_loop(lines, total, offset - 1, height, cols) 77 + Ok("space") | Ok("f") | Ok("page_down") | Ok("ctrl_f") -> 78 + page_loop(lines, total, offset + height, height, cols) 79 + Ok("b") | Ok("page_up") | Ok("ctrl_b") -> 80 + page_loop(lines, total, offset - height, height, cols) 81 + Ok("g") | Ok("home") -> page_loop(lines, total, 0, height, cols) 82 + Ok("G") | Ok("end") -> page_loop(lines, total, max_off, height, cols) 83 + Ok("ctrl_l") -> page_loop(lines, total, offset, height, cols) 84 + Ok("h") | Ok("?") -> { 85 + show_help(height, cols) 86 + page_loop(lines, total, offset, height, cols) 87 + } 88 + Ok(_) -> page_loop(lines, total, offset, height, cols) 89 + } 90 + } 91 + 92 + fn redraw( 93 + lines: List(String), 94 + total: Int, 95 + offset: Int, 96 + height: Int, 97 + cols: Int, 98 + ) -> Nil { 99 + // Home + clear + SGR reset inside the alternate buffer. 100 + // Do not reset SGR after every row: soft-wrapped chunks only open CSI on the 101 + // first physical line, so color must continue until the content resets it. 102 + sys.write("\u{001b}[H\u{001b}[2J\u{001b}[0m") 103 + let view = list_slice(lines, offset, height) 104 + let padded = pad_to(view, height) 105 + list.each(padded, fn(line) { 106 + sys.write(line <> "\u{001b}[K\r\n") 107 + }) 108 + // Ensure the status bar is not tinted by leftover content SGR. 109 + sys.write("\u{001b}[0m" <> status_line(offset, height, total, cols)) 110 + } 111 + 112 + fn status_line(offset: Int, height: Int, total: Int, cols: Int) -> String { 113 + let at_end = offset + height >= total 114 + let label = case total { 115 + 0 -> " (empty) " 116 + _ if at_end -> " (END) " 117 + _ -> { 118 + let bottom = int.min(total, offset + height) 119 + let pct = case total { 120 + 0 -> 100 121 + n -> bottom * 100 / n 122 + } 123 + " " 124 + <> int.to_string(offset + 1) 125 + <> "-" 126 + <> int.to_string(bottom) 127 + <> "/" 128 + <> int.to_string(total) 129 + <> " (" 130 + <> int.to_string(pct) 131 + <> "%) " 132 + } 133 + } 134 + let help = " q:quit j/k:line space/b:page g/G:top/end h:help " 135 + let plain = label <> help 136 + let plain = case color.visible_length(plain) > cols { 137 + True -> label 138 + False -> plain 139 + } 140 + let on = color.enabled() 141 + let body = case on { 142 + True -> "\u{001b}[7m" <> pad_status(plain, cols) <> "\u{001b}[0m" 143 + False -> pad_status(plain, cols) 144 + } 145 + // Stay on the status row (no trailing newline). 146 + body <> "\u{001b}[K" 147 + } 148 + 149 + fn pad_status(text: String, cols: Int) -> String { 150 + let vis = color.visible_length(text) 151 + case vis >= cols { 152 + True -> text 153 + False -> text <> string.repeat(" ", cols - vis) 154 + } 155 + } 156 + 157 + fn show_help(height: Int, cols: Int) -> Nil { 158 + let help_text = 159 + string.join( 160 + [ 161 + "gleshell less — color-aware pager", 162 + "", 163 + " j / ↓ / Enter one line down", 164 + " k / ↑ one line up", 165 + " space / f / PgDn one page down", 166 + " b / PgUp one page up", 167 + " g / Home top", 168 + " G / End bottom", 169 + " Ctrl+L redraw", 170 + " h / ? this help", 171 + " q / Q / Ctrl+C quit", 172 + "", 173 + "ANSI colors from tools and tables are kept (like less -R).", 174 + "", 175 + "Press any key to return…", 176 + ], 177 + "\n", 178 + ) 179 + let lines = display_lines(help_text, cols) 180 + redraw(lines, list.length(lines), 0, height, cols) 181 + let _ = sys.read_key_name() 182 + Nil 183 + } 184 + 185 + fn enter_alt_screen() -> Nil { 186 + // Save cursor + enter alternate screen buffer (xterm/most terminals). 187 + sys.write("\u{001b}[?1049h\u{001b}[H") 188 + } 189 + 190 + fn leave_alt_screen() -> Nil { 191 + sys.write("\u{001b}[?1049l") 192 + } 193 + 194 + fn list_slice(items: List(String), offset: Int, n: Int) -> List(String) { 195 + items 196 + |> list.drop(offset) 197 + |> list.take(n) 198 + } 199 + 200 + fn pad_to(lines: List(String), height: Int) -> List(String) { 201 + let missing = height - list.length(lines) 202 + case missing > 0 { 203 + True -> list.append(lines, list.repeat("", missing)) 204 + False -> lines 205 + } 206 + } 207 + 208 + /// Soft-wrap one logical line to at most `cols` visible columns. 209 + /// Escape sequences never count toward width and are never split mid-sequence. 210 + pub fn wrap_line(line: String, cols: Int) -> List(String) { 211 + let cols = int.max(1, cols) 212 + case color.visible_length(line) <= cols { 213 + True -> [line] 214 + False -> wrap_loop(string.to_utf_codepoints(line), cols, 0, "", []) 215 + } 216 + } 217 + 218 + fn wrap_loop( 219 + codes: List(UtfCodepoint), 220 + cols: Int, 221 + vis: Int, 222 + acc_text: String, 223 + out: List(String), 224 + ) -> List(String) { 225 + case codes { 226 + [] -> 227 + case acc_text { 228 + "" if out != [] -> list.reverse(out) 229 + "" -> [""] 230 + t -> list.reverse([t, ..out]) 231 + } 232 + [c, ..rest] -> { 233 + let n = string.utf_codepoint_to_int(c) 234 + case n == 0x1B { 235 + True -> { 236 + // Pull the whole CSI / ESC sequence into the current chunk. 237 + let #(seq, after) = take_ansi([c, ..rest]) 238 + wrap_loop( 239 + after, 240 + cols, 241 + vis, 242 + acc_text <> codepoints_to_string(seq), 243 + out, 244 + ) 245 + } 246 + False -> 247 + case vis >= cols { 248 + True -> 249 + // Start a new physical line with this visible character. 250 + wrap_loop(codes, cols, 0, "", [acc_text, ..out]) 251 + False -> 252 + wrap_loop( 253 + rest, 254 + cols, 255 + vis + 1, 256 + acc_text <> codepoints_to_string([c]), 257 + out, 258 + ) 259 + } 260 + } 261 + } 262 + } 263 + } 264 + 265 + fn take_ansi( 266 + codes: List(UtfCodepoint), 267 + ) -> #(List(UtfCodepoint), List(UtfCodepoint)) { 268 + case codes { 269 + [] -> #([], []) 270 + [esc, ..rest] -> 271 + case string.utf_codepoint_to_int(esc) == 0x1B { 272 + False -> #([], codes) 273 + True -> 274 + case rest { 275 + [bracket, ..params] -> 276 + case string.utf_codepoint_to_int(bracket) == 0x5B { 277 + // CSI: ESC [ … final(0x40–0x7E) 278 + True -> take_csi([esc, bracket], params) 279 + // Non-CSI ESC: include ESC + one following byte. 280 + False -> #([esc, bracket], params) 281 + } 282 + [] -> #([esc], []) 283 + } 284 + } 285 + } 286 + } 287 + 288 + fn take_csi( 289 + acc: List(UtfCodepoint), 290 + rest: List(UtfCodepoint), 291 + ) -> #(List(UtfCodepoint), List(UtfCodepoint)) { 292 + case rest { 293 + [] -> #(acc, []) 294 + [c, ..more] -> { 295 + let n = string.utf_codepoint_to_int(c) 296 + let acc2 = list.append(acc, [c]) 297 + case n >= 0x40 && n <= 0x7E { 298 + True -> #(acc2, more) 299 + False -> take_csi(acc2, more) 300 + } 301 + } 302 + } 303 + } 304 + 305 + fn codepoints_to_string(codes: List(UtfCodepoint)) -> String { 306 + case string.from_utf_codepoints(codes) { 307 + s -> s 308 + } 309 + }
+3
src/gleshell/parser.gleam
··· 235 235 ) -> Result(#(List(Arg), List(Token)), ParseError) { 236 236 case tokens { 237 237 [] | [Eof] | [Pipe, ..] -> Ok(#(list.reverse(acc), tokens)) 238 + // Bare `--` (lexer Flag("")) → literal argv element, not a named flag. 239 + [Flag(""), ..rest] -> 240 + parse_args(rest, [ValueArg(Lit(value.String("--"))), ..acc]) 238 241 [Flag(name), ..rest] -> { 239 242 case is_expr_start(rest) { 240 243 True -> {
+18
src/gleshell/sys.gleam
··· 8 8 @external(erlang, "gleshell_ffi", "println") 9 9 pub fn println(text: String) -> Nil 10 10 11 + /// Write text with no automatic trailing newline. ANSI is passed through. 12 + /// In raw TTY REPL mode, bare LFs become CRLF (same staircase fix as `println`). 13 + @external(erlang, "gleshell_ffi", "write") 14 + pub fn write(text: String) -> Nil 15 + 16 + /// Terminal `{rows, cols}` when stdout is a TTY; Error otherwise. 17 + @external(erlang, "gleshell_ffi", "term_size") 18 + pub fn term_size() -> Result(#(Int, Int), Nil) 19 + 20 + /// One keypress for the builtin pager. See `gleshell_ffi:read_key_name/0`. 21 + /// Prefer calling inside `with_key_mode` when not in the raw REPL. 22 + @external(erlang, "gleshell_ffi", "read_key_name") 23 + pub fn read_key_name() -> Result(String, String) 24 + 25 + /// Run `body` with the TTY in single-key mode when needed (non-raw REPL). 26 + @external(erlang, "gleshell_ffi", "with_key_mode") 27 + pub fn with_key_mode(body: fn() -> Nil) -> Nil 28 + 11 29 /// Run `body` as the interactive shell (raw TTY editor when possible). 12 30 /// Ensures Erlang `+Bc` so Ctrl+C cancels the line instead of aborting the VM. 13 31 @external(erlang, "gleshell_ffi", "run_as_shell")
+197 -27
src/gleshell_ffi.erl
··· 18 18 home_dir/0, 19 19 stdout_isatty/0, 20 20 println/1, 21 + write/1, 22 + term_size/0, 23 + read_key_name/0, 24 + with_key_mode/1, 21 25 take_output_shown/0, 22 26 clear_output_shown/0, 23 27 complete_word/2, ··· 44 48 nil 45 49 end. 46 50 51 + %% Write text without an automatic trailing newline. ANSI sequences are passed 52 + %% through unchanged. In raw TTY mode, bare LFs become CRLF so lines do not 53 + %% staircase (same as println/1). 54 + -spec write(binary()) -> nil. 55 + write(Text) when is_binary(Text) -> 56 + case get(gleshell_raw) of 57 + true -> 58 + io:put_chars(to_crlf(Text)), 59 + nil; 60 + _ -> 61 + io:put_chars(Text), 62 + nil 63 + end. 64 + 65 + %% Terminal size when stdout is a TTY. Defaults to 24×80 if the driver omits a 66 + %% dimension. Error when stdout is not a terminal (pager should dump instead). 67 + -spec term_size() -> {ok, {integer(), integer()}} | {error, nil}. 68 + term_size() -> 69 + case stdout_isatty() of 70 + false -> 71 + {error, nil}; 72 + true -> 73 + Rows = 74 + case io:rows() of 75 + {ok, R} when is_integer(R), R > 0 -> 76 + R; 77 + _ -> 78 + 24 79 + end, 80 + Cols = 81 + case io:columns() of 82 + {ok, C} when is_integer(C), C > 0 -> 83 + C; 84 + _ -> 85 + 80 86 + end, 87 + {ok, {Rows, Cols}} 88 + end. 89 + 90 + %% Run Fun with the TTY in single-key (non-canonical, no-echo) mode when the 91 + %% REPL is not already raw. Restores termios afterwards. Prefer wrapping a 92 + %% whole pager session rather than each keystroke. 93 + -spec with_key_mode(fun(() -> term())) -> term(). 94 + with_key_mode(Fun) when is_function(Fun, 0) -> 95 + case get(gleshell_raw) of 96 + true -> 97 + Fun(); 98 + _ -> 99 + case stdout_isatty() of 100 + false -> 101 + Fun(); 102 + true -> 103 + case stty_save() of 104 + {ok, Saved} -> 105 + _ = stty_run(["-icanon", "-echo", "min", "1", "time", "0"]), 106 + try 107 + Fun() 108 + after 109 + stty_restore(Saved) 110 + end; 111 + _ -> 112 + Fun() 113 + end 114 + end 115 + end. 116 + 117 + %% Blocking single-key read for the builtin pager. Returns a short name: 118 + %% "up" | "down" | "left" | "right" | "home" | "end" | "page_up" | "page_down" 119 + %% | "enter" | "backspace" | "tab" | "ctrl_c" | "ctrl_l" | "space" | "eof" 120 + %% | single printable grapheme ("q", "j", "G", …) | "other". 121 + %% 122 + %% Call inside `with_key_mode/1` when not already in the raw REPL, so cooked 123 + %% TTYs (e.g. `gleshell -c '… | less'`) still deliver keys one at a time. 124 + -spec read_key_name() -> {ok, binary()} | {error, binary()}. 125 + read_key_name() -> 126 + try 127 + {ok, key_to_name(read_key())} 128 + catch 129 + _:Reason -> 130 + {error, reason_to_bin(Reason)} 131 + end. 132 + 133 + key_to_name(eof) -> 134 + <<"eof">>; 135 + key_to_name(enter) -> 136 + <<"enter">>; 137 + key_to_name(tab) -> 138 + <<"tab">>; 139 + key_to_name(backspace) -> 140 + <<"backspace">>; 141 + key_to_name(delete) -> 142 + <<"delete">>; 143 + key_to_name(up) -> 144 + <<"up">>; 145 + key_to_name(down) -> 146 + <<"down">>; 147 + key_to_name(left) -> 148 + <<"left">>; 149 + key_to_name(right) -> 150 + <<"right">>; 151 + key_to_name(home) -> 152 + <<"home">>; 153 + key_to_name('end') -> 154 + <<"end">>; 155 + key_to_name(page_up) -> 156 + <<"page_up">>; 157 + key_to_name(page_down) -> 158 + <<"page_down">>; 159 + key_to_name(ctrl_c) -> 160 + <<"ctrl_c">>; 161 + key_to_name(ctrl_l) -> 162 + <<"ctrl_l">>; 163 + key_to_name(ctrl_a) -> 164 + <<"ctrl_a">>; 165 + key_to_name(ctrl_e) -> 166 + <<"ctrl_e">>; 167 + key_to_name(ctrl_d) -> 168 + <<"ctrl_d">>; 169 + key_to_name(ctrl_k) -> 170 + <<"ctrl_k">>; 171 + key_to_name(ctrl_u) -> 172 + <<"ctrl_u">>; 173 + key_to_name(ctrl_w) -> 174 + <<"ctrl_w">>; 175 + key_to_name(ctrl_r) -> 176 + <<"ctrl_r">>; 177 + key_to_name(ctrl_g) -> 178 + <<"ctrl_g">>; 179 + key_to_name({char, 32}) -> 180 + <<"space">>; 181 + key_to_name({char, C}) when is_integer(C), C >= 32 -> 182 + unicode:characters_to_binary([C]); 183 + key_to_name({error, _}) -> 184 + <<"eof">>; 185 + key_to_name(_) -> 186 + <<"other">>. 187 + 47 188 %% Normalize newlines to CRLF without turning existing \r\n into \r\r\n. 48 189 to_crlf(Bin) when is_binary(Bin) -> 49 190 B1 = binary:replace(Bin, <<"\r\n">>, <<"\n">>, [global]), ··· 555 696 556 697 fallback_builtin_names() -> 557 698 [ 558 - "append", "cat", "cd", "columns", "count", "describe", "echo", "env", 559 - "exit", "filter", "find", "first", "flatten", "from", "get", "help", 560 - "identity", "ignore", "is-empty", "is_empty", "keys", "last", "length", 561 - "lines", "ls", "open", "prepend", "print", "pwd", "quit", "range", 562 - "reverse", "save", "select", "skip", "sort-by", "sort_by", "sys", 563 - "table", "take", "to", "type", "typeof", "uniq", "unwrap", 564 - "values", "where", "which", "wrap" 699 + "about", "append", "cat", "cd", "columns", "count", "describe", "echo", 700 + "env", "exit", "filter", "find", "first", "flatten", "from", "get", 701 + "help", "identity", "ignore", "is-empty", "is_empty", "keys", "last", 702 + "length", "less", "lines", "ls", "open", "prepend", "print", "pwd", 703 + "quit", "range", "reverse", "save", "select", "skip", "sort-by", 704 + "sort_by", "sys", "table", "take", "to", "type", "typeof", "uniq", 705 + "unwrap", "values", "where", "which", "wrap" 565 706 ]. 566 707 567 708 %% Executable basenames on PATH that match Prefix (deduped, sorted). ··· 1023 1164 "1" -> home; 1024 1165 "3" -> delete; 1025 1166 "4" -> 'end'; 1167 + "5" -> page_up; 1168 + "6" -> page_down; 1026 1169 "7" -> home; 1027 1170 "8" -> 'end'; 1028 1171 _ -> other ··· 2096 2239 %% - SHELL=/bin/sh: `script` invokes $SHELL; nu/fish break `script -c`. 2097 2240 %% - LESS=FRX when unset: pagers (jj/git → less) pass through ANSI colors (-R) 2098 2241 %% and exit on short output (-F) without clearing the screen (-X). 2099 - %% - FORCE_COLOR / CLICOLOR_FORCE when the shell itself wants color and the 2100 - %% child has no TTY (direct path): tools like jj/git/ripgrep emit ANSI so 2101 - %% we can show their colors when re-printing the captured string. 2242 + %% - When the shell wants color and the child has no TTY (pipeline capture): 2243 + %% FORCE_COLOR / CLICOLOR_FORCE for tools that honor them (jj, many CLIs), 2244 + %% and git GIT_CONFIG_* overlays (git ignores FORCE_COLOR and treats pipes as 2245 + %% non-terminals): color.ui=always so `git log | less` is colored, and 2246 + %% log.decorate=short because decorate=auto drops ref names 2247 + %% (`(HEAD, origin/main, …)`) when stdout is not a TTY. 2102 2248 child_env() -> 2103 2249 Env0 = [{"SHELL", "/bin/sh"}], 2104 2250 Env1 = ··· 2114 2260 false -> 2115 2261 Env1; 2116 2262 true -> 2117 - Env2 = 2118 - case os:getenv("FORCE_COLOR") of 2119 - false -> 2120 - [{"FORCE_COLOR", "1"} | Env1]; 2121 - "0" -> 2122 - Env1; 2123 - _ -> 2124 - Env1 2125 - end, 2126 - case os:getenv("CLICOLOR_FORCE") of 2127 - false -> 2128 - [{"CLICOLOR_FORCE", "1"} | Env2]; 2129 - "0" -> 2130 - Env2; 2131 - _ -> 2132 - Env2 2133 - end 2263 + force_git_tty_env(force_color_env(Env1)) 2264 + end. 2265 + 2266 + %% FORCE_COLOR / CLICOLOR_FORCE for children that honor them. 2267 + force_color_env(Env) -> 2268 + Env1 = 2269 + case os:getenv("FORCE_COLOR") of 2270 + false -> 2271 + [{"FORCE_COLOR", "1"} | Env]; 2272 + "0" -> 2273 + Env; 2274 + _ -> 2275 + Env 2276 + end, 2277 + case os:getenv("CLICOLOR_FORCE") of 2278 + false -> 2279 + [{"CLICOLOR_FORCE", "1"} | Env1]; 2280 + "0" -> 2281 + Env1; 2282 + _ -> 2283 + Env1 2284 + end. 2285 + 2286 + %% Make git behave like a TTY for captured pipelines (git ≥ 2.31 GIT_CONFIG_*). 2287 + %% Skipped when the caller already set GIT_CONFIG_COUNT so we do not clobber. 2288 + %% 2289 + %% - color.ui=always: git ignores FORCE_COLOR 2290 + %% - log.decorate=short: default auto hides decorations on non-TTY stdout 2291 + force_git_tty_env(Env) -> 2292 + case os:getenv("GIT_CONFIG_COUNT") of 2293 + false -> 2294 + [ 2295 + {"GIT_CONFIG_COUNT", "2"}, 2296 + {"GIT_CONFIG_KEY_0", "color.ui"}, 2297 + {"GIT_CONFIG_VALUE_0", "always"}, 2298 + {"GIT_CONFIG_KEY_1", "log.decorate"}, 2299 + {"GIT_CONFIG_VALUE_1", "short"} 2300 + | Env 2301 + ]; 2302 + _ -> 2303 + Env 2134 2304 end. 2135 2305 2136 2306 %% Match gleshell color policy: off under NO_COLOR; otherwise on for a TTY
+176
test/gleshell_test.gleam
··· 8 8 import gleshell/eval 9 9 import gleshell/highlight 10 10 import gleshell/lexer 11 + import gleshell/pager 11 12 import gleshell/parser 12 13 import gleshell/sys 13 14 import gleshell/value.{Bool, Int, List, Nothing, Record, String, Table} ··· 50 51 lexer.Ident("~/code"), 51 52 lexer.Eof, 52 53 ] = tokens 54 + Nil 55 + } 56 + 57 + pub fn lexer_bare_double_dash_test() { 58 + // POSIX end-of-options: `nix run . -- args` must not lex-error on bare `--`. 59 + let assert Ok(tokens) = lexer.tokenize("nix run . -- chadfowler.com yolo") 60 + let assert [ 61 + lexer.Ident("nix"), 62 + lexer.Ident("run"), 63 + lexer.Ident("."), 64 + lexer.Flag(""), 65 + lexer.Ident("chadfowler.com"), 66 + lexer.Ident("yolo"), 67 + lexer.Eof, 68 + ] = tokens 69 + // Named long flags still work 70 + let assert Ok(tokens2) = lexer.tokenize("cmd --think --scale 3") 71 + let assert [ 72 + lexer.Ident("cmd"), 73 + lexer.Flag("think"), 74 + lexer.Flag("scale"), 75 + lexer.IntLit(3), 76 + lexer.Eof, 77 + ] = tokens2 78 + Nil 79 + } 80 + 81 + pub fn parse_bare_double_dash_test() { 82 + let assert Ok(parser.Expr(parser.Pipeline([ 83 + parser.Command("nix", args, False), 84 + ]))) = parser.parse("nix run . -- chadfowler.com yolo") 85 + let assert [ 86 + parser.ValueArg(parser.Lit(String("run"))), 87 + parser.ValueArg(parser.Lit(String("."))), 88 + parser.ValueArg(parser.Lit(String("--"))), 89 + parser.ValueArg(parser.Lit(String("chadfowler.com"))), 90 + parser.ValueArg(parser.Lit(String("yolo"))), 91 + ] = args 53 92 Nil 54 93 } 55 94 ··· 231 270 Nil 232 271 } 233 272 273 + pub fn about_command_test() { 274 + let env = env.new() 275 + let assert eval.Continue(_, String(text)) = eval.eval_source(env, "about") 276 + let assert True = string.contains(text, "gleshell") 277 + let assert True = string.contains(text, "nandi.uk") 278 + let assert True = string.contains(text, "NaNdi") 279 + let assert True = string.contains(text, "did:plc:ngokl2gnmpbvuvrfckja3g7p") 280 + let assert True = string.contains(text, "latha.org") 281 + let assert eval.Continue(_, String(which_out)) = 282 + eval.eval_source(env, "which about") 283 + let assert "builtin: about" = which_out 284 + Nil 285 + } 286 + 234 287 pub fn help_covers_all_builtins_test() { 235 288 // Every registered builtin must have a dedicated help_text entry. 236 289 let assert [] = builtins.missing_help() ··· 498 551 let assert 2 = color.visible_length(painted) 499 552 let assert 2 = color.visible_length("hi") 500 553 Nil 554 + } 555 + 556 + // --- less / pager --- 557 + 558 + pub fn pager_wrap_respects_ansi_width_test() { 559 + // 10 visible chars of content; wrap at 4 → three physical lines. 560 + let painted = color.paint(True, "\u{001b}[32m", "abcdefghij") 561 + let lines = pager.wrap_line(painted, 4) 562 + let assert 3 = list.length(lines) 563 + // Each physical line still carries / continues color; visible width ≤ 4. 564 + list.each(lines, fn(line) { 565 + let assert True = color.visible_length(line) <= 4 566 + }) 567 + // Joining without separators reconstructs the original SGR + text. 568 + let joined = string.join(lines, "") 569 + let assert True = string.contains(joined, "abcdefghij") 570 + let assert True = string.contains(joined, "\u{001b}[32m") 571 + Nil 572 + } 573 + 574 + pub fn pager_display_lines_splits_newlines_test() { 575 + let lines = pager.display_lines("a\nb\nc", 80) 576 + let assert ["a", "b", "c"] = lines 577 + Nil 578 + } 579 + 580 + pub fn less_short_output_passthrough_test() { 581 + // Non-TTY test runner: needs_paging is false → less returns the text. 582 + let env = env.new() 583 + let assert eval.Continue(_, String(out)) = 584 + eval.eval_source(env, "echo hello | less") 585 + let assert True = string.contains(out, "hello") 586 + let assert eval.Continue(_, String(which_out)) = 587 + eval.eval_source(env, "which less") 588 + let assert "builtin: less" = which_out 589 + Nil 590 + } 591 + 592 + pub fn less_preserves_ansi_in_string_test() { 593 + let env = env.new() 594 + // Pre-colored multi-line text must survive less (no re-paint / strip). 595 + let colored = "\u{001b}[31mred\u{001b}[0m\n\u{001b}[32mgreen\u{001b}[0m" 596 + let assert eval.Continue(_, String(out)) = 597 + eval.eval_source(env, "echo \"" <> escape_for_source(colored) <> "\" | less") 598 + let assert True = string.contains(out, "\u{001b}[31m") 599 + let assert True = string.contains(out, "red") 600 + let assert True = string.contains(out, "\u{001b}[32m") 601 + let assert True = string.contains(out, "green") 602 + Nil 603 + } 604 + 605 + pub fn less_no_input_errors_test() { 606 + let env = env.new() 607 + let assert eval.Continue(env2, value.Fail(msg)) = eval.eval_source(env, "less") 608 + let assert True = string.contains(msg, "no input") 609 + let assert 1 = env2.last_exit 610 + Nil 611 + } 612 + 613 + pub fn less_help_test() { 614 + let env = env.new() 615 + let assert eval.Continue(_, String(help_out)) = 616 + eval.eval_source(env, "help less") 617 + let assert True = string.contains(help_out, "ANSI") 618 + let assert True = string.contains(help_out, "q") 619 + Nil 620 + } 621 + 622 + pub fn pipeline_capture_forces_git_tty_config_test() { 623 + // git ignores FORCE_COLOR and hides decorations on pipes; child_env injects 624 + // color.ui=always + log.decorate=short. FORCE_COLOR makes want_child_color 625 + // true without a TTY. Use `let` so capture mode does not leak printenv. 626 + let prev = sys.getenv("FORCE_COLOR") 627 + let assert Ok(_) = sys.setenv("FORCE_COLOR", "1") 628 + let env = env.new() 629 + let color = eval.eval_source(env, "let x = ^printenv GIT_CONFIG_VALUE_0") 630 + let decorate = eval.eval_source(env, "let y = ^printenv GIT_CONFIG_VALUE_1") 631 + case prev { 632 + Ok(v) -> { 633 + let assert Ok(_) = sys.setenv("FORCE_COLOR", v) 634 + Nil 635 + } 636 + Error(Nil) -> { 637 + let assert Ok(_) = sys.setenv("FORCE_COLOR", "") 638 + Nil 639 + } 640 + } 641 + let assert eval.Continue(_, String(color_out)) = color 642 + let assert eval.Continue(_, String(decorate_out)) = decorate 643 + let assert True = string.contains(color_out, "always") 644 + let assert True = string.contains(decorate_out, "short") 645 + Nil 646 + } 647 + 648 + pub fn git_log_pipeline_emits_ansi_and_decorate_test() { 649 + let prev = sys.getenv("FORCE_COLOR") 650 + let assert Ok(_) = sys.setenv("FORCE_COLOR", "1") 651 + let env = env.new() 652 + // Full log (not --oneline): decorations appear on the commit line. 653 + let result = eval.eval_source(env, "git log -1 | identity") 654 + case prev { 655 + Ok(v) -> { 656 + let assert Ok(_) = sys.setenv("FORCE_COLOR", v) 657 + Nil 658 + } 659 + Error(Nil) -> { 660 + let assert Ok(_) = sys.setenv("FORCE_COLOR", "") 661 + Nil 662 + } 663 + } 664 + let assert eval.Continue(_, String(out)) = result 665 + // Real git colors, not gleshell string-green on plain text. 666 + let assert True = string.contains(out, "\u{001b}[") 667 + // decorate=short: ref names like HEAD / main (auto would omit on a pipe). 668 + let assert True = string.contains(out, "HEAD") || string.contains(out, "main") 669 + Nil 670 + } 671 + 672 + /// Embed a string in double-quoted source: escape `\` and `"`. 673 + fn escape_for_source(s: String) -> String { 674 + s 675 + |> string.replace("\\", "\\\\") 676 + |> string.replace("\"", "\\\"") 501 677 } 502 678 503 679 // --- input syntax highlighting ---