Skip to content

feat(segments): activation gates to skip provably-inactive segments - #7838

Merged
JanDeDobbeleer merged 16 commits into
mainfrom
claude/lazy-load-segment-perf-8wo3t7
Aug 30, 2026
Merged

feat(segments): activation gates to skip provably-inactive segments#7838
JanDeDobbeleer merged 16 commits into
mainfrom
claude/lazy-load-segment-perf-8wo3t7

Conversation

@JanDeDobbeleer

Copy link
Copy Markdown
Owner

Add a cheap, declarative pre-check to the segment execution path: a writer
can implement the optional segments.Activator interface to declare the
preconditions (cwd file globs) under which it can possibly be enabled. When
none of the globs match the cached directory listing, Segment.Execute skips
the writer's Enabled() probe entirely and leaves the segment disabled.

The gate is conservative by design:

  • Writers without Activator, or whose Activation resolves to Always (or the
    zero value), execute exactly as before.
  • Forced segments, segments with a fallback template, and segments pinned
    via a hand-written data file bypass the gate and run the full path.
  • The shared Language helper returns Always whenever anything but a cwd file
    could enable the segment: project files (parent-directory search), folders
    (stat-based check), or a display mode other than "files".

The whole language family opts in by extracting its spec construction out of
Enabled() into an idempotent loadSpec(), letting Activation() resolve the
same extensions/folders/display-mode options the probe would. The helper is
deliberately unexported so embedders that still build their spec inside
Enabled() never gate against an empty spec.

The gate evaluation lives beside the writer registry so the js/wasm build,
which has no writers, keeps linking without the segments package.

Co-Authored-By: Claude noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01M4RA9ZrP7vuqVSR7nddNiQ

Add a cheap, declarative pre-check to the segment execution path: a writer
can implement the optional segments.Activator interface to declare the
preconditions (cwd file globs) under which it can possibly be enabled. When
none of the globs match the cached directory listing, Segment.Execute skips
the writer's Enabled() probe entirely and leaves the segment disabled.

The gate is conservative by design:
- Writers without Activator, or whose Activation resolves to Always (or the
  zero value), execute exactly as before.
- Forced segments, segments with a fallback template, and segments pinned
  via a hand-written data file bypass the gate and run the full path.
- The shared Language helper returns Always whenever anything but a cwd file
  could enable the segment: project files (parent-directory search), folders
  (stat-based check), or a display mode other than "files".

The whole language family opts in by extracting its spec construction out of
Enabled() into an idempotent loadSpec(), letting Activation() resolve the
same extensions/folders/display-mode options the probe would. The helper is
deliberately unexported so embedders that still build their spec inside
Enabled() never gate against an empty spec.

The gate evaluation lives beside the writer registry so the js/wasm build,
which has no writers, keeps linking without the segments package.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4RA9ZrP7vuqVSR7nddNiQ
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown

📦 Release binary size report

Compares this PR's release-equivalent build against the latest published release, per OS (amd64).

OS Baseline This PR Delta
darwin 13.82 MB 14.16 MB +342.4 KB (+2.42%) ⚠️
linux 13.49 MB 13.82 MB +336.0 KB (+2.43%) ⚠️
windows 14.05 MB 14.30 MB +251.7 KB (+1.75%)

⚠️ Binary size grew by more than the 2% / 256 KiB threshold on at least one platform. If this PR doesn't add a feature that justifies it, double-check for a new or heavier dependency.

claude added 10 commits August 28, 2026 11:28
Activation() is now part of the SegmentWriter interface itself, with
segments.Base providing the ungated (Always) default, replacing the optional
Activator interface and its type-assertion helper. The Activation type moves
to the runtime package - where its evaluation (Active) lives - so the config
package can reference it in the interface without pulling the segments
package into the js/wasm build.

The spec grows three condition kinds beyond file globs, OR'd across all
kinds: Folders (cwd directories, stat-based), ProjectFiles (upward
parent-directory search, both symlink variants), and EnvVars (non-empty
environment variables). HasParentFilePath results are now memoized per
prompt invocation, so the gate and a segment's own Enabled() doing the same
search only walk the tree once.

Gate contract changes (deliberate):
- A fallback template no longer forces the full Enabled() evaluation when
  the gate fails; the segment counts as evaluated and the fallback renders
  against the zero-state writer.
- Force and pinned data (pendingData) still bypass the gate entirely.

