pomotty 1.0.1 → 1.2.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/dist/cli.js ADDED
@@ -0,0 +1,1621 @@
1
+ #!/usr/bin/env node
2
+ import process$1 from "node:process";
3
+ import { spawn } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { accessSync, constants, realpathSync, statSync, writeSync } from "node:fs";
6
+ import path from "node:path";
7
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
8
+ import { on } from "node:events";
9
+ import { emitKeypressEvents } from "node:readline";
10
+ //#region src/diagnostics/writer.ts
11
+ /**
12
+ * 診断の出力先が閉じていても失敗しない書き込み処理を生成します。
13
+ *
14
+ * 最初の書き込みより前に`error`リスナーを登録し、閉じたパイプなどで
15
+ * 発生する非同期エラーを未処理の例外にしません。同期例外も握りつぶし、
16
+ * 一度でも書き込みに失敗したら以後の診断を出力しません。
17
+ * これにより出力先の状態が終了コードへ影響しなくなります。
18
+ *
19
+ * @param stream 診断の出力先。
20
+ * @returns 出力先の状態にかかわらず例外を投げない書き込み処理。
21
+ */
22
+ const createDiagnosticWriter = (stream = process$1.stderr) => {
23
+ let unavailable = false;
24
+ stream.on("error", () => {
25
+ unavailable = true;
26
+ });
27
+ return (output) => {
28
+ if (unavailable) return;
29
+ try {
30
+ stream.write(output);
31
+ } catch {
32
+ unavailable = true;
33
+ }
34
+ };
35
+ };
36
+ //#endregion
37
+ //#region src/notification/operation.ts
38
+ const SUCCESS_EXIT_CODE$1 = 0;
39
+ const EMPTY_SIZE = 0;
40
+ const PLAYER_TIMEOUT_MS = 1e4;
41
+ const FORCE_KILL_DELAY_MS = 1e3;
42
+ const COMPLETION_GRACE_MS = 3e3;
43
+ const DETACH_DELAY_MS = 1500;
44
+ const bestEffort = (action) => {
45
+ try {
46
+ action();
47
+ } catch {}
48
+ };
49
+ const setUnrefTimeout = (callback, delayMs) => {
50
+ const timer = setTimeout(callback, delayMs);
51
+ timer.unref();
52
+ return timer;
53
+ };
54
+ const clearTimers = (timers) => {
55
+ for (const timer of timers) clearTimeout(timer);
56
+ timers.clear();
57
+ };
58
+ const spawnNodeChild = (commandPath, commandArguments, { env }) => {
59
+ const child = spawn(commandPath, [...commandArguments], {
60
+ env,
61
+ shell: false,
62
+ stdio: "ignore",
63
+ windowsHide: true
64
+ });
65
+ return {
66
+ detach: () => {
67
+ child.stdin?.destroy();
68
+ child.stdout?.destroy();
69
+ child.stderr?.destroy();
70
+ child.unref();
71
+ },
72
+ kill: (signal) => {
73
+ child.kill(signal);
74
+ },
75
+ onClose: (listener) => {
76
+ child.once("close", listener);
77
+ },
78
+ onError: (listener) => {
79
+ child.on("error", listener);
80
+ }
81
+ };
82
+ };
83
+ const createOperation = (completionNotice) => {
84
+ const callbacks = /* @__PURE__ */ new Set();
85
+ let cancelled = false;
86
+ return {
87
+ cancel: () => {
88
+ if (cancelled) return;
89
+ cancelled = true;
90
+ for (const callback of callbacks) bestEffort(callback);
91
+ callbacks.clear();
92
+ },
93
+ completionNotice,
94
+ isCancelled: () => cancelled,
95
+ onCancel: (callback) => {
96
+ if (cancelled) {
97
+ callback();
98
+ return () => {};
99
+ }
100
+ callbacks.add(callback);
101
+ return () => {
102
+ callbacks.delete(callback);
103
+ };
104
+ }
105
+ };
106
+ };
107
+ const createOperationTracker = (spawnChild = spawnNodeChild) => {
108
+ const activeChildren = /* @__PURE__ */ new Set(), activeOperations = /* @__PURE__ */ new Map(), forceKillTimers = /* @__PURE__ */ new Set(), terminationTimers = /* @__PURE__ */ new Set();
109
+ let shuttingDown = false, shutdownPromise;
110
+ const isOperationCancelled = (operation) => operation.isCancelled() || shuttingDown && !operation.completionNotice, scheduleForceKill = (child) => {
111
+ const timer = setUnrefTimeout(() => {
112
+ forceKillTimers.delete(timer);
113
+ if (activeChildren.has(child)) bestEffort(() => {
114
+ child.kill("SIGKILL");
115
+ });
116
+ }, FORCE_KILL_DELAY_MS);
117
+ forceKillTimers.add(timer);
118
+ }, spawnTracked = (operation, commandPath, commandArguments, options = {}) => {
119
+ if (isOperationCancelled(operation)) return { result: "cancelled" };
120
+ let child;
121
+ try {
122
+ child = spawnChild(commandPath, commandArguments, { env: options.env });
123
+ } catch {
124
+ return { result: "failure" };
125
+ }
126
+ activeChildren.add(child);
127
+ child.onError(() => {});
128
+ child.onClose(() => {
129
+ activeChildren.delete(child);
130
+ if (activeChildren.size === EMPTY_SIZE) clearTimers(terminationTimers);
131
+ });
132
+ if (isOperationCancelled(operation)) {
133
+ bestEffort(() => {
134
+ child.kill();
135
+ });
136
+ return { result: "cancelled" };
137
+ }
138
+ return {
139
+ child,
140
+ result: "success"
141
+ };
142
+ }, runChild = (operation, commandPath, commandArguments, { env, timeoutMs = PLAYER_TIMEOUT_MS } = {}) => new Promise((resolve) => {
143
+ if (isOperationCancelled(operation)) {
144
+ resolve("cancelled");
145
+ return;
146
+ }
147
+ const cleanups = [];
148
+ let settled = false;
149
+ const finish = (result) => {
150
+ if (settled) return;
151
+ settled = true;
152
+ for (const cleanup of cleanups) cleanup();
153
+ if (isOperationCancelled(operation)) resolve("cancelled");
154
+ else resolve(result);
155
+ };
156
+ cleanups.push(operation.onCancel(() => {
157
+ finish("cancelled");
158
+ }));
159
+ const spawned = spawnTracked(operation, commandPath, commandArguments, { env });
160
+ if (spawned.result !== "success") {
161
+ finish(spawned.result);
162
+ return;
163
+ }
164
+ const { child } = spawned;
165
+ child.onError(() => {
166
+ finish("failure");
167
+ });
168
+ child.onClose((code, signal) => {
169
+ if (code === SUCCESS_EXIT_CODE$1 && signal === null) finish("success");
170
+ else finish("failure");
171
+ });
172
+ const timeout = setTimeout(() => {
173
+ bestEffort(() => {
174
+ child.kill();
175
+ });
176
+ scheduleForceKill(child);
177
+ finish("failure");
178
+ }, timeoutMs);
179
+ cleanups.push(() => {
180
+ clearTimeout(timeout);
181
+ });
182
+ }), terminateChildren = () => {
183
+ clearTimers(forceKillTimers);
184
+ if (activeChildren.size === EMPTY_SIZE) return;
185
+ for (const child of activeChildren) bestEffort(() => {
186
+ child.kill();
187
+ });
188
+ terminationTimers.add(setUnrefTimeout(() => {
189
+ for (const child of activeChildren) bestEffort(() => {
190
+ child.kill("SIGKILL");
191
+ });
192
+ }, 500));
193
+ terminationTimers.add(setUnrefTimeout(() => {
194
+ for (const child of activeChildren) bestEffort(() => {
195
+ child.detach();
196
+ });
197
+ }, DETACH_DELAY_MS));
198
+ }, waitForCompletionNotices = () => {
199
+ const pending = [...activeOperations].filter(([operation]) => operation.completionNotice).map(([, promise]) => promise);
200
+ if (pending.length === EMPTY_SIZE) return Promise.resolve();
201
+ return new Promise((resolve) => {
202
+ const timer = setUnrefTimeout(resolve, COMPLETION_GRACE_MS);
203
+ Promise.all(pending).then(() => {
204
+ clearTimeout(timer);
205
+ resolve();
206
+ });
207
+ });
208
+ }, cancelOperations = (filter) => {
209
+ for (const operation of activeOperations.keys()) if (filter(operation)) operation.cancel();
210
+ };
211
+ return {
212
+ activeChildCount: () => activeChildren.size,
213
+ isOperationCancelled,
214
+ runChild,
215
+ shutdown: ({ complete }) => {
216
+ if (shutdownPromise) return shutdownPromise;
217
+ shuttingDown = true;
218
+ cancelOperations((operation) => !complete || !operation.completionNotice);
219
+ let grace = Promise.resolve();
220
+ if (complete) grace = waitForCompletionNotices();
221
+ shutdownPromise = grace.then(() => {
222
+ cancelOperations(() => true);
223
+ terminateChildren();
224
+ });
225
+ return shutdownPromise;
226
+ },
227
+ spawnTracked,
228
+ startOperation: (completionNotice, run) => {
229
+ if (shuttingDown) return;
230
+ const operation = createOperation(completionNotice), promise = run(operation).then(() => {}).catch(() => {}).finally(() => {
231
+ activeOperations.delete(operation);
232
+ });
233
+ activeOperations.set(operation, promise);
234
+ return operation;
235
+ }
236
+ };
237
+ };
238
+ //#endregion
239
+ //#region src/notification/sound.ts
240
+ const FALLBACK_SOUNDS = {
241
+ break: "\x07\x07",
242
+ work: "\x07"
243
+ };
244
+ const WINDOWS_SOUND_SCRIPT = [
245
+ "$player = New-Object System.Media.SoundPlayer",
246
+ "$player.SoundLocation = $env:POMOTTY_SOUND_FILE",
247
+ "$player.PlaySync()"
248
+ ].join("; ");
249
+ const soundFileFor = (soundDirectory, phase) => fileURLToPath(new URL(`${phase}-end.wav`, soundDirectory));
250
+ const commandsFor = (commands, soundFile, environment) => {
251
+ const candidates = [];
252
+ for (const commandPath of [
253
+ commands.afplay,
254
+ commands.paplay,
255
+ commands.aplay
256
+ ]) if (commandPath !== void 0) candidates.push({
257
+ commandArguments: [soundFile],
258
+ commandPath
259
+ });
260
+ if (commands.powershell !== void 0) candidates.push({
261
+ commandArguments: [
262
+ "-NoProfile",
263
+ "-NonInteractive",
264
+ "-Command",
265
+ WINDOWS_SOUND_SCRIPT
266
+ ],
267
+ commandPath: commands.powershell,
268
+ env: {
269
+ ...environment,
270
+ POMOTTY_SOUND_FILE: soundFile
271
+ }
272
+ });
273
+ return candidates;
274
+ };
275
+ const runSoundCommands = async (operation, tracker, candidates, fallback) => {
276
+ for (const candidate of candidates) if (await tracker.runChild(operation, candidate.commandPath, candidate.commandArguments, { env: candidate.env }) !== "failure" || tracker.isOperationCancelled(operation)) return;
277
+ if (!tracker.isOperationCancelled(operation)) fallback();
278
+ };
279
+ const createSoundPlayer = ({ commands, environment = process$1.env, soundDirectory, spawnChild, tracker = createOperationTracker(spawnChild), writeFallback = createDiagnosticWriter() }) => ({
280
+ play: (phase, { completionNotice = false } = {}) => {
281
+ const candidates = commandsFor(commands, soundFileFor(soundDirectory, phase), environment), fallback = () => {
282
+ writeFallback(FALLBACK_SOUNDS[phase]);
283
+ };
284
+ tracker.startOperation(completionNotice, (operation) => runSoundCommands(operation, tracker, candidates, fallback));
285
+ },
286
+ shutdown: (options) => tracker.shutdown(options)
287
+ });
288
+ //#endregion
289
+ //#region src/diagnostics/escape.ts
290
+ const BACKSLASH = "\\";
291
+ const BIDIRECTIONAL_PATTERN = /[\u061C\u200B\u200E\u200F\u2028-\u202E\u2066-\u206F\uFEFF]/u;
292
+ const CONTROL_HEX_LENGTH = 2;
293
+ const CONTROL_PATTERN = /[\u0000-\u001F\u007F-\u009F]/u;
294
+ const EMPTY_CODE_POINT = 0;
295
+ const FIRST_INDEX = 0;
296
+ const HEXADECIMAL_RADIX = 16;
297
+ const HEX_PAD_CHARACTER = "0";
298
+ const MAXIMUM_CODE_POINTS = 160;
299
+ const QUOTATION_MARK = "\"";
300
+ const SURROGATE_HEX_LENGTH = 4;
301
+ const SURROGATE_PATTERN = /[\uD800-\uDFFF]/u;
302
+ const TRUNCATION_MARK = "…";
303
+ const UNPADDED_HEX_LENGTH = 0;
304
+ const characterToHexadecimal = (character, length) => (character.codePointAt(FIRST_INDEX) ?? EMPTY_CODE_POINT).toString(HEXADECIMAL_RADIX).toUpperCase().padStart(length, HEX_PAD_CHARACTER);
305
+ const escapeUnsafeCharacter = (character) => {
306
+ if (CONTROL_PATTERN.test(character)) return String.raw`\x${characterToHexadecimal(character, CONTROL_HEX_LENGTH)}`;
307
+ if (BIDIRECTIONAL_PATTERN.test(character)) return String.raw`\u{${characterToHexadecimal(character, UNPADDED_HEX_LENGTH)}}`;
308
+ if (SURROGATE_PATTERN.test(character)) return String.raw`\u${characterToHexadecimal(character, SURROGATE_HEX_LENGTH)}`;
309
+ return character;
310
+ };
311
+ const escapeCharacter = (character) => {
312
+ if (character === BACKSLASH) return String.raw`\\`;
313
+ if (character === QUOTATION_MARK) return String.raw`\"`;
314
+ return escapeUnsafeCharacter(character);
315
+ };
316
+ const escapeDiagnostic = (value) => {
317
+ const codePoints = [...value], escaped = codePoints.slice(FIRST_INDEX, MAXIMUM_CODE_POINTS).map((character) => escapeCharacter(character)).join("");
318
+ if (codePoints.length > MAXIMUM_CODE_POINTS) return `${QUOTATION_MARK}${escaped}${TRUNCATION_MARK}${QUOTATION_MARK}`;
319
+ return `${QUOTATION_MARK}${escaped}${QUOTATION_MARK}`;
320
+ };
321
+ //#endregion
322
+ //#region src/platform/commands.ts
323
+ const AFPLAY_PATH = "/usr/bin/afplay";
324
+ const DEFAULT_LINUX_PATH = [
325
+ "/usr/local/bin",
326
+ "/usr/bin",
327
+ "/bin"
328
+ ];
329
+ const NODE_MODULES_SEGMENT = "node_modules";
330
+ const BIN_SEGMENT = ".bin";
331
+ const NEXT_SEGMENT_OFFSET = 1;
332
+ const POWERSHELL_SEGMENTS = [
333
+ "System32",
334
+ "WindowsPowerShell",
335
+ "v1.0",
336
+ "powershell.exe"
337
+ ];
338
+ const isExecutableFile = (target) => {
339
+ try {
340
+ if (!statSync(target).isFile()) return false;
341
+ accessSync(target, constants.X_OK);
342
+ return true;
343
+ } catch {
344
+ return false;
345
+ }
346
+ };
347
+ const nodeFileSystem = {
348
+ isExecutableFile,
349
+ realpath: (target) => realpathSync(target)
350
+ };
351
+ const comparablePath = (value, context) => {
352
+ const normalized = context.pathApi.normalize(value);
353
+ if (context.caseInsensitive) return normalized.toLowerCase();
354
+ return normalized;
355
+ };
356
+ const isWithin = (parent, child, context) => {
357
+ const relative = context.pathApi.relative(comparablePath(parent, context), comparablePath(child, context));
358
+ return relative === "" || !relative.startsWith(`..${context.pathApi.sep}`) && relative !== ".." && !context.pathApi.isAbsolute(relative);
359
+ };
360
+ const containsNodeModulesBin = (value, context) => {
361
+ const segments = comparablePath(value, context).split(context.pathApi.sep).filter((segment) => segment !== "");
362
+ return segments.some((segment, index) => segment === NODE_MODULES_SEGMENT && segments[index + NEXT_SEGMENT_OFFSET] === BIN_SEGMENT);
363
+ };
364
+ const isExcluded = (value, context) => containsNodeModulesBin(value, context) || context.excludedDirectories.some((directory) => isWithin(directory, value, context));
365
+ const tryRealpath = (target, context) => {
366
+ try {
367
+ return context.fileSystem.realpath(target);
368
+ } catch {
369
+ return;
370
+ }
371
+ };
372
+ const excludedDirectoriesFor = (cwd, fileSystem) => {
373
+ try {
374
+ return [cwd, fileSystem.realpath(cwd)];
375
+ } catch {
376
+ return [cwd];
377
+ }
378
+ };
379
+ const commandInDirectory = (directory, name, context) => {
380
+ if (!context.pathApi.isAbsolute(directory) || isExcluded(directory, context)) return;
381
+ const realDirectory = tryRealpath(directory, context);
382
+ if (realDirectory === void 0 || isExcluded(realDirectory, context)) return;
383
+ const candidate = tryRealpath(context.pathApi.join(realDirectory, name), context);
384
+ if (candidate === void 0 || isExcluded(candidate, context) || !context.fileSystem.isExecutableFile(candidate)) return;
385
+ return candidate;
386
+ };
387
+ const findInPath = (name, environment, context) => {
388
+ const directories = environment.PATH?.split(path.posix.delimiter) ?? DEFAULT_LINUX_PATH;
389
+ for (const directory of directories) {
390
+ const resolved = commandInDirectory(directory, name, context);
391
+ if (resolved !== void 0) return resolved;
392
+ }
393
+ };
394
+ const findWindowsPowerShell = (environment, context) => {
395
+ const systemRoot = environment.SystemRoot;
396
+ if (systemRoot === void 0 || !path.win32.isAbsolute(systemRoot)) return;
397
+ const realSystemRoot = tryRealpath(systemRoot, context);
398
+ if (realSystemRoot === void 0) return;
399
+ const candidate = tryRealpath(path.win32.join(realSystemRoot, ...POWERSHELL_SEGMENTS), context);
400
+ if (candidate === void 0 || isExcluded(candidate, context) || !context.fileSystem.isExecutableFile(candidate)) return;
401
+ return candidate;
402
+ };
403
+ const resolvePlayerCommands = ({ cwd = process$1.cwd(), environment = process$1.env, fileSystem = nodeFileSystem, platform = process$1.platform } = {}) => {
404
+ switch (platform) {
405
+ case "darwin":
406
+ if (fileSystem.isExecutableFile(AFPLAY_PATH)) return { afplay: AFPLAY_PATH };
407
+ return {};
408
+ case "linux": {
409
+ const context = {
410
+ caseInsensitive: false,
411
+ excludedDirectories: excludedDirectoriesFor(cwd, fileSystem),
412
+ fileSystem,
413
+ pathApi: path.posix
414
+ };
415
+ return {
416
+ aplay: findInPath("aplay", environment, context),
417
+ paplay: findInPath("paplay", environment, context)
418
+ };
419
+ }
420
+ case "win32": {
421
+ const context = {
422
+ caseInsensitive: true,
423
+ excludedDirectories: excludedDirectoriesFor(cwd, fileSystem),
424
+ fileSystem,
425
+ pathApi: path.win32
426
+ };
427
+ return { powershell: findWindowsPowerShell(environment, context) };
428
+ }
429
+ default: return {};
430
+ }
431
+ };
432
+ //#endregion
433
+ //#region src/timer/state.ts
434
+ const FIRST_CYCLE$1 = 1;
435
+ const COUNT_INCREMENT = 1;
436
+ const NO_TIME$1 = 0;
437
+ const durationFor$1 = (phase, settings) => {
438
+ if (phase === "work") return settings.workDurationMs;
439
+ return settings.breakDurationMs;
440
+ };
441
+ const startPhase = ({ completedPomodoros, cycle, phase }, now, settings) => ({
442
+ completedPomodoros,
443
+ cycle,
444
+ endsAt: now + durationFor$1(phase, settings),
445
+ paused: false,
446
+ phase,
447
+ remainingMs: NO_TIME$1
448
+ });
449
+ const createInitialState = (settings, startedAt) => startPhase({
450
+ completedPomodoros: 0,
451
+ cycle: FIRST_CYCLE$1,
452
+ phase: "work"
453
+ }, startedAt, settings);
454
+ const nextPhase = ({ completedPomodoros, cycle, phase }, now, settings) => {
455
+ if (phase === "work") return startPhase({
456
+ completedPomodoros,
457
+ cycle,
458
+ phase: "break"
459
+ }, now, settings);
460
+ if (cycle < settings.loopCount) return startPhase({
461
+ completedPomodoros,
462
+ cycle: cycle + COUNT_INCREMENT,
463
+ phase: "work"
464
+ }, now, settings);
465
+ };
466
+ const completedPomodorosAfterTimeout = (state) => {
467
+ if (state.phase === "work") return state.completedPomodoros + COUNT_INCREMENT;
468
+ return state.completedPomodoros;
469
+ };
470
+ const transitionFromTimeout = (state, now, settings) => {
471
+ const next = nextPhase({
472
+ completedPomodoros: completedPomodorosAfterTimeout(state),
473
+ cycle: state.cycle,
474
+ phase: state.phase
475
+ }, now, settings);
476
+ if (next === void 0) return {
477
+ completedPhase: state.phase,
478
+ final: true,
479
+ state
480
+ };
481
+ return {
482
+ completedPhase: state.phase,
483
+ final: false,
484
+ state: next
485
+ };
486
+ };
487
+ const transitionFromSkip = (state, now, settings) => {
488
+ const next = nextPhase(state, now, settings);
489
+ if (next === void 0) return {
490
+ final: true,
491
+ skippedPhase: state.phase,
492
+ state
493
+ };
494
+ return {
495
+ final: false,
496
+ skippedPhase: state.phase,
497
+ state: next
498
+ };
499
+ };
500
+ const remainingMsAt = (state, now) => {
501
+ if (state.paused) return state.remainingMs;
502
+ return Math.max(NO_TIME$1, state.endsAt - now);
503
+ };
504
+ const togglePause = (state, now) => {
505
+ if (state.paused) return {
506
+ completedPomodoros: state.completedPomodoros,
507
+ cycle: state.cycle,
508
+ endsAt: now + state.remainingMs,
509
+ paused: false,
510
+ phase: state.phase,
511
+ remainingMs: NO_TIME$1
512
+ };
513
+ return {
514
+ completedPomodoros: state.completedPomodoros,
515
+ cycle: state.cycle,
516
+ endsAt: state.endsAt,
517
+ paused: true,
518
+ phase: state.phase,
519
+ remainingMs: remainingMsAt(state, now)
520
+ };
521
+ };
522
+ const MILLISECONDS_PER_MINUTE = 6e4;
523
+ const DEFAULT_BREAK_DURATION_MS = 5 * MILLISECONDS_PER_MINUTE;
524
+ const DEFAULT_WORK_DURATION_MS = 15 * MILLISECONDS_PER_MINUTE;
525
+ const sleepWithTimer = (durationMs, signal) => setTimeout$1(durationMs, void 0, { signal });
526
+ const combineSignals = (signal, internal) => {
527
+ if (signal === void 0) return internal;
528
+ return AbortSignal.any([signal, internal]);
529
+ };
530
+ const waitForTick = async (sleep, signal) => {
531
+ if (signal?.aborted) return false;
532
+ try {
533
+ await sleep(250, signal);
534
+ } catch (error) {
535
+ if (signal?.aborted) return false;
536
+ throw error;
537
+ }
538
+ return signal?.aborted !== true;
539
+ };
540
+ const runPomodoroTimer = async ({ workDurationMs = DEFAULT_WORK_DURATION_MS, breakDurationMs = DEFAULT_BREAK_DURATION_MS, loopCount, now = Date.now, sleep = sleepWithTimer, signal, onPhaseStarted, onPhaseCompleted, onPhaseSkipped, onTick, bindControls }) => {
541
+ const settings = {
542
+ breakDurationMs,
543
+ loopCount,
544
+ workDurationMs
545
+ }, startedAt = now(), finishController = new AbortController(), tickSignal = combineSignals(signal, finishController.signal);
546
+ let state = createInitialState(settings, startedAt), finished = false;
547
+ /** 最後の休憩の終了でタイマーを完了し、待機中のtickを打ち切ります。 */
548
+ const finish = () => {
549
+ finished = true;
550
+ finishController.abort();
551
+ }, notifyStarted = () => {
552
+ onPhaseStarted?.(state.phase, durationFor$1(state.phase, settings));
553
+ }, settleExpiredPhase = (currentTime) => {
554
+ if (finished || state.paused || currentTime < state.endsAt) return finished;
555
+ const transition = transitionFromTimeout(state, currentTime, settings);
556
+ ({state} = transition);
557
+ onPhaseCompleted?.(transition.completedPhase, { final: transition.final });
558
+ if (transition.final) {
559
+ finish();
560
+ return true;
561
+ }
562
+ notifyStarted();
563
+ return false;
564
+ }, applyManualAction = (action) => {
565
+ if (finished || tickSignal.aborted) return finished;
566
+ const currentTime = now();
567
+ if (settleExpiredPhase(currentTime)) return true;
568
+ action(currentTime);
569
+ if (!finished) onTick?.(state, currentTime);
570
+ return finished;
571
+ };
572
+ bindControls?.({
573
+ settle: () => applyManualAction(() => {}),
574
+ skip: () => {
575
+ applyManualAction((currentTime) => {
576
+ const transition = transitionFromSkip(state, currentTime, settings);
577
+ ({state} = transition);
578
+ onPhaseSkipped?.(transition.skippedPhase);
579
+ if (transition.final) {
580
+ finish();
581
+ return;
582
+ }
583
+ notifyStarted();
584
+ });
585
+ },
586
+ togglePause: () => {
587
+ applyManualAction((currentTime) => {
588
+ state = togglePause(state, currentTime);
589
+ });
590
+ }
591
+ });
592
+ notifyStarted();
593
+ onTick?.(state, startedAt);
594
+ for (;;) {
595
+ if (!await waitForTick(sleep, tickSignal)) return {
596
+ completed: finished,
597
+ completedPomodoros: state.completedPomodoros
598
+ };
599
+ const currentTime = now();
600
+ if (settleExpiredPhase(currentTime)) return {
601
+ completed: true,
602
+ completedPomodoros: state.completedPomodoros
603
+ };
604
+ onTick?.(state, currentTime);
605
+ }
606
+ };
607
+ //#endregion
608
+ //#region src/terminal/output.ts
609
+ /** 出力先が閉じたパイプであることを示すエラーコードです。 */
610
+ const BROKEN_PIPE_CODE = "EPIPE";
611
+ const isBrokenPipe = (error) => typeof error === "object" && error !== null && Reflect.get(error, "code") === BROKEN_PIPE_CODE;
612
+ const createStandardOutput = (onFailure, stream = process$1.stdout) => {
613
+ let unavailable = false;
614
+ const fail = (error) => {
615
+ if (unavailable) return;
616
+ unavailable = true;
617
+ onFailure(error);
618
+ };
619
+ stream.on("error", fail);
620
+ return (output) => {
621
+ if (unavailable) return;
622
+ try {
623
+ stream.write(output);
624
+ } catch (error) {
625
+ fail(error);
626
+ }
627
+ };
628
+ };
629
+ const OPTIONS = [
630
+ {
631
+ description: `Work duration in minutes (1-1440, default: 15)`,
632
+ name: "--work",
633
+ valueName: "<minutes>"
634
+ },
635
+ {
636
+ description: `Break duration in minutes (1-1440, default: 5)`,
637
+ name: "--break",
638
+ valueName: "<minutes>"
639
+ },
640
+ {
641
+ description: `Work-break repetitions (positive integer, default: 3)`,
642
+ name: "--loop",
643
+ valueName: "<count>"
644
+ },
645
+ {
646
+ alias: "-h",
647
+ description: "Print help",
648
+ name: "--help"
649
+ }
650
+ ];
651
+ //#endregion
652
+ //#region src/terminal/input.ts
653
+ const CHOICES = ["OK", "NG"];
654
+ const ENTER_KEY_NAMES = /* @__PURE__ */ new Set(["enter", "return"]);
655
+ const FIRST_CHOICE_INDEX = 0;
656
+ const KEYPRESS_EVENT_KEY_INDEX = 1;
657
+ const MOVE_KEY_NAMES = /* @__PURE__ */ new Set(["down", "up"]);
658
+ const NEXT_INDEX_OFFSET = 1;
659
+ const REDRAW_SEQUENCE = `\r\u001B[${CHOICES.length}A\u001B[J`;
660
+ const STDIN_CLOSE_EVENTS = ["end", "close"];
661
+ const SELECTION_QUESTION = "Start working? (Use ↑/↓ to select, Enter to confirm)";
662
+ const keyFrom = (value) => {
663
+ let key = {}, normalizedName = "";
664
+ if (typeof value === "object" && value !== null) key = value;
665
+ const ctrl = Reflect.get(key, "ctrl"), name = Reflect.get(key, "name");
666
+ if (typeof name === "string") normalizedName = name;
667
+ return {
668
+ ctrl: ctrl === true,
669
+ name: normalizedName
670
+ };
671
+ };
672
+ const createSelectionTerminal = ({ input = process$1.stdin, signal, write = (output) => {
673
+ process$1.stdout.write(output);
674
+ } } = {}) => {
675
+ emitKeypressEvents(input);
676
+ const inputWasFlowing = input.readableFlowing === true, keypressEvents = on(input, "keypress", { close: STDIN_CLOSE_EVENTS }), rawModeWasEnabled = input.isRaw === true, supportsRawMode = input.isTTY === true && typeof input.setRawMode === "function", stopListening = () => {
677
+ keypressEvents.return?.();
678
+ };
679
+ let restored = false;
680
+ signal?.addEventListener("abort", stopListening, { once: true });
681
+ if (signal?.aborted) stopListening();
682
+ if (supportsRawMode && !rawModeWasEnabled) input.setRawMode?.(true);
683
+ if (!inputWasFlowing) input.resume();
684
+ return {
685
+ nextKey: async () => {
686
+ const event = await keypressEvents.next();
687
+ if (event.done) return {
688
+ ctrl: true,
689
+ name: "c"
690
+ };
691
+ return keyFrom(event.value.at(KEYPRESS_EVENT_KEY_INDEX));
692
+ },
693
+ restore: async () => {
694
+ if (restored) return;
695
+ restored = true;
696
+ signal?.removeEventListener("abort", stopListening);
697
+ await keypressEvents.return?.();
698
+ if (supportsRawMode && !rawModeWasEnabled) input.setRawMode?.(false);
699
+ if (!inputWasFlowing) input.pause();
700
+ },
701
+ write
702
+ };
703
+ };
704
+ const markerFor = (selected) => {
705
+ if (selected) return "❯";
706
+ return " ";
707
+ };
708
+ const renderSelection = (selectedIndex) => [SELECTION_QUESTION, ...CHOICES.map((choice, index) => `${markerFor(index === selectedIndex)} ${choice}`)].join("\n");
709
+ const nextChoiceIndex = (selectedIndex) => {
710
+ if (selectedIndex === FIRST_CHOICE_INDEX) return CHOICES.length - NEXT_INDEX_OFFSET;
711
+ return FIRST_CHOICE_INDEX;
712
+ };
713
+ const handleKey = (key, selectedIndex, terminal) => {
714
+ if (key.ctrl && key.name === "c") {
715
+ terminal.write("\n");
716
+ return false;
717
+ }
718
+ if (MOVE_KEY_NAMES.has(key.name)) {
719
+ const newSelectedIndex = nextChoiceIndex(selectedIndex);
720
+ terminal.write(`${REDRAW_SEQUENCE}${renderSelection(newSelectedIndex)}`);
721
+ return newSelectedIndex;
722
+ }
723
+ if (ENTER_KEY_NAMES.has(key.name)) {
724
+ terminal.write("\n");
725
+ return CHOICES[selectedIndex] === "OK";
726
+ }
727
+ return selectedIndex;
728
+ };
729
+ const confirmWorkStart = async ({ createTerminal = createSelectionTerminal, ...terminalParameters } = {}) => {
730
+ const terminal = createTerminal(terminalParameters);
731
+ let selectedIndex = FIRST_CHOICE_INDEX;
732
+ try {
733
+ terminal.write(renderSelection(selectedIndex));
734
+ for (;;) {
735
+ const key = await terminal.nextKey(), selection = handleKey(key, selectedIndex, terminal);
736
+ if (typeof selection === "boolean") return selection;
737
+ selectedIndex = selection;
738
+ }
739
+ } finally {
740
+ await terminal.restore();
741
+ }
742
+ };
743
+ //#endregion
744
+ //#region src/terminal/constants.ts
745
+ /**
746
+ * ロゴが収まらない狭い端末で、ロゴの代わりに表示する見出しです。
747
+ */
748
+ const COMPACT_LOGO = `
749
+ 🍅 pomotty
750
+ `;
751
+ const LOGO = `
752
+ ██████╗ ██████╗ ███╗ ███╗ ██████╗ ████████╗████████╗██╗ ██╗
753
+ ██╔══██╗██╔═══██╗████╗ ████║██╔═══██╗╚══██╔══╝╚══██╔══╝╚██╗ ██╔╝
754
+ ██████╔╝██║ ██║██╔████╔██║██║ ██║ ██║ ██║ ╚████╔╝
755
+ ██╔═══╝ ██║ ██║██║╚██╔╝██║██║ ██║ ██║ ██║ ╚██╔╝
756
+ ██║ ╚██████╔╝██║ ╚═╝ ██║╚██████╔╝ ██║ ██║ ██║
757
+ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝
758
+ `;
759
+ //#endregion
760
+ //#region src/terminal/frame-line.ts
761
+ const ELLIPSIS = "…";
762
+ const NO_WIDTH = 0;
763
+ const SINGLE_WIDTH = 1;
764
+ const DOUBLE_WIDTH = 2;
765
+ const WIDE_CHARACTER = /[\u1100-\u115F\u2615\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}]/u;
766
+ const ZERO_WIDTH_CHARACTERS = /* @__PURE__ */ new Set([
767
+ "‍",
768
+ "︎",
769
+ "️"
770
+ ]);
771
+ const SGR_DEFAULT_FOREGROUND = "\x1B[39m";
772
+ const SGR_TONES = {
773
+ break: "\x1B[32m",
774
+ work: "\x1B[31m"
775
+ };
776
+ const characterWidth = (character) => {
777
+ if (ZERO_WIDTH_CHARACTERS.has(character)) return NO_WIDTH;
778
+ if (WIDE_CHARACTER.test(character)) return DOUBLE_WIDTH;
779
+ return SINGLE_WIDTH;
780
+ };
781
+ const cellWidth = (text) => [...text].reduce((width, character) => width + characterWidth(character), NO_WIDTH);
782
+ const lineWidth = (line) => line.reduce((width, segment) => width + cellWidth(segment.text), NO_WIDTH);
783
+ const takeWithin = (text, maxWidth) => {
784
+ let taken = "", used = NO_WIDTH;
785
+ for (const character of text) {
786
+ used += characterWidth(character);
787
+ if (used > maxWidth) break;
788
+ taken += character;
789
+ }
790
+ return taken;
791
+ };
792
+ const takeSegmentsWithin = (line, maxWidth) => {
793
+ const kept = [];
794
+ let remaining = maxWidth;
795
+ for (const { text, tone } of line) {
796
+ const taken = takeWithin(text, remaining);
797
+ if (taken !== "") kept.push({
798
+ text: taken,
799
+ tone
800
+ });
801
+ if (taken !== text) break;
802
+ remaining -= cellWidth(taken);
803
+ }
804
+ return kept;
805
+ };
806
+ const truncateLine = (line, maxWidth) => {
807
+ if (lineWidth(line) <= maxWidth) return line;
808
+ return [...takeSegmentsWithin(line, maxWidth - cellWidth(ELLIPSIS)), { text: ELLIPSIS }];
809
+ };
810
+ const stringifySegment = ({ text, tone }, color) => {
811
+ if (!color || !tone || text === "") return text;
812
+ return `${SGR_TONES[tone]}${text}${SGR_DEFAULT_FOREGROUND}`;
813
+ };
814
+ const stringifyFrameLine = (line, color) => line.map((segment) => stringifySegment(segment, color)).join("").trimEnd();
815
+ const isColorEnabled = (env) => (env.NO_COLOR ?? "") === "";
816
+ //#endregion
817
+ //#region src/terminal/render.ts
818
+ const CLI_OPTION_INDEX = 2;
819
+ const LOGO_WIDTH = Math.max(...LOGO.split("\n").map((line) => cellWidth(line)));
820
+ const RIGHT_MARGIN$1 = 1;
821
+ const canShowLogo = (columns) => !columns || LOGO_WIDTH <= columns - RIGHT_MARGIN$1;
822
+ const createCliOutput = ({ columns, options, optionName = process.argv.at(CLI_OPTION_INDEX) }) => {
823
+ const option = options.find(({ alias, name }) => alias === optionName || name === optionName);
824
+ if (!option && canShowLogo(columns)) return `${LOGO}\n`;
825
+ if (!option) return `${COMPACT_LOGO}\n`;
826
+ return [
827
+ "Pomotty CLI",
828
+ "",
829
+ "Usage: pomotty [OPTIONS]",
830
+ "",
831
+ "Options:",
832
+ ...options.flatMap(({ alias, description, name, valueName }) => {
833
+ return [` ${[alias, [name, valueName].filter(Boolean).join(" ")].filter(Boolean).join(", ")}`, ` ${description}`];
834
+ }),
835
+ ""
836
+ ].join("\n");
837
+ };
838
+ //#endregion
839
+ //#region src/terminal/inline-renderer.ts
840
+ const CSI = "\x1B[";
841
+ const NO_ROWS = 0;
842
+ const CLEAR_LINE = `${CSI}2K`;
843
+ const cursorUp = (rows) => {
844
+ if (rows <= NO_ROWS) return "";
845
+ return `${CSI}${rows}A`;
846
+ };
847
+ const createInlineRenderer = (write) => {
848
+ let previous, renderedRows = NO_ROWS;
849
+ const eraseSequence = () => `${cursorUp(renderedRows)}${`${CLEAR_LINE}\n`.repeat(renderedRows)}${cursorUp(renderedRows)}`;
850
+ return {
851
+ log: (output) => {
852
+ write(`${eraseSequence()}${output}`);
853
+ previous = void 0;
854
+ renderedRows = NO_ROWS;
855
+ },
856
+ release: () => {
857
+ previous = void 0;
858
+ renderedRows = NO_ROWS;
859
+ },
860
+ render: (lines) => {
861
+ const frame = lines.join("\n");
862
+ if (frame === previous) return;
863
+ const leftoverRows = Math.max(NO_ROWS, renderedRows - lines.length);
864
+ write([
865
+ cursorUp(renderedRows),
866
+ ...lines.map((line) => `${CLEAR_LINE}${line}\n`),
867
+ `${CLEAR_LINE}\n`.repeat(leftoverRows),
868
+ cursorUp(leftoverRows)
869
+ ].join(""));
870
+ previous = frame;
871
+ renderedRows = lines.length;
872
+ }
873
+ };
874
+ };
875
+ //#endregion
876
+ //#region src/terminal/terminal-guard.ts
877
+ const HIDE_CURSOR = "\x1B[?25l";
878
+ const SHOW_CURSOR = "\x1B[?25h";
879
+ const TERMINATION_SIGNALS = [
880
+ "SIGHUP",
881
+ "SIGINT",
882
+ "SIGTERM"
883
+ ];
884
+ const createSyncCursorRestorer = (terminal) => () => {
885
+ if (terminal.fd === void 0) return;
886
+ try {
887
+ writeSync(terminal.fd, SHOW_CURSOR);
888
+ } catch {}
889
+ };
890
+ const createTerminalGuard = ({ onSignal, processEvents, terminal, write }) => {
891
+ const restoreCursorSync = createSyncCursorRestorer(terminal), signalHandlers = TERMINATION_SIGNALS.map((signal) => {
892
+ const handler = () => {
893
+ onSignal(signal);
894
+ };
895
+ processEvents.on(signal, handler);
896
+ return [signal, handler];
897
+ });
898
+ let cursorHidden = false, released = false;
899
+ return {
900
+ hideCursor: () => {
901
+ if (cursorHidden || released) return;
902
+ cursorHidden = true;
903
+ processEvents.on("exit", restoreCursorSync);
904
+ write(HIDE_CURSOR);
905
+ },
906
+ release: () => {
907
+ if (released) return;
908
+ released = true;
909
+ for (const [signal, handler] of signalHandlers) processEvents.off(signal, handler);
910
+ if (cursorHidden) {
911
+ write(SHOW_CURSOR);
912
+ processEvents.off("exit", restoreCursorSync);
913
+ }
914
+ }
915
+ };
916
+ };
917
+ //#endregion
918
+ //#region src/terminal/clock.ts
919
+ const MILLISECONDS_PER_SECOND = 1e3;
920
+ const SECONDS_PER_MINUTE = 60;
921
+ const SECONDS_PER_HOUR = 3600;
922
+ const PERCENT$1 = 100;
923
+ const TIME_PART_WIDTH = 2;
924
+ const NO_TIME = 0;
925
+ const MIN_RATIO = 0;
926
+ const MAX_RATIO = 1;
927
+ const twoDigits = (value) => String(value).padStart(TIME_PART_WIDTH, "0");
928
+ const formatClock = (seconds) => {
929
+ const hours = Math.floor(seconds / SECONDS_PER_HOUR), minutes = Math.floor(seconds % SECONDS_PER_HOUR / SECONDS_PER_MINUTE), restSeconds = seconds % SECONDS_PER_MINUTE;
930
+ if (hours === NO_TIME) return `${minutes}:${twoDigits(restSeconds)}`;
931
+ return `${hours}:${twoDigits(minutes)}:${twoDigits(restSeconds)}`;
932
+ };
933
+ const toDisplaySeconds = (milliseconds) => Math.ceil(milliseconds / MILLISECONDS_PER_SECOND);
934
+ const formatDuration = (milliseconds) => formatClock(toDisplaySeconds(milliseconds));
935
+ const durationFor = (phase, settings) => {
936
+ if (phase === "work") return settings.workDurationMs;
937
+ return settings.breakDurationMs;
938
+ };
939
+ const progressFor = (state, now, settings) => {
940
+ const durationMs = durationFor(state.phase, settings), remainingMs = remainingMsAt(state, now), ratio = Math.min(MAX_RATIO, Math.max(MIN_RATIO, (durationMs - remainingMs) / durationMs)), remainingSeconds = toDisplaySeconds(remainingMs), totalSeconds = toDisplaySeconds(durationMs);
941
+ return {
942
+ elapsedSeconds: Math.max(NO_TIME, totalSeconds - remainingSeconds),
943
+ percent: Math.floor(ratio * PERCENT$1),
944
+ ratio,
945
+ remainingSeconds,
946
+ totalSeconds
947
+ };
948
+ };
949
+ //#endregion
950
+ //#region src/terminal/timeline.ts
951
+ const BLOCK = "█";
952
+ const EMPTY_CELL = "·";
953
+ const EIGHTH_BLOCKS = [
954
+ "",
955
+ "▏",
956
+ "▎",
957
+ "▍",
958
+ "▌",
959
+ "▋",
960
+ "▊",
961
+ "▉"
962
+ ];
963
+ const EIGHTHS_PER_CELL = 8;
964
+ const INDENT$1 = " ";
965
+ const LABEL_GAP = " ";
966
+ const BAR_GAP = " ";
967
+ const CURRENT_LABEL = "◀ now";
968
+ const DONE_LABEL = "✓";
969
+ const FIRST_CYCLE = 1;
970
+ const NOT_STARTED = 0;
971
+ const NO_CYCLES = 0;
972
+ const COMPLETED = 1;
973
+ const ONE_CELL = 1;
974
+ const TIMELINE_CONTEXT = 2;
975
+ const TIMELINE_CELLS = 20;
976
+ const TIMELINE_MIN_CELLS = 2;
977
+ const TIMELINE_MAX_ROWS = 5;
978
+ const createSmoothBar = (ratio, cells, tone) => {
979
+ const eighths = Math.floor(ratio * cells * EIGHTHS_PER_CELL), fullCells = Math.floor(eighths / EIGHTHS_PER_CELL), partial = EIGHTH_BLOCKS[eighths % EIGHTHS_PER_CELL] ?? "", filled = `${BLOCK.repeat(fullCells)}${partial}`;
980
+ return [{
981
+ text: filled,
982
+ tone
983
+ }, { text: EMPTY_CELL.repeat(cells - cellWidth(filled)) }];
984
+ };
985
+ const timelineCellsFor = (settings) => {
986
+ const totalMs = settings.workDurationMs + settings.breakDurationMs, work = Math.min(18, Math.max(TIMELINE_MIN_CELLS, Math.round(TIMELINE_CELLS * settings.workDurationMs / totalMs)));
987
+ return {
988
+ break: TIMELINE_CELLS - work,
989
+ work
990
+ };
991
+ };
992
+ const timelineRangeFor = (cycle, loopCount) => {
993
+ const lastStart = Math.max(FIRST_CYCLE, loopCount - TIMELINE_MAX_ROWS + FIRST_CYCLE), first = Math.min(lastStart, Math.max(FIRST_CYCLE, cycle - TIMELINE_CONTEXT));
994
+ return [first, Math.min(loopCount, first + TIMELINE_MAX_ROWS - FIRST_CYCLE)];
995
+ };
996
+ const cycleStatusFor = (row, state, ratio) => {
997
+ if (row < state.cycle) return {
998
+ breakRatio: COMPLETED,
999
+ label: DONE_LABEL,
1000
+ workRatio: COMPLETED
1001
+ };
1002
+ if (row > state.cycle) return {
1003
+ breakRatio: NOT_STARTED,
1004
+ label: "",
1005
+ workRatio: NOT_STARTED
1006
+ };
1007
+ if (state.phase === "work") return {
1008
+ breakRatio: NOT_STARTED,
1009
+ label: CURRENT_LABEL,
1010
+ workRatio: ratio
1011
+ };
1012
+ return {
1013
+ breakRatio: ratio,
1014
+ label: CURRENT_LABEL,
1015
+ workRatio: COMPLETED
1016
+ };
1017
+ };
1018
+ const createCycleRow = (label, status, cells) => {
1019
+ const row = [
1020
+ { text: `${INDENT$1}${label}${LABEL_GAP}` },
1021
+ ...createSmoothBar(status.workRatio, cells.work, "work"),
1022
+ { text: BAR_GAP },
1023
+ ...createSmoothBar(status.breakRatio, cells.break, "break")
1024
+ ];
1025
+ if (status.label !== "") row.push({ text: `${LABEL_GAP}${status.label}` });
1026
+ return row;
1027
+ };
1028
+ const createLegend = (settings, cells) => {
1029
+ const workLegend = `work ${formatDuration(settings.workDurationMs)}`, breakLegend = `break ${formatDuration(settings.breakDurationMs)}`, breakColumn = cells.work + ONE_CELL;
1030
+ if (cellWidth(workLegend) < breakColumn) return `${workLegend.padEnd(breakColumn)}${breakLegend}`;
1031
+ return `${workLegend} · ${breakLegend}`;
1032
+ };
1033
+ const hiddenCyclesLines = (text, count) => {
1034
+ if (count <= NO_CYCLES) return [];
1035
+ return [[{ text }]];
1036
+ };
1037
+ const createTimeline = (state, settings, ratio) => {
1038
+ const cells = timelineCellsFor(settings), [first, last] = timelineRangeFor(state.cycle, settings.loopCount), labelWidth = cellWidth(`#${last}`), prefix = " ".repeat(cellWidth(`${INDENT$1}${LABEL_GAP}`) + labelWidth), hiddenBefore = first - FIRST_CYCLE, hiddenAfter = settings.loopCount - last, rows = Array.from({ length: last - first + FIRST_CYCLE }, (_value, index) => {
1039
+ const cycle = first + index;
1040
+ return createCycleRow(`#${cycle}`.padEnd(labelWidth), cycleStatusFor(cycle, state, ratio), cells);
1041
+ });
1042
+ return [
1043
+ ...hiddenCyclesLines(`${prefix}⋮ ${hiddenBefore} done`, hiddenBefore),
1044
+ ...rows,
1045
+ ...hiddenCyclesLines(`${prefix}⋮ ${hiddenAfter} more`, hiddenAfter),
1046
+ [{ text: `${prefix}${createLegend(settings, cells)}` }]
1047
+ ];
1048
+ };
1049
+ //#endregion
1050
+ //#region src/terminal/timer-view.ts
1051
+ const BAR_WIDTH = 40;
1052
+ const INDENT = " ";
1053
+ const MIN_GAP = 2;
1054
+ const HALF = 2;
1055
+ const NO_CELLS = 0;
1056
+ const MIN_WIDTH = 1;
1057
+ const PERCENT = 100;
1058
+ const FALLBACK_COLUMNS = 80;
1059
+ const RIGHT_MARGIN = 1;
1060
+ const COMPACT_MIN_WIDTH = 20;
1061
+ const BIG_DIGIT_ROWS = 5;
1062
+ const BIG_DIGITS = {
1063
+ "0": [
1064
+ "###",
1065
+ "# #",
1066
+ "# #",
1067
+ "# #",
1068
+ "###"
1069
+ ],
1070
+ "1": [
1071
+ "## ",
1072
+ " # ",
1073
+ " # ",
1074
+ " # ",
1075
+ "###"
1076
+ ],
1077
+ "2": [
1078
+ "###",
1079
+ " #",
1080
+ "###",
1081
+ "# ",
1082
+ "###"
1083
+ ],
1084
+ "3": [
1085
+ "###",
1086
+ " #",
1087
+ "###",
1088
+ " #",
1089
+ "###"
1090
+ ],
1091
+ "4": [
1092
+ "# #",
1093
+ "# #",
1094
+ "###",
1095
+ " #",
1096
+ " #"
1097
+ ],
1098
+ "5": [
1099
+ "###",
1100
+ "# ",
1101
+ "###",
1102
+ " #",
1103
+ "###"
1104
+ ],
1105
+ "6": [
1106
+ "###",
1107
+ "# ",
1108
+ "###",
1109
+ "# #",
1110
+ "###"
1111
+ ],
1112
+ "7": [
1113
+ "###",
1114
+ " #",
1115
+ " #",
1116
+ " #",
1117
+ " #"
1118
+ ],
1119
+ "8": [
1120
+ "###",
1121
+ "# #",
1122
+ "###",
1123
+ "# #",
1124
+ "###"
1125
+ ],
1126
+ "9": [
1127
+ "###",
1128
+ "# #",
1129
+ "###",
1130
+ " #",
1131
+ "###"
1132
+ ],
1133
+ ":": [
1134
+ " ",
1135
+ "#",
1136
+ " ",
1137
+ "#",
1138
+ " "
1139
+ ]
1140
+ };
1141
+ const BIG_PIXEL_ON = "██";
1142
+ const BIG_PIXEL_OFF = " ";
1143
+ const BIG_DIGIT_GAP = " ";
1144
+ const LINE_FILLED = "━";
1145
+ const LINE_EMPTY = "─";
1146
+ const PHASE_ICONS$1 = {
1147
+ break: "☕",
1148
+ work: "🍅"
1149
+ };
1150
+ const PHASE_LABELS = {
1151
+ break: "BREAK",
1152
+ work: "WORK"
1153
+ };
1154
+ const PAUSED_LABEL = "PAUSED";
1155
+ const KEY_GUIDES = {
1156
+ paused: "[space] resume [s] skip [q] quit",
1157
+ running: "[space] pause [s] skip [q] quit"
1158
+ };
1159
+ const COMPACT_KEY_GUIDES = {
1160
+ paused: "[sp]resume [s]skip [q]quit",
1161
+ running: "[sp]pause [s]skip [q]quit"
1162
+ };
1163
+ const keyGuideKind = (state) => {
1164
+ if (state.paused) return "paused";
1165
+ return "running";
1166
+ };
1167
+ const spaces = (cells) => " ".repeat(Math.max(NO_CELLS, cells));
1168
+ const bigPixel = (pixel) => {
1169
+ if (pixel === "#") return BIG_PIXEL_ON;
1170
+ return BIG_PIXEL_OFF;
1171
+ };
1172
+ const renderBigClock = (clock) => Array.from({ length: BIG_DIGIT_ROWS }, (_value, row) => [...clock].map((character) => [...(BIG_DIGITS[character] ?? []).at(row) ?? ""].map((pixel) => bigPixel(pixel)).join("")).join(BIG_DIGIT_GAP));
1173
+ const createBigClockLines = (clock, phase) => {
1174
+ const rows = renderBigClock(clock), offset = Math.floor((BAR_WIDTH - cellWidth(rows.at(NO_CELLS) ?? "")) / HALF);
1175
+ return rows.map((row) => [{ text: `${INDENT}${spaces(offset)}` }, {
1176
+ text: row.trimEnd(),
1177
+ tone: phase
1178
+ }]);
1179
+ };
1180
+ const createLineBar = (percent, cells, tone) => {
1181
+ const filled = Math.floor(percent * cells / PERCENT);
1182
+ return [{
1183
+ text: LINE_FILLED.repeat(filled),
1184
+ tone
1185
+ }, { text: LINE_EMPTY.repeat(cells - filled) }];
1186
+ };
1187
+ const spreadLabels = ([left, center, right]) => {
1188
+ const centerStart = Math.floor((BAR_WIDTH - cellWidth(center)) / HALF), leftGap = centerStart - cellWidth(left), rightGap = BAR_WIDTH - centerStart - cellWidth(center) - cellWidth(right);
1189
+ if (leftGap < MIN_GAP || rightGap < MIN_GAP) return [
1190
+ left,
1191
+ center,
1192
+ right
1193
+ ].join(spaces(MIN_GAP));
1194
+ return `${left}${spaces(leftGap)}${center}${spaces(rightGap)}${right}`;
1195
+ };
1196
+ const phaseSegment = ({ paused, phase }) => {
1197
+ const label = `${PHASE_ICONS$1[phase]} ${PHASE_LABELS[phase]}`;
1198
+ if (paused) return {
1199
+ text: `${label} ${PAUSED_LABEL}`,
1200
+ tone: phase
1201
+ };
1202
+ return {
1203
+ text: label,
1204
+ tone: phase
1205
+ };
1206
+ };
1207
+ const minimalPhaseLabel = ({ paused, phase }) => {
1208
+ if (paused) return PAUSED_LABEL;
1209
+ return PHASE_LABELS[phase];
1210
+ };
1211
+ const createHeader = (state, settings) => {
1212
+ const phase = phaseSegment(state), cycle = `Cycle ${state.cycle} / ${settings.loopCount}`, gap = Math.max(MIN_GAP, BAR_WIDTH - cellWidth(phase.text) - cellWidth(cycle));
1213
+ return [
1214
+ { text: INDENT },
1215
+ phase,
1216
+ { text: `${spaces(gap)}${cycle}` }
1217
+ ];
1218
+ };
1219
+ const createFullFrame = ({ progress, settings, state }) => [
1220
+ createHeader(state, settings),
1221
+ [],
1222
+ ...createBigClockLines(formatClock(progress.remainingSeconds), state.phase),
1223
+ [],
1224
+ [{ text: INDENT }, ...createLineBar(progress.percent, BAR_WIDTH, state.phase)],
1225
+ [{ text: `${INDENT}${spreadLabels([
1226
+ `${formatClock(progress.elapsedSeconds)} elapsed`,
1227
+ `${progress.percent}%`,
1228
+ `${formatClock(progress.totalSeconds)} total`
1229
+ ])}` }],
1230
+ [],
1231
+ ...createTimeline(state, settings, progress.ratio),
1232
+ [],
1233
+ [{ text: `${INDENT}${KEY_GUIDES[keyGuideKind(state)]}` }]
1234
+ ];
1235
+ const createCompactFrame = ({ progress, settings, state, width }) => [
1236
+ [phaseSegment(state), { text: `${spaces(MIN_GAP)}${state.cycle}/${settings.loopCount}` }],
1237
+ [{ text: `${formatClock(progress.remainingSeconds)} left · ${progress.percent}%` }],
1238
+ createLineBar(progress.percent, Math.min(BAR_WIDTH, width), state.phase),
1239
+ [{ text: COMPACT_KEY_GUIDES[keyGuideKind(state)] }]
1240
+ ];
1241
+ const createMinimalFrame = ({ progress, settings, state }) => [[{
1242
+ text: minimalPhaseLabel(state),
1243
+ tone: state.phase
1244
+ }, { text: ` ${formatClock(progress.remainingSeconds)} ${state.cycle}/${settings.loopCount}` }]];
1245
+ const createTimerFrame = ({ state, now, settings, columns }) => {
1246
+ const width = Math.max(MIN_WIDTH, (columns || FALLBACK_COLUMNS) - RIGHT_MARGIN), layout = {
1247
+ progress: progressFor(state, now, settings),
1248
+ settings,
1249
+ state,
1250
+ width
1251
+ }, full = createFullFrame(layout);
1252
+ if (full.every((line) => lineWidth(line) <= width)) return [...full];
1253
+ if (width >= COMPACT_MIN_WIDTH) return createCompactFrame(layout).map((line) => truncateLine(line, width));
1254
+ return createMinimalFrame(layout).map((line) => truncateLine(line, width));
1255
+ };
1256
+ //#endregion
1257
+ //#region src/terminal/timer-keys.ts
1258
+ /** Ctrlなしで受け付けるキー名と操作の対応です。Shift(大文字)は区別しません。 */
1259
+ const PLAIN_KEYS = /* @__PURE__ */ new Map([
1260
+ ["q", "quit"],
1261
+ ["s", "skip"],
1262
+ ["space", "toggle-pause"]
1263
+ ]);
1264
+ const timerKeyFrom = (value) => {
1265
+ const key = keyFrom(value);
1266
+ if (typeof value === "object" && value !== null && Reflect.get(value, "meta") === true) return;
1267
+ if (key.ctrl) {
1268
+ if (key.name === "c") return "interrupt";
1269
+ return;
1270
+ }
1271
+ return PLAIN_KEYS.get(key.name);
1272
+ };
1273
+ const watchTimerKeys = ({ input = process$1.stdin, onKey }) => {
1274
+ emitKeypressEvents(input);
1275
+ const inputWasFlowing = input.readableFlowing === true, rawModeWasEnabled = input.isRaw === true, supportsRawMode = input.isTTY === true && typeof input.setRawMode === "function", handleKeypress = (_sequence, value) => {
1276
+ const key = timerKeyFrom(value);
1277
+ if (key !== void 0) onKey(key);
1278
+ };
1279
+ let stopped = false;
1280
+ input.on("keypress", handleKeypress);
1281
+ if (supportsRawMode && !rawModeWasEnabled) input.setRawMode?.(true);
1282
+ if (!inputWasFlowing) input.resume();
1283
+ return () => {
1284
+ if (stopped) return;
1285
+ stopped = true;
1286
+ input.removeListener("keypress", handleKeypress);
1287
+ if (supportsRawMode && !rawModeWasEnabled) input.setRawMode?.(false);
1288
+ if (!inputWasFlowing) input.pause();
1289
+ };
1290
+ };
1291
+ //#endregion
1292
+ //#region src/cli/progress-view.ts
1293
+ const PHASE_ICONS = {
1294
+ break: "☕",
1295
+ work: "🍅"
1296
+ };
1297
+ const PHASE_NAMES = {
1298
+ break: "Break",
1299
+ work: "Work"
1300
+ };
1301
+ const createPhaseStartedOutput = (phase, durationMs) => {
1302
+ const durationMinutes = durationMs / MILLISECONDS_PER_MINUTE;
1303
+ return `${PHASE_ICONS[phase]} ${PHASE_NAMES[phase]} started (${durationMinutes} min)\n`;
1304
+ };
1305
+ const createPhaseCompletedOutput = (phase) => `✅ ${PHASE_NAMES[phase]} complete.\n`;
1306
+ const createPhaseSkippedOutput = (phase) => `⏩ ${PHASE_NAMES[phase]} skipped.\n`;
1307
+ const applyTimerKey = (key, controls, onStop) => {
1308
+ if (key === "toggle-pause") {
1309
+ controls.togglePause();
1310
+ return;
1311
+ }
1312
+ if (key === "skip") {
1313
+ controls.skip();
1314
+ return;
1315
+ }
1316
+ if (!controls.settle()) onStop(key);
1317
+ };
1318
+ const isInteractiveTerminal = (stdin, stdout) => stdin.isTTY === true && stdout.isTTY === true && process$1.env.TERM !== "dumb";
1319
+ const createLineView = (write) => ({
1320
+ finish: () => {},
1321
+ onPhaseCompleted: (phase) => {
1322
+ write(createPhaseCompletedOutput(phase));
1323
+ },
1324
+ onPhaseStarted: (phase, durationMs) => {
1325
+ write(createPhaseStartedOutput(phase, durationMs));
1326
+ }
1327
+ });
1328
+ const createInteractiveView = ({ color, onStop, processEvents, settings, stdin, terminal, write }) => {
1329
+ const renderer = createInlineRenderer(write), guard = createTerminalGuard({
1330
+ onSignal: onStop,
1331
+ processEvents,
1332
+ terminal,
1333
+ write
1334
+ });
1335
+ let controls, latest;
1336
+ const draw = () => {
1337
+ if (latest === void 0) return;
1338
+ guard.hideCursor();
1339
+ renderer.render(createTimerFrame({
1340
+ columns: terminal.columns,
1341
+ now: latest.now,
1342
+ settings,
1343
+ state: latest.state
1344
+ }).map((line) => stringifyFrameLine(line, color)));
1345
+ }, handleResize = () => {
1346
+ renderer.release();
1347
+ draw();
1348
+ }, stopWatchingKeys = watchTimerKeys({
1349
+ input: stdin,
1350
+ onKey: (key) => {
1351
+ if (controls !== void 0) applyTimerKey(key, controls, onStop);
1352
+ }
1353
+ });
1354
+ terminal.on("resize", handleResize);
1355
+ return {
1356
+ bindControls: (bound) => {
1357
+ controls = bound;
1358
+ },
1359
+ finish: () => {
1360
+ stopWatchingKeys();
1361
+ terminal.off?.("resize", handleResize);
1362
+ guard.release();
1363
+ },
1364
+ onPhaseCompleted: (phase) => {
1365
+ renderer.log(createPhaseCompletedOutput(phase));
1366
+ },
1367
+ onPhaseSkipped: (phase) => {
1368
+ renderer.log(createPhaseSkippedOutput(phase));
1369
+ },
1370
+ onPhaseStarted: () => {},
1371
+ onTick: (state, now) => {
1372
+ latest = {
1373
+ now,
1374
+ state
1375
+ };
1376
+ draw();
1377
+ }
1378
+ };
1379
+ };
1380
+ const createProgressView = ({ color = isColorEnabled(process$1.env), interactive, onStop, processEvents = process$1, settings, stdin = process$1.stdin, stdout = process$1.stdout, write }) => {
1381
+ if (!(interactive ?? isInteractiveTerminal(stdin, stdout))) return createLineView(write);
1382
+ return createInteractiveView({
1383
+ color,
1384
+ onStop,
1385
+ processEvents,
1386
+ settings,
1387
+ stdin,
1388
+ terminal: stdout,
1389
+ write
1390
+ });
1391
+ };
1392
+ //#endregion
1393
+ //#region src/cli/parse-arguments.ts
1394
+ const ARGUMENT_INDEX_INCREMENT = 1;
1395
+ const ARGUMENTS_START_INDEX = 0;
1396
+ const DURATION_PATTERN = /^[1-9][0-9]{0,3}$/u;
1397
+ const EQUALS_SIGN = "=";
1398
+ const EQUALS_SIGN_NOT_FOUND = -1;
1399
+ const HELP_OPTIONS = ["--help", "-h"];
1400
+ const MAXIMUM_DURATION_MINUTES = 1440;
1401
+ const NEXT_ARGUMENT_OFFSET = 1;
1402
+ const LOOP_PATTERN = /^[1-9][0-9]*$/u;
1403
+ const isNumericOption = (value) => value === "--break" || value === "--work" || value === "--loop";
1404
+ const isHelpOption = (value) => HELP_OPTIONS.some((option) => option === value);
1405
+ const createValueError = (option) => {
1406
+ let maximum = MAXIMUM_DURATION_MINUTES;
1407
+ if (option === "--loop") maximum = Number.MAX_SAFE_INTEGER;
1408
+ return {
1409
+ kind: "error",
1410
+ message: `Error: ${option} requires an integer from 1 to ${maximum}.\n`
1411
+ };
1412
+ };
1413
+ const createDuplicateError = (option) => ({
1414
+ kind: "error",
1415
+ message: `Error: ${option} may only be specified once.\n`
1416
+ });
1417
+ const parseNumericValue = (option, value) => {
1418
+ let pattern = DURATION_PATTERN, maximum = MAXIMUM_DURATION_MINUTES;
1419
+ if (option === "--loop") {
1420
+ pattern = LOOP_PATTERN;
1421
+ maximum = Number.MAX_SAFE_INTEGER;
1422
+ }
1423
+ if (typeof value !== "string" || !pattern.test(value)) return;
1424
+ const numericValue = Number(value);
1425
+ if (!Number.isSafeInteger(numericValue) || numericValue > maximum) return;
1426
+ return numericValue;
1427
+ };
1428
+ const parseNumericArgument = (argument, nextArgument) => {
1429
+ const equalsSignIndex = argument.indexOf(EQUALS_SIGN);
1430
+ if (equalsSignIndex === EQUALS_SIGN_NOT_FOUND) return {
1431
+ consumesNextArgument: true,
1432
+ optionName: argument,
1433
+ value: nextArgument
1434
+ };
1435
+ return {
1436
+ consumesNextArgument: false,
1437
+ optionName: argument.slice(ARGUMENTS_START_INDEX, equalsSignIndex),
1438
+ value: argument.slice(equalsSignIndex + NEXT_ARGUMENT_OFFSET)
1439
+ };
1440
+ };
1441
+ const parseCliArguments = (arguments_) => {
1442
+ if (arguments_.some((argument) => isHelpOption(argument))) return { kind: "help" };
1443
+ const specifiedOptions = /* @__PURE__ */ new Set();
1444
+ let breakDurationMinutes = 5, loopCount = 3, workDurationMinutes = 15;
1445
+ for (let argumentIndex = ARGUMENTS_START_INDEX; argumentIndex < arguments_.length; argumentIndex += ARGUMENT_INDEX_INCREMENT) {
1446
+ const argument = arguments_[argumentIndex] ?? "", { consumesNextArgument, optionName, value } = parseNumericArgument(argument, arguments_[argumentIndex + NEXT_ARGUMENT_OFFSET]);
1447
+ if (!isNumericOption(optionName)) return {
1448
+ kind: "error",
1449
+ message: `Error: Unknown option: ${escapeDiagnostic(argument)}\n`
1450
+ };
1451
+ if (specifiedOptions.has(optionName)) return createDuplicateError(optionName);
1452
+ const numericValue = parseNumericValue(optionName, value);
1453
+ if (typeof numericValue !== "number") return createValueError(optionName);
1454
+ specifiedOptions.add(optionName);
1455
+ if (consumesNextArgument) argumentIndex += NEXT_ARGUMENT_OFFSET;
1456
+ if (optionName === "--work") workDurationMinutes = numericValue;
1457
+ else if (optionName === "--loop") loopCount = numericValue;
1458
+ else breakDurationMinutes = numericValue;
1459
+ }
1460
+ return {
1461
+ breakDurationMinutes,
1462
+ kind: "run",
1463
+ loopCount,
1464
+ workDurationMinutes
1465
+ };
1466
+ };
1467
+ //#endregion
1468
+ //#region src/cli/run.ts
1469
+ const CLI_ARGUMENTS_START_INDEX = 2;
1470
+ const FAILURE_EXIT_CODE$1 = 1;
1471
+ const SUCCESS_EXIT_CODE = 0;
1472
+ const STOP_EXIT_CODES = {
1473
+ SIGHUP: 129,
1474
+ SIGINT: 130,
1475
+ SIGTERM: 143,
1476
+ interrupt: 130,
1477
+ quit: SUCCESS_EXIT_CODE
1478
+ };
1479
+ const USAGE_ERROR_EXIT_CODE = 2;
1480
+ const SHUTDOWN_DEADLINE_MS = 2e3;
1481
+ const COMPLETION_SHUTDOWN_DEADLINE_MS = 5e3;
1482
+ const exitCodeForOutputFailure = (error) => {
1483
+ if (isBrokenPipe(error)) return SUCCESS_EXIT_CODE;
1484
+ return FAILURE_EXIT_CODE$1;
1485
+ };
1486
+ const emitDiagnostic = (writeError, message) => {
1487
+ try {
1488
+ writeError(message);
1489
+ } catch {}
1490
+ };
1491
+ const scheduleProcessExit = (exitCode, delayMs) => {
1492
+ setTimeout(() => {
1493
+ process$1.exit(exitCode);
1494
+ }, delayMs).unref();
1495
+ };
1496
+ const setProcessExitCode = (exitCode) => {
1497
+ process$1.exitCode = exitCode;
1498
+ };
1499
+ const runHelp = (stdout, setExitCode) => {
1500
+ let exitCode = SUCCESS_EXIT_CODE;
1501
+ createStandardOutput((error) => {
1502
+ exitCode = exitCodeForOutputFailure(error);
1503
+ setExitCode(exitCode);
1504
+ }, stdout)(createCliOutput({
1505
+ optionName: "--help",
1506
+ options: OPTIONS
1507
+ }));
1508
+ return exitCode;
1509
+ };
1510
+ const runCli = async ({ arguments_ = process$1.argv.slice(CLI_ARGUMENTS_START_INDEX), confirmStart = confirmWorkStart, createSoundPlayer, now, scheduleExit = scheduleProcessExit, setExitCode = setProcessExitCode, sleep, stdin, stdout, interactive, processEvents, color, writeError = createDiagnosticWriter() }) => {
1511
+ const parsedArguments = parseCliArguments(arguments_);
1512
+ if (parsedArguments.kind === "error") {
1513
+ emitDiagnostic(writeError, parsedArguments.message);
1514
+ return USAGE_ERROR_EXIT_CODE;
1515
+ }
1516
+ if (parsedArguments.kind === "help") return runHelp(stdout, setExitCode);
1517
+ const controller = new AbortController();
1518
+ let shutdownExitCode, soundPlayer, view;
1519
+ /**
1520
+ * 終了処理を1回だけ開始します。
1521
+ *
1522
+ * 最初の呼び出しで終了コードを固定し、確認待ちとタイマーを中断して、
1523
+ * 再生中の子プロセスへ終了要求を送ります。親プロセスは期限に達すると
1524
+ * 強制終了するため、終了しない子プロセスがあっても残り続けません。
1525
+ */
1526
+ const requestShutdown = (exitCode, complete) => {
1527
+ if (shutdownExitCode !== void 0) return shutdownExitCode;
1528
+ shutdownExitCode = exitCode;
1529
+ setExitCode(exitCode);
1530
+ controller.abort();
1531
+ soundPlayer?.shutdown({ complete });
1532
+ if (complete) scheduleExit(exitCode, COMPLETION_SHUTDOWN_DEADLINE_MS);
1533
+ else scheduleExit(exitCode, SHUTDOWN_DEADLINE_MS);
1534
+ return exitCode;
1535
+ }, writeOutput = createStandardOutput((error) => {
1536
+ requestShutdown(exitCodeForOutputFailure(error), false);
1537
+ }, stdout);
1538
+ try {
1539
+ soundPlayer = createSoundPlayer();
1540
+ writeOutput(createCliOutput({
1541
+ columns: (stdout ?? process$1.stdout).columns,
1542
+ optionName: "",
1543
+ options: OPTIONS
1544
+ }));
1545
+ if (shutdownExitCode !== void 0) return shutdownExitCode;
1546
+ const confirmed = await confirmStart({
1547
+ signal: controller.signal,
1548
+ write: writeOutput
1549
+ });
1550
+ if (shutdownExitCode !== void 0) return shutdownExitCode;
1551
+ if (!confirmed) {
1552
+ writeOutput("⏹️ Work was not started.\n");
1553
+ return requestShutdown(SUCCESS_EXIT_CODE, false);
1554
+ }
1555
+ const player = soundPlayer, settings = {
1556
+ breakDurationMs: parsedArguments.breakDurationMinutes * MILLISECONDS_PER_MINUTE,
1557
+ loopCount: parsedArguments.loopCount,
1558
+ workDurationMs: parsedArguments.workDurationMinutes * MILLISECONDS_PER_MINUTE
1559
+ };
1560
+ view = createProgressView({
1561
+ color,
1562
+ interactive,
1563
+ onStop: (reason) => {
1564
+ requestShutdown(STOP_EXIT_CODES[reason], false);
1565
+ },
1566
+ processEvents,
1567
+ settings,
1568
+ stdin,
1569
+ stdout,
1570
+ write: writeOutput
1571
+ });
1572
+ const timerView = view, result = await runPomodoroTimer({
1573
+ ...settings,
1574
+ bindControls: timerView.bindControls,
1575
+ now,
1576
+ onPhaseCompleted: (phase, { final }) => {
1577
+ timerView.onPhaseCompleted(phase);
1578
+ player.play(phase, { completionNotice: final });
1579
+ },
1580
+ onPhaseSkipped: timerView.onPhaseSkipped,
1581
+ onPhaseStarted: timerView.onPhaseStarted,
1582
+ onTick: timerView.onTick,
1583
+ signal: controller.signal,
1584
+ sleep
1585
+ });
1586
+ view.finish();
1587
+ if (!result.completed) return shutdownExitCode ?? FAILURE_EXIT_CODE$1;
1588
+ writeOutput("🎉 Pomodoro complete.\n");
1589
+ return requestShutdown(SUCCESS_EXIT_CODE, true);
1590
+ } catch (error) {
1591
+ view?.finish();
1592
+ requestShutdown(FAILURE_EXIT_CODE$1, false);
1593
+ throw error;
1594
+ }
1595
+ };
1596
+ //#endregion
1597
+ //#region src/cli.ts
1598
+ const FAILURE_EXIT_CODE = 1;
1599
+ const SOUND_DIRECTORY = new URL("../assets/", import.meta.url);
1600
+ const UNCONVERTIBLE_VALUE_MESSAGE = "Unable to convert the thrown value to a string";
1601
+ const stringifyError = (error) => {
1602
+ try {
1603
+ return String(error);
1604
+ } catch {
1605
+ return UNCONVERTIBLE_VALUE_MESSAGE;
1606
+ }
1607
+ };
1608
+ const handleFailure = (error) => {
1609
+ process$1.exitCode = FAILURE_EXIT_CODE;
1610
+ createDiagnosticWriter()(`Error: ${escapeDiagnostic(stringifyError(error))}\n`);
1611
+ };
1612
+ try {
1613
+ process$1.exitCode = await runCli({ createSoundPlayer: () => createSoundPlayer({
1614
+ commands: resolvePlayerCommands(),
1615
+ soundDirectory: SOUND_DIRECTORY
1616
+ }) });
1617
+ } catch (error) {
1618
+ handleFailure(error);
1619
+ }
1620
+ //#endregion
1621
+ export {};