Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,46 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [3.8.0] - 2026-06-15

### Added

- **Crash recovery — resume an interrupted session (`claude --resume` style).**
A live dashboard session is now journaled to disk
(`~/.local/share/flowclock/active-session.json`) on start, on every control
change, and on a ~5s heartbeat. If the process is killed instead of stopped
cleanly — a freeze, a hard reset, an OOM — the next launch detects the orphaned
journal and opens a **"Resume previous session?"** overlay showing the recovered
focus, break, goal and details. **`[r]`** (or `[Enter]`) resumes; **`[d]`** /
**`[Esc]`** discards. Recovery is conservative: it restores exactly to the last
heartbeat and never invents focus time for the frozen gap. The journal is
removed on any clean stop or cancel.
- **Cancel a session without saving — `[x]`.** While a session runs, **`[x]`**
opens a confirm overlay and discards the session entirely (no record written,
journal cleared) — for test/throwaway sessions, instead of save-then-delete.
`[q]` still stops **and saves**.
- **Two new break categories — `coffee` and `sleep`.**
- **Break-category picker overlay — `[m]`.** Number keys **1–6** stay as quick
categories; **`[m]`** opens a picker listing **all** categories (incl. coffee
and sleep) so the footer stays uncluttered as the set grows. `↑↓`/`j`/`k`
select, `1–8` pick directly, `Enter` chooses, `Esc` cancels.

### Changed

- **"Name" → "Details" everywhere.** The session label is now surfaced as
**Details** in the new-session form, the edit overlay, the session detail view
and help — the field never appeared as "Name" in stored data, and "Details"
matches how it is actually used. CLI: `start`, `log` and `edit` now take
**`--details <text>`**; the old **`--label`** / **`--name`** flags keep working
as hidden deprecated aliases (no workflow breaks). `flowclock manifest --json`
advertises `--details`.
- **Text fields are real line editors.** Every text input in the TUI (goal,
details, target, break budget, and the edit overlay) now supports a moving
cursor (**← → Home End**), **insert/delete at the cursor** (Backspace/Del), and
**paste** — including multi-line clipboard content, which is flattened to a
single line. Previously a pasted string kept only its first character.
- The `[c]` break-category cycle now spans all categories (incl. coffee/sleep).

## [3.7.0] - 2026-06-12

### Added
Expand Down
59 changes: 52 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,15 @@ Live session controls inside the dashboard:
| --- | ------ |
| `p` | Pause / resume |
| `b` | Start / end a break |
| `1`–`6` | Pick break category (rest · meal · exercise · walk · distraction · other) |
| `1`–`6` | Pick a quick break category (rest · meal · exercise · walk · distraction · other) |
| `m` | Open the break-category picker — **all** categories, incl. **coffee** and **sleep** |
| `x` | **Cancel** the session (discard without saving — confirmed first) |
| `r` | Reset the session clock |
| `q` | Stop & save the session (a summary shows, then the Session view) |
| `q` | Stop & **save** the session (a summary shows, then the Session view) |

`q` saves, `x` discards. The full category set is `rest · meal · exercise ·
walk · distraction · other · coffee · sleep`; keys `1`–`6` stay bound to the
first six so the footer stays short, and `[m]` reaches the rest.

### Starting a session — the in-dashboard form

Expand All @@ -222,7 +228,7 @@ palette) to open a centered form:
```
╔══════════════════ New session ══════════════════╗
║ › Goal Deep work — StreamNet▏ ║
Name
Details
║ Target 1h ║
║ Break budget 20m ║
║ ║
Expand All @@ -243,6 +249,44 @@ The shell entry point still works and behaves identically:
flowclock start --goal "Deep work — StreamNet" --target 1h --break-budget 20m
```

Every text field is a real editor: move with **← →**, **Home**/**End**, delete
around the cursor with **Backspace**/**Del**, and **paste** clipboard text
(multi-line pastes are flattened to one line). The same applies to the edit
overlay (`[e]` on the Sessions view).

### Cancelling a session — `[x]` (discard) vs `[q]` (save)

