diff --git a/README.md b/README.md
index 34d8180..1865b82 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
[](./package.json)
[](./LICENSE)
-
+
A statusline widget for [omp](https://github.com/can1357/oh-my-pi) (Oh My Pi) — the omp-native successor to [pi-statusline](https://github.com/getpipher/pi-statusline). It renders below the editor in omp's own visual language (` · ` separators, nerd glyphs, theme tokens), and omp's native statusline closes the block beneath it (extension widgets cannot render under it yet — tracked upstream as [can1357/oh-my-pi#11100](https://github.com/can1357/oh-my-pi/issues/11100)).
@@ -22,7 +22,7 @@ Render order (top → bottom): **info · prayers · money · zai (one row per wi
| **prayers** | All five prayers with wall times, from [aladhan](https://api.aladhan.com) (cached per local day, stale-marker on degradation). Past prayers get a dim `✓`; the next prayer is green with a countdown on its segment only — `Dhuhr 11:51 (3h 36m)`. |
| **money** | API spend + token volume per window: `REPO $68.36 (1.3B)` (all-time for the current project) · `DAY` (since 00:00 local) · `7DAY` / `30DAY` (rolling, hour-aligned). Token volumes are dim, from the same sessions scan. |
| **zai** | One row per quota window — ` zai 5hrs …` / ` zai 7DAY …` — each `LABEL usage%/elapsed% (pace · reset · absolute)`, e.g. `5hrs 16%/26% (30m under · 3h 43m · 11:58)`. **Provider-gated**: renders only while the active model's provider is `zai` (or the provider is unreadable); vanishes entirely on other providers. |
-| **versions** | ` omp 18.7.0 · sl 0.6.3` — host from one `--version` spawn of the **running omp binary** (`process.execPath`, so it is install-agnostic (brew/npm/curl/source) and correct even for sessions resumed across an upgrade); plugin from its own package.json. Omitted entirely when no version is known — never a placeholder. |
+| **versions** | ` omp 18.7.0 · sl 0.6.3 · tern 0.5.3` — host from one `--version` spawn of the **running omp binary** (`process.execPath`, so it is install-agnostic (brew/npm/curl/source) and correct even for sessions resumed across an upgrade); plugin from its own package.json; tern from the `TERM_PROGRAM`/`TERM_PROGRAM_VERSION` env the [Tern terminal](https://stencil.so) sets — **Tern-gated**: strict env match, segment omitted outside Tern or when the version is unknown. Omitted entirely when no version is known — never a placeholder. |
**Pace** — `window × (usage% − elapsed%)/100` — renders `1h 36m over` (orange: burning faster than the clock, quota exhausts early by that much) or `1d 5h under` (green: behind the clock). Percent heat: accent < 70%, warning ≥ 70%, error ≥ 90% — raw, so over-quota stays error-red while display caps at `100%+`.
diff --git a/package.json b/package.json
index 43e8686..5dc6701 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "@getpipher/omp-statusline",
- "version": "0.7.0",
- "description": "Six-line omp statusline widget: hijri clock, prayer times, API-spend ledger (REPO/DAY/7DAY/30DAY, subagent-inclusive), per-window zai coding-plan quota rows (provider-gated), and an omp/sl version row.",
+ "version": "0.8.0",
+ "description": "Six-line omp statusline widget: hijri clock, prayer times, API-spend ledger (REPO/DAY/7DAY/30DAY, subagent-inclusive), per-window zai coding-plan quota rows (provider-gated), and an omp/sl/tern version row.",
"keywords": [
"pi-package",
"pi-extension",
diff --git a/src/index.ts b/src/index.ts
index 8f7fa1f..a394584 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,7 +6,7 @@
// REPO $68.36 (117.6M) · DAY $26.50 (4.0M) · 7DAY $315.27 (4.0B) · 30DAY $492.88 (11.3B)
// zai 5hrs 16%/26% (30m under · 3h 43m · 11:58) ← provider-gated (zai only)
// zai 7DAY 33%/31% (3h 22m over · 4d 19h · Sat Sep 12 03:30) ← provider-gated (zai only)
-// omp 18.3.4 · sl 0.6.2 ← omitted when versions unknown
+// omp 18.3.4 · sl 0.6.2 · tern 0.5.3 ← tern only under Tern; omitted when versions unknown
// Data layer vendored from @getpipher/pi-statusline (quota/zai, format, deen, adapters);
// money comes from the omp sessions disk-scan (money.ts — subagent-inclusive). State
// lives under ~/.omp/agent/omp-statusline/. The zai key resolves from omp's own
@@ -23,7 +23,7 @@ import { zaiStatusDetail } from "./adapters/zai.ts";
import { scanMoney, type MoneySnapshot } from "./money.ts";
import { fetchZaiReport } from "./quota/zai-extra.ts";
import { resolveAccent, type AccentFn, type SessionAccentInputs } from "./accent.ts";
-import { ompVersion, slVersion } from "./versions.ts";
+import { ompVersion, slVersion, ternVersion } from "./versions.ts";
// Dashboard-style compact credits: 7664 → "7.7K", 28000 → "28K", 37160 → "37.2K", 140000 → "140K".
export function compactK(v: number): string {
if (v < 1000) return `${Math.round(v)}`;
@@ -244,13 +244,14 @@ export function prayerLine(theme: SlTheme, s: DeenSnapshot, sep: string, accent?
return ` ${glyph("")} ${cells.join(sep)}${stale}`;
}
-// Version stamps: `omp · sl `. v0.6.2 pinned them to the info
-// line; v0.7.0 (RECTOR, 2026-10-07) moves them to a dedicated version row
-// (versionLine). Segments whose version is unavailable are omitted — never
+// Version stamps: `omp · sl · tern `. v0.6.2 pinned them
+// to the info line; v0.7.0 (RECTOR, 2026-10-07) moves them to a dedicated version
+// row (versionLine). Segments whose version is unavailable are omitted — never
// rendered as a placeholder lie.
export interface VersionStamp {
omp: string | null;
sl: string | null;
+ tern?: string | null;
}
export function infoLine(theme: SlTheme, s: DeenSnapshot, now: number, sep: string, accent?: AccentFn | null): string {
@@ -262,7 +263,7 @@ export function infoLine(theme: SlTheme, s: DeenSnapshot, now: number, sep: stri
}
// v0.7.0 dedicated version row (RECTOR, 2026-10-07): the money-row treatment —
-// glyph + accent omp/sl labels, dim values. Renders as the widget's last row,
+// glyph + accent omp/sl/tern labels, dim values. Renders as the widget's last row,
// unconditionally (no zai-fallback chain: zai rows come and go, this one stays).
// Returns null when NO version is known — an all-lies row is worse than no row
// (same contract as the old v0.6.2 stamp segments).
@@ -271,6 +272,7 @@ export function versionLine(theme: SlTheme, stamp: VersionStamp, accent?: Accent
const parts: string[] = [];
if (stamp.omp) parts.push(`${glyph("omp")} ${theme.fg("dim", stamp.omp)}`);
if (stamp.sl) parts.push(`${glyph("sl")} ${theme.fg("dim", stamp.sl)}`);
+ if (stamp.tern) parts.push(`${glyph("tern")} ${theme.fg("dim", stamp.tern)}`);
if (parts.length === 0) return null;
return ` ${glyph("")} ${parts.join(theme.fg("dim", " · "))}`;
}
@@ -304,10 +306,12 @@ export default function ompStatusline(pi: SlApi): void {
let ctx: SlCtx | null = null;
let started = false;
let warnedNoKey = false;
- // Clock-line stamp: sl resolves synchronously (package.json next to the source);
- // omp resolves async (one `--version` spawn of the RUNNING binary,
- // process.execPath — install-agnostic) and re-renders.
- let stamp: VersionStamp = { omp: null, sl: slVersion() };
+ // Clock-line stamp: sl resolves synchronously (package.json next to the source)
+ // and tern synchronously from the environment (TERM_PROGRAM/TERM_PROGRAM_VERSION,
+ // fixed before omp launches — no re-render needed); omp resolves async (one
+ // `--version` spawn of the RUNNING binary, process.execPath — install-agnostic)
+ // and re-renders.
+ let stamp: VersionStamp = { omp: null, sl: slVersion(), tern: ternVersion() };
// Managed timers when the host exposes them (omp ≥18.1: a throw inside the tick
// is logged through the extension error channel — NOT a fatal session teardown —
diff --git a/src/versions.ts b/src/versions.ts
index da5d9ce..3fba281 100644
--- a/src/versions.ts
+++ b/src/versions.ts
@@ -1,6 +1,6 @@
-// src/versions.ts — clock-line version stamp sources. Both resolved once per
-// process and cached: the stamp describes the RUNNING omp + the INSTALLED sl,
-// neither of which changes while the session lives.
+// src/versions.ts — version-row stamp sources. All resolved once per process and
+// cached: the stamp describes the RUNNING omp, the INSTALLED sl, and the HOSTING
+// tern terminal, none of which changes while the session lives.
import { readFileSync } from "node:fs";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
@@ -53,6 +53,29 @@ export function ompVersion(): Promise {
return ompCache;
}
+// Tern (stencil.so terminal) sets TERM_PROGRAM=tern + TERM_PROGRAM_VERSION for the
+// sessions it hosts — a synchronous, install-agnostic source needing no spawn.
+// Strict, case-sensitive gate: a lookalike ("Tern") or any other terminal
+// (Ghostty, tmux — even with a version set) must not borrow the segment, and
+// TERN_* side-channel vars don't count. Unknown version → null: the segment is
+// omitted upstream, never rendered as a placeholder lie.
+export function ternVersionSource(env: NodeJS.ProcessEnv): string | null {
+ if (env.TERM_PROGRAM !== "tern") return null;
+ return extractSemver(env.TERM_PROGRAM_VERSION ?? "");
+}
+
+let ternCache: string | null | undefined;
+
+// Env read, cached process-long: Tern's version cannot change while the process
+// lives (TERM_PROGRAM/TERM_PROGRAM_VERSION are fixed before omp launches). The
+// explicit undefined check caches null misses too (slVersion's pattern) — `??=`
+// would re-run the gate on every call in the common non-Tern case.
+export function ternVersion(): string | null {
+ if (ternCache !== undefined) return ternCache;
+ ternCache = ternVersionSource(process.env);
+ return ternCache;
+}
+
let slCache: string | null | undefined;
// This plugin's own package.json, resolved relative to this module — works both in
diff --git a/test/lines.test.ts b/test/lines.test.ts
index d1753ee..147834e 100644
--- a/test/lines.test.ts
+++ b/test/lines.test.ts
@@ -121,17 +121,35 @@ test("infoLine: clock · hijri (gregorian in parens) · city, all dim", () => {
test("versionLine: glyph + omp/sl labels, dim values, dim separator (no accent → dim identity)", () => {
assert.equal(
- versionLine(theme, { omp: "18.3.4", sl: "0.6.1" }),
+ versionLine(theme, { omp: "18.3.4", sl: "0.6.1", tern: null }),
" > omp> 18.3.4> · >sl> 0.6.1>",
);
});
test("versionLine: null omp drops its segment; sl still renders", () => {
- assert.equal(versionLine(theme, { omp: null, sl: "0.6.1" }), " > sl> 0.6.1>");
+ assert.equal(versionLine(theme, { omp: null, sl: "0.6.1", tern: null }), " > sl> 0.6.1>");
});
test("versionLine: no versions → null, never a placeholder row", () => {
- assert.equal(versionLine(theme, { omp: null, sl: null }), null);
+ assert.equal(versionLine(theme, { omp: null, sl: null, tern: null }), null);
+});
+
+test("versionLine: tern omitted (optional field) → exact v0.7.0 two-segment row", () => {
+ assert.equal(
+ versionLine(theme, { omp: "18.9.0", sl: "0.8.0" }),
+ " > omp> 18.9.0> · >sl> 0.8.0>",
+ );
+});
+
+test("versionLine: tern segment closes the row — omp · sl · tern order (RECTOR mock)", () => {
+ assert.equal(
+ versionLine(theme, { omp: "18.9.0", sl: "0.7.0", tern: "0.7.2" }),
+ " > omp> 18.9.0> · >sl> 0.7.0> · >tern> 0.7.2>",
+ );
+});
+
+test("versionLine: only tern known → tern-only row (parts.length>0 contract)", () => {
+ assert.equal(versionLine(theme, { omp: null, sl: null, tern: "0.7.2" }), " > tern> 0.7.2>");
});
test("moneyLine v0.3.0: every window carries dim token parens; REPO included", () => {
diff --git a/test/versions.test.ts b/test/versions.test.ts
index 47e1644..20d3b8c 100644
--- a/test/versions.test.ts
+++ b/test/versions.test.ts
@@ -1,13 +1,13 @@
// test/versions.test.ts — clock-line stamp sources: pure semver extraction, the
// omp `--version` output guard, the running-binary spawn (fixture scripts plus
-// the test runner's own runtime as the bare-version false positive), and the
-// plugin's own package.json resolver.
+// the test runner's own runtime as the bare-version false positive), the
+// plugin's own package.json resolver, and the tern env gate.
import { test } from "node:test";
import assert from "node:assert/strict";
import { chmodSync, mkdtempSync, writeFileSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
-import { extractSemver, parseOmpVersionOutput, spawnOmpVersion, slVersion } from "../src/versions.ts";
+import { extractSemver, parseOmpVersionOutput, spawnOmpVersion, slVersion, ternVersion, ternVersionSource } from "../src/versions.ts";
test("extractSemver: bare marker, prefixed banner, prerelease+build, garbage", () => {
assert.equal(extractSemver("18.3.4\n"), "18.3.4");
@@ -54,3 +54,69 @@ test("slVersion: resolves this package's package.json, matching the manifest ver
assert.equal(slVersion(), pkg.version);
assert.match(slVersion() ?? "", /^\d+\.\d+\.\d+$/);
});
+
+test("ternVersionSource: tern + version parses; junk around the semver tolerated via extractSemver", () => {
+ assert.equal(ternVersionSource({ TERM_PROGRAM: "tern", TERM_PROGRAM_VERSION: "0.7.2" }), "0.7.2");
+ assert.equal(ternVersionSource({ TERM_PROGRAM: "tern", TERM_PROGRAM_VERSION: "tern 0.10.3" }), "0.10.3");
+});
+
+test("ternVersionSource: missing or garbage version under tern → null (segment omitted)", () => {
+ assert.equal(ternVersionSource({ TERM_PROGRAM: "tern" }), null);
+ assert.equal(ternVersionSource({ TERM_PROGRAM: "tern", TERM_PROGRAM_VERSION: "" }), null);
+ assert.equal(ternVersionSource({ TERM_PROGRAM: "tern", TERM_PROGRAM_VERSION: "dev" }), null);
+ assert.equal(ternVersionSource({ TERM_PROGRAM: "tern", TERM_PROGRAM_VERSION: "0.7" }), null); // two-component shape
+});
+
+test("ternVersionSource: gate is tern-exclusive — other terminals with a version set rejected", () => {
+ assert.equal(ternVersionSource({ TERM_PROGRAM: "ghostty", TERM_PROGRAM_VERSION: "1.2.3" }), null);
+ assert.equal(ternVersionSource({ TERM_PROGRAM: "tmux", TERM_PROGRAM_VERSION: "3.4.1" }), null);
+});
+
+test("ternVersionSource: strict case-sensitive match — 'Tern' is not tern; absent vars → null", () => {
+ assert.equal(ternVersionSource({ TERM_PROGRAM: "Tern", TERM_PROGRAM_VERSION: "0.7.2" }), null);
+ assert.equal(ternVersionSource({}), null);
+});
+
+test("ternVersion: caches the miss process-long — later env changes don't resurrect the segment", () => {
+ // First ternVersion() call in this suite: the module cache is guaranteed empty,
+ // so a non-Tern env must populate the null cache and hold it (slVersion pattern).
+ const savedProgram = process.env.TERM_PROGRAM;
+ const savedVersion = process.env.TERM_PROGRAM_VERSION;
+ try {
+ delete process.env.TERM_PROGRAM;
+ delete process.env.TERM_PROGRAM_VERSION;
+ assert.equal(ternVersion(), null);
+ process.env.TERM_PROGRAM = "tern";
+ process.env.TERM_PROGRAM_VERSION = "9.9.9";
+ assert.equal(ternVersion(), null); // a ??= cache would recompute here and yield "9.9.9"
+ } finally {
+ if (savedProgram === undefined) delete process.env.TERM_PROGRAM;
+ else process.env.TERM_PROGRAM = savedProgram;
+ if (savedVersion === undefined) delete process.env.TERM_PROGRAM_VERSION;
+ else process.env.TERM_PROGRAM_VERSION = savedVersion;
+ }
+});
+
+test("ternVersion: fresh module instance applies the gate to the live env and caches its value", async () => {
+ const savedProgram = process.env.TERM_PROGRAM;
+ const savedVersion = process.env.TERM_PROGRAM_VERSION;
+ try {
+ process.env.TERM_PROGRAM = "tern";
+ process.env.TERM_PROGRAM_VERSION = "9.9.9";
+ // Static import cannot work here: it would bind to the SAME module record the
+ // suite already imported (ternCache already populated by the miss test above),
+ // so only a runtime-selected query-param specifier yields a fresh instance.
+ // This is a test intentionally exercising the module-loading boundary.
+ const fresh = (await import(`../src/versions.ts?gate-live-${Date.now()}`)) as {
+ ternVersion: () => string | null;
+ };
+ assert.equal(fresh.ternVersion(), "9.9.9");
+ process.env.TERM_PROGRAM_VERSION = "8.8.8";
+ assert.equal(fresh.ternVersion(), "9.9.9"); // cached process-long
+ } finally {
+ if (savedProgram === undefined) delete process.env.TERM_PROGRAM;
+ else process.env.TERM_PROGRAM = savedProgram;
+ if (savedVersion === undefined) delete process.env.TERM_PROGRAM_VERSION;
+ else process.env.TERM_PROGRAM_VERSION = savedVersion;
+ }
+});