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.

@@ -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
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