Skip to content

feat(pager): add configurable pager selection with condition-based profiles#1345

Merged
pamburus merged 1 commit intomasterfrom
010-pager
Feb 26, 2026
Merged

feat(pager): add configurable pager selection with condition-based profiles#1345
pamburus merged 1 commit intomasterfrom
010-pager

Conversation

@pamburus
Copy link
Copy Markdown
Owner

@pamburus pamburus commented Feb 24, 2026

Summary

This PR replaces the hard-coded HL_PAGER / PAGER pager logic with a fully configurable pager system. Users can now define named pager profiles with role-specific arguments, control candidate search order, use platform/mode conditions, and enable pager support in --follow mode.

What's New

Pager profiles

Pager programs are now described as named profiles under [[pager.profiles]]. Each profile specifies the command, base arguments, optional environment variables, an entry delimiter, and mode-specific overrides for view and follow modes.

[[pager.profiles]]
name = "less"
command = "less"
args = ["-R", "--mouse"]
env = { LESSCHARSET = "UTF-8" }
modes.follow.enabled = false
modes.follow.args = ["+F"]

[[pager.profiles]]
name = "fzf"
command = "fzf"
args = ["--ansi", "--no-sort"]
delimiter = "newline"
modes.follow.enabled = true
modes.follow.args = ["--tac", "--track"]

The modes.follow.enabled flag controls pager behaviour in follow mode. Profile selection always ignores the mode — the first available profile wins. Mode is applied afterwards: when false (the default), the selected profile disables paging for hl --follow rather than falling through to a lower-priority candidate.

Candidate search list

The pager.candidates array defines the priority-ordered list of things to try. hl works through the list and uses the first candidate whose pager executable is found in PATH. Three candidate forms are supported:

[pager]
candidates = [
  # Structured env ref: role-specific vars + optional delimiter var
  { env = { pager = "HL_PAGER", follow = "HL_FOLLOW_PAGER", delimiter = "HL_PAGER_DELIMITER" }, profiles = true },
  # Simple env ref: view mode only, treated as a literal command
  { env = "PAGER" },
  # Named profile reference
  { profile = "less" },
  { profile = "fzf", if = "!os:windows" },
]

Profile references via @ syntax

When an env candidate has profiles = true, a value starting with @ is treated as an explicit profile name rather than a command:

HL_PAGER=@fzf hl app.log        # forces fzf profile (with fzf's args, delimiter, follow config)
HL_PAGER=less hl app.log        # uses `less` as a direct command, no extra args injected

Profile references bypass the env delimiter override — the profile's own delimiter field takes precedence. This prevents incoherent combinations of command and delimiter.

The PAGER variable intentionally does not have profiles = true because it is a universal system variable shared with other programs. @ in PAGER is treated literally.

Follow-mode pager support

Follow mode (hl --follow) now supports a pager when the selected profile has modes.follow.enabled = true.

HL_PAGER is the sole candidate-selection gate for the structured env candidate. Its value drives the complete follow-mode behaviour:

  • Profile reference (HL_PAGER=@fzf): the profile resolves all configuration for both view and follow modes. HL_FOLLOW_PAGER is completely ignored — the profile's own modes.follow.enabled controls whether paging is active.

    HL_PAGER=@fzf hl --follow app.log    # fzf profile decides; HL_FOLLOW_PAGER is ignored
  • Direct command (HL_PAGER=cmd): HL_FOLLOW_PAGER specifies the follow-mode command. If HL_FOLLOW_PAGER is not set, paging is disabled for that invocation.

    HL_PAGER=cat HL_FOLLOW_PAGER=fzf hl --follow app.log   # view: cat, follow: fzf
    HL_PAGER=cat hl --follow app.log                       # follow: paging disabled

HL_FOLLOW_PAGER alone (without HL_PAGER) has no effect on the structured env candidate — the candidate is skipped entirely when HL_PAGER is not set.

