From e8029654e4590dd8611e75be2827274a55329948 Mon Sep 17 00:00:00 2001 From: Mick F Date: Mon, 17 Nov 2025 12:52:31 +0100 Subject: [PATCH 1/2] feat: support Ctrl+C This helps with both interrupting a running pomodoro, or start an indefinite pomodoro --- Sources/Pomodoro/Hook.swift | 8 +- Sources/Pomodoro/InterruptHandler.swift | 69 ++++++++++++ Sources/Pomodoro/PomodoroDescription.swift | 27 ++++- Sources/Pomodoro/TimerViewCLI.swift | 125 ++++++++++++++++++++- Sources/PomodoroCLI/RootCommand.swift | 17 ++- 5 files changed, 234 insertions(+), 12 deletions(-) create mode 100644 Sources/Pomodoro/InterruptHandler.swift diff --git a/Sources/Pomodoro/Hook.swift b/Sources/Pomodoro/Hook.swift index de93c5c..6fb07b5 100644 --- a/Sources/Pomodoro/Hook.swift +++ b/Sources/Pomodoro/Hook.swift @@ -44,10 +44,14 @@ extension Hook { canBeExecuted { executable, path in if executable, let path { + // For indefinite pomodoros, pass "indefinite" as end date + let endDateString = description.isIndefinite ? "indefinite" : formatter.string(from: description.endDate) + let durationString = description.isIndefinite ? "indefinite" : description.duration.description + let task = Process.launchedProcess(launchPath: path, arguments: [ formatter.string(from: description.startDate), - formatter.string(from: description.endDate), - description.duration.description, + endDateString, + durationString, description.message ?? "n/a" ]) task.waitUntilExit() diff --git a/Sources/Pomodoro/InterruptHandler.swift b/Sources/Pomodoro/InterruptHandler.swift new file mode 100644 index 0000000..c4f247b --- /dev/null +++ b/Sources/Pomodoro/InterruptHandler.swift @@ -0,0 +1,69 @@ +import Foundation + +/// Manages SIGINT (Ctrl+C) signal handling for graceful interruption +class InterruptHandler { + enum State { + case none + case firstInterrupt + case secondInterrupt + } + + private var state: State = .none + private let stateLock = NSLock() + private var semaphore: DispatchSemaphore? + private var signalSource: DispatchSourceSignal? + + /// Configures the handler to listen for SIGINT signals + /// - Parameter semaphore: The semaphore to signal when interrupted + func setup(semaphore: DispatchSemaphore) { + self.semaphore = semaphore + + // Ignore default SIGINT handling so we can handle it ourselves + signal(SIGINT, SIG_IGN) + + // Create dispatch source for SIGINT on global queue (CLI apps don't run main RunLoop) + signalSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .global()) + + signalSource?.setEventHandler { [weak self] in + self?.handleInterrupt() + } + + signalSource?.resume() + } + + /// Handles an interrupt signal + private func handleInterrupt() { + stateLock.lock() + defer { stateLock.unlock() } + + switch state { + case .none: + // First interrupt - signal the semaphore to wake the timer + state = .firstInterrupt + semaphore?.signal() + + case .firstInterrupt: + // Second interrupt - force exit immediately + state = .secondInterrupt + exit(0) + + case .secondInterrupt: + // Should never reach here, but just in case + exit(0) + } + } + + /// Checks if an interrupt has occurred + var isInterrupted: Bool { + stateLock.lock() + defer { stateLock.unlock() } + return state == .firstInterrupt + } + + /// Cancels the signal handler + func cancel() { + signalSource?.cancel() + signalSource = nil + signal(SIGINT, SIG_DFL) // Restore default signal handling + } +} diff --git a/Sources/Pomodoro/PomodoroDescription.swift b/Sources/Pomodoro/PomodoroDescription.swift index d9040be..d2e7282 100644 --- a/Sources/Pomodoro/PomodoroDescription.swift +++ b/Sources/Pomodoro/PomodoroDescription.swift @@ -21,12 +21,32 @@ public struct PomodoroDescription { self.message = message } + /// Creates a pomodoro with a custom start date. + /// - Parameters: + /// - startDate: the start date of the pomodoro. + /// - duration: the duration of the pomodoro. + /// - message: a message describing the intent of the pomodoro. + public init(startDate: Date, duration: TimeInterval, message: String?) { + self.startDate = startDate + self.duration = duration + self.message = message + } + let startDate: Date let duration: TimeInterval let message: String? + /// Returns true if this is an indefinite pomodoro (runs until manually stopped) + var isIndefinite: Bool { + duration.isInfinite + } + var endDate: Date { - startDate.addingTimeInterval(duration) + if isIndefinite { + // Return far future date for indefinite pomodoros + return Date.distantFuture + } + return startDate.addingTimeInterval(duration) } var formattedStartDate: String { @@ -34,7 +54,10 @@ public struct PomodoroDescription { } var formattedEndDate: String { - PomodoroDescription.dateFormatter.string(from: endDate) + if isIndefinite { + return "indefinite" + } + return PomodoroDescription.dateFormatter.string(from: endDate) } } diff --git a/Sources/Pomodoro/TimerViewCLI.swift b/Sources/Pomodoro/TimerViewCLI.swift index 673e50c..54f6372 100644 --- a/Sources/Pomodoro/TimerViewCLI.swift +++ b/Sources/Pomodoro/TimerViewCLI.swift @@ -33,14 +33,55 @@ public class TimerViewCLI { let timerViewModel = TimerViewModel(timeInterval: pomodoro.duration) self.timerViewModel = timerViewModel + // Set up interrupt handling + let semaphore = DispatchSemaphore(value: 0) + let interruptHandler = InterruptHandler() + interruptHandler.setup(semaphore: semaphore) + Hook.didStart.execute(description: pomodoro, completionHandler: hookCompletionHandler) - output.write(string: "🍅 from \(pomodoro.formattedStartDate) to \(pomodoro.formattedEndDate)\n") - sleepTime = pomodoro.duration / TimeInterval(outputLength) - while !timerViewModel.outputs.progress.isFinished { - outputLine(for: timerViewModel.outputs.progress.fractionCompleted) - Thread.sleep(forTimeInterval: sleepTime) + if pomodoro.isIndefinite { + output.write(string: "🍅 from \(pomodoro.formattedStartDate) (indefinite - press Ctrl+C to finish)\n") + sleepTime = 1.0 // Update every second for elapsed time display + } else { + output.write(string: "🍅 from \(pomodoro.formattedStartDate) to \(pomodoro.formattedEndDate)\n") + sleepTime = pomodoro.duration / TimeInterval(outputLength) + } + + var wasInterrupted = false + // For indefinite pomodoros, loop forever until interrupted + // For regular pomodoros, loop until finished or interrupted + while pomodoro.isIndefinite || !timerViewModel.outputs.progress.isFinished { + if pomodoro.isIndefinite { + // Show elapsed time for indefinite pomodoros + let elapsed = Date().timeIntervalSince(timerViewModel.outputs.startDate) + outputElapsedTime(elapsed: elapsed) + } else { + // Show progress bar for regular pomodoros + outputLine(for: timerViewModel.outputs.progress.fractionCompleted) + } + + // Wait with timeout - can be woken by interrupt or timeout + let result = semaphore.wait(timeout: .now() + sleepTime) + + if result == .success { + // Woken by interrupt signal + wasInterrupted = true + break + } + // Otherwise, timeout occurred - continue normal progress + } + + // Cancel interrupt handler + interruptHandler.cancel() + + if wasInterrupted { + // Handle interrupt - show menu and let user decide + handleInterrupt(pomodoro: pomodoro, timerViewModel: timerViewModel) + return } + + // Normal completion outputLine(for: 1.0) output.write(string: "\nPomodoro ended\n") } @@ -51,6 +92,63 @@ public class TimerViewCLI { exit(EXIT_SUCCESS) } + // MARK: - Interrupt Handling + + /// Handles an interrupt by showing a menu and processing user choice + private func handleInterrupt(pomodoro: PomodoroDescription, timerViewModel: TimerViewModel) { + // Clear the current line + output.write(string: "\n") + + // Show menu + output.write(string: "\nPomodoro interrupted!\n") + output.write(string: "1) Exit without saving\n") + output.write(string: "2) Save shortened pomodoro\n") + output.write(string: "Choose (1 or 2): ") + + // Read user choice + guard let input = readLine()?.trimmingCharacters(in: .whitespaces) else { + exit(0) + } + + switch input { + case "1": + // Exit without saving + exit(0) + + case "2": + // Save shortened pomodoro + saveShortened(pomodoro: pomodoro, timerViewModel: timerViewModel) + + default: + // Invalid input - show error and try again + output.write(string: "Invalid choice. Please enter 1 or 2.\n") + handleInterrupt(pomodoro: pomodoro, timerViewModel: timerViewModel) + } + } + + /// Saves a shortened pomodoro with actual elapsed time + private func saveShortened(pomodoro: PomodoroDescription, timerViewModel: TimerViewModel) { + // Calculate actual elapsed time + let actualDuration = Date().timeIntervalSince(timerViewModel.outputs.startDate) + + // Create new pomodoro description with actual duration + let shortenedPomodoro = PomodoroDescription( + startDate: timerViewModel.outputs.startDate, + duration: actualDuration, + message: pomodoro.message + ) + + output.write(string: "\nSaving shortened pomodoro (actual duration: \(Int(actualDuration))s)\n") + + // Execute didFinish hook with actual times + Hook.didFinish.execute(description: shortenedPomodoro, completionHandler: hookCompletionHandler) + + // Write to journal with actual times + LogWriter().writeLog(pomodoroDescription: shortenedPomodoro) + + exit(EXIT_SUCCESS) + } + private func outputLine(for fractionCompleted: Double) { let completedChars = Int(fractionCompleted * Double(outputLength)) let remainingChars = outputLength - completedChars @@ -59,6 +157,23 @@ public class TimerViewCLI { output.write(string: "[\(completedString)\(remainingString)]\r") } + /// Displays elapsed time for indefinite pomodoros + private func outputElapsedTime(elapsed: TimeInterval) { + let hours = Int(elapsed) / 3600 + let minutes = (Int(elapsed) % 3600) / 60 + let seconds = Int(elapsed) % 60 + + let timeString = if hours > 0 { + String(format: "%dh %dm %ds", hours, minutes, seconds) + } else if minutes > 0 { + String(format: "%dm %ds", minutes, seconds) + } else { + String(format: "%ds", seconds) + } + + output.write(string: "Running: \(timeString)...\r") + } + private func hookCompletionHandler(result: Result) { if case let .failure(error) = result { switch error { diff --git a/Sources/PomodoroCLI/RootCommand.swift b/Sources/PomodoroCLI/RootCommand.swift index de7615d..2ec3b59 100644 --- a/Sources/PomodoroCLI/RootCommand.swift +++ b/Sources/PomodoroCLI/RootCommand.swift @@ -14,10 +14,21 @@ struct PomodoroCLI: ParsableCommand { @Flag(name: .shortAndLong, help: "Exit right away (escape-hatch to run didFinish hook only)") var catchUp: Bool = false + @Flag(name: .long, help: "Run pomodoro indefinitely until interrupted (for meetings with unknown duration)") + var indefinite: Bool = false + func run() throws { - guard let durationAsTimeInterval = TimeIntervalFormatter().timeInterval(from: duration) else { - CLIUtils.write(message: "Invalid duration: \(duration)") - return + let durationAsTimeInterval: TimeInterval + + if indefinite { + // Use infinity to represent indefinite duration + durationAsTimeInterval = TimeInterval.infinity + } else { + guard let parsedDuration = TimeIntervalFormatter().timeInterval(from: duration) else { + CLIUtils.write(message: "Invalid duration: \(duration)") + return + } + durationAsTimeInterval = parsedDuration } let pomodoroMessage: String From 70fe9d775e599b4f325832217500802cf4ec5442 Mon Sep 17 00:00:00 2001 From: Mick F Date: Tue, 18 Nov 2025 09:35:16 +0100 Subject: [PATCH 2/2] docs: update README --- README.md | 118 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 107 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 1bf00d3..934b6e9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Pomodoro is a command-line interface to run timers from your terminal for people using the [Pomodoro Technique](https://en.wikipedia.org/wiki/Pomodoro_Technique). -I started this project in 2017, as a pet project to learn how to write a CLI in [Swift](https://swift.org). I never stopped writing CLI tools from then. +[I started this project in 2017][blog-post], as a pet project to learn how to write a CLI in [Swift](https://swift.org). I never stopped writing command-line tools from then.
pomodoro-cli usage example @@ -11,26 +11,122 @@ I started this project in 2017, as a pet project to learn how to write a CLI in ## Usage -``` -➜ Usage: pomodoro-cli [options] +### Basic Examples + +```bash +# Standard 25-minute pomodoro +pomodoro-cli -m "Write documentation" + +# Custom duration (5 minutes) +pomodoro-cli -d 5m -m "Quick review" -CLI pomodoro +# Duration in seconds +pomodoro-cli -d 300 -m "Five minute break" -Options: - -d, --duration The duration of the pomodoro in seconds (100) or in minutes (10m) (default to 25m) - -h, --help Show help information - -m, --message The intent of the pomodoro (example: email zero) +# Indefinite pomodoro (for meetings with unknown duration) +pomodoro-cli --indefinite -m "Team meeting" + +# Catch-up mode (log a pomodoro that already happened) +pomodoro-cli --catch-up -m "Forgot to log earlier work" ``` +### Available Options + +| Option | Description | Default | +| ------------------------ | ------------------------------------------------- | ----------------------- | +| `-d, --duration ` | Duration in seconds (100) or minutes (10m) | 25m | +| `-m, --message ` | Description of the pomodoro's intent | Prompts if not provided | +| `--indefinite` | Run indefinitely until interrupted (for meetings) | Off | +| `--catch-up` | Exit immediately, only run finish hook | Off | +| `-h, --help` | Show help information | - | + +## Features + +### Interrupt Handling + +Press **Ctrl+C** during a running pomodoro to see an interactive menu: + +1. **Exit without saving** - Abandon the pomodoro (no hooks, no journal entry) +2. **Save shortened pomodoro** - Record the actual time worked with hooks and journal + +**Double Ctrl+C** forces immediate exit without any prompts. + +**Use case**: Perfect for when emergencies arise or priorities shift mid-pomodoro. + +### Indefinite Mode + +Use `--indefinite` for meetings or tasks with unknown duration: + +- Timer shows elapsed time: `Running: 5m 23s...` (updates every second) +- No progress bar (since there's no known end time) +- Must be stopped with Ctrl+C to finish +- When saved, records actual duration in journal and calls hooks with real times + +**Use case**: Ideal for meetings, pair programming sessions, or any task where you don't know the duration upfront. + ## Hooks Pomodoro can optionally run shell scripts when a pomodoro starts and/or finishes. +### Hook Locations + +- **Start hook**: `~/.pomodoro-cli/pomodoro-start.sh` +- **Finish hook**: `~/.pomodoro-cli/pomodoro-finish.sh` + +### Hook Parameters + +Both hooks receive 4 arguments: + +| Parameter | Description | Example | +| --------- | ---------------------------------- | -------------------------- | +| `$1` | Start date (ISO8601 format) | `2025-11-17T10:30:00.123Z` | +| `$2` | End date (ISO8601 or "indefinite") | `2025-11-17T10:55:00.123Z` | +| `$3` | Duration (seconds or "indefinite") | `1500.0` | +| `$4` | Message | `Write documentation` | + +**Note**: For indefinite pomodoros, the start hook receives `"indefinite"` for end date and duration. The finish hook always receives actual values. + +### Example Hook + +```bash +#!/usr/bin/env bash +# ~/.pomodoro-cli/pomodoro-finish.sh + +START_DATE=$1 +END_DATE=$2 +DURATION=$3 +MESSAGE=$4 + +# Send a notification +osascript -e "display notification \"$MESSAGE\" with title \"Pomodoro Complete!\"" + +# Log to a custom file +echo "$(date): Completed pomodoro - $MESSAGE ($DURATION seconds)" >> ~/pomodoro-log.txt +``` + Sample scripts can be found in [the `SampleHooks` directory](https://github.com/dirtyhenry/pomodoro-cli/blob/main/Resources/SampleHooks). ## Journal -A journal of pomodoros is created in `~/.pomodoro-cli/journal.yml`. +A journal of all completed pomodoros is automatically maintained at `~/.pomodoro-cli/journal.yml`. + +### Format + +The journal uses YAML format with the following structure: + +```yaml +- - startDate: 11/17/25, 10:30:15 AM + - endDate: 11/17/25, 10:55:15 AM + - message: Write documentation +- - startDate: 11/17/25, 11:00:42 AM + - endDate: 11/17/25, 11:05:42 AM + - message: Quick review +- - startDate: 11/17/25, 2:15:30 PM + - endDate: 11/17/25, 2:47:18 PM + - message: Team meeting +``` + +**Note**: Duration can be calculated from the difference between start and end dates. Shortened and indefinite pomodoros record their actual elapsed time. ## Installation @@ -38,7 +134,7 @@ A journal of pomodoros is created in `~/.pomodoro-cli/journal.yml`. To install from sources, [Swift](https://swift.org/getting-started/) is required. -Installing `swiftlint` and `swiftformat` via [Homebrew](https://brew.sh/), and having installed [Ruby](https://www.ruby-lang.org/fr/)/[Bundler](https://bundler.io) are recommended for an easy installation. +Installing `swiftlint` and `swiftformat` via [Homebrew](https://brew.sh/) is recommended for an easy installation. - `make install` will install development dependencies; - `make deploy` will build a release binary, move it to `/usr/local/bin` by default, with default hooks installed; @@ -49,7 +145,7 @@ Check out [`Makefile`](https://github.com/dirtyhenry/pomodoro-cli/blob/main/Make The `.dmg` files are created via: -``` +```bash make clean notarize # and upon successful feedback from the Apple notary service: make image