pi-deepseek-tariff 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ A [pi](https://pi.dev) extension that shows the current **DeepSeek API tariff wi
10
10
  DeepSeek charges double during peak hours, so knowing the current window is worth real money.
11
11
 
12
12
  ```
13
- ● DeepSeek Off-peak for 11h 59m ← footer line, always visible
13
+ ◷ deepseek OFF-PEAK → 11h 59m ← footer line, always visible
14
14
 
15
15
  Now OFF-PEAK
16
16
  Computer: 14:43:52 26-10-2026(MSK) | UTC: 2026-10-26 11:43
@@ -38,7 +38,7 @@ Commands: /dsp on|off|refresh|en|ru · /dsp footer on|off
38
38
  pi install npm:pi-deepseek-tariff
39
39
 
40
40
  # from git: tags mirror the npm versions, so pin whichever you want
41
- pi install git:github.com/valderan/pi-deepseek-tariff@v0.3.0
41
+ pi install git:github.com/valderan/pi-deepseek-tariff@v0.4.0
42
42
 
43
43
  # local checkout, for development
44
44
  git clone git@github.com:valderan/pi-deepseek-tariff.git
@@ -66,14 +66,26 @@ Type `/dsp ` and press Tab: the arguments above are offered as completions, with
66
66
 
67
67
  ## What it does
68
68
 
69
- - **Footer line** — one compact status line: `● DeepSeek Off-peak for 11h 59m`, or `● DeepSeek Peak for 1h 40m` during peak. The text is always yellow and only the dot changes colour — green for off-peak, red for peak. Shown only while a DeepSeek model is active; `/dsp footer off` hides it for good.
69
+ - **Footer line** — one compact status line: `◷ deepseek OFF-PEAK → 11h 59m`, or `◷ deepseek PEAK → 1h 40m` during peak. The clock glyph leads the line and takes the state colour — green for off-peak, red for peak — and the tariff word next to it is coloured the same way. The brand is written lowercase in DeepSeek blue (`#4175E6`), the countdown uses the theme accent. Shown only while a DeepSeek model is active; `/dsp footer off` hides it for good.
70
70
 
71
- Russian looks like `● DeepSeek Обычный ещё 11ч 59м` / `● DeepSeek Пиковый ещё 1ч 40м`.
71
+ The tariff is spelled `PEAK` / `OFF-PEAK` in English regardless of the panel language; only the countdown units are localized, so Russian reads `◷ deepseek OFF-PEAK → 11ч 59м`.
72
+
73
+ The line escalates as the switch approaches — clock and countdown first, then the tariff word itself:
74
+
75
+ | Left to the switch | Clock + countdown | Tariff word |
76
+ |---|---|---|
77
+ | more than 15 min | state colour / accent | whole word in the state colour |
78
+ | below 15 min | **yellow** (`warning`) | unchanged |
79
+ | counter shows `4m` / `3m` / `2m` / `1m` / `0m` | yellow | last **1 / 2 / 3 / 3 / 4** letters take the incoming colour, the rest dims |
80
+
81
+ So the word is eaten from the end, one letter per minute of the visible countdown: `OFF-PEA` + `K`, then `OFF-PE` + `AK`, … up to `OFF-` + `PEAK` in the final seconds. The tail is **red** when PEAK is about to start and **green** when off-peak is about to start, and the tick drops to 1 s during the last five minutes so each step lands on the minute.
82
+
83
+ ![Countdown states: every step of the footer for both switches, in English and Russian](https://raw.githubusercontent.com/valderan/pi-deepseek-tariff/main/assets/footer-states.png)
72
84
 
73
85
  Pi renders every extension status on a single footer line, joined alphabetically, so the segment is kept short.
74
86
  - **Peak rules** — DeepSeek defines peak as `01:00-04:00` and `06:00-10:00` **UTC, Monday to Friday**; weekends and Chinese public holidays are off-peak all day.
75
87
  - **Local time everywhere** — the UTC clock comes from the machine clock, so peak windows, weekday labels and countdowns are rendered in *your* zone (`(MSK)`, `(UTC-04:00)`, …). Weekday labels shift with the zone: at UTC−4 the windows land on Sun–Thu.
76
- - **Prices** — parsed from `api-docs.deepseek.com/quick_start/pricing/`: cache hit, cache miss and output per model, plus the Search & Fetch request rates. The tariff in effect is highlighted.
88
+ - **Prices** — parsed from `api-docs.deepseek.com/quick_start/pricing/`: cache hit, cache miss and output per model, plus the Search & Fetch request rates. The tariff in effect is highlighted, and the `DeepSeek` word in the `DeepSeek prices:` label keeps the same brand blue as the footer.
77
89
  - **Caching** — the page is parsed at most once per `pricingTtlHours` (default 6), and the result is cached on disk, so restarts stay offline. `PI_OFFLINE` disables network access entirely and the panel falls back to built-in values.
78
90
 
79
91
  ## Configuration
@@ -96,7 +108,7 @@ Type `/dsp ` and press Tab: the arguments above are offered as completions, with
96
108
  | Key | Default | Meaning |
97
109
  |---|---|---|
98
110
  | `language` | pi's `settings.json` language, else `en` | `"en"` or `"ru"` |
99
- | `intervalMs` | `30000` | minimum refresh cadence (clamped to ≥ 5000) |
111
+ | `intervalMs` | `30000` | repaint cadence (clamped to ≥ 5000); the footer switches to a 1 s tick by itself in the last five minutes before a switch |
100
112
  | `pricingTtlHours` | `6` | how long parsed prices stay valid |
101
113
  | `alwaysShow` | `false` | show the footer line for non-DeepSeek models too |
102
114
  | `footer` | `true` | show the footer status line at all; `/dsp footer off` writes this |
@@ -136,11 +148,11 @@ the commit whose `package.json` says `X.Y.Z`, and npm publishes that same versio
136
148
 
137
149
  ```bash
138
150
  # 1. bump package.json, add a CHANGELOG entry, commit
139
- npm version 0.3.2 --no-git-tag-version # or edit by hand
140
- git commit -am "chore: release 0.3.2"
151
+ npm version 0.4.0 --no-git-tag-version # or edit by hand
152
+ git commit -am "chore: release 0.4.0"
141
153
 
142
154
  # 2. tag the release commit and push
143
- git tag -a v0.3.2 -m v0.3.2
155
+ git tag -a v0.4.0 -m v0.4.0
144
156
  git push origin main --tags
145
157
 
146
158
  # 3. publish the tag to npm (2FA prompt, or --otp=123456)
@@ -151,7 +163,7 @@ npm publish --access public
151
163
  Check the result:
152
164
 
153
165
  ```bash
154
- npm view pi-deepseek-tariff dist-tags # { latest: '0.3.2' }
166
+ npm view pi-deepseek-tariff dist-tags # { latest: '0.4.0' }
155
167
  npm view pi-deepseek-tariff versions
156
168
  ```
157
169
 
@@ -159,7 +171,7 @@ npm view pi-deepseek-tariff versions
159
171
  `NPM_TOKEN` secret is configured; with trusted publishing enabled on npmjs.com the
160
172
  token is not needed at all and the build is signed with provenance.
161
173
 
162
- Tags are pinnable with `pi install git:…@v0.3.2`. Published versions show up in the
174
+ Tags are pinnable with `pi install git:…@v0.4.0`. Published versions show up in the
163
175
  [pi package gallery](https://pi.dev/packages) through the `pi-package` keyword.
164
176
 
165
177
  ## Changelog
@@ -14,15 +14,22 @@ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } f
14
14
  import { loadConfig, PRICING_CACHE, saveConfig, type Config } from "../src/config.ts";
15
15
  import { STRINGS, type Lang } from "../src/i18n.ts";
16
16
  import { createPricingStore, isOffline, type Pricing } from "../src/pricing.ts";
17
+ import { nextTransition } from "../src/rules.ts";
17
18
  import { SEPARATOR, panelLines, statusText } from "../src/view.ts";
18
19
 
19
20
  const STATUS_KEY = "deepseek-peak";
20
21
  const WIDGET_KEY = "deepseek-peak";
21
22
 
23
+ /** Tick used while the footer counts down the last minutes before a switch. */
24
+ const FAST_TICK_MS = 1_000;
25
+
26
+ /** The footer changes every minute below this gap, so it needs the fast tick. */
27
+ const FAST_WINDOW_MS = 5 * 60_000;
28
+
22
29
  export default function deepseekPeak(pi: ExtensionAPI) {
23
30
  let config = loadConfig();
24
31
  let lang: Lang = config.language ?? "en";
25
- let timer: ReturnType<typeof setInterval> | undefined;
32
+ let timer: ReturnType<typeof setTimeout> | undefined;
26
33
  let panelVisible = false;
27
34
 
28
35
  const pricing = createPricingStore({
@@ -80,21 +87,36 @@ export default function deepseekPeak(pi: ExtensionAPI) {
80
87
  };
81
88
 
82
89
  const stop = () => {
83
- if (timer !== undefined) clearInterval(timer);
90
+ if (timer !== undefined) clearTimeout(timer);
84
91
  timer = undefined;
85
92
  };
86
93
 
94
+ /**
95
+ * Normally the footer only needs `intervalMs`, but in the last five minutes the
96
+ * countdown word changes every minute, so poll faster to land on the boundary.
97
+ */
98
+ const nextDelay = () => {
99
+ const msLeft = nextTransition(new Date(), config.holidays).getTime() - Date.now();
100
+ return msLeft < FAST_WINDOW_MS ? Math.min(FAST_TICK_MS, config.intervalMs) : config.intervalMs;
101
+ };
102
+
87
103
  const start = (ctx: ExtensionContext) => {
88
104
  stop();
89
105
  render(ctx);
90
- timer = setInterval(() => {
91
- // Only hits the network when the TTL expired.
92
- if (!pricing.expired()) {
93
- render(ctx);
94
- return;
95
- }
96
- void pricing.refresh().then((value) => render(ctx, value));
97
- }, config.intervalMs);
106
+
107
+ // A self-rescheduling timeout instead of an interval: the cadence follows the countdown.
108
+ const schedule = () => {
109
+ timer = setTimeout(() => {
110
+ const paint = (value?: Pricing) => {
111
+ render(ctx, value);
112
+ schedule();
113
+ };
114
+ // Only hits the network when the TTL expired.
115
+ if (pricing.expired()) void pricing.refresh().then(paint);
116
+ else paint();
117
+ }, nextDelay());
118
+ };
119
+ schedule();
98
120
  };
99
121
 
100
122
  pi.on("session_start", async (_event, ctx) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-deepseek-tariff",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "DeepSeek peak/off-peak tariff panel for pi: local clock, next window, live per-model prices and Search & Fetch rates",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/i18n.ts CHANGED
@@ -23,6 +23,8 @@ type Strings = {
23
23
  nextIn: (duration: string) => string;
24
24
  and: string;
25
25
  prices: string;
26
+ /** The product name inside `prices`, rendered in the DeepSeek blue instead of the label token. */
27
+ pricesBrand: string;
26
28
  pricesState: (state: string) => string;
27
29
  activeModel: string;
28
30
  peakWindows: (zone: string) => string;
@@ -34,6 +36,7 @@ type Strings = {
34
36
  sourceBuiltin: string;
35
37
  requestPricing: string;
36
38
  commands: string;
39
+ /** Footer line — the state is always written in English (PEAK / OFF-PEAK), in every language. */
37
40
  statusBrand: string;
38
41
  statusPeak: string;
39
42
  statusOffPeak: string;
@@ -53,6 +56,7 @@ export const STRINGS: Record<Lang, Strings> = {
53
56
  nextIn: (duration) => `in ${duration}`,
54
57
  and: "and",
55
58
  prices: "DeepSeek prices:",
59
+ pricesBrand: "DeepSeek",
56
60
  pricesState: (state) => `$ per 1M tokens, active: ${state}`,
57
61
  activeModel: "← active",
58
62
  peakWindows: (zone) => `Peak windows (${zone}):`,
@@ -64,10 +68,10 @@ export const STRINGS: Record<Lang, Strings> = {
64
68
  sourceBuiltin: "built-in values (page unavailable)",
65
69
  requestPricing: "Search & Fetch:",
66
70
  commands: "Commands:",
67
- statusBrand: "DeepSeek",
68
- statusPeak: "Peak",
69
- statusOffPeak: "Off-peak",
70
- statusFor: (duration) => `for ${duration}`,
71
+ statusBrand: "deepseek",
72
+ statusPeak: "PEAK",
73
+ statusOffPeak: "OFF-PEAK",
74
+ statusFor: (duration) => `\u2192 ${duration}`,
71
75
  locale: "en-GB",
72
76
  hourUnit: "h",
73
77
  minuteUnit: "m",
@@ -81,6 +85,7 @@ export const STRINGS: Record<Lang, Strings> = {
81
85
  nextIn: (duration) => `через ${duration}`,
82
86
  and: "и",
83
87
  prices: "Цены DeepSeek:",
88
+ pricesBrand: "DeepSeek",
84
89
  pricesState: (state) => `$ за 1M токенов, активен ${state}`,
85
90
  activeModel: "← активна",
86
91
  peakWindows: (zone) => `Окна peak (${zone}):`,
@@ -92,10 +97,10 @@ export const STRINGS: Record<Lang, Strings> = {
92
97
  sourceBuiltin: "встроенные значения (страница недоступна)",
93
98
  requestPricing: "Search & Fetch:",
94
99
  commands: "Команды:",
95
- statusBrand: "DeepSeek",
96
- statusPeak: "Пиковый",
97
- statusOffPeak: "Обычный",
98
- statusFor: (duration) => `ещё ${duration}`,
100
+ statusBrand: "deepseek",
101
+ statusPeak: "PEAK",
102
+ statusOffPeak: "OFF-PEAK",
103
+ statusFor: (duration) => `\u2192 ${duration}`,
99
104
  locale: "ru-RU",
100
105
  hourUnit: "ч",
101
106
  minuteUnit: "м",
package/src/rules.ts CHANGED
@@ -111,8 +111,12 @@ export function clockTime(date: Date, lang: Lang): string {
111
111
  return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
112
112
  }
113
113
 
114
+ export function countdownMinutes(ms: number): number {
115
+ return Math.max(0, Math.round(ms / 60_000));
116
+ }
117
+
114
118
  export function humanize(ms: number, lang: Lang = "en"): string {
115
- const minutes = Math.max(0, Math.round(ms / 60_000));
119
+ const minutes = countdownMinutes(ms);
116
120
  const hourUnit = lang === "ru" ? "ч" : "h";
117
121
  const minuteUnit = lang === "ru" ? "м" : "m";
118
122
  if (minutes < 60) return `${minutes}${minuteUnit}`;
@@ -121,6 +125,44 @@ export function humanize(ms: number, lang: Lang = "en"): string {
121
125
  return rest === 0 ? `${hours}${hourUnit}` : `${hours}${hourUnit} ${rest}${minuteUnit}`;
122
126
  }
123
127
 
128
+ export type CountdownPhase = {
129
+ /** Minutes the counter shows, so the animation can follow the visible number. */
130
+ minutes: number;
131
+ /** Less than 15 minutes to the switch: the clock and the countdown turn yellow. */
132
+ urgent: boolean;
133
+ /** Trailing letters of the tariff word painted in the incoming colour (0..4). */
134
+ swallowed: number;
135
+ };
136
+
137
+ /** Yellow warning starts below this gap to the next switch. */
138
+ export const URGENT_MS = 15 * 60_000;
139
+
140
+ /** A four-letter tail is all the animation ever covers, so `PEAK` is eaten whole. */
141
+ export const MAX_SWALLOWED = 4;
142
+
143
+ /**
144
+ * Letters of the four-letter tail already painted in the incoming colour, keyed by
145
+ * the minute the counter shows. `1` repeats the `2` step, and the whole tail goes
146
+ * only when the counter drops to `0` in the last seconds.
147
+ */
148
+ const SWALLOWED_BY_MINUTES: Readonly<Record<number, number>> = { 0: 4, 1: 3, 2: 3, 3: 2, 4: 1 };
149
+
150
+ /**
151
+ * How close the next switch is, expressed as what the footer should paint.
152
+ *
153
+ * The step follows the *displayed* countdown (`4м` → one letter, `3м` → two, …),
154
+ * so the colours change exactly when the number changes instead of up to a
155
+ * minute earlier.
156
+ */
157
+ export function countdownPhase(msLeft: number): CountdownPhase {
158
+ const minutes = countdownMinutes(msLeft);
159
+ return {
160
+ minutes,
161
+ urgent: msLeft < URGENT_MS,
162
+ swallowed: minutes >= 5 ? 0 : (SWALLOWED_BY_MINUTES[minutes] ?? 0),
163
+ };
164
+ }
165
+
124
166
  /** Local wall-clock time of a UTC hour on the machine's current date. */
125
167
  export function localClock(date: Date, utcHour: number): string {
126
168
  const instant = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), utcHour, 0, 0));
package/src/view.ts CHANGED
@@ -1,15 +1,19 @@
1
1
  /**
2
2
  * Presentation: the footer status line and the panel body.
3
3
  *
4
- * Colors follow the pi theme: labels in the warning token, values in the text
5
- * token, active rows bold, inactive rows dim.
4
+ * Colors follow the pi theme: the footer clock and the tariff word share the
5
+ * state token (green off-peak, red peak), the countdown uses the accent token,
6
+ * and only the brand keeps a fixed colour. The panel below the editor keeps the
7
+ * warning/text pairing.
6
8
  */
7
9
 
8
10
  import type { Theme } from "@earendil-works/pi-coding-agent";
11
+ import type { Color } from "@earendil-works/pi-tui";
9
12
  import type { Config } from "./config.ts";
10
13
  import { localizeUnit, STRINGS, type Lang } from "./i18n.ts";
11
14
  import type { Pricing, RequestPricing } from "./pricing.ts";
12
15
  import {
16
+ countdownPhase,
13
17
  dayRangeLabel,
14
18
  humanize,
15
19
  isHoliday,
@@ -21,11 +25,22 @@ import {
21
25
  PEAK_WINDOWS_UTC,
22
26
  tzLabel,
23
27
  utcStamp,
28
+ type CountdownPhase,
24
29
  } from "./rules.ts";
25
30
 
26
31
  /** Placeholder for a full-width rule; the renderer expands it to the panel width. */
27
32
  export const SEPARATOR = "\u0000rule";
28
33
 
34
+ /** Footer clock: one cell wide, so the segment stays short on the shared status line. */
35
+ const CLOCK = "\u25F7"; // ◷
36
+
37
+ /**
38
+ * DeepSeek brand blue (#4175E6). Fixed in every theme: it is the product colour, not a
39
+ * theme role. Written as a plain `Color` literal rather than `parseColor("#4175E6")`, so
40
+ * this module keeps zero runtime imports — the test job runs without `npm install`.
41
+ */
42
+ const BRAND_BLUE: Color = { kind: "rgb", r: 0x41, g: 0x75, b: 0xe6 };
43
+
29
44
  /** "Search & Fetch" value: a shared price is printed once. */
30
45
  export function requestPricingText(entries: RequestPricing[], lang: Lang): string | undefined {
31
46
  if (entries.length === 0) return undefined;
@@ -54,9 +69,31 @@ export function requestPricingText(entries: RequestPricing[], lang: Lang): strin
54
69
  }
55
70
 
56
71
  /**
57
- * Compact footer line: a coloured dot plus always-yellow text, for example
58
- * "● DeepSeek Off-peak for 11h 59m" or "● DeepSeek Peak for 1h 40m".
59
- * Only the dot changes colour, so the line reads calmly in both states.
72
+ * The tariff word with its tail painted as the switch approaches: the letters the
73
+ * feed already consumed go dim and the last up to four take the colour of the tariff
74
+ * that is about to start (red towards PEAK, green towards OFF-PEAK).
75
+ */
76
+ function tariffWord(
77
+ theme: Theme,
78
+ word: string,
79
+ current: "success" | "error",
80
+ incoming: "success" | "error",
81
+ phase: CountdownPhase,
82
+ ): string {
83
+ if (phase.swallowed <= 0) return theme.fg(current, word);
84
+ const cut = Math.max(0, word.length - phase.swallowed);
85
+ const head = cut > 0 ? theme.fg("dim", word.slice(0, cut)) : "";
86
+ return `${head}${theme.fg(incoming, word.slice(cut))}`;
87
+ }
88
+
89
+ /**
90
+ * Compact footer line: a clock, the brand in DeepSeek blue, the tariff word and
91
+ * the countdown — for example "◷ deepseek OFF-PEAK → 11h 59m".
92
+ *
93
+ * The clock carries the state colour (green off-peak, red peak). Below 15 minutes
94
+ * to the next switch both the clock and the countdown turn yellow, and in the last
95
+ * four minutes the tariff word is eaten from the end, one letter per minute shown
96
+ * by the counter. The duration keeps the panel language units.
60
97
  */
61
98
  export function statusText(theme: Theme, config: Config, lang: Lang, pricing: Pricing): string {
62
99
  void pricing;
@@ -64,10 +101,29 @@ export function statusText(theme: Theme, config: Config, lang: Lang, pricing: Pr
64
101
  const now = new Date();
65
102
  const peak = isPeak(now, config.holidays);
66
103
  const next = nextTransition(now, config.holidays);
67
- const dot = theme.fg(peak ? "error" : "success", "●");
68
- const label = peak ? text.statusPeak : text.statusOffPeak;
69
- const duration = humanize(next.getTime() - now.getTime(), lang);
70
- return `${dot} ${theme.fg("warning", `${text.statusBrand} ${label} ${text.statusFor(duration)}`)}`;
104
+ const msLeft = next.getTime() - now.getTime();
105
+ const phase = countdownPhase(msLeft);
106
+ const state = peak ? "error" : "success";
107
+ const incoming = peak ? "success" : "error";
108
+ const clock = theme.fg(phase.urgent ? "warning" : state, CLOCK);
109
+ const brand = theme.style(text.statusBrand, { fg: BRAND_BLUE });
110
+ const word = peak ? text.statusPeak : text.statusOffPeak;
111
+ const label = tariffWord(theme, word, state, incoming, phase);
112
+ const countdown = theme.fg(phase.urgent ? "warning" : "accent", text.statusFor(humanize(msLeft, lang)));
113
+ return `${clock} ${brand} ${label} ${countdown}`;
114
+ }
115
+
116
+ /**
117
+ * Colour the product name inside a panel label without splitting the string in
118
+ * i18n: the label stays one translatable line, only the brand word is swapped to
119
+ * the DeepSeek blue. Falls back to `rest(label)` when the name is missing.
120
+ */
121
+ function brandLabel(theme: Theme, label: string, brand: string, rest: (value: string) => string): string {
122
+ const at = label.indexOf(brand);
123
+ if (at < 0) return rest(label);
124
+ const head = rest(label.slice(0, at));
125
+ const tail = rest(label.slice(at + brand.length));
126
+ return `${head}${theme.style(brand, { fg: BRAND_BLUE })}${tail}`;
71
127
  }
72
128
 
73
129
  export function panelLines(
@@ -111,7 +167,7 @@ export function panelLines(
111
167
  );
112
168
 
113
169
  // 4. Model prices with the active tariff highlighted.
114
- lines.push(`${yellow(text.prices)} ${white(text.pricesState(state))}`);
170
+ lines.push(`${brandLabel(theme, text.prices, text.pricesBrand, yellow)} ${white(text.pricesState(state))}`);
115
171
  const value = (amount: number) => amount.toFixed(4).padStart(8);
116
172
  for (const [name, model] of Object.entries(pricing.models)) {
117
173
  const id = `${config.providerPrefix}${name}`;