Simple env candidates like { env = "PAGER" } are skipped in follow mode entirely.

Condition-based candidates and profiles

Candidates can carry an if field that gates them on platform or mode:

candidates = [
  { profile = "fzf", if = "!os:windows" },   # skip fzf on Windows
  { profile = "fzf", if = "os:windows" },    # Windows-specific entry (e.g. different flags)
]

Profile conditions entries add or override arguments and env vars conditionally:

[[pager.profiles]]
name = "fzf"
command = "fzf"
args = ["--ansi"]
conditions = [
  { if = "os:macos",  args = ["--bind=ctrl-y:execute-silent(printf %s\\\\n {+} | pbcopy)"] },
  { if = "os:linux",  args = ["--bind=ctrl-y:execute-silent(printf %s\\\\n {+} | xclip -in -sel clip)"] },
  { if = "mode:follow", args = ["--tac", "--track", "--tail=100000"] },
  { if = "!mode:follow", args = ["--layout=reverse-list"] },
]
modes.follow.enabled = true

Supported condition strings:

Condition Meaning
os:macos macOS only
os:linux Linux only
os:windows Windows only
os:unix macOS or Linux
mode:view Non-follow mode
mode:follow --follow mode
!<condition> Negation of any condition above

Entry delimiter per profile

Profiles can declare the output entry delimiter used by the pager:

[[pager.profiles]]
name = "fzf"
command = "fzf"
delimiter = "newline"   # entries separated by newlines (fzf line-based input)
[[pager.profiles]]
name = "fzf"
command = "fzf"
delimiter = "nul"       # entries separated by NUL bytes
args = ["--read0", "--ansi"]

For direct commands (not profiles), the delimiter can be controlled via HL_PAGER_DELIMITER.

Built-in profiles

The default config ships with ready-to-use profiles for less, fzf, ov, and most, including sensible defaults and platform-specific bindings. They are used automatically when the corresponding binary is found in PATH. Users can override any profile by adding a [[pager.profiles]] entry with the same name in their config file.

Behaviour Changes

Breaking: HL_PAGER=less no longer injects -R and LESSCHARSET=UTF-8

Previously, using less as a direct command via HL_PAGER (or PAGER) caused hl to silently inject the -R flag and set LESSCHARSET=UTF-8. With the introduction of named profiles, less is now configured explicitly in the default [[pager.profiles]] entry and carries those same flags there, making the implicit injection redundant. It has been removed.

Migration: Use the profile instead of the bare command name:

# Before (still works, but -R is no longer added automatically)
HL_PAGER=less hl app.log

# Option 1: delegate to the less profile (recommended)
HL_PAGER=@less hl app.log

# Option 2: pass the flags yourself
HL_PAGER="less -R" hl app.log

Configuration Reference

[pager]
# Ordered list of candidates. hl tries each in turn.
candidates = [
  # Structured env reference. `profiles = true` enables @name profile references.
  { env = { pager = "HL_PAGER", follow = "HL_FOLLOW_PAGER", delimiter = "HL_PAGER_DELIMITER" }, profiles = true },
  # Simple env reference (view mode only, literal command).
  { env = "PAGER" },
  # Named profile. Optional `if` condition restricts to matching platforms/modes.
  { profile = "less" },
  { profile = "fzf", if = "!os:windows" },
]

# Named profiles.
[[pager.profiles]]
name = "less"
command = "less"
args = ["-R", "--mouse"]
env = { LESSCHARSET = "UTF-8" }
# delimiter = "newline"              # optional: "newline" or "nul"
modes.view.enabled = true            # default true, can be omitted
modes.follow.enabled = false         # must be explicitly true to enable follow paging
modes.follow.args = ["+F"]           # appended when used in follow mode

