diff --git a/README.md b/README.md index 34d8180..1865b82 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![node](https://img.shields.io/badge/node-%E2%89%A5%2020-339933?style=flat-square)](./package.json) [![MIT](https://img.shields.io/badge/license-MIT-007ec6?style=flat-square)](./LICENSE) -omp-statusline rendering its rows beneath the editor in a terminal: clock, Hijri date with Gregorian gloss and city; the five prayer times with a green countdown on the next prayer; API spend and token volume per window; one z.ai quota pace row per window with over/under indicators; and an omp/sl version line. +omp-statusline rendering its rows beneath the editor in a terminal: clock, Hijri date with Gregorian gloss and city; the five prayer times with a green countdown on the next prayer; API spend and token volume per window; one z.ai quota pace row per window with over/under indicators; and an omp/sl/tern version line. 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; + } +});