Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

<img src="assets/hero.svg" alt="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." width="866">
<img src="assets/hero.svg" alt="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." width="866">

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)).

Expand All @@ -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%+`.

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
24 changes: 14 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)}`;
Expand Down Expand Up @@ -244,13 +244,14 @@ export function prayerLine(theme: SlTheme, s: DeenSnapshot, sep: string, accent?
return ` ${glyph("󰣎")} ${cells.join(sep)}${stale}`;
}

// Version stamps: `omp <host> · sl <plugin>`. 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 <host> · sl <plugin> · tern <terminal>`. 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 {
Expand All @@ -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).
Expand All @@ -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", " · "))}`;
}
Expand Down Expand Up @@ -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 —
Expand Down
29 changes: 26 additions & 3 deletions src/versions.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -53,6 +53,29 @@ export function ompVersion(): Promise<string | null> {
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
Expand Down
24 changes: 21 additions & 3 deletions test/lines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
" <dim>󰚦</> <dim>omp</> <dim>18.3.4</><dim> · </><dim>sl</> <dim>0.6.1</>",
);
});

test("versionLine: null omp drops its segment; sl still renders", () => {
assert.equal(versionLine(theme, { omp: null, sl: "0.6.1" }), " <dim>󰚦</> <dim>sl</> <dim>0.6.1</>");
assert.equal(versionLine(theme, { omp: null, sl: "0.6.1", tern: null }), " <dim>󰚦</> <dim>sl</> <dim>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" }),
" <dim>󰚦</> <dim>omp</> <dim>18.9.0</><dim> · </><dim>sl</> <dim>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" }),
" <dim>󰚦</> <dim>omp</> <dim>18.9.0</><dim> · </><dim>sl</> <dim>0.7.0</><dim> · </><dim>tern</> <dim>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" }), " <dim>󰚦</> <dim>tern</> <dim>0.7.2</>");
});

test("moneyLine v0.3.0: every window carries dim token parens; REPO included", () => {
Expand Down
72 changes: 69 additions & 3 deletions test/versions.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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;
}
});
Loading