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 live search to the builtin less pager and PTY color capture.

Incremental `/` find (case-insensitive, ANSI-stripped) with n/N next/prev
and match highlighting. Captured pipeline stages run under a throwaway PTY
so colorizing tools keep ANSI without per-tool env hacks; nested pagers
are forced to cat. Falls back to FORCE_COLOR when script is unavailable.

author
nandi
date (Jul 25, 2026, 7:37 PM -0700) commit e8c80bbb parent af16c86d change-id lwzmrqwl
+860 -91
+11 -8
README.md
··· 140 140 141 141 Pager: `less` — builtin color-aware pager for pipeline input or files (`ls | less`, 142 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. 143 + printed without an interactive session. Interactive keys include live `/` search 144 + (case-insensitive, finds as you type) and `n`/`N` next/previous match. `^less` 145 + still runs the external binary. 144 146 145 147 Unknown command names fall through to external executables on `PATH`. 146 148 ··· 165 167 Input and output are colorized on a TTY (Nu-like shapes on the command line; 166 168 headers bold green, numbers purple, bools cyan, dirs blue, errors red, …). 167 169 External tools (`jj`, `git`, `fastfetch`, …) keep their own colors: final-stage 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 170 + commands inherit the real TTY; captured pipeline stages (e.g. `jj log | less`, 171 + `git log | less`) run under a throwaway PTY when the shell wants color so tools 172 + that only colorize on a terminal still emit ANSI — without per-tool env hacks. 173 + Nested pagers are forced to `cat` so the producer cannot hang in its own 174 + `less`. If `script` is unavailable, capture falls back to pipes plus 175 + `FORCE_COLOR` / `CLICOLOR_FORCE` and a git `GIT_CONFIG_*` overlay. Pre-colored 176 + text is not re-painted by the shell. While the raw line editor is active, the 177 + TTY is switched to cooked mode for those children so LF-only output does not 175 178 staircase. The builtin `less` keeps ANSI (like `less -R`); external pagers 176 179 still get `LESS=FRX` when unset so they pass colors through. 177 180 Disable with `NO_COLOR=1`; force with `FORCE_COLOR=1`.
+1
src/gleshell/builtins.gleam
··· 232 232 " j / ↓ / Enter line down k / ↑ line up", 233 233 " space / f / PgDn page down b / PgUp page up", 234 234 " g / Home top G / End bottom", 235 + " /pattern live search n / N next/prev", 235 236 " h / ? help q / Ctrl+C quit", 236 237 "", 237 238 "Examples:",
+37
src/gleshell/color.gleam
··· 1 1 //// Nushell-inspired ANSI colors for structured values. 2 2 3 + import gleam/list 3 4 import gleam/string 4 5 import gleshell/sys 5 6 ··· 277 278 visible_length_loop(string.to_utf_codepoints(s), 0, AnsiNormal) 278 279 } 279 280 281 + /// Drop ANSI/VT escape sequences, leaving only visible text. 282 + /// Used by the pager search so color codes do not break matches. 283 + pub fn strip_ansi(s: String) -> String { 284 + strip_ansi_loop(string.to_utf_codepoints(s), [], AnsiNormal) 285 + } 286 + 280 287 type AnsiScan { 281 288 AnsiNormal 282 289 /// Saw ESC; next byte chooses the sequence kind. ··· 314 321 } 315 322 } 316 323 } 324 + 325 + fn strip_ansi_loop( 326 + codes: List(UtfCodepoint), 327 + acc: List(UtfCodepoint), 328 + state: AnsiScan, 329 + ) -> String { 330 + case codes, state { 331 + [], _ -> 332 + case string.from_utf_codepoints(list.reverse(acc)) { 333 + s -> s 334 + } 335 + [c, ..rest], AnsiNormal -> 336 + case string.utf_codepoint_to_int(c) == 0x1B { 337 + True -> strip_ansi_loop(rest, acc, AnsiEsc) 338 + False -> strip_ansi_loop(rest, [c, ..acc], AnsiNormal) 339 + } 340 + [c, ..rest], AnsiEsc -> 341 + case string.utf_codepoint_to_int(c) { 342 + 0x5B -> strip_ansi_loop(rest, acc, AnsiCsi) 343 + _ -> strip_ansi_loop(rest, acc, AnsiNormal) 344 + } 345 + [c, ..rest], AnsiCsi -> { 346 + let n = string.utf_codepoint_to_int(c) 347 + case n >= 0x40 && n <= 0x7E { 348 + True -> strip_ansi_loop(rest, acc, AnsiNormal) 349 + False -> strip_ansi_loop(rest, acc, AnsiCsi) 350 + } 351 + } 352 + } 353 + }
+579 -39
src/gleshell/pager.gleam
··· 4 4 //// and uses the alternate screen while interactive so the REPL is restored 5 5 //// on quit. When stdout is not a TTY, or the text fits on one screen, the 6 6 //// caller should print the text itself (see `needs_paging`). 7 + //// 8 + //// Search: `/` live-finds a fixed string (ANSI-stripped, case-insensitive) as 9 + //// you type; Enter accepts. `n` / `N` jump to the next / previous match 10 + //// (wraps at the ends). 7 11 8 12 import gleam/int 9 13 import gleam/list 14 + import gleam/option.{type Option, None, Some} 10 15 import gleam/string 11 16 import gleshell/color 12 17 import gleshell/sys ··· 35 40 let total = list.length(lines) 36 41 sys.with_key_mode(fn() { 37 42 enter_alt_screen() 38 - page_loop(lines, total, 0, height, cols) 43 + page_loop(lines, total, 0, height, cols, None, None) 39 44 leave_alt_screen() 40 45 }) 41 46 } ··· 57 62 |> list.flat_map(fn(line) { wrap_line(line, cols) }) 58 63 } 59 64 65 + /// True if the line matches `pattern` as a fixed substring of its ANSI-stripped 66 + /// text (case-insensitive). Empty patterns never match. 67 + pub fn line_matches(line: String, pattern: String) -> Bool { 68 + case pattern { 69 + "" -> False 70 + p -> 71 + string.contains( 72 + string.lowercase(color.strip_ansi(line)), 73 + string.lowercase(p), 74 + ) 75 + } 76 + } 77 + 78 + /// Wrap each non-overlapping fixed-string match in black-on-bright-yellow 79 + /// (`CSI 30;103m` / `CSI 39;49m`). Matches are located on ANSI-stripped text 80 + /// so color codes do not break search; matching is case-insensitive. Existing 81 + /// escapes are left in place. While inside a match, SGR sequences are followed 82 + /// by a re-open of the accent so a content reset does not cancel the highlight. 83 + /// Empty pattern or no hits returns `line` unchanged. 84 + pub fn highlight_matches(line: String, pattern: String) -> String { 85 + case pattern { 86 + "" -> line 87 + p -> { 88 + let plain = color.strip_ansi(line) 89 + // Lowercase for matching only; ranges still map to visible codepoint 90 + // positions on the original line (ASCII case fold keeps lengths equal). 91 + let plain_ci = string.lowercase(plain) 92 + let p_ci = string.lowercase(p) 93 + case string.contains(plain_ci, p_ci) { 94 + False -> line 95 + True -> 96 + case match_ranges(plain_ci, p_ci) { 97 + [] -> line 98 + ranges -> apply_match_highlight(line, ranges) 99 + } 100 + } 101 + } 102 + } 103 + } 104 + 105 + /// Black on bright yellow — readable accent over any content colors. 106 + /// Off restores default fg/bg (`39;49`). 107 + const match_on = "\u{001b}[30;103m" 108 + 109 + const match_off = "\u{001b}[39;49m" 110 + 111 + /// Non-overlapping match ranges as visible-codepoint `#(start, end)` (end exclusive). 112 + fn match_ranges(plain: String, pattern: String) -> List(#(Int, Int)) { 113 + let plain_cps = string.to_utf_codepoints(plain) 114 + let pat_cps = string.to_utf_codepoints(pattern) 115 + let plen = list.length(pat_cps) 116 + case plen { 117 + 0 -> [] 118 + _ -> match_ranges_loop(plain_cps, pat_cps, plen, 0, []) 119 + } 120 + } 121 + 122 + fn match_ranges_loop( 123 + plain: List(UtfCodepoint), 124 + pat: List(UtfCodepoint), 125 + plen: Int, 126 + index: Int, 127 + acc: List(#(Int, Int)), 128 + ) -> List(#(Int, Int)) { 129 + case plain { 130 + [] -> list.reverse(acc) 131 + _ -> 132 + case list_starts_with_cp(plain, pat) { 133 + True -> 134 + match_ranges_loop( 135 + list.drop(plain, plen), 136 + pat, 137 + plen, 138 + index + plen, 139 + [#(index, index + plen), ..acc], 140 + ) 141 + False -> 142 + case plain { 143 + [_, ..rest] -> 144 + match_ranges_loop(rest, pat, plen, index + 1, acc) 145 + [] -> list.reverse(acc) 146 + } 147 + } 148 + } 149 + } 150 + 151 + fn list_starts_with_cp( 152 + hay: List(UtfCodepoint), 153 + needle: List(UtfCodepoint), 154 + ) -> Bool { 155 + case needle { 156 + [] -> True 157 + [n, ..ns] -> 158 + case hay { 159 + [h, ..hs] -> 160 + case string.utf_codepoint_to_int(h) == string.utf_codepoint_to_int(n) { 161 + True -> list_starts_with_cp(hs, ns) 162 + False -> False 163 + } 164 + [] -> False 165 + } 166 + } 167 + } 168 + 169 + fn apply_match_highlight(line: String, ranges: List(#(Int, Int))) -> String { 170 + apply_hl_loop(string.to_utf_codepoints(line), ranges, 0, False, "") 171 + } 172 + 173 + fn apply_hl_loop( 174 + codes: List(UtfCodepoint), 175 + ranges: List(#(Int, Int)), 176 + vis: Int, 177 + in_match: Bool, 178 + acc: String, 179 + ) -> String { 180 + case codes { 181 + [] -> 182 + case in_match { 183 + True -> acc <> match_off 184 + False -> acc 185 + } 186 + [c, ..rest] -> { 187 + let n = string.utf_codepoint_to_int(c) 188 + case n == 0x1B { 189 + True -> { 190 + let #(seq, after) = take_ansi([c, ..rest]) 191 + let seq_s = codepoints_to_string(seq) 192 + let acc2 = case in_match && is_sgr_sequence(seq) { 193 + // Content reset/color change would drop the accent bg; put it back. 194 + True -> acc <> seq_s <> match_on 195 + False -> acc <> seq_s 196 + } 197 + apply_hl_loop(after, ranges, vis, in_match, acc2) 198 + } 199 + False -> { 200 + let next_in = visible_in_match(vis, ranges) 201 + let acc2 = case in_match, next_in { 202 + True, False -> acc <> match_off 203 + False, True -> acc <> match_on 204 + _, _ -> acc 205 + } 206 + apply_hl_loop( 207 + rest, 208 + ranges, 209 + vis + 1, 210 + next_in, 211 + acc2 <> codepoints_to_string([c]), 212 + ) 213 + } 214 + } 215 + } 216 + } 217 + } 218 + 219 + fn visible_in_match(vis: Int, ranges: List(#(Int, Int))) -> Bool { 220 + list.any(ranges, fn(range) { 221 + let #(start, end) = range 222 + vis >= start && vis < end 223 + }) 224 + } 225 + 226 + fn is_sgr_sequence(seq: List(UtfCodepoint)) -> Bool { 227 + case list.reverse(seq) { 228 + [last, ..] -> string.utf_codepoint_to_int(last) == 0x6D 229 + [] -> False 230 + } 231 + } 232 + 233 + /// First match strictly after `after` (use `-1` to search from the start). 234 + /// Wraps once from the top when nothing is found past `after`. 235 + /// Returns `#(index, wrapped)`. 236 + pub fn find_after( 237 + lines: List(String), 238 + pattern: String, 239 + after: Int, 240 + ) -> Result(#(Int, Bool), Nil) { 241 + case pattern { 242 + "" -> Error(Nil) 243 + p -> { 244 + let total = list.length(lines) 245 + case total { 246 + 0 -> Error(Nil) 247 + _ -> { 248 + let start = after + 1 249 + case first_match_in(lines, p, start, total) { 250 + Ok(i) -> Ok(#(i, False)) 251 + Error(Nil) -> 252 + case first_match_in(lines, p, 0, int.min(total, after + 1)) { 253 + Ok(i) -> Ok(#(i, True)) 254 + Error(Nil) -> Error(Nil) 255 + } 256 + } 257 + } 258 + } 259 + } 260 + } 261 + } 262 + 263 + /// First match strictly before `before` (use `total` to search from the end). 264 + /// Wraps once from the bottom when nothing is found before `before`. 265 + /// Returns `#(index, wrapped)`. 266 + pub fn find_before( 267 + lines: List(String), 268 + pattern: String, 269 + before: Int, 270 + ) -> Result(#(Int, Bool), Nil) { 271 + case pattern { 272 + "" -> Error(Nil) 273 + p -> { 274 + let total = list.length(lines) 275 + case total { 276 + 0 -> Error(Nil) 277 + _ -> { 278 + let until = int.clamp(before, 0, total) 279 + case last_match_in(lines, p, 0, until) { 280 + Ok(i) -> Ok(#(i, False)) 281 + Error(Nil) -> 282 + case last_match_in(lines, p, int.max(0, before), total) { 283 + Ok(i) -> Ok(#(i, True)) 284 + Error(Nil) -> Error(Nil) 285 + } 286 + } 287 + } 288 + } 289 + } 290 + } 291 + } 292 + 293 + fn first_match_in( 294 + lines: List(String), 295 + pattern: String, 296 + from: Int, 297 + until: Int, 298 + ) -> Result(Int, Nil) { 299 + case from >= until { 300 + True -> Error(Nil) 301 + False -> 302 + case list_at(lines, from) { 303 + Ok(line) -> 304 + case line_matches(line, pattern) { 305 + True -> Ok(from) 306 + False -> first_match_in(lines, pattern, from + 1, until) 307 + } 308 + Error(Nil) -> Error(Nil) 309 + } 310 + } 311 + } 312 + 313 + fn last_match_in( 314 + lines: List(String), 315 + pattern: String, 316 + from: Int, 317 + until: Int, 318 + ) -> Result(Int, Nil) { 319 + case until <= from { 320 + True -> Error(Nil) 321 + False -> { 322 + let i = until - 1 323 + case list_at(lines, i) { 324 + Ok(line) -> 325 + case line_matches(line, pattern) { 326 + True -> Ok(i) 327 + False -> last_match_in(lines, pattern, from, i) 328 + } 329 + Error(Nil) -> Error(Nil) 330 + } 331 + } 332 + } 333 + } 334 + 335 + fn list_at(items: List(String), index: Int) -> Result(String, Nil) { 336 + case index < 0 { 337 + True -> Error(Nil) 338 + False -> 339 + case list.drop(items, index) { 340 + [x, ..] -> Ok(x) 341 + [] -> Error(Nil) 342 + } 343 + } 344 + } 345 + 60 346 fn page_loop( 61 347 lines: List(String), 62 348 total: Int, 63 349 offset: Int, 64 350 height: Int, 65 351 cols: Int, 352 + pattern: Option(String), 353 + message: Option(String), 66 354 ) -> Nil { 67 355 let max_off = int.max(0, total - height) 68 356 let offset = int.clamp(offset, 0, max_off) 69 - redraw(lines, total, offset, height, cols) 357 + redraw(lines, total, offset, height, cols, pattern, message) 70 358 case sys.read_key_name() { 71 359 Error(_) -> Nil 72 360 Ok("eof") -> Nil 73 361 Ok("q") | Ok("Q") | Ok("ctrl_c") | Ok("ctrl_d") -> Nil 74 362 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) 363 + page_loop(lines, total, offset + 1, height, cols, pattern, None) 364 + Ok("up") | Ok("k") -> 365 + page_loop(lines, total, offset - 1, height, cols, pattern, None) 77 366 Ok("space") | Ok("f") | Ok("page_down") | Ok("ctrl_f") -> 78 - page_loop(lines, total, offset + height, height, cols) 367 + page_loop(lines, total, offset + height, height, cols, pattern, None) 79 368 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) 369 + page_loop(lines, total, offset - height, height, cols, pattern, None) 370 + Ok("g") | Ok("home") -> 371 + page_loop(lines, total, 0, height, cols, pattern, None) 372 + Ok("G") | Ok("end") -> 373 + page_loop(lines, total, max_off, height, cols, pattern, None) 374 + Ok("ctrl_l") -> 375 + page_loop(lines, total, offset, height, cols, pattern, None) 376 + Ok("/") -> 377 + handle_search(lines, total, offset, height, cols, pattern, Forward) 378 + Ok("n") -> 379 + handle_repeat(lines, total, offset, height, cols, pattern, Forward) 380 + Ok("N") -> 381 + handle_repeat(lines, total, offset, height, cols, pattern, Backward) 84 382 Ok("h") | Ok("?") -> { 85 383 show_help(height, cols) 86 - page_loop(lines, total, offset, height, cols) 384 + page_loop(lines, total, offset, height, cols, pattern, None) 385 + } 386 + Ok(_) -> page_loop(lines, total, offset, height, cols, pattern, None) 387 + } 388 + } 389 + 390 + type SearchDir { 391 + Forward 392 + Backward 393 + } 394 + 395 + fn handle_search( 396 + lines: List(String), 397 + total: Int, 398 + offset: Int, 399 + height: Int, 400 + cols: Int, 401 + pattern: Option(String), 402 + dir: SearchDir, 403 + ) -> Nil { 404 + // Live search: matches highlight and the view jumps as the query grows. 405 + // Cancel restores `offset` + prior pattern; Enter accepts the live position. 406 + case live_search_loop(lines, total, offset, height, cols, "") { 407 + Error(Nil) -> 408 + page_loop(lines, total, offset, height, cols, pattern, None) 409 + Ok(#(entered, live_offset)) -> 410 + case entered { 411 + "" -> 412 + // Empty Enter reuses the previous pattern and jumps from start. 413 + case pattern { 414 + None -> 415 + page_loop( 416 + lines, 417 + total, 418 + offset, 419 + height, 420 + cols, 421 + None, 422 + Some("No previous pattern"), 423 + ) 424 + Some(p) -> 425 + apply_search(lines, total, offset, height, cols, p, dir) 426 + } 427 + p -> 428 + // Already on the live match; keep that offset (do not re-search). 429 + page_loop(lines, total, live_offset, height, cols, Some(p), None) 430 + } 431 + } 432 + } 433 + 434 + fn handle_repeat( 435 + lines: List(String), 436 + total: Int, 437 + offset: Int, 438 + height: Int, 439 + cols: Int, 440 + pattern: Option(String), 441 + dir: SearchDir, 442 + ) -> Nil { 443 + case pattern { 444 + None -> 445 + page_loop( 446 + lines, 447 + total, 448 + offset, 449 + height, 450 + cols, 451 + None, 452 + Some("No previous pattern"), 453 + ) 454 + Some(p) -> apply_search(lines, total, offset, height, cols, p, dir) 455 + } 456 + } 457 + 458 + fn apply_search( 459 + lines: List(String), 460 + total: Int, 461 + offset: Int, 462 + height: Int, 463 + cols: Int, 464 + pattern: String, 465 + dir: SearchDir, 466 + ) -> Nil { 467 + let result = case dir { 468 + Forward -> find_after(lines, pattern, offset) 469 + Backward -> find_before(lines, pattern, offset) 470 + } 471 + case result { 472 + Error(Nil) -> 473 + page_loop( 474 + lines, 475 + total, 476 + offset, 477 + height, 478 + cols, 479 + Some(pattern), 480 + Some("Pattern not found"), 481 + ) 482 + Ok(#(i, wrapped)) -> { 483 + let msg = case wrapped { 484 + True -> Some("Search wrapped") 485 + False -> None 486 + } 487 + page_loop(lines, total, i, height, cols, Some(pattern), msg) 488 + } 489 + } 490 + } 491 + 492 + /// Live incremental search. Returns `#(query, view_offset)` on Enter, or 493 + /// `Error` on cancel. Empty query on Enter is left for the caller (reuse prior). 494 + /// 495 + /// While typing, the page jumps to the first match at or after `start_offset` 496 + /// and highlights hits; no match keeps the start view with the query on the 497 + /// status line. 498 + fn live_search_loop( 499 + lines: List(String), 500 + total: Int, 501 + start_offset: Int, 502 + height: Int, 503 + cols: Int, 504 + query: String, 505 + ) -> Result(#(String, Int), Nil) { 506 + let #(view_offset, paint, status_msg) = 507 + live_search_preview(lines, query, start_offset) 508 + redraw(lines, total, view_offset, height, cols, paint, None) 509 + draw_search_status(cols, query, status_msg) 510 + case sys.read_key_name() { 511 + Error(_) -> Error(Nil) 512 + Ok("eof") | Ok("ctrl_c") | Ok("ctrl_g") | Ok("ctrl_d") -> Error(Nil) 513 + Ok("enter") -> Ok(#(query, view_offset)) 514 + Ok("backspace") -> 515 + live_search_loop( 516 + lines, 517 + total, 518 + start_offset, 519 + height, 520 + cols, 521 + drop_last_grapheme(query), 522 + ) 523 + Ok("ctrl_u") -> 524 + live_search_loop(lines, total, start_offset, height, cols, "") 525 + Ok("space") -> 526 + live_search_loop(lines, total, start_offset, height, cols, query <> " ") 527 + Ok(key) -> 528 + case is_search_char(key) { 529 + True -> 530 + live_search_loop( 531 + lines, 532 + total, 533 + start_offset, 534 + height, 535 + cols, 536 + query <> key, 537 + ) 538 + False -> 539 + live_search_loop(lines, total, start_offset, height, cols, query) 540 + } 541 + } 542 + } 543 + 544 + /// Pure preview for live `/` search: view offset, highlight pattern, and an 545 + /// optional status suffix (e.g. "not found"). Empty query restores 546 + /// `start_offset` with no highlight. 547 + pub fn live_search_preview( 548 + lines: List(String), 549 + query: String, 550 + start_offset: Int, 551 + ) -> #(Int, Option(String), Option(String)) { 552 + case query { 553 + "" -> #(start_offset, None, None) 554 + p -> 555 + // Inclusive of the line at start_offset (find_after is strictly after). 556 + case find_after(lines, p, start_offset - 1) { 557 + Ok(#(i, _)) -> #(i, Some(p), None) 558 + Error(Nil) -> #(start_offset, Some(p), Some("not found")) 559 + } 560 + } 561 + } 562 + 563 + fn is_search_char(key: String) -> Bool { 564 + case string.starts_with(key, "ctrl_") { 565 + True -> False 566 + False -> 567 + // key_to_name returns one printable grapheme for {char, C}; named keys 568 + // ("up", "enter", …) are multi-grapheme or handled elsewhere. 569 + case string.to_graphemes(key) { 570 + [_] -> True 571 + _ -> False 572 + } 573 + } 574 + } 575 + 576 + fn drop_last_grapheme(s: String) -> String { 577 + case list.reverse(string.to_graphemes(s)) { 578 + [] -> "" 579 + [_, ..rest] -> string.concat(list.reverse(rest)) 580 + } 581 + } 582 + 583 + fn draw_search_status( 584 + cols: Int, 585 + query: String, 586 + message: Option(String), 587 + ) -> Nil { 588 + // Move to last row (status line) without clearing the page content. 589 + case sys.term_size() { 590 + Error(Nil) -> Nil 591 + Ok(#(rows, _)) -> { 592 + let row = int.max(1, rows) 593 + let plain = case message { 594 + Some(msg) -> "/" <> query <> " (" <> msg <> ") " 595 + None -> "/" <> query 596 + } 597 + let body = case color.enabled() { 598 + True -> "\u{001b}[7m" <> pad_status(plain, cols) <> "\u{001b}[0m" 599 + False -> pad_status(plain, cols) 600 + } 601 + sys.write( 602 + "\u{001b}[" 603 + <> int.to_string(row) 604 + <> ";1H\u{001b}[0m" 605 + <> body 606 + <> "\u{001b}[K", 607 + ) 87 608 } 88 - Ok(_) -> page_loop(lines, total, offset, height, cols) 89 609 } 90 610 } 91 611 ··· 95 615 offset: Int, 96 616 height: Int, 97 617 cols: Int, 618 + pattern: Option(String), 619 + message: Option(String), 98 620 ) -> Nil { 99 621 // Home + clear + SGR reset inside the alternate buffer. 100 622 // Do not reset SGR after every row: soft-wrapped chunks only open CSI on the ··· 103 625 let view = list_slice(lines, offset, height) 104 626 let padded = pad_to(view, height) 105 627 list.each(padded, fn(line) { 106 - sys.write(line <> "\u{001b}[K\r\n") 628 + let painted = case pattern { 629 + Some(p) -> highlight_matches(line, p) 630 + None -> line 631 + } 632 + sys.write(painted <> "\u{001b}[K\r\n") 107 633 }) 108 634 // Ensure the status bar is not tinted by leftover content SGR. 109 - sys.write("\u{001b}[0m" <> status_line(offset, height, total, cols)) 635 + sys.write("\u{001b}[0m" <> status_line(offset, height, total, cols, message)) 110 636 } 111 637 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 638 + fn status_line( 639 + offset: Int, 640 + height: Int, 641 + total: Int, 642 + cols: Int, 643 + message: Option(String), 644 + ) -> String { 645 + let plain = case message { 646 + Some(msg) -> " " <> msg <> " " 647 + None -> { 648 + let at_end = offset + height >= total 649 + let label = case total { 650 + 0 -> " (empty) " 651 + _ if at_end -> " (END) " 652 + _ -> { 653 + let bottom = int.min(total, offset + height) 654 + let pct = case total { 655 + 0 -> 100 656 + n -> bottom * 100 / n 657 + } 658 + " " 659 + <> int.to_string(offset + 1) 660 + <> "-" 661 + <> int.to_string(bottom) 662 + <> "/" 663 + <> int.to_string(total) 664 + <> " (" 665 + <> int.to_string(pct) 666 + <> "%) " 667 + } 122 668 } 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 - <> "%) " 669 + let help = 670 + " q:quit /:search n/N j/k:line space/b:page g/G h:help " 671 + case color.visible_length(label <> help) > cols { 672 + True -> label 673 + False -> label <> help 674 + } 132 675 } 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 676 } 140 677 let on = color.enabled() 141 678 let body = case on { ··· 166 703 " b / PgUp one page up", 167 704 " g / Home top", 168 705 " G / End bottom", 706 + " /pattern live search forward (fixed string, ignore case)", 707 + " n / N next / previous match", 169 708 " Ctrl+L redraw", 170 709 " h / ? this help", 171 710 " q / Q / Ctrl+C quit", 172 711 "", 173 712 "ANSI colors from tools and tables are kept (like less -R).", 713 + "Search finds as you type (case-insensitive; ANSI ignored); hits are black on yellow.", 174 714 "", 175 715 "Press any key to return…", 176 716 ], 177 717 "\n", 178 718 ) 179 719 let lines = display_lines(help_text, cols) 180 - redraw(lines, list.length(lines), 0, height, cols) 720 + redraw(lines, list.length(lines), 0, height, cols, None, None) 181 721 let _ = sys.read_key_name() 182 722 Nil 183 723 }
+101 -28
src/gleshell_ffi.erl
··· 1431 1431 %% Two modes: 1432 1432 %% 1433 1433 %% 1. `run_cmd/2` — capture stdout/stderr into a binary (pipelines, `let x =`, 1434 - %% non-TTY). Uses pipes; the child does NOT get a real terminal. 1434 + %% non-TTY display). Prefers a throwaway PTY when color is wanted so tools 1435 + %% emit ANSI for `cmd | less`; falls back to pipes. 1435 1436 %% 1436 1437 %% 2. `run_cmd_tty/2` — foreground interactive. Prefer util-linux `script` 1437 1438 %% (PTY + key relay via `io:get_chars`) so Ctrl+C can SIGINT the child. ··· 1634 1635 {ok, Path, PortArgs} 1635 1636 end. 1636 1637 1637 - %% Capture mode: pipes, no TTY. `child_env` forces color when the shell wants 1638 - %% it so tools like `jj` still embed ANSI we can pass through on display. 1638 + %% Capture mode: collect stdout/stderr into a binary (pipelines, `let x =`). 1639 + %% 1640 + %% When the shell wants color, prefer a throwaway PTY (`script`) so tools that 1641 + %% only colorize on a TTY (`jj`, `git`, …) still emit ANSI for `cmd | less` — 1642 + %% without tool-specific env hacks. Nested pagers are forced to `cat` so the 1643 + %% child cannot hang waiting for interactive `less`. Falls back to plain pipes 1644 + %% (+ FORCE_COLOR / git overlays) when `script` is missing. 1639 1645 %% 1640 - %% Stdin is always redirected via `sh -c` + `$GLESHELL_STDIN` (either a temp 1641 - %% file with pipeline bytes, or `/dev/null`) so programs never hang on an 1642 - %% open-but-never-written Erlang port pipe. 1646 + %% Stdin is redirected via temp file / `$GLESHELL_STDIN` so programs never hang 1647 + %% on an open-but-never-written Erlang port pipe. 1643 1648 run_cmd_capture(Path, PortArgs, Stdin) when is_binary(Stdin) -> 1649 + case want_child_color() of 1650 + true -> 1651 + case os:find_executable("script") of 1652 + Script when is_list(Script) -> 1653 + run_cmd_capture_pty(Script, Path, PortArgs, Stdin); 1654 + _ -> 1655 + run_cmd_capture_pipe(Path, PortArgs, Stdin) 1656 + end; 1657 + false -> 1658 + run_cmd_capture_pipe(Path, PortArgs, Stdin) 1659 + end. 1660 + 1661 + run_cmd_capture_pipe(Path, PortArgs, Stdin) when is_binary(Stdin) -> 1644 1662 with_stdin_file(Stdin, fun(StdinPath) -> 1645 1663 sh_exec(Path, PortArgs, StdinPath, capture) 1664 + end). 1665 + 1666 + %% PTY capture: child sees a TTY (colors, auto decorations) but we only collect 1667 + %% output — nothing is relayed to the user's terminal. 1668 + run_cmd_capture_pty(Script, Path, PortArgs, <<>>) -> 1669 + run_cmd_capture_pty_argv(Script, [Path | PortArgs]); 1670 + run_cmd_capture_pty(Script, Path, PortArgs, Stdin) when is_binary(Stdin) -> 1671 + with_stdin_file(Stdin, fun(StdinPath) -> 1672 + with_exec_runner(Path, PortArgs, StdinPath, fun(Runner) -> 1673 + run_cmd_capture_pty_argv(Script, [Runner]) 1674 + end) 1675 + end). 1676 + 1677 + run_cmd_capture_pty_argv(Script, Argv) when is_list(Argv) -> 1678 + with_trapped_exits(fun() -> 1679 + Port = open_port( 1680 + {spawn_executable, Script}, 1681 + [ 1682 + binary, 1683 + exit_status, 1684 + stderr_to_stdout, 1685 + use_stdio, 1686 + stream, 1687 + {env, child_env_capture_pty()}, 1688 + {args, ["-q", "-e", "/dev/null", "--" | Argv]} 1689 + ] 1690 + ), 1691 + put(gleshell_output_shown, false), 1692 + try 1693 + case collect_output(Port, <<>>) of 1694 + {ok, {Status, Acc}} -> 1695 + {ok, {Status, normalize_pty_output(Acc)}}; 1696 + Other -> 1697 + Other 1698 + end 1699 + after 1700 + catch port_close(Port) 1701 + end 1646 1702 end). 1647 1703 1648 1704 %% Inherit real stdio — pagers/editors talk to the terminal directly. ··· 2237 2293 %% Extra env for external commands (merged into the process environment). 2238 2294 %% 2239 2295 %% - SHELL=/bin/sh: `script` invokes $SHELL; nu/fish break `script -c`. 2240 - %% - LESS=FRX when unset: pagers (jj/git → less) pass through ANSI colors (-R) 2241 - %% and exit on short output (-F) without clearing the screen (-X). 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. 2296 + %% - LESS=FRX when unset: external pagers pass through ANSI (-R) and exit on 2297 + %% short output (-F) without clearing the screen (-X). 2298 + %% - When the shell wants color and the child has no real TTY (pipe capture 2299 + %% fallback): FORCE_COLOR / CLICOLOR_FORCE, plus git GIT_CONFIG_* overlays 2300 + %% (git ignores FORCE_COLOR; decorate=auto drops ref names on pipes). 2301 + %% Prefer PTY capture (`child_env_capture_pty`) so jj/git/etc. colorize 2302 + %% naturally for `cmd | less` without per-tool config. 2248 2303 child_env() -> 2249 - Env0 = [{"SHELL", "/bin/sh"}], 2250 - Env1 = 2251 - case os:getenv("LESS") of 2252 - false -> 2253 - [{"LESS", "FRX"} | Env0]; 2254 - "" -> 2255 - [{"LESS", "FRX"} | Env0]; 2256 - _ -> 2257 - Env0 2258 - end, 2304 + Env0 = child_env_base(), 2259 2305 case want_child_color() of 2260 2306 false -> 2261 - Env1; 2307 + Env0; 2262 2308 true -> 2263 - force_git_tty_env(force_color_env(Env1)) 2309 + force_git_tty_env(force_color_env(Env0)) 2310 + end. 2311 + 2312 + %% PTY capture env: color via TTY detection; never nest an interactive pager. 2313 + child_env_capture_pty() -> 2314 + disable_nested_pagers(force_color_env(child_env_base())). 2315 + 2316 + child_env_base() -> 2317 + Env0 = [{"SHELL", "/bin/sh"}], 2318 + case os:getenv("LESS") of 2319 + false -> 2320 + [{"LESS", "FRX"} | Env0]; 2321 + "" -> 2322 + [{"LESS", "FRX"} | Env0]; 2323 + _ -> 2324 + Env0 2264 2325 end. 2265 2326 2266 2327 %% FORCE_COLOR / CLICOLOR_FORCE for children that honor them. ··· 2283 2344 Env1 2284 2345 end. 2285 2346 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. 2347 + %% Capture must not hang inside the child's own pager (git/jj → less). 2348 + %% Always override for PTY capture; interactive `run_cmd_tty` uses child_env/0. 2349 + disable_nested_pagers(Env) -> 2350 + [ 2351 + {"PAGER", "cat"}, 2352 + {"GIT_PAGER", "cat"}, 2353 + {"JJ_PAGER", "cat"}, 2354 + {"SYSTEMD_PAGER", "cat"}, 2355 + {"MANPAGER", "cat"} 2356 + | Env 2357 + ]. 2358 + 2359 + %% Pipe-capture fallback only (git ≥ 2.31 GIT_CONFIG_*). Skipped when the 2360 + %% caller already set GIT_CONFIG_COUNT so we do not clobber. 2288 2361 %% 2289 2362 %% - color.ui=always: git ignores FORCE_COLOR 2290 2363 %% - log.decorate=short: default auto hides decorations on non-TTY stdout
+131 -16
test/gleshell_test.gleam
··· 1 1 import gleam/list 2 + import gleam/option.{None, Some} 2 3 import gleam/string 3 4 import gleeunit 4 5 import gleshell/builtins ··· 577 578 Nil 578 579 } 579 580 581 + pub fn strip_ansi_removes_csi_test() { 582 + let painted = color.paint(True, "\u{001b}[32m", "hello") 583 + let assert "hello" = color.strip_ansi(painted) 584 + let assert "plain" = color.strip_ansi("plain") 585 + let assert "ab" = color.strip_ansi("a\u{001b}[1;31mb\u{001b}[0m") 586 + Nil 587 + } 588 + 589 + pub fn pager_line_matches_strips_ansi_test() { 590 + let painted = color.paint(True, "\u{001b}[31m", "needle") 591 + let assert True = pager.line_matches(painted, "needle") 592 + let assert True = pager.line_matches(painted, "eed") 593 + // Case-insensitive: mixed / upper pattern still hits. 594 + let assert True = pager.line_matches(painted, "NEEDLE") 595 + let assert True = pager.line_matches(painted, "NeEd") 596 + let assert False = pager.line_matches(painted, "") 597 + // Pattern must not match inside CSI itself. 598 + let assert False = pager.line_matches(painted, "[31m") 599 + Nil 600 + } 601 + 602 + pub fn pager_find_after_and_before_test() { 603 + let lines = ["alpha", "bravo", "alpha", "charlie"] 604 + let assert Ok(#(0, False)) = pager.find_after(lines, "alpha", -1) 605 + let assert Ok(#(2, False)) = pager.find_after(lines, "alpha", 0) 606 + // Wrap from end back to first match. 607 + let assert Ok(#(0, True)) = pager.find_after(lines, "alpha", 2) 608 + let assert Error(Nil) = pager.find_after(lines, "zzz", -1) 609 + // Case-insensitive search. 610 + let assert Ok(#(0, False)) = pager.find_after(lines, "ALPHA", -1) 611 + let assert Ok(#(2, False)) = pager.find_after(lines, "AlPhA", 0) 612 + 613 + let assert Ok(#(2, False)) = pager.find_before(lines, "alpha", 3) 614 + let assert Ok(#(0, False)) = pager.find_before(lines, "alpha", 2) 615 + // Wrap from start back to last match. 616 + let assert Ok(#(2, True)) = pager.find_before(lines, "alpha", 0) 617 + let assert Ok(#(2, False)) = pager.find_before(lines, "ALPHA", 3) 618 + Nil 619 + } 620 + 621 + pub fn pager_find_empty_pattern_test() { 622 + let lines = ["a", "b"] 623 + let assert Error(Nil) = pager.find_after(lines, "", -1) 624 + let assert Error(Nil) = pager.find_before(lines, "", 2) 625 + Nil 626 + } 627 + 628 + pub fn pager_live_search_preview_test() { 629 + let lines = ["alpha", "bravo", "charlie", "alpha again"] 630 + // Empty query: stay put, no highlight. 631 + let assert #(2, None, None) = pager.live_search_preview(lines, "", 2) 632 + // Inclusive of start_offset (match on current top line). 633 + let assert #(0, Some("alpha"), None) = 634 + pager.live_search_preview(lines, "alpha", 0) 635 + // From mid-buffer: first match at or after start. 636 + let assert #(3, Some("alpha"), None) = 637 + pager.live_search_preview(lines, "alpha", 1) 638 + // Not found: keep start offset, still paint pattern, status suffix. 639 + let assert #(1, Some("zzz"), Some("not found")) = 640 + pager.live_search_preview(lines, "zzz", 1) 641 + // Progressive typing narrows: "ch" → charlie. 642 + let assert #(2, Some("ch"), None) = pager.live_search_preview(lines, "ch", 0) 643 + // Case-insensitive: upper pattern still finds lower content. 644 + let assert #(0, Some("ALPHA"), None) = 645 + pager.live_search_preview(lines, "ALPHA", 0) 646 + let assert #(3, Some("Alpha"), None) = 647 + pager.live_search_preview(lines, "Alpha", 1) 648 + Nil 649 + } 650 + 651 + pub fn pager_highlight_matches_test() { 652 + let out = pager.highlight_matches("hello world", "world") 653 + // Black on bright yellow accent. 654 + let assert True = string.contains(out, "\u{001b}[30;103m") 655 + let assert True = string.contains(out, "world") 656 + let assert True = string.contains(out, "\u{001b}[39;49m") 657 + let assert "hello world" = color.strip_ansi(out) 658 + 659 + // Case-insensitive highlight preserves original casing in the line. 660 + let ci = pager.highlight_matches("Hello World", "WORLD") 661 + let assert True = string.contains(ci, "\u{001b}[30;103m") 662 + let assert True = string.contains(ci, "World") 663 + let assert "Hello World" = color.strip_ansi(ci) 664 + 665 + // No match / empty pattern: unchanged. 666 + let assert "nope" = pager.highlight_matches("nope", "zzz") 667 + let assert "x" = pager.highlight_matches("x", "") 668 + 669 + // Multiple non-overlapping hits. 670 + let multi = pager.highlight_matches("aa x aa", "aa") 671 + let parts = string.split(multi, "\u{001b}[30;103m") 672 + let assert 3 = list.length(parts) 673 + 674 + // ANSI around the match is preserved; highlight still finds visible text. 675 + let painted = color.paint(True, "\u{001b}[32m", "needle here") 676 + let hi = pager.highlight_matches(painted, "needle") 677 + let assert True = string.contains(hi, "\u{001b}[32m") 678 + let assert True = string.contains(hi, "\u{001b}[30;103m") 679 + let assert "needle here" = color.strip_ansi(hi) 680 + let assert True = string.contains(hi, "\u{001b}[39;49m") 681 + Nil 682 + } 683 + 684 + pub fn pager_highlight_match_spanning_sgr_test() { 685 + // Match crosses a mid-string color change: black-on-yellow re-opens after SGR. 686 + let line = "ab\u{001b}[31mcd\u{001b}[0mef" 687 + let hi = pager.highlight_matches(line, "bcde") 688 + let assert True = string.contains(hi, "\u{001b}[30;103m") 689 + let assert "abcdef" = color.strip_ansi(hi) 690 + // After the red open, accent should be re-applied inside the match. 691 + let assert True = string.contains(hi, "\u{001b}[31m\u{001b}[30;103m") 692 + Nil 693 + } 694 + 580 695 pub fn less_short_output_passthrough_test() { 581 696 // Non-TTY test runner: needs_paging is false → less returns the text. 582 697 let env = env.new() ··· 616 731 eval.eval_source(env, "help less") 617 732 let assert True = string.contains(help_out, "ANSI") 618 733 let assert True = string.contains(help_out, "q") 734 + let assert True = string.contains(help_out, "/pattern") 735 + let assert True = string.contains(help_out, "n / N") 619 736 Nil 620 737 } 621 738 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. 739 + pub fn git_log_pipeline_emits_ansi_and_decorate_test() { 740 + // Capture uses a throwaway PTY when color is wanted, so git colorizes and 741 + // keeps ref decorations without GIT_CONFIG_* hacks. FORCE_COLOR makes 742 + // want_child_color true in non-TTY test runners. 626 743 let prev = sys.getenv("FORCE_COLOR") 627 744 let assert Ok(_) = sys.setenv("FORCE_COLOR", "1") 628 745 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") 746 + // Full log (not --oneline): decorations appear on the commit line. 747 + let result = eval.eval_source(env, "git log -1 | identity") 631 748 case prev { 632 749 Ok(v) -> { 633 750 let assert Ok(_) = sys.setenv("FORCE_COLOR", v) ··· 638 755 Nil 639 756 } 640 757 } 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") 758 + let assert eval.Continue(_, String(out)) = result 759 + // Real git colors, not gleshell string-green on plain text. 760 + let assert True = string.contains(out, "\u{001b}[") 761 + // TTY-style decorate: ref names like HEAD / main. 762 + let assert True = string.contains(out, "HEAD") || string.contains(out, "main") 645 763 Nil 646 764 } 647 765 648 - pub fn git_log_pipeline_emits_ansi_and_decorate_test() { 766 + pub fn jj_log_pipeline_emits_ansi_test() { 767 + // jj ignores FORCE_COLOR; PTY capture is what keeps colors for `jj log | less`. 649 768 let prev = sys.getenv("FORCE_COLOR") 650 769 let assert Ok(_) = sys.setenv("FORCE_COLOR", "1") 651 770 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") 771 + let result = eval.eval_source(env, "jj log -n 1 | identity") 654 772 case prev { 655 773 Ok(v) -> { 656 774 let assert Ok(_) = sys.setenv("FORCE_COLOR", v) ··· 662 780 } 663 781 } 664 782 let assert eval.Continue(_, String(out)) = result 665 - // Real git colors, not gleshell string-green on plain text. 666 783 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 784 Nil 670 785 } 671 786