For a throwaway or test session you don't want recorded, press **`x`**. A
confirm overlay appears (`[y]` / `[n]`); confirming discards the session
entirely — nothing is written and the recovery journal is cleared. Use **`q`**
when you want to stop **and save**.

### Crash recovery — resume an interrupted session

Hardware froze? Hard reset? While a session runs, FlowClock journals it to
`~/.local/share/flowclock/active-session.json` (on start, on each control
change, and on a ~5-second heartbeat). The next time you open the dashboard, an
orphaned journal triggers a resume prompt:

```
╔═══════════ Resume previous session? ════════════╗
║ A session was interrupted: ║
║ Deep work — StreamNet ║
║ details: CKIS Backup Session ║
║ ║
║ focus 3h 12m ║
║ break 0s ║
║ ║
║ [r] resume · [d] discard · [Esc] discard ║
╚══════════════════════════════════════════════════╝
```

**`[r]`** (or `Enter`) rebuilds the session and keeps going; **`[d]`** / `Esc`
discards it. Recovery is conservative — it restores to the **last heartbeat**
and never counts the frozen gap as focus, so at most a few seconds are lost. A
clean stop (`q`) or cancel (`x`) removes the journal, so you are only ever
prompted after a real crash.

### Display style — `block` / `simple` / `outline` / `minimal` / `classic` / `bold` (cycle live)

The Session counter has six looks, all using the **same reserve-first scaling**
Expand Down Expand Up @@ -372,8 +416,8 @@ Every interactive flow has a non-interactive equivalent, so terminal AI agents