Migration:
- Language family: the file/extension/folder presence checks move out of
  Enabled() into activation(); files display mode now trusts the gate.
  Environment/context modes gate on declared context triggers
  (contextEnvVars/contextFiles): python and mojo on their venv variables,
  cds on package.json. An opaque context callback still resolves to Always
  (ui5tooling's depth-limited parent glob search). A python venv only
  discoverable through pyvenv.cfg, with no env var and no python files in
  the cwd, no longer activates the segment - an accepted trade-off.
- VCS family gates on its repository marker (.git, .hg, .svn, .sl, .jj,
  .plastic, .dvc; gitversion on .git), keeping the search in Enabled()
  since its result feeds the segment. fossil stays ungated: its detection
  runs the CLI rather than a marker search.
- terraform's context check is lifted into the gate and removed from
  Enabled(); docker gates its files mode; unity, umbraco, project and
  sitecore gate on their markers. nixshell stays ungated (PATH-based nix
  shell detection). Everything else inherits Always from Base.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4RA9ZrP7vuqVSR7nddNiQ
Run the full pre-commit gate from the golang skill (modernize,
fieldalignment, go mod tidy, gofmt, golangci-lint) over the activation
changes and resolve what it surfaced:

- modernize: fold the FileGlobs loop into slices.ContainsFunc
- gocritic hugeParam: Activation.Active takes a pointer receiver; call
  sites bind the returned spec to a local first
- dupl: react/aurelia are deliberately parallel, suppressed with reason

fieldalignment and go mod tidy came back clean; golangci-lint now
reports zero issues module-wide.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4RA9ZrP7vuqVSR7nddNiQ
The activation gate is bypassed for forced segments and for segments pinned
via a hand-written data file, but the previous refactor made several
Enabled() branches TRUST that the gate had run: a forced language segment
(or a pinned one) in a directory without matching files became enabled -
and ran its version toolchain - where the old code kept it hidden. The
symlink-following project-file gate variant could likewise pass where the
segment's own non-following search misses.

Restore the presence re-checks in the weakened branches so Enabled stays
standalone-correct and the gate remains a pure skip-optimization:

- language files mode re-runs hasLanguageFiles/hasLanguageFolders
- docker files mode re-checks its file globs
- terraform re-checks inContext, rebuilt on a helper shared with
  Activation so the two cannot diverge
- sitecore re-checks the sitecore.json marker

The re-checks are near-free: the directory listing and parent-path
searches are memoized per invocation, so after a gate pass they are map
hits. Also align hasLanguageFolders' stat with the gate's (joined with
Pwd), removing the remaining gate/Enabled divergence when the PWD flag
differs from the process cwd.

Tests: reinstate the standalone Enabled() assertions for gradle, docker,
terraform and the language base (alongside the gate-composition checks),
and add a regression test proving force does not enable a segment its own
detection rejects.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4RA9ZrP7vuqVSR7nddNiQ
HasFilesInDir answered every glob with a linear filepath.Match scan over
the cached directory listing. Activation gates turned that into the hot
path: ~30 segments times several globs each times every entry in the
directory, on every prompt, with no win from gating in directories with
many files.

Invert the work instead of repeating it: build a per-directory index once
(a set of lower-cased file names, plus every dotted suffix of each name -
".c" and ".b.c" for "a.b.c" - so multi-dot patterns like "*.gradle.kts"
resolve in one lookup) and classify each incoming pattern into a literal
name-set lookup, a dotted-suffix-set lookup, or the unchanged linear scan
for anything shaped differently. The index is built lazily per Terminal
instance, the same lifecycle and duplicate-build tolerance as the existing
lsDirMap listing cache.

Classification is deliberately conservative: any pattern shape that is not
provably a literal or a "*.suffix" glob (bare "*", non-dot-anchored
suffixes, character classes, escapes, extra wildcards) falls through to
the original filepath.Match scan, which stays as the correctness fallback
and as the reference implementation a differential test checks the fast
paths against.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4RA9ZrP7vuqVSR7nddNiQ
readDir deliberately re-reads a directory whose cached listing is empty,
but the index built from that empty listing was cached unconditionally: a
file appearing mid-render would surface in the fresh listing (and in
linearMatch fallbacks) while the stale empty index kept answering false
for fast-path patterns. Mirror readDir's quirk so both caches share one
lifecycle.

Also cover the empty-stem dotfile shape (*.env vs .env) in the curated
differential patterns - filepath.Match's * matches the empty string, the
least intuitive equivalence the suffix set must reproduce.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4RA9ZrP7vuqVSR7nddNiQ
Fetch options used to be the only way to switch on the git segment's
expensive probes, even when the config's templates clearly display the
data those probes produce. The config now analyzes every template at
engine construction - segment templates, foreground/background/extra
templates, tooltips, extra prompts and the console title, including
cross-segment .Segments.<name> references - and stamps each segment
with the set of top-level fields its rendering can touch.

Writers implementing the new config.FieldSetConsumer interface receive
that set right after Init. Git is the first consumer: each optional
probe (status, push status, upstream icon, user, bare info) runs when
the analyzed templates reference one of the fields it populates. An
explicitly configured fetch_* option always wins in both directions,
and a template shape the analysis cannot follow (whole-dot use,
index over the context, variable laundering, template includes) falls
back to the option defaults, so an unanalyzable config never fetches
more than it used to.

The referenced-field set is folded into the segment cache key for
field-set consumers, so a gob snapshot taken under one set is never
restored under another.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4RA9ZrP7vuqVSR7nddNiQ
Language segments run <tool> --version subprocesses on every prompt even
though the output is constant for a given binary. Cache it in the device
cache keyed by the resolved executable's path, mtime and size plus the exact
args (FNV hash), with a one-week TTL that exists only for garbage-collection
hygiene - correctness comes entirely from the key, so a toolchain upgrade
misses the cache automatically and nothing time-based has to be guessed.

Each cmd opts in individually via a new versionCacheable flag, defaulting to
off. Enabled it for tools verified to depend only on the binary (node, npm,
bun, deno, java, and most single-purpose language/build-tool binaries).
Left it off, with a comment explaining why, for tools whose output is
directory- or environment-dependent even though the resolved binary doesn't
change: dotnet (reads global.json), python/pyenv and ruby/elixir (asdf/rbenv
shims), yarn/pnpm (Corepack's package.json pinning), gradle/mvn (wrapper
scripts), bazel (bazelisk + .bazelversion), rustc (rustup toolchain
switching, verified directly), go (GOTOOLCHAIN auto-switching via go.mod,
verified directly), cds (reports the local project's dependency version),
stack and fvm (project-scoped resolver/SDK config).

Added Environment.StatFile so the mtime/size lookup goes through the same
mockable runtime layer as everything else language.go already depends on.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4RA9ZrP7vuqVSR7nddNiQ
The field-set analysis ran on every prompt render because the stamps
lived in unexported fields the session cache's gob round trip dropped -
a measured ~1ms wall regression per prompt on template-heavy themes.

The stamps (Segment.ReferencedFields/FieldsAnalyzable and the
Config.FieldSetsResolved marker) are now exported and ride along in the
gob payload, kept out of json/yaml/toml like Needs so no config format
ever surfaces them. Store stamps before encoding, so init's seed write
and every reload persist an analyzed config in the write they were
already making; a restored, stamped config makes ResolveFieldSets a
no-op. A pre-stamp cache entry written by an older binary analyzes once
on restore and re-Stores - one extra cache write, after which the rest
of the session skips the analysis. Reload parses fresh content and
re-stamps through the same Store path, and the serve daemon's per-cycle
config decode inherits the stamps for free.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4RA9ZrP7vuqVSR7nddNiQ
Review of the version-cache commit surfaced tools whose resolved binary
stays byte-identical while its output follows external state, so the
path+mtime+size key can serve stale versions:

- flutter: bin/flutter is a stable wrapper dispatching into the SDK
  checkout next to it; `flutter upgrade`/`flutter channel` swap that SDK
  via git without touching the script (same class as mvnw/gradlew).
- swift: on macOS /usr/bin/swift is an xcrun shim dispatching per
  xcode-select/DEVELOPER_DIR (the rustup-proxy class).
- java (plain): /usr/bin/java on macOS is Apple's stub resolving the
  JDK via java_home at run time. The JAVA_HOME-resolved cmd keeps the
  cache: there the resolved path itself keys correctly.
- ui5: the global @ui5/cli shim delegates to a project-local
  node_modules install when present, reporting per-project versions.

Also hardened versionCacheKey to mix cmd.envs into the hash: no
cacheable cmd sets envs today, but the key must not silently
under-specify one that does.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4RA9ZrP7vuqVSR7nddNiQ
Review of the template-derived fetch units surfaced template sources
that render with segment or global context without the analysis seeing
them, plus two hardening gaps:

- A segment's style is itself a template resolved against its writer
  (SegmentStyle.resolve); it now counts toward that segment's field
  set, as do fillers on extra prompts.
- Templated option values (branch_template and friends) render against
  per-option contexts the walk cannot model; a segment carrying any is
  now stamped unanalyzable - falling back to the explicit fetch
  options, display-correct by construction - while cross-segment
  references inside those options still count toward the segments they
  name.
- Global nil-context strings that can reach .Segments - block fillers,
  pwd, cursor style, terminal background, and palette values - now go
  through the same cross-segment extraction as the console title.
- Persisted stamps carry an analyzer generation marker
  (fieldSetAnalysisVersion): a session-cached config stamped by another
  binary generation re-analyzes once on restore and re-Stores, so
  long-lived sessions never trust stale stamps after an upgrade.
- Bare repos populate .Upstream through the upstream-icon probe
  (getBareRepoInfo), so Upstream now maps to that unit as well as the
  status unit.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4RA9ZrP7vuqVSR7nddNiQ
@JanDeDobbeleer
JanDeDobbeleer force-pushed the claude/lazy-load-segment-perf-8wo3t7 branch 2 times, most recently from 3e8bf85 to 2d81119 Compare August 28, 2026 16:32
claude added 5 commits August 30, 2026 11:36
SCM segments now fetch the data referenced by their templates, so
unused status and metadata commands no longer need manual controls.

BREAKING CHANGE: SCM fetch options are no longer used. Remove
fetch_status, fetch_push_status, fetch_upstream_icon, fetch_bare_info,
and fetch_user. To control fetching, add or remove the corresponding
fields from the segment templates. Existing configs still load, and
obsolete options are ignored.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4RA9ZrP7vuqVSR7nddNiQ
…erprints

Two review findings on the template-derived fetch fallback:

The heuristic scanned only the unanalyzable segment's own templates and
options, but unanalyzability can be caused by a text OUTSIDE the
segment - another segment laundering a cross-reference through a
variable, or a global template. In that shape the field name that
defeated the analysis was never scanned, the probe stayed off, and
with the fetch options gone there was no user-side fix. ResolveFieldSets
now assembles the whole-config text corpus (every segment's templates
and templated options plus the global nil-context strings) once and
stamps it on each unanalyzable segment; the fallback scan covers it
all. The stamp gained a field, so fieldSetAnalysisVersion bumps.

templatedOptionValues iterated option maps in Go's randomized order,
and the collected sources feed the unanalyzable cache-key fingerprint:
any segment with two or more templated option values got a fresh key
per process, so its snapshots never hit and stale entries piled up.
Collection now sorts map keys recursively, making the delivered order -
and the fingerprint - stable across parses.

Also strips the removed fetch_* keys from the src/test fixtures and
updates the segment-docs skill's worked example off the deleted
FetchStatus const.

Acknowledged default changes now stated in the git segment docs'
migration note: the default config's statusline derives the upstream
icon its old explicit fetch_upstream_icon: false suppressed; the svn,
mercurial and jujutsu default templates fetch status by default; and
jujutsu's default rendering requires the jj binary.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4RA9ZrP7vuqVSR7nddNiQ
Jujutsu templates can now render bookmark names and their distance
independently with .ClosestBookmarks and .AheadCount.

BREAKING CHANGE: fetch_ahead_counter and ahead_icon are no longer
used. .ClosestBookmarks now returns undecorated bookmark names. Add
.AheadCount and an icon to the template to keep showing the distance.
Existing configs still load, and obsolete options are ignored.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4RA9ZrP7vuqVSR7nddNiQ
Language and Terraform segments now fetch version data only when their
templates use it.

BREAKING CHANGE: fetch_version is no longer used by language and
Terraform segments. Remove the option and reference a version field in
the template to fetch it. Remove version fields from the template to
skip version commands. Existing configs still load, and the obsolete
option is ignored.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4RA9ZrP7vuqVSR7nddNiQ
Three segments populate template-visible fields inside the gated
version fetch that the shared unit list missed, so referencing them
alone silently skipped the fetch and rendered empty. Language gains an
extraVersionFields hook: a concrete segment declares in its spec the
fields its own fetch-path closures populate, and versionFields appends
them to the shared list for the fetch decision.

- gradle: KotlinVersion/GroovyVersion/AntVersion/JVMVersion, populated
  by parseExtraVersions inside the gated getVersion closure
- dotnet: Unsupported, derived from the gated fetch's exit code
- python: Venv, which the pyenv getVersion overrides with the resolved
  virtualenv name - a .Venv-only template now fetches to keep that
  naming (a slight over-fetch for non-pyenv users, deliberately)

A sweep of the remaining custom getVersion/tooling closures (node
packages, golang mod parsers, helm and friends) found no other gated
field writes. Also restores the contextEnvVars/contextFiles contract
comments the struct reshuffle dropped and removes the stale
fetch_version row from the segment-docs skill.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4RA9ZrP7vuqVSR7nddNiQ
@JanDeDobbeleer
JanDeDobbeleer force-pushed the claude/lazy-load-segment-perf-8wo3t7 branch from aa17142 to 7dd5e8b Compare August 30, 2026 09:43
@JanDeDobbeleer
JanDeDobbeleer merged commit 41a7d6d into main Aug 30, 2026
14 checks passed
@JanDeDobbeleer
JanDeDobbeleer deleted the claude/lazy-load-segment-perf-8wo3t7 branch August 30, 2026 10:13
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.

2 participants