[[pager.profiles]]
name = "fzf"
command = "fzf"
delimiter = "newline"
args = ["--ansi", "--no-sort", "--no-bold"]
conditions = [
  { if = "os:macos", args = ["--bind=ctrl-y:execute-silent(printf %s\\\\n {+} | pbcopy)"] },
  { if = "mode:follow", args = ["--tac", "--track"] },
  { if = "!mode:follow", args = ["--layout=reverse-list"] },
]
modes.follow.enabled = true

Testing Checklist

  • HL_PAGER=less hl app.log — direct command, no extra args injected
  • HL_PAGER=@fzf hl app.log — profile reference (only when profiles = true)
  • HL_PAGER=@nonexistent hl app.log — exits with profile-not-found error
  • HL_PAGER=nonexistent hl app.log — exits with command-not-found error
  • PAGER=@less hl app.log — treats @less as a literal command name (not a profile)
  • PAGER=less hl --follow app.logPAGER is skipped in follow mode
  • HL_PAGER=cat HL_FOLLOW_PAGER=fzf hl --follow app.log — direct command in HL_PAGER, fzf used for follow
  • HL_PAGER=cat hl --follow app.log — direct command without HL_FOLLOW_PAGER disables paging
  • HL_PAGER=@fzf HL_FOLLOW_PAGER=less hl --follow app.log — profile ref ignores HL_FOLLOW_PAGER
  • Profile with modes.follow.enabled = false disables paging in follow mode (no fallthrough)
  • Profile with modes.follow.enabled = true is used in follow mode with modes.follow.args
  • { profile = "fzf", if = "!os:windows" } skips fzf on Windows
  • Conditions in conditions = [...] append args only when matching
  • Profile delimiter is used; env HL_PAGER_DELIMITER ignored for profile refs
  • First available profile is used; unavailable ones are silently skipped

@pamburus pamburus force-pushed the 010-pager branch 3 times, most recently from 495f886 to 0aa436f Compare February 24, 2026 23:07
@codecov
Copy link
Copy Markdown

codecov Bot commented Feb 24, 2026

Codecov Report

❌ Patch coverage is 97.95222% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.84%. Comparing base (c2141ed) to head (98d0aa0).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
src/main.rs 92.53% 5 Missing ⚠️
src/pager/mod.rs 87.50% 3 Missing ⚠️
crates/pager/src/lib.rs 97.50% 2 Missing ⚠️
src/app.rs 90.90% 1 Missing ⚠️
src/pager/config/mod.rs 97.72% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1345      +/-   ##
==========================================
+ Coverage   88.53%   89.84%   +1.30%     
==========================================
  Files          68       72       +4     
  Lines       11780    12246     +466     
==========================================
+ Hits        10430    11002     +572     
+ Misses       1350     1244     -106     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@pamburus pamburus force-pushed the 010-pager branch 2 times, most recently from cbd573b to 8f51abd Compare February 26, 2026 04:46
…rofiles

Users can now configure multiple pager profiles in config.toml, each with its own command, arguments, and activation conditions (e.g. based on environment variables or output follow mode). The pager is selected at runtime by evaluating each profile in priority order.

HL_PAGER is the sole candidate-selection gate for the structured env candidate. Profile references (HL_PAGER=@name) resolve the profile for both modes and ignore HL_FOLLOW_PAGER entirely. For direct commands, HL_FOLLOW_PAGER specifies the follow-mode command; if it is not set, paging is disabled in follow mode.

BREAKING CHANGE: Using `less` as a direct command via HL_PAGER or PAGER no longer automatically adds -R or sets LESSCHARSET=UTF-8. Use HL_PAGER=@less to delegate to the built-in less profile, which includes these settings explicitly, or pass the flags yourself: HL_PAGER="less -R".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@pamburus pamburus marked this pull request as ready for review February 26, 2026 07:32
@pamburus pamburus merged commit fbfb7f6 into master Feb 26, 2026
13 checks passed
@pamburus pamburus deleted the 010-pager branch February 26, 2026 07:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant