opencode-deepseek-peak 0.2.0 → 0.2.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-deepseek-peak",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
4
4
  "description": "OpenCode V2 plugin showing DeepSeek peak and off-peak pricing periods",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -1,5 +1,6 @@
1
1
  import type { Plugin } from "@opencode/plugin/tui"
2
- import type { T, TranslationKey } from "../i18n.js"
2
+ import type { LangCode, T, TranslationKey } from "../i18n.js"
3
+ import { nextTransition } from "../schedule.js"
3
4
  import type { PeakStatus, TuiSettings } from "../types.js"
4
5
 
5
6
  export const INITIAL_SETTINGS: TuiSettings = {
@@ -9,6 +10,7 @@ export const INITIAL_SETTINGS: TuiSettings = {
9
10
  sidebarEvidence: true,
10
11
  toastEveryRequest: false,
11
12
  lang: "en",
13
+ timezone: "local",
12
14
  }
13
15
 
14
16
  export function statusLabel(status: PeakStatus, t: T) {
@@ -26,9 +28,65 @@ const HOLIDAY_KEYS: Record<string, TranslationKey> = {
26
28
  "Labour Day": "holiday.labourDay",
27
29
  }
28
30
 
31
+ export interface LabelOptions {
32
+ /** Reference instant for the next transition; defaults to now. */
33
+ now?: Date
34
+ lang?: LangCode
35
+ /** IANA time zone; undefined uses the system zone, "UTC" shows the official times. */
36
+ timeZone?: string
37
+ }
38
+
39
+ function zoneDate(date: Date, timeZone: string | undefined): string {
40
+ const parts = new Intl.DateTimeFormat("en-CA", {
41
+ timeZone,
42
+ year: "numeric",
43
+ month: "2-digit",
44
+ day: "2-digit",
45
+ }).formatToParts(date)
46
+ const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? ""
47
+ return `${value("year")}-${value("month")}-${value("day")}`
48
+ }
49
+
50
+ function formatClock(date: Date, timeZone: string | undefined): string {
51
+ const parts = new Intl.DateTimeFormat("en-GB", {
52
+ timeZone,
53
+ hour: "2-digit",
54
+ minute: "2-digit",
55
+ hourCycle: "h23",
56
+ }).formatToParts(date)
57
+ const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? ""
58
+ return `${value("hour")}:${value("minute")}`
59
+ }
60
+
61
+ /** "10:00 UTC" in UTC mode, "07:00" in the local zone; adds a short weekday on another day. */
62
+ function formatTransitionAt(at: Date, now: Date, lang: LangCode, timeZone: string | undefined): string {
63
+ const clock = timeZone === "UTC" ? `${formatClock(at, timeZone)} UTC` : formatClock(at, timeZone)
64
+ if (zoneDate(at, timeZone) === zoneDate(now, timeZone)) return clock
65
+ const weekday = new Intl.DateTimeFormat(lang, { weekday: "short", timeZone }).format(at)
66
+ return `${weekday} ${clock}`
67
+ }
68
+
69
+ /** Localized "peak ends 10:00 UTC" / "peak starts Mon 01:00" for the next change. */
70
+ export function transitionLabel(t: T, options: LabelOptions = {}): string {
71
+ const now = options.now ?? new Date()
72
+ const next = nextTransition(now)
73
+ return t(next.kind === "peakEnd" ? "evidence.peakEnds" : "evidence.peakStarts", {
74
+ when: formatTransitionAt(next.at, now, options.lang ?? "en", options.timeZone),
75
+ })
76
+ }
77
+
78
+ /** Compact sidebar form: ▲ peak starts / ▼ peak ends. Arrows need no translation. */
79
+ export function transitionCompact(options: LabelOptions = {}): string {
80
+ const now = options.now ?? new Date()
81
+ const next = nextTransition(now)
82
+ const arrow = next.kind === "peakEnd" ? "▼" : "▲"
83
+ return `${arrow} ${formatTransitionAt(next.at, now, options.lang ?? "en", options.timeZone)}`
84
+ }
85
+
29
86
  /** Short, sidebar-friendly, localized version of the status evidence. */
30
- export function evidenceLabel(status: PeakStatus, t: T): string {
87
+ export function evidenceLabel(status: PeakStatus, t: T, options: LabelOptions = {}): string {
31
88
  let label: string
89
+ let transition = ""
32
90
  // An API response header is the source of truth when present.
33
91
  if (status.source === "api-header") {
34
92
  label = t("evidence.apiHeader", { header: status.evidence.split(":")[0] })
@@ -37,10 +95,12 @@ export function evidenceLabel(status: PeakStatus, t: T): string {
37
95
  label = t("evidence.holiday", { name: key ? t(key) : status.holiday })
38
96
  } else {
39
97
  label = t("evidence.schedule")
98
+ transition = ` · ${transitionCompact(options)}`
40
99
  }
41
100
  if (status.evidence.endsWith("(cached)")) label += ` ${t("evidence.cached")}`
42
- if (status.evidence.includes("no request observed yet")) label += ` ${t("evidence.preview")}`
43
- return label
101
+ // A trailing marker (*) means computed from the schedule, not yet observed.
102
+ if (status.evidence.includes("no request observed yet")) label += t("evidence.preview")
103
+ return label + transition
44
104
  }
45
105
 
46
106
  /**
package/src/i18n.ts CHANGED
@@ -10,11 +10,13 @@ export type LangCode = "en" | "pt" | "zh" | "ja" | "ko"
10
10
  const EN_T = {
11
11
  "period.peak": "PEAK",
12
12
  "period.offPeak": "OFF-PEAK",
13
- "evidence.schedule": "official schedule",
13
+ "evidence.schedule": "Official schedule",
14
14
  "evidence.apiHeader": "via {header}",
15
15
  "evidence.cached": "(cached)",
16
- "evidence.preview": "(no request yet)",
16
+ "evidence.preview": "*",
17
17
  "evidence.holiday": "Holiday: {name}",
18
+ "evidence.peakEnds": "peak ends {when}",
19
+ "evidence.peakStarts": "peak starts {when}",
18
20
  "label.mismatch": "mismatch",
19
21
  "toast.title": "DeepSeek pricing",
20
22
  "status.title": "DeepSeek Peak",
@@ -22,6 +24,8 @@ const EN_T = {
22
24
  "status.noRequest": "No DeepSeek request observed in this session yet.",
23
25
  "status.source": "Source",
24
26
  "status.evidence": "Evidence",
27
+ "status.windows": "Peak: 01:00–04:00 and 06:00–10:00 UTC, Mon–Fri, except Chinese public holidays",
28
+ "status.nextChange": "Next change",
25
29
  "status.unavailable": "DeepSeek Peak server plugin is unavailable.",
26
30
  "command.status.title": "DeepSeek Peak: Show Status",
27
31
  "command.status.description": "Show the last observed DeepSeek pricing period",
@@ -36,6 +40,9 @@ const EN_T = {
36
40
  "settings.evidence": "Sidebar evidence",
37
41
  "settings.frequency": "Toast frequency",
38
42
  "settings.lang": "Language",
43
+ "settings.timezone": "Timezone",
44
+ "timezone.local": "Local",
45
+ "timezone.utc": "UTC",
39
46
  "settings.on": "on",
40
47
  "settings.off": "off",
41
48
  "frequency.change": "on change",
@@ -54,11 +61,13 @@ export type TranslationKey = keyof Translation
54
61
  const PT_T: Translation = {
55
62
  "period.peak": "PICO",
56
63
  "period.offPeak": "FORA DE PICO",
57
- "evidence.schedule": "horário oficial",
64
+ "evidence.schedule": "Horário oficial",
58
65
  "evidence.apiHeader": "via {header}",
59
66
  "evidence.cached": "(cache)",
60
- "evidence.preview": "(sem requisição ainda)",
67
+ "evidence.preview": "*",
61
68
  "evidence.holiday": "Feriado: {name}",
69
+ "evidence.peakEnds": "pico termina {when}",
70
+ "evidence.peakStarts": "pico começa {when}",
62
71
  "label.mismatch": "divergente",
63
72
  "toast.title": "Preço DeepSeek",
64
73
  "status.title": "DeepSeek Peak",
@@ -66,6 +75,8 @@ const PT_T: Translation = {
66
75
  "status.noRequest": "Nenhuma requisição DeepSeek observada nesta sessão ainda.",
67
76
  "status.source": "Fonte",
68
77
  "status.evidence": "Evidência",
78
+ "status.windows": "Pico: 01:00–04:00 e 06:00–10:00 UTC, seg–sex, exceto feriados chineses",
79
+ "status.nextChange": "Próxima mudança",
69
80
  "status.unavailable": "O plugin de servidor do DeepSeek Peak está indisponível.",
70
81
  "command.status.title": "DeepSeek Peak: Mostrar status",
71
82
  "command.status.description": "Mostra o último período de preço observado da DeepSeek",
@@ -80,6 +91,9 @@ const PT_T: Translation = {
80
91
  "settings.evidence": "Evidência na barra lateral",
81
92
  "settings.frequency": "Frequência do toast",
82
93
  "settings.lang": "Idioma",
94
+ "settings.timezone": "Fuso horário",
95
+ "timezone.local": "Local",
96
+ "timezone.utc": "UTC",
83
97
  "settings.on": "ativado",
84
98
  "settings.off": "desativado",
85
99
  "frequency.change": "ao mudar",
@@ -98,8 +112,10 @@ const ZH_T: Translation = {
98
112
  "evidence.schedule": "官方时段",
99
113
  "evidence.apiHeader": "来自 {header}",
100
114
  "evidence.cached": "(缓存)",
101
- "evidence.preview": "(尚无请求)",
115
+ "evidence.preview": "*",
102
116
  "evidence.holiday": "节假日:{name}",
117
+ "evidence.peakEnds": "高峰于 {when} 结束",
118
+ "evidence.peakStarts": "高峰于 {when} 开始",
103
119
  "label.mismatch": "不一致",
104
120
  "toast.title": "DeepSeek 价格",
105
121
  "status.title": "DeepSeek Peak",
@@ -107,6 +123,8 @@ const ZH_T: Translation = {
107
123
  "status.noRequest": "此会话尚未观察到 DeepSeek 请求。",
108
124
  "status.source": "来源",
109
125
  "status.evidence": "证据",
126
+ "status.windows": "高峰时段:01:00–04:00、06:00–10:00 UTC,周一至周五,中国法定节假日除外",
127
+ "status.nextChange": "下次变化",
110
128
  "status.unavailable": "DeepSeek Peak 服务端插件不可用。",
111
129
  "command.status.title": "DeepSeek Peak:显示状态",
112
130
  "command.status.description": "显示最近观察到的 DeepSeek 价格时段",
@@ -121,6 +139,9 @@ const ZH_T: Translation = {
121
139
  "settings.evidence": "侧边栏证据",
122
140
  "settings.frequency": "通知频率",
123
141
  "settings.lang": "语言",
142
+ "settings.timezone": "时区",
143
+ "timezone.local": "本地",
144
+ "timezone.utc": "UTC",
124
145
  "settings.on": "开",
125
146
  "settings.off": "关",
126
147
  "frequency.change": "变化时",
@@ -139,8 +160,10 @@ const JA_T: Translation = {
139
160
  "evidence.schedule": "公式スケジュール",
140
161
  "evidence.apiHeader": "{header} 経由",
141
162
  "evidence.cached": "(キャッシュ)",
142
- "evidence.preview": "(まだリクエストなし)",
163
+ "evidence.preview": "*",
143
164
  "evidence.holiday": "祝日:{name}",
165
+ "evidence.peakEnds": "ピークは {when} に終了",
166
+ "evidence.peakStarts": "ピークは {when} に開始",
144
167
  "label.mismatch": "不一致",
145
168
  "toast.title": "DeepSeek 料金",
146
169
  "status.title": "DeepSeek Peak",
@@ -148,6 +171,8 @@ const JA_T: Translation = {
148
171
  "status.noRequest": "このセッションではまだ DeepSeek リクエストがありません。",
149
172
  "status.source": "ソース",
150
173
  "status.evidence": "エビデンス",
174
+ "status.windows": "ピーク:01:00–04:00、06:00–10:00 UTC、月〜金、中国の祝日を除く",
175
+ "status.nextChange": "次の切り替え",
151
176
  "status.unavailable": "DeepSeek Peak サーバープラグインが利用できません。",
152
177
  "command.status.title": "DeepSeek Peak:ステータス表示",
153
178
  "command.status.description": "最後に観測した DeepSeek 料金期間を表示",
@@ -162,6 +187,9 @@ const JA_T: Translation = {
162
187
  "settings.evidence": "サイドバーのエビデンス",
163
188
  "settings.frequency": "トースト頻度",
164
189
  "settings.lang": "言語",
190
+ "settings.timezone": "タイムゾーン",
191
+ "timezone.local": "ローカル",
192
+ "timezone.utc": "UTC",
165
193
  "settings.on": "オン",
166
194
  "settings.off": "オフ",
167
195
  "frequency.change": "変更時",
@@ -180,8 +208,10 @@ const KO_T: Translation = {
180
208
  "evidence.schedule": "공식 일정",
181
209
  "evidence.apiHeader": "{header} 기준",
182
210
  "evidence.cached": "(캐시)",
183
- "evidence.preview": "(아직 요청 없음)",
211
+ "evidence.preview": "*",
184
212
  "evidence.holiday": "공휴일: {name}",
213
+ "evidence.peakEnds": "피크 {when} 종료",
214
+ "evidence.peakStarts": "피크 {when} 시작",
185
215
  "label.mismatch": "불일치",
186
216
  "toast.title": "DeepSeek 요금",
187
217
  "status.title": "DeepSeek Peak",
@@ -189,6 +219,8 @@ const KO_T: Translation = {
189
219
  "status.noRequest": "이 세션에는 아직 DeepSeek 요청이 없습니다.",
190
220
  "status.source": "출처",
191
221
  "status.evidence": "근거",
222
+ "status.windows": "피크: 01:00–04:00, 06:00–10:00 UTC, 월–금, 중국 공휴일 제외",
223
+ "status.nextChange": "다음 전환",
192
224
  "status.unavailable": "DeepSeek Peak 서버 플러그인을 사용할 수 없습니다.",
193
225
  "command.status.title": "DeepSeek Peak: 상태 표시",
194
226
  "command.status.description": "마지막으로 관측된 DeepSeek 요금 기간 표시",
@@ -203,6 +235,9 @@ const KO_T: Translation = {
203
235
  "settings.evidence": "사이드바 근거",
204
236
  "settings.frequency": "토스트 빈도",
205
237
  "settings.lang": "언어",
238
+ "settings.timezone": "시간대",
239
+ "timezone.local": "로컬",
240
+ "timezone.utc": "UTC",
206
241
  "settings.on": "켜짐",
207
242
  "settings.off": "꺼짐",
208
243
  "frequency.change": "변경 시",
package/src/index.ts CHANGED
@@ -28,34 +28,41 @@ export default Plugin.define({
28
28
  const status = statuses.get(sessionID)
29
29
  return status ? { found: true, status } : { found: false }
30
30
  },
31
- // Lets the TUI show the current period as soon as a session opens with a
32
- // DeepSeek model selected, without waiting for the first request.
31
+ // Re-synthesizes the current period on every call so an idle TUI can
32
+ // refresh itself without waiting for a request. A recent API header
33
+ // still wins over the official schedule.
33
34
  preview: async (input) => {
34
35
  const { sessionID } = input as { sessionID: string }
35
- const observed = statuses.get(sessionID)
36
- if (observed) return { found: true, status: observed }
37
36
  try {
38
37
  const session = await context.session.get({ sessionID })
39
38
  const model = session.model
40
39
  if (!model || !isDeepSeek(model.providerID)) return { found: false }
41
- const schedule = scheduleInfo(new Date())
42
- const evidence = schedule.holiday
43
- ? `Chinese public holiday: ${schedule.holiday} (no request observed yet)`
44
- : "Current schedule (no request observed yet)"
40
+ const observed = statuses.get(sessionID)
41
+ const now = Date.now()
42
+ const schedule = scheduleInfo(new Date(now))
43
+ const cached = apiSignals.get(modelKey(model.providerID, model.id))
44
+ const api = cached && now - cached.observedAt <= apiSignalTtlMs ? cached : undefined
45
+ const evidence = api
46
+ ? `${api.evidence} (cached)`
47
+ : schedule.holiday
48
+ ? `Chinese public holiday: ${schedule.holiday}${observed ? "" : " (no request observed yet)"}`
49
+ : observed
50
+ ? "DeepSeek official UTC pricing schedule"
51
+ : "Current schedule (no request observed yet)"
45
52
  return {
46
53
  found: true,
47
54
  status: {
48
55
  sessionID,
49
56
  providerID: model.providerID,
50
57
  modelID: model.id,
51
- period: schedule.period,
58
+ period: api?.period ?? schedule.period,
52
59
  scheduledPeriod: schedule.period,
53
- source: "official-schedule",
54
- phase: "request",
55
- mismatch: false,
56
- observedAt: Date.now(),
60
+ source: api ? "api-header" : "official-schedule",
61
+ phase: observed?.phase ?? "request",
62
+ mismatch: api ? api.period !== schedule.period : false,
63
+ observedAt: now,
57
64
  evidence,
58
- holiday: schedule.holiday,
65
+ ...(schedule.holiday !== undefined ? { holiday: schedule.holiday } : {}),
59
66
  },
60
67
  }
61
68
  } catch {
@@ -93,7 +100,7 @@ export default Plugin.define({
93
100
  : schedule.holiday
94
101
  ? `Chinese public holiday: ${schedule.holiday}`
95
102
  : "DeepSeek official UTC pricing schedule",
96
- holiday: schedule.holiday,
103
+ ...(schedule.holiday !== undefined ? { holiday: schedule.holiday } : {}),
97
104
  })
98
105
  })
99
106
 
@@ -118,7 +125,7 @@ export default Plugin.define({
118
125
  mismatch: api.period !== schedule.period,
119
126
  observedAt: now,
120
127
  evidence: api.evidence,
121
- holiday: schedule.holiday,
128
+ ...(schedule.holiday !== undefined ? { holiday: schedule.holiday } : {}),
122
129
  })
123
130
  })
124
131
 
@@ -17,6 +17,7 @@ export interface IndicatorProps {
17
17
 
18
18
  export interface PanelProps extends IndicatorProps {
19
19
  evidence: () => boolean
20
+ timeZone: () => string | undefined
20
21
  }
21
22
 
22
23
  export function PeakPanel(props: PanelProps) {
@@ -51,7 +52,11 @@ export function PeakPanel(props: PanelProps) {
51
52
  <For each={segments()}>{(segment) => <span style={{ fg: segment.color }}>{segment.text}</span>}</For>
52
53
  </text>
53
54
  <Show when={props.evidence() ? props.status() : undefined}>
54
- {(status) => <text fg={themeColors(props.context.theme).muted}>{evidenceLabel(status(), t)}</text>}
55
+ {(status) => (
56
+ <text wrapMode="none" fg={themeColors(props.context.theme).muted}>
57
+ {evidenceLabel(status(), t, { lang: props.lang(), timeZone: props.timeZone() })}
58
+ </text>
59
+ )}
55
60
  </Show>
56
61
  </box>
57
62
  </Show>
package/src/schedule.ts CHANGED
@@ -56,6 +56,45 @@ export function scheduleInfo(
56
56
  return { period: peakWindow(date) }
57
57
  }
58
58
 
59
+ export interface Transition {
60
+ kind: "peakStart" | "peakEnd"
61
+ /** Instant of the next change of the official schedule. */
62
+ at: Date
63
+ }
64
+
65
+ function utcAt(date: Date, dayOffset: number, hour: number): Date {
66
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + dayOffset, hour))
67
+ }
68
+
69
+ function isPeakDay(date: Date, holidayName: (value: Date) => string | undefined): boolean {
70
+ const day = date.getUTCDay()
71
+ return day !== 0 && day !== 6 && holidayName(date) === undefined
72
+ }
73
+
74
+ /**
75
+ * Next instant at which the official schedule changes period, considering the
76
+ * two weekday peak windows and China's public holidays.
77
+ */
78
+ export function nextTransition(
79
+ date: Date,
80
+ holidayName: (value: Date) => string | undefined = chinesePublicHoliday,
81
+ ): Transition {
82
+ if (scheduleInfo(date, holidayName).period === "peak") {
83
+ return { kind: "peakEnd", at: utcAt(date, 0, date.getUTCHours() < 4 ? 4 : 10) }
84
+ }
85
+ // Off-peak: the earliest future peak start on a weekday without a holiday.
86
+ // A public holiday can run for several consecutive days, so scan further.
87
+ for (let offset = 0; offset < 15; offset += 1) {
88
+ if (!isPeakDay(utcAt(date, offset, 0), holidayName)) continue
89
+ for (const hour of [1, 6]) {
90
+ const candidate = utcAt(date, offset, hour)
91
+ if (candidate.getTime() > date.getTime()) return { kind: "peakStart", at: candidate }
92
+ }
93
+ }
94
+ // date-holidays always yields a working weekday within two weeks; keep a fallback.
95
+ return { kind: "peakStart", at: utcAt(date, 1, 1) }
96
+ }
97
+
59
98
  export function officialPricePeriod(
60
99
  date: Date,
61
100
  holidayCheck: (value: Date) => boolean = isChinesePublicHoliday,
package/src/tui.tsx CHANGED
@@ -21,6 +21,8 @@ const mod: Plugin.Definition = {
21
21
  // Stored settings may predate the `lang` field.
22
22
  const lang = () => (settings.lang ?? detectLang()) as LangCode
23
23
  const t = createT(lang)
24
+ // Local by default; `utc` pins the official schedule times.
25
+ const timeZone = () => (settings.timezone === "utc" ? "UTC" : undefined)
24
26
  const [statuses, setStatuses] = createSignal<Record<string, PeakStatus>>({})
25
27
  const lastToast = new Map<string, string>()
26
28
 
@@ -40,13 +42,19 @@ const mod: Plugin.Definition = {
40
42
  })
41
43
 
42
44
  const readStatus = async (sessionID: string) => {
43
- const result = await client.status({ sessionID }) as { found: boolean; status?: PeakStatus }
44
- if (result.found && result.status) return result.status
45
+ // Preview re-synthesizes the current schedule, so an idle session stays
46
+ // fresh; it already folds in a recent API header when one exists.
45
47
  try {
46
48
  const preview = await client.preview({ sessionID }) as { found: boolean; status?: PeakStatus }
47
- return preview.found ? preview.status : undefined
48
- } catch {
49
+ if (preview.found && preview.status) return preview.status
49
50
  return undefined
51
+ } catch {
52
+ try {
53
+ const result = await client.status({ sessionID }) as { found: boolean; status?: PeakStatus }
54
+ return result.found ? result.status : undefined
55
+ } catch {
56
+ return undefined
57
+ }
50
58
  }
51
59
  }
52
60
 
@@ -60,10 +68,22 @@ const mod: Plugin.Definition = {
60
68
  .catch(() => {})
61
69
  }
62
70
 
71
+ // Keep the period and the next-change label fresh while the session is idle.
72
+ const timer = setInterval(() => {
73
+ for (const sessionID of Object.keys(statuses())) refresh(sessionID)
74
+ }, 30_000)
75
+
63
76
  const unregisterCommands = context.ui.slot({
64
77
  append: "app",
65
78
  render: () => (
66
- <CommandRoot context={context} settings={settings} update={update} readStatus={readStatus} lang={lang} />
79
+ <CommandRoot
80
+ context={context}
81
+ settings={settings}
82
+ update={update}
83
+ readStatus={readStatus}
84
+ lang={lang}
85
+ timeZone={timeZone}
86
+ />
67
87
  ),
68
88
  })
69
89
  const unregisterSidebar = context.ui.slot({
@@ -77,6 +97,7 @@ const mod: Plugin.Definition = {
77
97
  enabled={() => settings.sidebarIndicator !== false}
78
98
  evidence={() => settings.sidebarEvidence !== false}
79
99
  lang={lang}
100
+ timeZone={timeZone}
80
101
  sessionID={sessionID}
81
102
  refresh={refresh}
82
103
  />
@@ -101,6 +122,7 @@ const mod: Plugin.Definition = {
101
122
  })
102
123
 
103
124
  return () => {
125
+ clearInterval(timer)
104
126
  unsubscribe()
105
127
  unregisterCommands()
106
128
  unregisterSidebar()
package/src/types.ts CHANGED
@@ -3,6 +3,7 @@ import type { LangCode } from "./i18n.js"
3
3
  export type PricePeriod = "peak" | "off-peak"
4
4
  export type StatusSource = "official-schedule" | "api-header"
5
5
  export type StatusPhase = "request" | "response"
6
+ export type TimezoneMode = "local" | "utc"
6
7
 
7
8
  export interface PeakStatus {
8
9
  sessionID: string
@@ -26,4 +27,5 @@ export interface TuiSettings {
26
27
  sidebarEvidence: boolean
27
28
  toastEveryRequest: boolean
28
29
  lang: LangCode
30
+ timezone: TimezoneMode
29
31
  }
package/src/v1/tui.tsx CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import type { TuiDialogStack, TuiPlugin, TuiPluginApi, TuiSlotContext, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
4
4
  import { createMemo, createSignal, For, Show } from "solid-js"
5
- import { evidenceLabel, statusLabel } from "../core/display.js"
5
+ import { evidenceLabel, statusLabel, transitionLabel } from "../core/display.js"
6
6
  import { createT, detectLang, LANG_META, type LangCode } from "../i18n.js"
7
7
  import { scheduleInfo } from "../schedule.js"
8
8
  import type { PeakStatus } from "../types.js"
@@ -13,6 +13,7 @@ const KV = {
13
13
  evidence: "deepseek-peak.evidence",
14
14
  footer: "deepseek-peak.footer",
15
15
  toast: "deepseek-peak.toast",
16
+ timezone: "deepseek-peak.timezone",
16
17
  } as const
17
18
 
18
19
  const REFRESH_MS = 30_000
@@ -34,7 +35,7 @@ function createStatus(sessionID: string): PeakStatus {
34
35
  mismatch: false,
35
36
  observedAt: Date.now(),
36
37
  evidence: schedule.holiday ? `Chinese public holiday: ${schedule.holiday}` : "DeepSeek official UTC pricing schedule",
37
- holiday: schedule.holiday,
38
+ ...(schedule.holiday !== undefined ? { holiday: schedule.holiday } : {}),
38
39
  }
39
40
  }
40
41
 
@@ -43,6 +44,7 @@ function StatusPanel(props: {
43
44
  status: () => PeakStatus
44
45
  lang: () => LangCode
45
46
  evidence: () => boolean
47
+ timeZone: () => string | undefined
46
48
  }) {
47
49
  const t = createT(props.lang)
48
50
  const segments = createMemo(() => {
@@ -62,7 +64,9 @@ function StatusPanel(props: {
62
64
  <For each={segments()}>{(segment) => <span style={{ fg: segment.color }}>{segment.text}</span>}</For>
63
65
  </text>
64
66
  <Show when={props.evidence()}>
65
- <text fg={props.colors().textMuted}>{evidenceLabel(props.status(), t)}</text>
67
+ <text wrapMode="none" fg={props.colors().textMuted}>
68
+ {evidenceLabel(props.status(), t, { lang: props.lang(), timeZone: props.timeZone() })}
69
+ </text>
66
70
  </Show>
67
71
  </box>
68
72
  )
@@ -98,9 +102,11 @@ export const tui: TuiPlugin = async (api: TuiPluginApi, options) => {
98
102
  const [showEvidence, setShowEvidence] = createSignal(true)
99
103
  const [showFooter, setShowFooter] = createSignal(true)
100
104
  const [showToast, setShowToast] = createSignal(true)
105
+ const [utcZone, setUtcZone] = createSignal(false)
101
106
  const [status, setStatus] = createSignal<PeakStatus>(createStatus(""))
102
107
  const [revision, setRevision] = createSignal(0)
103
108
  const t = createT(lang)
109
+ const timeZone = () => (utcZone() ? "UTC" : undefined)
104
110
 
105
111
  // KV may not be ready during setup; restore persisted preferences when it is.
106
112
  const restore = () => {
@@ -110,6 +116,7 @@ export const tui: TuiPlugin = async (api: TuiPluginApi, options) => {
110
116
  setShowEvidence(api.kv.get<boolean>(KV.evidence, true) !== false)
111
117
  setShowFooter(api.kv.get<boolean>(KV.footer, true) !== false)
112
118
  setShowToast(api.kv.get<boolean>(KV.toast, true) !== false)
119
+ setUtcZone(String(api.kv.get(KV.timezone, "local") ?? "local") === "utc")
113
120
  }
114
121
  if (api.kv.ready) {
115
122
  restore()
@@ -171,6 +178,7 @@ export const tui: TuiPlugin = async (api: TuiPluginApi, options) => {
171
178
  status={() => status()}
172
179
  lang={lang}
173
180
  evidence={() => showEvidence()}
181
+ timeZone={timeZone}
174
182
  />
175
183
  </Show>
176
184
  )
@@ -202,6 +210,10 @@ export const tui: TuiPlugin = async (api: TuiPluginApi, options) => {
202
210
  { title: `${t("settings.evidence")}: ${onOff(showEvidence())}`, value: "evidence" },
203
211
  { title: `${t("settings.footer")}: ${onOff(showFooter())}`, value: "footer" },
204
212
  { title: `${t("settings.toast")}: ${onOff(showToast())}`, value: "toast" },
213
+ {
214
+ title: `${t("settings.timezone")}: ${utcZone() ? t("timezone.utc") : t("timezone.local")}`,
215
+ value: "timezone",
216
+ },
205
217
  ]}
206
218
  onSelect={(option) => {
207
219
  if (option.value === "sidebar") {
@@ -220,6 +232,10 @@ export const tui: TuiPlugin = async (api: TuiPluginApi, options) => {
220
232
  const value = !showToast()
221
233
  setShowToast(value)
222
234
  api.kv.set(KV.toast, value)
235
+ } else if (option.value === "timezone") {
236
+ const value = !utcZone()
237
+ setUtcZone(value)
238
+ api.kv.set(KV.timezone, value ? "utc" : "local")
223
239
  }
224
240
  openSections(dialog)
225
241
  }}
@@ -235,12 +251,14 @@ export const tui: TuiPlugin = async (api: TuiPluginApi, options) => {
235
251
  slash: { name: "deepseek-peak" },
236
252
  onSelect: (dialog) => {
237
253
  const current = status()
238
- dialog?.replace(() => (
239
- <api.ui.DialogAlert
240
- title={t("status.title")}
241
- message={`${statusLabel(current, t)}\n${t("status.source")}: ${current.source}\n${t("status.evidence")}: ${current.evidence}`}
242
- />
243
- ))
254
+ const message = [
255
+ statusLabel(current, t),
256
+ `${t("status.source")}: ${current.source}`,
257
+ `${t("status.evidence")}: ${current.evidence}`,
258
+ t("status.windows"),
259
+ `${t("status.nextChange")}: ${transitionLabel(t, { lang: lang(), timeZone: timeZone() })}`,
260
+ ].join("\n")
261
+ dialog?.replace(() => <api.ui.DialogAlert title={t("status.title")} message={message} />)
244
262
  },
245
263
  },
246
264
  {
@@ -1,5 +1,5 @@
1
1
  import type { Plugin } from "@opencode/plugin/tui"
2
- import { statusLabel } from "../core/display.js"
2
+ import { statusLabel, transitionLabel } from "../core/display.js"
3
3
  import { createT, LANG_META, type LangCode } from "../i18n.js"
4
4
  import type { PeakStatus, TuiSettings } from "../types.js"
5
5
  import { openSettingsMenu } from "./settings-menu.js"
@@ -10,6 +10,7 @@ interface CommandProps {
10
10
  update: (mutation: (draft: TuiSettings) => void) => Promise<void>
11
11
  readStatus: (sessionID: string) => Promise<PeakStatus | undefined>
12
12
  lang: () => LangCode
13
+ timeZone: () => string | undefined
13
14
  }
14
15
 
15
16
  /** Command layer (registered in the app slot: slash commands stay available when the sidebar is hidden). */
@@ -38,7 +39,13 @@ export function CommandRoot(props: CommandProps) {
38
39
  await context.ui.dialog.alert({
39
40
  title: t("status.title"),
40
41
  message: status
41
- ? `${statusLabel(status, t)}\n${t("status.source")}: ${status.source}\n${t("status.evidence")}: ${status.evidence}`
42
+ ? [
43
+ statusLabel(status, t),
44
+ `${t("status.source")}: ${status.source}`,
45
+ `${t("status.evidence")}: ${status.evidence}`,
46
+ t("status.windows"),
47
+ `${t("status.nextChange")}: ${transitionLabel(t, { lang: props.lang(), timeZone: props.timeZone() })}`,
48
+ ].join("\n")
42
49
  : t("status.noRequest"),
43
50
  })
44
51
  } catch {