```bash
flowclock log --duration 3000 --goal "Deep work" \
--target 1h --break-budget 20m --json # record a full session
flowclock edit <id> --focus 90m --json # fix a session that ran while you slept
--details "CKIS backup" --target 1h --break-budget 20m --json # record a full session
flowclock edit <id> --focus 90m --details "renamed" --json # fix a session that ran while you slept
flowclock dashboard --json | jq .data.game # the whole snapshot, no TTY
flowclock stats --json | jq .data.game.flowScore # compose with pipes
echo "$SESSION_JSON" | flowclock log --json # accept session JSON on stdin
Expand Down Expand Up @@ -425,7 +469,7 @@ on read.
## Data model

A session records **active focus seconds** (`durationS`) plus categorized
**breaks** (each with `category` ∈ `rest·meal·exercise·walk·distraction·other`),
**breaks** (each with `category` ∈ `rest·meal·exercise·walk·distraction·other·coffee·sleep`),
the total `breakS`, and the optional `focusTargetS` / `breakBudgetS` you set. The
on-disk schema is **v3**; migrations are non-destructive.

Expand All @@ -446,7 +490,8 @@ on-disk schema is **v3**; migrations are non-destructive.
| **v3.5.0** ✅ | `classic`/`bold` redesigned as 5-row shade weights (`▒`/`▓`) for exact footprint parity with all styles — no more towering, goal covering, or silent fallback to `block` |
| **v3.5.1** ✅ | `classic` lightened to `░` shade so block `█` / classic `░` / bold `▓` read as three distinct surfaces while keeping exact 5-row footprint parity |
| **v3.6.0** ✅ | `classic`/`bold` redesigned as native 5-row fonts with distinct glyph shapes (cornered `classic` / heavy-slab `bold`, both solid) — replaces the v3.5.x shade-fill approach so the three solid styles read as clearly distinct while keeping exact footprint parity |
| **v3.7.0** ✅ | Session editing — in-dashboard `[e]` overlay (view 3 · Sessions) and `flowclock edit <id>` CLI; editable fields: focus, break total, goal, name; start immutable; end + break timeline recompute automatically |
| **v3.7.0** ✅ | Session editing — in-dashboard `[e]` overlay (view 3 · Sessions) and `flowclock edit <id>` CLI; editable fields: focus, break total, goal, details; start immutable; end + break timeline recompute automatically |
| **v3.8.0** ✅ | Crash recovery (resume an interrupted session); `[x]` cancel-without-saving; `coffee` + `sleep` break categories with an `[m]` picker; full line editors (cursor + paste) in every text field; "Name" → "Details" (CLI `--details`, `--name`/`--label` kept as aliases) |
| **next** | `flowclock sync` — push `sessions.json` to a self-hosted/cloud endpoint; recurring goals; dashboard filters |
| **later** | Per-goal analytics deep-dives, calendar heatmap, Homebrew tap |

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "flowclock-cli",
"version": "3.7.0",
"version": "3.8.0",
"description": "Flowtime count-up terminal timer (HUD, not Pomodoro) with silent session logging — agent-native, like gh and vercel.",
"type": "module",
"license": "AGPL-3.0-or-later",
Expand Down
24 changes: 16 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Command, InvalidArgumentError } from "commander";
import { Command, InvalidArgumentError, Option } from "commander";
import { VERSION } from "./version.js";
import {
buildContext,
Expand Down Expand Up @@ -93,7 +93,8 @@ export function buildProgram(): Command {
"auto-stop after N active seconds",
toInt,
)
.option("--label <text>", "attach a label to the session")
.option("--details <text>", "extra details for this session")
.addOption(new Option("--label <text>", "deprecated alias for --details").hideHelp())
.option("--goal <text>", "name the goal/intention for this session")
.option("--theme <name>", "theme override: neon|amber|blue|mono")
.option("--big", "render the time in big ASCII (7-segment)")
Expand Down Expand Up @@ -125,7 +126,7 @@ export function buildProgram(): Command {

return runStart(ctx, {
duration: opts.duration,
label: opts.label,
label: (opts.details ?? opts.label) as string | undefined,
goal: opts.goal,
theme: opts.theme,
big: opts.big,
Expand All @@ -149,7 +150,8 @@ export function buildProgram(): Command {
.option("-d, --duration <seconds>", "active seconds", toInt)
.option("--start <iso>", "ISO-8601 start time")
.option("--end <iso>", "ISO-8601 end time")
.option("--label <text>", "session label")
.option("--details <text>", "session details")
.addOption(new Option("--label <text>", "deprecated alias for --details").hideHelp())
.option("--note <text>", "session note")
.option("--tags <csv>", "comma-separated tags")
.option("--goal <text>", "goal/intention for this session")
Expand Down Expand Up @@ -177,7 +179,12 @@ export function buildProgram(): Command {
}
}

return runLog(ctx, { ...opts, focusTargetS, breakBudgetS });
return runLog(ctx, {
...opts,
label: (opts.details ?? opts.label) as string | undefined,
focusTargetS,
breakBudgetS,
});
}),
),
);
Expand All @@ -187,13 +194,14 @@ export function buildProgram(): Command {
program
.command("edit")
.description(
"edit a logged session (focus/break/goal/name); end + timeline recompute automatically",
"edit a logged session (focus/break/goal/details); end + timeline recompute automatically",
)
.argument("<id>", "session id (or a unique id prefix)")
.option("--focus <dur>", "new active focus time, e.g. 1h30m, 90m, 45s")
.option("--break <dur>", "new total break time, e.g. 20m (0 clears breaks)")
.option("--goal <text>", "new goal/intention (empty string clears)")
.option("--name <text>", "new session name/label (empty string clears)")
.option("--details <text>", "new session details (empty string clears)")
.addOption(new Option("--name <text>", "deprecated alias for --details").hideHelp())
.action((id: string, opts, cmd: Command) =>
guard("edit", cmd, (ctx) => {
let focusS: number | undefined;
Expand All @@ -218,7 +226,7 @@ export function buildProgram(): Command {
focusS,
breakS,
goal: opts.goal as string | undefined,
name: opts.name as string | undefined,
name: (opts.details ?? opts.name) as string | undefined,
});
}),
),
Expand Down
16 changes: 7 additions & 9 deletions src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import { jsonSuccess, printJson } from "../lib/output.js";
import { ExitCode, fail } from "../lib/exit.js";
import { humanDuration } from "../lib/format.js";
import { suggestBreakS } from "../lib/flowtime.js";
import type { Session, SessionSource, BreakCategory } from "../schemas/session.js";
import {
QUICK_BREAK_CATEGORIES,
type Session,
type SessionSource,
type BreakCategory,
} from "../schemas/session.js";
import { ThemeNameSchema, type ThemeName } from "../schemas/config.js";
import { runDashboardApp } from "../tui/app.js";

Expand Down Expand Up @@ -64,14 +69,7 @@ export function shouldUseDashboard(
const TICK_MS = 100; // matches flowtime.sh `sleep 0.1`

/** Ordered break categories — digit keys 1..6 map to this array. */
const BREAK_CATEGORIES: BreakCategory[] = [
"rest",
"meal",
"exercise",
"walk",
"distraction",
"other",
];
const BREAK_CATEGORIES: readonly BreakCategory[] = QUICK_BREAK_CATEGORIES;

export async function runStart(
ctx: CommandContext,
Expand Down
2 changes: 2 additions & 0 deletions src/lib/hud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,8 @@ const BREAK_CATEGORY_LABELS: Record<BreakCategory, string> = {
walk: "walk",
distraction: "distraction",
other: "other",
coffee: "coffee",
sleep: "sleep",
};

/**
Expand Down
91 changes: 91 additions & 0 deletions src/lib/journal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Crash-recovery journal for the live dashboard session.
*
* While a session is running we periodically write a snapshot of it to disk
* (focus so far, breaks, goal, details, targets) tagged with a `heartbeat`
* timestamp. On a normal stop/cancel the journal is deleted. If the process is
* killed instead — a freeze, a hard reset, an OOM — the journal survives, and
* the next launch detects the orphan and offers to resume it (à la
* `claude --resume`).
*
* The snapshot reuses the Timer's own `toSession()` serializer, so a recovered
* session reconstructs through the exact same code path a normal one would.
*/

import { existsSync, readFileSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { writeFileAtomic } from "./fsutil.js";
import { sessionsPathFor } from "./config.js";
import type { Config } from "../schemas/config.js";
import { resolvePaths, type FlowclockPaths } from "./paths.js";
import {
BreakCategorySchema,
SessionSchema,
type BreakCategory,
type Session,
} from "../schemas/session.js";
import { z } from "zod";

/** The on-disk active-session record. */
export const ActiveSessionSchema = z.object({
v: z.literal(1),
/** Epoch ms of the last write — how far the recovery is trusted. */
heartbeat: z.number().int().nonnegative(),
/** Whether the timer was on a break at the heartbeat (informational). */
onBreak: z.boolean().default(false),
/** Category of the break in progress at the heartbeat, if any. */
breakCategory: BreakCategorySchema.default("rest"),
/** The session-so-far snapshot (durationS = focus, breaks closed). */
session: SessionSchema,
});
export type ActiveSession = z.infer<typeof ActiveSessionSchema>;

/** Path of the active-session journal, next to sessions.json. */
export function journalPathFor(
config: Config,
paths: FlowclockPaths = resolvePaths(),
): string {
return join(dirname(sessionsPathFor(config, paths)), "active-session.json");
}

export interface JournalInput {
session: Session;
onBreak: boolean;
breakCategory: BreakCategory;
heartbeat?: number;
}

/** Write (or overwrite) the journal atomically. */
export function writeJournal(file: string, input: JournalInput): void {
const rec: ActiveSession = {
v: 1,
heartbeat: input.heartbeat ?? Date.now(),
onBreak: input.onBreak,
breakCategory: input.breakCategory,
session: input.session,
};
writeFileAtomic(file, JSON.stringify(rec) + "\n");
}

/**
* Read and validate the journal. Returns null if it's missing, unreadable, or
* malformed (a corrupt journal must never block startup or crash the app).
*/
export function readJournal(file: string): ActiveSession | null {
if (!existsSync(file)) return null;
try {
const parsed = ActiveSessionSchema.safeParse(JSON.parse(readFileSync(file, "utf8")));
return parsed.success ? parsed.data : null;
} catch {
return null;
}
}

/** Delete the journal. No-op if it's already gone. */
export function clearJournal(file: string): void {
try {
rmSync(file, { force: true });
} catch {
/* best-effort; a stale journal is harmless and re-offered next launch */
}
}
Loading
Loading