dsh-working-activity 0.3.0 → 0.3.2

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/src/config.ts ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Working-activity user configuration, mirroring the pi extension's
3
+ * `~/.pi/agent/working-activity.json` shape: the UI owns the file (dsh-tui
4
+ * persists `frames` at `~/.dsh-tui/working-activity.json`) and consumers use
5
+ * the pure parser here to honor `mode` / `features` / `customPhrases` /
6
+ * `customActions` without duplicating validation. No I/O — filesystem
7
+ * ownership stays with the host.
8
+ * @module @deepseek-ai/dsh-working-activity/config
9
+ */
10
+
11
+ import { DEFAULT_PRESET, isPresetName } from './frames.js'
12
+
13
+ /** Feature flags that can be switched independently (pi extension parity). */
14
+ export const FEATURE_FLAGS = [
15
+ 'phrases', // 俏皮文案池(思考/等待/收尾/工具动词)
16
+ 'rareEggs', // 稀有彩虹彩蛋
17
+ 'nightPhrases', // 深夜文案
18
+ 'weekend', // 周末问候
19
+ 'holidays', // 节假日彩蛋
20
+ 'combo', // 连击火力全开
21
+ 'failPhrases', // 失败文案池
22
+ 'modelQuips', // 模型切换梗
23
+ 'shimmer', // 文案星辉扫过/彩虹流光
24
+ 'continuePhrases', // 打断后接梗
25
+ 'cost', // 成本与 token 核算(结束时展示)
26
+ ] as const
27
+
28
+ /** Feature-flag name type. */
29
+ export type FeatureFlag = (typeof FEATURE_FLAGS)[number]
30
+
31
+ /** Chinese labels for the settings panel / docs. */
32
+ export const FEATURE_LABELS: Record<FeatureFlag, string> = {
33
+ phrases: '俏皮文案',
34
+ rareEggs: '稀有彩蛋',
35
+ nightPhrases: '深夜文案',
36
+ weekend: '周末问候',
37
+ holidays: '节假日彩蛋',
38
+ combo: '工具连击',
39
+ failPhrases: '失败文案',
40
+ modelQuips: '模型切换梗',
41
+ shimmer: '星辉效果',
42
+ continuePhrases: '打断接梗',
43
+ cost: '成本与 token',
44
+ }
45
+
46
+ /** The file-based config, same keys as the pi extension. */
47
+ export type WorkingActivityConfig = {
48
+ /** Frame preset name (`random` picks one per process). */
49
+ frames?: string
50
+ /** Extra thinking phrases appended to the base pool. */
51
+ customPhrases?: readonly string[]
52
+ /** Exact tool-name → action-copy pools (case-insensitive match). */
53
+ customActions?: Readonly<Record<string, readonly string[]>>
54
+ /** Inject the `⏵` self-narration contract into the system prompt. */
55
+ narrate?: boolean
56
+ /** Debug logging to `working-activity-debug.log`. */
57
+ debugLog?: boolean
58
+ /** Context usage warning threshold in percent. */
59
+ contextWarnAt?: number
60
+ /** Context usage danger threshold in percent. */
61
+ contextDangerAt?: number
62
+ /** Show estimated output tokens per second. */
63
+ showTokPerSec?: boolean
64
+ /** Hourly work-reminder threshold (0–24, 0 = off). */
65
+ workRemindAt?: number
66
+ /** lively: full flourish (default) / minimal: functional labels only. */
67
+ mode?: 'lively' | 'minimal'
68
+ /** Per-feature switches; explicit values override `mode` defaults. */
69
+ features?: Readonly<Record<string, boolean>>
70
+ }
71
+
72
+ /** The defaults when nothing is configured. */
73
+ export const DEFAULT_ACTIVITY_CONFIG: WorkingActivityConfig = {
74
+ frames: DEFAULT_PRESET,
75
+ narrate: true,
76
+ }
77
+
78
+ /** Result of parsing a config file. */
79
+ export type ConfigReadResult = {
80
+ readonly config: WorkingActivityConfig
81
+ /** Raw parsed object, preserving unknown keys for lossless write-back. */
82
+ readonly raw: Record<string, unknown>
83
+ /** Parse error message, when the text was not a valid config object. */
84
+ readonly error?: string
85
+ }
86
+
87
+ function errorText(error: unknown): string {
88
+ return error instanceof Error ? error.message : String(error)
89
+ }
90
+
91
+ /** Clamp `dangerAt` to be at least `warnAt` (percent thresholds). */
92
+ export function normalizeThresholds(cfg: WorkingActivityConfig): WorkingActivityConfig {
93
+ const warnAt = cfg.contextWarnAt ?? 80
94
+ const dangerAt = cfg.contextDangerAt ?? 95
95
+ return dangerAt < warnAt ? { ...cfg, contextDangerAt: warnAt } : cfg
96
+ }
97
+
98
+ /**
99
+ * Parse a `working-activity.json` text into a validated config. Unknown keys
100
+ * are preserved in `raw` (for lossless write-back); invalid values are
101
+ * dropped. A non-object root yields the defaults plus an `error`.
102
+ * @param text - Raw file contents.
103
+ * @returns Validated config + raw object (+ optional error).
104
+ */
105
+ export function parseWorkingActivityConfig(text: string): ConfigReadResult {
106
+ let parsed: unknown
107
+ try {
108
+ parsed = JSON.parse(text)
109
+ } catch (error) {
110
+ return { config: { ...DEFAULT_ACTIVITY_CONFIG }, raw: {}, error: errorText(error) }
111
+ }
112
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
113
+ return {
114
+ config: { ...DEFAULT_ACTIVITY_CONFIG },
115
+ raw: {},
116
+ error: '配置根节点必须是 JSON 对象',
117
+ }
118
+ }
119
+ const raw = parsed as Record<string, unknown>
120
+ const config: WorkingActivityConfig = { ...DEFAULT_ACTIVITY_CONFIG }
121
+ if (typeof raw.frames === 'string' && isPresetName(raw.frames)) config.frames = raw.frames
122
+ if (Array.isArray(raw.customPhrases)) {
123
+ const phrases = raw.customPhrases.filter((s): s is string => typeof s === 'string' && s.trim().length > 0)
124
+ if (phrases.length > 0) config.customPhrases = phrases
125
+ }
126
+ if (raw.customActions !== null && typeof raw.customActions === 'object' && !Array.isArray(raw.customActions)) {
127
+ const customActions: Record<string, string[]> = {}
128
+ for (const [key, value] of Object.entries(raw.customActions)) {
129
+ const actions = Array.isArray(value)
130
+ ? value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
131
+ .map((entry) => entry.trim())
132
+ : []
133
+ if (key.trim().length > 0 && actions.length > 0) customActions[key.trim()] = actions
134
+ }
135
+ if (Object.keys(customActions).length > 0) config.customActions = customActions
136
+ }
137
+ if (typeof raw.narrate === 'boolean') config.narrate = raw.narrate
138
+ if (typeof raw.debugLog === 'boolean') config.debugLog = raw.debugLog
139
+ if (typeof raw.contextWarnAt === 'number' && Number.isFinite(raw.contextWarnAt)
140
+ && raw.contextWarnAt >= 0 && raw.contextWarnAt <= 100) {
141
+ config.contextWarnAt = raw.contextWarnAt
142
+ }
143
+ if (typeof raw.contextDangerAt === 'number' && Number.isFinite(raw.contextDangerAt)
144
+ && raw.contextDangerAt >= 0 && raw.contextDangerAt <= 100) {
145
+ config.contextDangerAt = raw.contextDangerAt
146
+ }
147
+ if (typeof raw.showTokPerSec === 'boolean') config.showTokPerSec = raw.showTokPerSec
148
+ if (typeof raw.workRemindAt === 'number' && Number.isFinite(raw.workRemindAt)
149
+ && raw.workRemindAt >= 0 && raw.workRemindAt <= 24) {
150
+ config.workRemindAt = raw.workRemindAt
151
+ }
152
+ if (raw.mode === 'lively' || raw.mode === 'minimal') config.mode = raw.mode
153
+ if (raw.features !== null && typeof raw.features === 'object' && !Array.isArray(raw.features)) {
154
+ const features: Record<string, boolean> = {}
155
+ for (const [key, value] of Object.entries(raw.features)) {
156
+ if (typeof value === 'boolean') features[key] = value
157
+ }
158
+ if (Object.keys(features).length > 0) config.features = features
159
+ }
160
+ return { config: normalizeThresholds(config), raw }
161
+ }
162
+
163
+ /**
164
+ * Whether a feature is on. Explicit `features` entries win; otherwise
165
+ * `minimal` mode turns everything off (except `cost`, always on).
166
+ * @param config - Parsed config.
167
+ * @param name - Feature flag name.
168
+ */
169
+ export function featureOn(config: WorkingActivityConfig, name: FeatureFlag): boolean {
170
+ const explicit = config.features?.[name]
171
+ if (typeof explicit === 'boolean') return explicit
172
+ if (name === 'cost') return true
173
+ return config.mode !== 'minimal'
174
+ }
package/src/frames.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Working-activity indicator frame presets — the single home for every
3
+ * spinner animation, ported from the pi working-activity extension
4
+ * (`FRAME_PRESETS`) plus the dsh-tui additions (moon8 / rainbow / whale
5
+ * family / clock / traffic lights). Consumers (TUI row, Web slot) render the
6
+ * current frame next to the live working line; the preset name is chosen by
7
+ * the UI and persisted in `working-activity.json` (`frames` key).
8
+ *
9
+ * `\uFE0E` forces text rendering so Windows never paints the glyphs as color
10
+ * emoji (the green-block problem); emoji presets (moon8, clock, whales)
11
+ * deliberately omit it to keep colorful rendering on modern terminals.
12
+ * @module @deepseek-ai/dsh-working-activity/frames
13
+ */
14
+
15
+ /** Text-variant selector: keep symbols monochrome on Windows. */
16
+ const TE = '\uFE0E'
17
+
18
+ /** One working-activity preset: the frame sequence and the per-frame interval. */
19
+ export interface FramePreset {
20
+ readonly frames: readonly string[]
21
+ readonly intervalMs: number
22
+ }
23
+
24
+ /** Named working-activity frame presets, keyed by preset name (`claude`, `moon`, `sand`, ...). */
25
+ export const FRAME_PRESETS: Record<string, FramePreset> = {
26
+ // Claude Code's real sequence: · ✢ * ✶ ✻ ✽ forward + backward.
27
+ claude: {
28
+ frames: ['·', `✢${TE}`, '*', `✶${TE}`, `✻${TE}`, `✽${TE}`, `✻${TE}`, `✶${TE}`, '*', `✢${TE}`],
29
+ intervalMs: 150,
30
+ },
31
+ star2: { frames: [`✶${TE}`, `✸${TE}`, `✹${TE}`, `✺${TE}`, `✹${TE}`, `✷${TE}`], intervalMs: 140 },
32
+ sand: {
33
+ frames: ['⠁', '⠂', '⠄', '⡀', '⡈', '⡐', '⡠', '⣀', '⣁', '⣂', '⣄', '⣌', '⣔', '⣤', '⣥', '⣦', '⣮', '⣶', '⣷', '⣿', '⡿', '⠿', '⢟', '⠟', '⡛', '⠛', '⠫', '⢋', '⠋', '⠍', '⡉', '⠉', '⠑', '⠡', '⢁'],
34
+ intervalMs: 120,
35
+ },
36
+ triangle: { frames: ['◢', '◣', '◤', '◥'], intervalMs: 180 },
37
+ box: { frames: ['▖', '▘', '▝', '▗'], intervalMs: 180 },
38
+ box2: { frames: ['▌', '▀', '▐', '▄'], intervalMs: 180 },
39
+ corners: { frames: ['◰', '◳', '◲', '◱'], intervalMs: 190 },
40
+ point: { frames: ['∙∙∙', '●∙∙', '∙●∙', '∙∙●', '∙∙∙'], intervalMs: 190 },
41
+ layer: { frames: ['-', '=', '≡'], intervalMs: 220 },
42
+ flip: { frames: ['_', '_', '_', '-', '`', '`', "'", '´', '-', '_', '_', '_'], intervalMs: 140 },
43
+ aesthetic: {
44
+ frames: ['▰▱▱▱▱▱▱', '▰▰▱▱▱▱▱', '▰▰▰▱▱▱▱', '▰▰▰▰▱▱▱', '▰▰▰▰▰▱▱', '▰▰▰▰▰▰▱', '▰▰▰▰▰▰▰', '▰▱▱▱▱▱▱'],
45
+ intervalMs: 140,
46
+ },
47
+ hamburger: { frames: ['☱', '☲', '☴'], intervalMs: 220 },
48
+ moon: { frames: ['◐', '◓', '◑', '◒'], intervalMs: 240 },
49
+ // kimi-code MoonLoader 同款:8 帧 emoji 月相,120ms 一帧,比半圆版更丝滑。
50
+ // 不带 \uFE0E:保留彩色 emoji 渲染(Windows Terminal 等现代终端效果最佳)。
51
+ moon8: { frames: ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'], intervalMs: 120 },
52
+ // 鲸鱼喷水:🐳 固定,水柱升起(· → | → ║)再回落,顶珠 ° 模拟水花(对齐 pi 版)。
53
+ 'whale-spout': {
54
+ frames: ['🐳 ', '🐳° ', '🐳|°', '🐳║°', '🐳|°', '🐳° ', '🐳 '],
55
+ intervalMs: 160,
56
+ },
57
+ // 鲸鱼转圈:🐳 + 环绕方向指示(逆时针转圈语义,对齐 pi 版)。
58
+ 'whale-spin': {
59
+ frames: ['🐳→', '🐳↘', '🐳↓', '🐳↙', '🐳←', '🐳↖', '🐳↑', '🐳↗'],
60
+ intervalMs: 150,
61
+ },
62
+ // 鲸鱼吐泡泡:🐳 固定,泡泡从头顶冒出(大泡 ○ 先行、小泡 ∘ 跟上)向右飘走。
63
+ 'whale-bubbles': {
64
+ frames: ['🐳 ', '🐳○ ', '🐳 ○', '🐳 ', '🐳∘ ', '🐳 ∘', '🐳 '],
65
+ intervalMs: 170,
66
+ },
67
+ // 时钟:12 个整点表盘循环(🕛 → 🕚),emoji 彩色渲染(同 moon8,不带 \uFE0E)。
68
+ clock: {
69
+ frames: ['🕛', '🕐', '🕑', '🕒', '🕓', '🕔', '🕕', '🕖', '🕗', '🕘', '🕙', '🕚'],
70
+ intervalMs: 300,
71
+ },
72
+ // 红绿灯:🔴 → 🟡 → 🟢 循环,一轮 1.2s,节奏从容。
73
+ traffic_lights: {
74
+ frames: ['🔴', '🟡', '🟢'],
75
+ intervalMs: 400,
76
+ },
77
+ comet: {
78
+ frames: ['● ', ' ● ', ' ● ', ' ● ', ' ●', ' ● ', ' ● ', ' ● '],
79
+ intervalMs: 160,
80
+ },
81
+ breathe: { frames: ['▁', '▃', '▅', '▇', '▅', '▃'], intervalMs: 210 },
82
+ dots: { frames: ['⣾', '⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽'], intervalMs: 140 },
83
+ arrow: { frames: ['←', '↖', '↑', '↗', '→', '↘', '↓', '↙'], intervalMs: 160 },
84
+ spark: { frames: ['·', '∘', '°', '✧', '°', '∘'], intervalMs: 240 },
85
+ bar: { frames: ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█', '▉', '▊', '▋', '▌', '▍', '▎'], intervalMs: 120 },
86
+ braille: { frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], intervalMs: 120 },
87
+ arc: { frames: ['◜', '◠', '◝', '◞', '◡', '◟'], intervalMs: 160 },
88
+ circle: { frames: ['◴', '◷', '◶', '◵'], intervalMs: 190 },
89
+ grow: { frames: ['.', 'o', 'O', '0', 'O', 'o'], intervalMs: 210 },
90
+ noise: { frames: ['▓', '▒', '░', '▒'], intervalMs: 160 },
91
+ bounce: { frames: ['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'], intervalMs: 140 },
92
+ rainbow: {
93
+ frames: ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█', '▇', '▆', '▅', '▄', '▃', '▂'],
94
+ intervalMs: 120,
95
+ },
96
+ // 双端进度条往返(pi 版独有,补全并集)。
97
+ bar2: {
98
+ frames: [
99
+ '[ ]', '[= ]', '[== ]', '[=== ]', '[ ===]',
100
+ '[ ==]', '[ =]', '[ ]', '[ =]', '[ ==]',
101
+ '[ ===]', '[=== ]', '[== ]', '[= ]',
102
+ ],
103
+ intervalMs: 140,
104
+ },
105
+ dqpb: { frames: ['d', 'q', 'p', 'b'], intervalMs: 210 },
106
+ toggle: { frames: ['⊶', '⊷'], intervalMs: 300 },
107
+ }
108
+
109
+ /** The default preset (moon8: the smoothest of the emoji moons). */
110
+ export const DEFAULT_PRESET = 'moon8'
111
+
112
+ /** Every selectable preset name, `random` first (the pi selector order). */
113
+ export const PRESET_NAMES: readonly string[] = ['random', ...Object.keys(FRAME_PRESETS)]
114
+
115
+ /**
116
+ * Whether `name` selects a known preset or `random`.
117
+ * @param name - Candidate preset name.
118
+ * @returns True when the name resolves to a preset.
119
+ */
120
+ export function isPresetName(name: string): boolean {
121
+ return name === 'random' || Object.hasOwn(FRAME_PRESETS, name)
122
+ }
123
+
124
+ /**
125
+ * Resolve a preset name (`random` picks one per process).
126
+ * @param name - Preset name, or undefined for the default.
127
+ * @returns The matching preset; unknown or absent names fall back to the default.
128
+ */
129
+ export function resolvePreset(name: string | undefined): FramePreset {
130
+ if (name === 'random') {
131
+ const names = Object.keys(FRAME_PRESETS)
132
+ // FRAME_PRESETS is non-empty by construction, so any random index is in
133
+ // range and the lookup always lands on a preset.
134
+ const pick = names[Math.floor(Math.random() * names.length)]
135
+ return FRAME_PRESETS[pick]
136
+ }
137
+ return FRAME_PRESETS[name ?? ''] ?? FRAME_PRESETS[DEFAULT_PRESET]
138
+ }
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
25
25
  import { ActivityTracker } from './status.js'
26
26
  import { registerActivityEventType } from './registration.js'
27
27
  import { setLangOverride, t } from './lang.js'
28
+ import { DEFAULT_PRESET } from './frames.js'
28
29
  import type { ActivityState } from './status.js'
29
30
  import type { ActivityStatusEvent } from './events.js'
30
31
  // Re-export the event type + SessionEventMap merge: the package root must carry
@@ -58,6 +59,19 @@ export type Config = {
58
59
  /** UI language: `auto` follows `DSH_TUI_LANG` → `~/.dsh-tui/lang.json` →
59
60
  * OS locale → zh; `zh`/`en` pin the copy directly. */
60
61
  lang?: 'auto' | 'zh' | 'en'
62
+ /** Default frame preset name (informational for UI consumers; the TUI
63
+ * resolves the persisted `frames` choice itself). */
64
+ frames?: string
65
+ /** lively: full flourish (default) / minimal: functional labels only. */
66
+ mode?: 'lively' | 'minimal'
67
+ /** Per-feature switches; explicit values override `mode` defaults. */
68
+ features?: Record<string, boolean>
69
+ /** Extra thinking phrases appended to the base pool. */
70
+ customPhrases?: string[]
71
+ /** Show an estimated tokens/s prefix while streaming. */
72
+ showTokPerSec?: boolean
73
+ /** Work reminder after this many turn-hours (0 = off). */
74
+ workRemindAt?: number
61
75
  }
62
76
 
63
77
  // Explicit annotation: the inferred z.dict output references cosmokit's
@@ -73,6 +87,12 @@ export const Config: Schemastery<Config> = z.object({
73
87
  customActions: z.dict(z.array(z.string())).default({}),
74
88
  narrate: z.boolean().default(true),
75
89
  lang: z.union(['auto', 'zh', 'en']).default('auto'),
90
+ frames: z.string().default(DEFAULT_PRESET),
91
+ mode: z.union(['lively', 'minimal']).default('lively'),
92
+ features: z.dict(z.boolean()).default({}),
93
+ customPhrases: z.array(z.string()).default([]),
94
+ showTokPerSec: z.boolean().default(false),
95
+ workRemindAt: z.number().min(0).max(24).default(0),
76
96
  })
77
97
 
78
98
  /** Structural view of the TUI prompt service; the real type lives in dsh-tui. */
@@ -93,6 +113,12 @@ interface ResolvedConfig {
93
113
  customActions: Record<string, string[]>
94
114
  narrate: boolean
95
115
  lang: 'auto' | 'zh' | 'en'
116
+ frames: string
117
+ mode: 'lively' | 'minimal'
118
+ features: Record<string, boolean>
119
+ customPhrases: string[]
120
+ showTokPerSec: boolean
121
+ workRemindAt: number
96
122
  }
97
123
 
98
124
  /**
@@ -108,7 +134,8 @@ export function apply(ctx: Context, config: Config = {}): void {
108
134
  // in processes where publishing itself is off. See registration.ts.
109
135
  registerActivityEventType()
110
136
  const resolved: ResolvedConfig = {
111
- phrases: config.phrases ?? true,
137
+ // `mode: minimal` renders functional labels only (pi extension parity).
138
+ phrases: config.phrases ?? config.mode !== 'minimal',
112
139
  publish: config.publish ?? false,
113
140
  tickMs: config.tickMs ?? 500,
114
141
  publishIntervalMs: config.publishIntervalMs ?? 2000,
@@ -116,6 +143,12 @@ export function apply(ctx: Context, config: Config = {}): void {
116
143
  narrate: config.narrate ?? true,
117
144
  lang: config.lang ?? 'auto',
118
145
  customActions: config.customActions ?? {},
146
+ frames: config.frames ?? DEFAULT_PRESET,
147
+ mode: config.mode ?? 'lively',
148
+ features: config.features ?? {},
149
+ customPhrases: config.customPhrases ?? [],
150
+ showTokPerSec: config.showTokPerSec ?? false,
151
+ workRemindAt: config.workRemindAt ?? 0,
119
152
  }
120
153
  // A pinned plugin-level language beats the env/file chain; releasing it on
121
154
  // dispose restores `auto` for any other composition in the process.
@@ -151,7 +184,15 @@ export function apply(ctx: Context, config: Config = {}): void {
151
184
  let tracker = trackers.get(session)
152
185
  if (tracker === undefined) {
153
186
  tracker = new ActivityTracker(
154
- { phrases: resolved.phrases, detailLimit: resolved.detailLimit, showIdle: false },
187
+ {
188
+ phrases: resolved.phrases,
189
+ detailLimit: resolved.detailLimit,
190
+ showIdle: false,
191
+ features: resolved.features,
192
+ customPhrases: resolved.customPhrases,
193
+ showTokPerSec: resolved.showTokPerSec,
194
+ workRemindAt: resolved.workRemindAt,
195
+ },
155
196
  Date.now,
156
197
  resolved.customActions,
157
198
  )
package/src/lang.ts CHANGED
@@ -57,6 +57,10 @@ const dict = {
57
57
  },
58
58
  'tool-count-one': { zh: '{{count}} 工具', en: '{{count}} tool' },
59
59
  'tool-count-many': { zh: '{{count}} 工具', en: '{{count}} tools' },
60
+ /** Subagent count in the done summary. */
61
+ 'subagent-count': { zh: '子代理 {{count}} 个', en: '{{count}} subagents' },
62
+ /** Work-reminder copy after `workRemindAt` turn-hours. */
63
+ 'work-remind': { zh: '已连续工作 {{hours}} 小时,歇会儿?', en: 'Worked {{hours}}h straight — take a break?' },
60
64
  } as const
61
65
 
62
66
  export type I18nKey = keyof typeof dict