Skip to content

Commit c8f42da

Browse files
feat(display): route long output through \$PAGER (#203)
Add LinearCli.CLI.Pager module that pipes text output through the user's configured pager (default: less -FRX) when stdout is a terminal and the content exceeds the terminal height. Refactor Display.show/2 to collect all text first, then delegate to Pager.maybe_page/2. Paging is skipped for JSON output, non-TTY environments, PAGER="" or PAGER=cat, and content that fits on screen. Falls back to IO.puts when the pager exits with a non-zero status. Closes EXT-23 Co-authored-by: bougyman's bot <ruby-automation@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f966f14 commit c8f42da

3 files changed

Lines changed: 360 additions & 19 deletions

File tree

app/lib/linear_cli/cli/display.ex

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ defmodule LinearCli.CLI.Display do
77
own `#to_s`/`#full`/`#display` methods.
88
"""
99

10+
alias LinearCli.CLI.Pager
1011
alias LinearCli.Linear.{Comment, Issue, Project, ProjectUpdate, Team, User}
1112
alias LinearCli.Profiles.Profile
1213

@@ -15,55 +16,58 @@ defmodule LinearCli.CLI.Display do
1516
@doc """
1617
Prints `subject` (a resource, or a list of resources) per `opts[:output]`
1718
(`"text"`, the default, or `"json"`).
19+
20+
Text output is routed through `$PAGER` (see `LinearCli.CLI.Pager`) when
21+
stdout is a terminal and the content exceeds the terminal height.
22+
`--output json` is never paged.
1823
"""
1924
def show(subject, opts \\ %{}) do
2025
if Map.get(opts, :output, "text") == "json" do
2126
subject |> to_plain() |> Jason.encode!(pretty: true) |> IO.puts()
2227
else
23-
subject |> List.wrap() |> Enum.each(&puts_text(&1, opts))
28+
text = subject |> List.wrap() |> Enum.map_join("\n", &format(&1, opts))
29+
Pager.maybe_page(text, opts)
2430
end
2531
end
2632

27-
defp puts_text(%Team{} = team, _opts) do
28-
IO.puts("#{String.pad_trailing(team.key || "", 6)} #{team.name}")
33+
defp format(%Team{} = team, _opts) do
34+
"#{String.pad_trailing(team.key || "", 6)} #{team.name}"
2935
end
3036

31-
defp puts_text(%Project{} = project, _opts) do
32-
IO.puts("#{String.pad_trailing(project.name || "", 12)} #{project.url}")
37+
defp format(%Project{} = project, _opts) do
38+
"#{String.pad_trailing(project.name || "", 12)} #{project.url}"
3339
end
3440

35-
defp puts_text(%ProjectUpdate{} = update, _opts) do
41+
defp format(%ProjectUpdate{} = update, _opts) do
3642
health = if update.health, do: " (#{update.health})", else: ""
37-
IO.puts("Posted#{health}: #{update.url}")
43+
"Posted#{health}: #{update.url}"
3844
end
3945

4046
# New in this port - Ruby has no equivalent (no bare `Comment` command
4147
# existed to display one). `LinearCli.CLI.IssueHelpers.issue_comment/2`/
4248
# `upsert_comment/4` already print a "Comment added to.../updated on..."
4349
# confirmation via `Prompt.ok/1` before this runs, so this only needs to
4450
# add the one thing that isn't in that line: a link to the comment.
45-
defp puts_text(%Comment{} = comment, _opts) do
46-
IO.puts(comment.url || "(no URL returned)")
51+
defp format(%Comment{} = comment, _opts) do
52+
comment.url || "(no URL returned)"
4753
end
4854

49-
defp puts_text(%Profile{} = profile, _opts) do
55+
defp format(%Profile{} = profile, _opts) do
5056
marker = if profile.active, do: "* ", else: " "
5157

52-
IO.puts(
53-
"#{marker}#{String.pad_trailing(profile.name, 12)} team=#{profile.team || "-"} project=#{profile.project || "-"}"
54-
)
58+
"#{marker}#{String.pad_trailing(profile.name, 12)} team=#{profile.team || "-"} project=#{profile.project || "-"}"
5559
end
5660

57-
defp puts_text(%User{} = user, opts) do
58-
IO.puts(user_line(user, opts))
61+
defp format(%User{} = user, opts) do
62+
user_line(user, opts)
5963
end
6064

61-
defp puts_text(%Issue{} = issue, %{full: true}) do
62-
IO.puts(issue_full(issue))
65+
defp format(%Issue{} = issue, %{full: true}) do
66+
issue_full(issue)
6367
end
6468

65-
defp puts_text(%Issue{} = issue, _opts) do
66-
IO.puts(issue_line(issue))
69+
defp format(%Issue{} = issue, _opts) do
70+
issue_line(issue)
6771
end
6872

6973
defp user_line(user, opts) do

app/lib/linear_cli/cli/pager.ex

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
defmodule LinearCli.CLI.Pager do
2+
@moduledoc """
3+
Routes text output through `$PAGER` when stdout is a real terminal and
4+
the content exceeds the terminal height — matching the behavior of
5+
`git log`, `gh pr view`, and similar CLI tools.
6+
7+
Paging is skipped when any of the following is true:
8+
9+
- stdout is not a terminal (`Owl.IO.rows/0` returns `nil`)
10+
- `$PAGER` is `""` or `"cat"` (user has explicitly disabled paging)
11+
- The content's line count does not exceed the terminal's row count
12+
13+
When paging is needed the content is written to a temp file and the
14+
pager is invoked via `System.shell/1`, which — like
15+
`Owl.IO.open_in_editor/2`'s own use of the same function — lets the
16+
child process open `/dev/tty` directly for interactive keyboard
17+
control.
18+
19+
## Testing
20+
21+
In tests, `Owl.IO.rows/0` returns `nil` (no real terminal), so paging
22+
is automatically skipped and `IO.puts/1` is called instead —
23+
`capture_io` keeps working with no changes to existing tests.
24+
25+
To exercise the pager path directly, pass injectable overrides:
26+
27+
- `rows_fn: fn -> 24 end` — simulate a 24-row terminal
28+
- `shell_fn: fn cmd -> ... end` — capture or mock the shell invocation
29+
"""
30+
31+
@default_pager "less -FRX"
32+
33+
@doc """
34+
Prints `text` directly via `IO.puts/1`, or through `$PAGER` when
35+
stdout is a terminal and the content exceeds the terminal height.
36+
37+
`opts` accepts injectable overrides for testing:
38+
- `:rows_fn` — 0-arity function returning terminal row count or `nil`
39+
(defaults to `&Owl.IO.rows/0`)
40+
- `:shell_fn` — 1-arity function receiving the full shell command
41+
(defaults to `&System.shell/1`)
42+
"""
43+
@spec maybe_page(String.t(), map()) :: :ok
44+
def maybe_page(text, opts \\ %{}) do
45+
rows_fn = Map.get(opts, :rows_fn, &Owl.IO.rows/0)
46+
shell_fn = Map.get(opts, :shell_fn, &System.shell/1)
47+
terminal_rows = rows_fn.()
48+
pager = resolve_pager()
49+
50+
if should_page?(text, terminal_rows, pager) do
51+
invoke_pager(text, pager, shell_fn)
52+
else
53+
IO.puts(text)
54+
end
55+
end
56+
57+
defp should_page?(_text, nil, _pager), do: false
58+
defp should_page?(_text, _rows, nil), do: false
59+
defp should_page?(text, terminal_rows, _pager), do: count_lines(text) > terminal_rows
60+
61+
defp resolve_pager do
62+
case System.get_env("PAGER") do
63+
nil -> @default_pager
64+
"" -> nil
65+
"cat" -> nil
66+
pager -> pager
67+
end
68+
end
69+
70+
defp count_lines(text) do
71+
text |> String.trim_trailing("\n") |> String.split("\n") |> length()
72+
end
73+
74+
defp invoke_pager(text, pager, shell_fn) do
75+
path = Path.join(System.tmp_dir!(), "lc-pager-#{System.unique_integer([:positive])}")
76+
File.write!(path, text)
77+
78+
try do
79+
case shell_fn.("#{pager} #{shell_quote(path)}") do
80+
{_, 0} -> :ok
81+
_ -> IO.puts(text)
82+
end
83+
after
84+
File.rm(path)
85+
end
86+
87+
:ok
88+
end
89+
90+
defp shell_quote(path) do
91+
"'" <> String.replace(path, "'", "'\\''") <> "'"
92+
end
93+
end

0 commit comments

Comments
 (0)