diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dd32b1..4895c9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `**; 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 diff --git a/README.md b/README.md index 020a21d..c3337ad 100644 --- a/README.md +++ b/README.md @@ -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 @@ -222,7 +228,7 @@ palette) to open a centered form: ``` ╔══════════════════ New session ══════════════════╗ ║ › Goal Deep work — StreamNet▏ ║ - ║ Name ║ + ║ Details ║ ║ Target 1h ║ ║ Break budget 20m ║ ║ ║ @@ -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** @@ -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 --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 --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 @@ -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. @@ -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 ` 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 ` 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 | diff --git a/package.json b/package.json index 964bf5a..b43393f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli.ts b/src/cli.ts index b35fa41..9f7910a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,4 @@ -import { Command, InvalidArgumentError } from "commander"; +import { Command, InvalidArgumentError, Option } from "commander"; import { VERSION } from "./version.js"; import { buildContext, @@ -93,7 +93,8 @@ export function buildProgram(): Command { "auto-stop after N active seconds", toInt, ) - .option("--label ", "attach a label to the session") + .option("--details ", "extra details for this session") + .addOption(new Option("--label ", "deprecated alias for --details").hideHelp()) .option("--goal ", "name the goal/intention for this session") .option("--theme ", "theme override: neon|amber|blue|mono") .option("--big", "render the time in big ASCII (7-segment)") @@ -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, @@ -149,7 +150,8 @@ export function buildProgram(): Command { .option("-d, --duration ", "active seconds", toInt) .option("--start ", "ISO-8601 start time") .option("--end ", "ISO-8601 end time") - .option("--label ", "session label") + .option("--details ", "session details") + .addOption(new Option("--label ", "deprecated alias for --details").hideHelp()) .option("--note ", "session note") .option("--tags ", "comma-separated tags") .option("--goal ", "goal/intention for this session") @@ -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, + }); }), ), ); @@ -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("", "session id (or a unique id prefix)") .option("--focus ", "new active focus time, e.g. 1h30m, 90m, 45s") .option("--break ", "new total break time, e.g. 20m (0 clears breaks)") .option("--goal ", "new goal/intention (empty string clears)") - .option("--name ", "new session name/label (empty string clears)") + .option("--details ", "new session details (empty string clears)") + .addOption(new Option("--name ", "deprecated alias for --details").hideHelp()) .action((id: string, opts, cmd: Command) => guard("edit", cmd, (ctx) => { let focusS: number | undefined; @@ -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, }); }), ), diff --git a/src/commands/start.ts b/src/commands/start.ts index 2665f5a..8da91aa 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -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"; @@ -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, diff --git a/src/lib/hud.ts b/src/lib/hud.ts index 381687b..e25869c 100644 --- a/src/lib/hud.ts +++ b/src/lib/hud.ts @@ -347,6 +347,8 @@ const BREAK_CATEGORY_LABELS: Record = { walk: "walk", distraction: "distraction", other: "other", + coffee: "coffee", + sleep: "sleep", }; /** diff --git a/src/lib/journal.ts b/src/lib/journal.ts new file mode 100644 index 0000000..c475cf5 --- /dev/null +++ b/src/lib/journal.ts @@ -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; + +/** 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 */ + } +} diff --git a/src/lib/manifest.ts b/src/lib/manifest.ts index 2859c74..dfc2c29 100644 --- a/src/lib/manifest.ts +++ b/src/lib/manifest.ts @@ -99,9 +99,9 @@ export function buildManifest(): Manifest { description: "Auto-stop after N active seconds.", }, { - name: "--label", + name: "--details", type: "string", - description: "Attach a label to the session.", + description: "Extra details for this session (was --label).", }, { name: "--goal", @@ -170,7 +170,7 @@ export function buildManifest(): Manifest { description: "ISO-8601 start time.", }, { name: "--end", type: "string", description: "ISO-8601 end time." }, - { name: "--label", type: "string", description: "Session label." }, + { name: "--details", type: "string", description: "Session details (was --label)." }, { name: "--note", type: "string", description: "Session note." }, { name: "--tags", @@ -199,7 +199,7 @@ export function buildManifest(): Manifest { }, ], jsonData: "The stored Session record (v3 schema).", - examples: ["flowclock log --duration 600 --label deep-work --json"], + examples: ["flowclock log --duration 600 --details deep-work --json"], }, { name: "edit", @@ -223,9 +223,9 @@ export function buildManifest(): Manifest { description: "New goal/intention (empty string clears it).", }, { - name: "--name", + name: "--details", type: "string", - description: "New session name/label (empty string clears it).", + description: "New session details (empty string clears it; was --name).", }, ], jsonData: "The updated Session record (v3 schema) with recomputed end + breaks[].", diff --git a/src/lib/timer.ts b/src/lib/timer.ts index fd1854e..d73730f 100644 --- a/src/lib/timer.ts +++ b/src/lib/timer.ts @@ -42,6 +42,43 @@ export class Timer { this.startMs = startMs ?? this.clock(); } + /** + * Rebuild a *running* timer from a recovered snapshot (crash recovery). + * + * The reconstructed timer's active focus equals `focusS` and its accumulated + * break equals the sum of `breaks` durations, continuing forward from `now`. + * The wall-clock gap between the crash and the resume is intentionally + * discarded — we never invent focus time, so the session picks up exactly + * where the last heartbeat left it. Resumes in focus mode (any break that was + * open at crash time is already folded into `breaks` by the journal writer). + * + * Because the start anchor moves forward by the discarded gap, the recorded + * breaks are shifted by the same delta so they keep their original spacing + * and stay inside the reconstructed [start, now] window — the final record + * is internally consistent (end - start === focus + break). + */ + static fromResume( + focusS: number, + breaks: Break[], + originalStartISO: string, + clock: Clock = Date.now, + ): Timer { + const now = clock(); + const breakMs = breaks.reduce((sum, b) => sum + b.durationS, 0) * 1000; + const startMs = now - Math.max(0, focusS) * 1000 - breakMs; + const delta = startMs - Date.parse(originalStartISO); + const t = new Timer(clock, startMs); + t.totalBreakMs = breakMs; + for (const b of breaks) { + t.breaks.push({ + ...b, + start: new Date(Date.parse(b.start) + delta).toISOString(), + end: new Date(Date.parse(b.end) + delta).toISOString(), + }); + } + return t; + } + get isPaused(): boolean { return this.onBreak; } diff --git a/src/lib/tui/input.ts b/src/lib/tui/input.ts index 85f4f04..022dfe0 100644 --- a/src/lib/tui/input.ts +++ b/src/lib/tui/input.ts @@ -24,8 +24,16 @@ export type Key = | { name: "delete" } | { name: "home" } | { name: "end" } + | { name: "paste"; text: string } | { name: "char"; char: string }; +// Bracketed-paste markers (DEC mode 2004). When enabled, terminals wrap pasted +// text in these so a multi-line paste can't be misread as Enter/keystrokes. +export const PASTE_START = "\x1b[200~"; +export const PASTE_END = "\x1b[201~"; +const PASTE_ON = "\x1b[?2004h"; +const PASTE_OFF = "\x1b[?2004l"; + // --------------------------------------------------------------------------- // Pure key parser // --------------------------------------------------------------------------- @@ -86,6 +94,63 @@ export function parseKey(chunk: string): Key { return { name: "char", char }; } +// Escape-sequence keys, longest first, so `tokenize` matches greedily. +const SEQ_KEYS = Object.keys(SEQUENCES).sort((a, b) => b.length - a.length); + +/** + * Split a raw chunk into a stream of structured Keys. + * + * Unlike `parseKey` (one Key for the whole chunk), this walks the chunk so a + * multi-character data event — fast typing batched by the OS, an arrow-key + * auto-repeat burst, or a plain (non-bracketed) paste — yields one Key per + * logical token instead of silently dropping all but the first character. + * + * Bracketed-paste assembly is handled by the reader (it can span chunks); this + * function assumes `chunk` contains no paste markers. + */ +export function tokenize(chunk: string): Key[] { + const out: Key[] = []; + let i = 0; + while (i < chunk.length) { + // Longest known escape sequence at the cursor wins. + let matched: string | null = null; + for (const seq of SEQ_KEYS) { + if (chunk.startsWith(seq, i)) { + matched = seq; + break; + } + } + if (matched) { + out.push(SEQUENCES[matched]!); + i += matched.length; + continue; + } + const ch = chunk[i]!; + if (ch === "\x1b") { + out.push({ name: "escape" }); + i += 1; + } else if (ch === "\r" || ch === "\n") { + out.push({ name: "enter" }); + i += 1; + } else if (ch === "\t") { + out.push({ name: "tab" }); + i += 1; + } else if (ch === "\x7f") { + out.push({ name: "backspace" }); + i += 1; + } else if (ch === CTRL_C) { + out.push({ name: "char", char: CTRL_C }); + i += 1; + } else { + // Full Unicode code point (handles surrogate pairs / multibyte UTF-8). + const cp = String.fromCodePoint(chunk.codePointAt(i)!); + out.push({ name: "char", char: cp }); + i += cp.length; + } + } + return out; +} + // --------------------------------------------------------------------------- // Raw-mode reader // --------------------------------------------------------------------------- @@ -102,21 +167,57 @@ export function parseKey(chunk: string): Key { export function startNavReader( input: NodeJS.ReadStream, onKey: (k: Key) => void, + output?: NodeJS.WriteStream, ): () => void { const wasRaw = (input as NodeJS.ReadStream & { isRaw?: boolean }).isRaw ?? false; if (input.isTTY) input.setRawMode(true); input.resume(); input.setEncoding("utf8"); + // Ask the terminal to wrap pastes in PASTE_START/PASTE_END so a multi-line + // paste arrives as one event instead of a flood of keystrokes (incl. Enter). + if (output?.isTTY) output.write(PASTE_ON); + + // Buffer for a bracketed paste in progress (may span several data events). + let pasteBuf: string | null = null; - const onData = (chunk: string) => { - onKey(parseKey(chunk)); + const emitPlain = (s: string) => { + if (!s) return; + for (const key of tokenize(s)) onKey(key); }; + const flushPaste = () => { + if (pasteBuf === null) return; + const end = pasteBuf.indexOf(PASTE_END); + if (end === -1) return; // still accumulating + const text = pasteBuf.slice(0, end); + const rest = pasteBuf.slice(end + PASTE_END.length); + pasteBuf = null; + onKey({ name: "paste", text }); + if (rest) onData(rest); + }; + + function onData(chunk: string) { + if (pasteBuf !== null) { + pasteBuf += chunk; + flushPaste(); + return; + } + const start = chunk.indexOf(PASTE_START); + if (start !== -1) { + emitPlain(chunk.slice(0, start)); + pasteBuf = chunk.slice(start + PASTE_START.length); + flushPaste(); + return; + } + emitPlain(chunk); + } + input.on("data", onData); return function stop() { input.off("data", onData); + if (output?.isTTY) output.write(PASTE_OFF); if (input.isTTY) input.setRawMode(wasRaw); input.pause(); }; diff --git a/src/lib/tui/lineedit.ts b/src/lib/tui/lineedit.ts new file mode 100644 index 0000000..4a4b0b3 --- /dev/null +++ b/src/lib/tui/lineedit.ts @@ -0,0 +1,139 @@ +/** + * A tiny, pure single-line text editor model. + * + * Every text field in the TUI (goal, details, target, break budget, …) is a + * one-line input. This module holds the editing logic — cursor position, + * insert-at-cursor, paste, word-free navigation — so the form reducers stay + * thin and the behaviour is unit-testable in isolation. + * + * `cursor` is a code-unit index in `[0, text.length]` pointing *before* the + * character it would insert at. Pure: every function returns a new state. + */ + +import type { Key } from "./input.js"; + +export interface LineState { + text: string; + cursor: number; +} + +/** A fresh line, cursor parked at the end of any seed text. */ +export function lineFrom(text = ""): LineState { + return { text, cursor: text.length }; +} + +const clamp = (n: number, max: number) => Math.max(0, Math.min(n, max)); + +// Code-point-aware stepping so a surrogate pair (emoji) moves/deletes as one +// unit and is never split into a broken half. +function nextBoundary(text: string, i: number): number { + const c = text.charCodeAt(i); + return Math.min(text.length, i + (c >= 0xd800 && c <= 0xdbff ? 2 : 1)); +} +function prevBoundary(text: string, i: number): number { + const c = text.charCodeAt(i - 1); + return Math.max(0, i - (c >= 0xdc00 && c <= 0xdfff ? 2 : 1)); +} + +/** + * Sanitize text for a single-line field: newlines/tabs collapse to a space and + * other C0/C1 control characters are dropped. Keeps printable text (incl. + * multibyte/emoji) intact. Used for pastes and defensively for typed input. + */ +export function sanitizeInline(s: string): string { + let out = ""; + for (const ch of s) { + const code = ch.codePointAt(0)!; + if (ch === "\n" || ch === "\r" || ch === "\t") out += " "; + else if (code < 0x20 || code === 0x7f) continue; // drop control chars + else out += ch; + } + return out; +} + +/** Insert sanitized text at the cursor and advance past it. */ +export function insert(state: LineState, raw: string): LineState { + const ins = sanitizeInline(raw); + if (!ins) return state; + const at = clamp(state.cursor, state.text.length); + return { + text: state.text.slice(0, at) + ins + state.text.slice(at), + cursor: at + ins.length, + }; +} + +/** Delete the character before the cursor (Backspace). */ +export function backspace(state: LineState): LineState { + const at = clamp(state.cursor, state.text.length); + if (at === 0) return state; + const prev = prevBoundary(state.text, at); + return { text: state.text.slice(0, prev) + state.text.slice(at), cursor: prev }; +} + +/** Delete the character at the cursor (Delete / Supr). */ +export function deleteForward(state: LineState): LineState { + const at = clamp(state.cursor, state.text.length); + if (at >= state.text.length) return state; + const next = nextBoundary(state.text, at); + return { text: state.text.slice(0, at) + state.text.slice(next), cursor: at }; +} + +export function left(state: LineState): LineState { + const at = clamp(state.cursor, state.text.length); + return { ...state, cursor: prevBoundary(state.text, at) }; +} +export function right(state: LineState): LineState { + const at = clamp(state.cursor, state.text.length); + return { ...state, cursor: nextBoundary(state.text, at) }; +} +export function home(state: LineState): LineState { + return { ...state, cursor: 0 }; +} +export function end(state: LineState): LineState { + return { ...state, cursor: state.text.length }; +} + +/** + * Render `text` with a visible cursor bar inserted before the character at + * `cursor` (or at the end). The bar sits between cells so it reads like a + * caret without overwriting a character. + */ +export function withCursor(text: string, cursor: number, glyph = "▏"): string { + const at = clamp(cursor, text.length); + return text.slice(0, at) + glyph + text.slice(at); +} + +/** + * Apply a Key to the line. Returns `{ state, handled }`: `handled` is false for + * keys this editor doesn't own (tab/enter/escape/up/down) so the caller's form + * reducer can act on them. Ctrl-C is never consumed. + */ +export function lineApplyKey( + state: LineState, + key: Key, +): { state: LineState; handled: boolean } { + switch (key.name) { + case "char": { + const ch = key.char; + const code = ch.codePointAt(0) ?? 0; + if (code < 0x20 || ch === "\x03") return { state, handled: false }; // Ctrl-C etc. + return { state: insert(state, ch), handled: true }; + } + case "paste": + return { state: insert(state, key.text), handled: true }; + case "backspace": + return { state: backspace(state), handled: true }; + case "delete": + return { state: deleteForward(state), handled: true }; + case "left": + return { state: left(state), handled: true }; + case "right": + return { state: right(state), handled: true }; + case "home": + return { state: home(state), handled: true }; + case "end": + return { state: end(state), handled: true }; + default: + return { state, handled: false }; + } +} diff --git a/src/schemas/session.ts b/src/schemas/session.ts index 7843d37..6591ae5 100644 --- a/src/schemas/session.ts +++ b/src/schemas/session.ts @@ -37,9 +37,23 @@ export const BreakCategorySchema = z.enum([ "walk", "distraction", "other", + "coffee", + "sleep", ]); export type BreakCategory = z.infer; +/** Every break category, in canonical order (single source of truth). */ +export const ALL_BREAK_CATEGORIES: readonly BreakCategory[] = + BreakCategorySchema.options; + +/** + * The six categories bound to number keys 1–6 in the live session footer. The + * rest (coffee, sleep, …) are reachable through the break-category picker so the + * footer stays uncluttered as the list grows. + */ +export const QUICK_BREAK_CATEGORIES: readonly BreakCategory[] = + BreakCategorySchema.options.slice(0, 6); + /** A single break interval within a session. */ export const BreakSchema = z.object({ start: z.string().datetime(), diff --git a/src/tui/app.ts b/src/tui/app.ts index 337c698..800ece5 100644 --- a/src/tui/app.ts +++ b/src/tui/app.ts @@ -11,6 +11,7 @@ import type { CommandContext } from "../lib/context.js"; import type { Session } from "../schemas/session.js"; import type { ThemeName, DisplayStyle } from "../schemas/config.js"; +import { ALL_BREAK_CATEGORIES, QUICK_BREAK_CATEGORIES } from "../schemas/session.js"; import type { BreakCategory } from "../schemas/session.js"; import { buildSnapshot } from "../lib/snapshot.js"; import { readSessions, appendSession, deleteSession, updateSession } from "../lib/session.js"; @@ -59,6 +60,27 @@ import { renderEditForm, } from "./editform.js"; import type { EditFormState, EditFormValues } from "./editform.js"; +import { + emptyBreakPickerState, + openBreakPickerState, + breakPickerApplyKey, + renderBreakPicker, +} from "./breakpicker.js"; +import type { BreakPickerState } from "./breakpicker.js"; +import { + emptyResumeState, + openResumeState, + resumeApplyKey, + renderResume, +} from "./resume.js"; +import type { ResumeState } from "./resume.js"; +import { + journalPathFor, + writeJournal, + readJournal, + clearJournal, +} from "../lib/journal.js"; +import type { ActiveSession } from "../lib/journal.js"; // --------------------------------------------------------------------------- // Types @@ -77,15 +99,9 @@ const VIEW_LABELS: Record = { help: "6:Help", }; -/** Ordered break categories — digit keys 1..6 map to this array. */ -const BREAK_CATEGORIES: BreakCategory[] = [ - "rest", - "meal", - "exercise", - "walk", - "distraction", - "other", -]; +/** Ordered break categories — digit keys 1..6 map to this array. The full set + * (incl. coffee/sleep) is reachable via the break-category picker ([m]). */ +const BREAK_CATEGORIES: readonly BreakCategory[] = QUICK_BREAK_CATEGORIES; /** All available themes in cycle order. */ const THEMES: ThemeName[] = ["neon", "amber", "blue", "mono"]; @@ -95,6 +111,9 @@ const DISPLAY_STYLES: DisplayStyle[] = ["block", "simple", "outline", "minimal", const CTRL_C = "\x03"; +/** Confirm-modal payload sentinel: discard the live session without saving. */ +const CANCEL_LIVE = "__cancel_live__"; + // --------------------------------------------------------------------------- // Live session + summary state // --------------------------------------------------------------------------- @@ -125,6 +144,10 @@ interface AppState { form: SessionFormState; edit: EditFormState; confirm: ConfirmState; + /** Break-category picker overlay ([m] during a live session). */ + breakpicker: BreakPickerState; + /** Resume-previous-session overlay (shown at launch after a crash). */ + resume: ResumeState; summary: SummaryState | null; theme: ThemeName; displayStyle: DisplayStyle; @@ -346,7 +369,9 @@ export function buildFrame( // longer renders its own, so they never duplicate. `z` toggles zen (panel // metadata) and Enter toggles this control row's visibility. let footerHints: string; - if (state.summary) { + if (state.resume.open) { + footerHints = "[r] resume · [d] discard · [Esc] discard"; + } else if (state.summary) { if (state.summary.askGoal) { footerHints = "[y] met · [n] missed · [any key] dismiss"; } else { @@ -354,6 +379,8 @@ export function buildFrame( } } else if (state.confirm.open) { footerHints = "[y] confirm · [n] cancel"; + } else if (state.breakpicker.open) { + footerHints = "[↑↓] select · [1-8] pick · [Enter] choose · [Esc] cancel"; } else if (state.form.open) { footerHints = "[Tab] next field · [Enter] start · [Esc] cancel"; } else if (state.edit.open) { @@ -367,9 +394,9 @@ export function buildFrame( } else { const kb = ctx.config.keybindings; if (state.live.timer.isOnBreak) { - footerHints = `[1]rest [2]meal [3]exercise [4]walk [5]distraction [6]other · [${kb.break}] resume · [Enter] hide`; + footerHints = `[1-6] cat · [m] more · [${kb.break}] resume · [Enter] hide`; } else { - footerHints = `[${kb.pause}] pause · [${kb.break}] break · [1-6] cat · [${kb.reset}] reset · [z] zen · [Enter] hide · [${kb.quit}] stop · [Tab] views`; + footerHints = `[${kb.pause}] pause · [${kb.break}] break · [1-6]/[m] cat · [x] cancel · [z] zen · [Enter] hide · [${kb.quit}] stop · [Tab] views`; } } } else if (state.live) { @@ -387,6 +414,12 @@ export function buildFrame( const baseFrame = frame.slice(0, rows); // ── OVERLAYS ────────────────────────────────────────────────────────────── + // Resume overlay (crash recovery) — highest priority, shown at launch. + if (state.resume.open) { + const resumeOverlay = renderResume(state.resume, cols, rows, theme, color); + return compositeOverlay(baseFrame, resumeOverlay, cols); + } + // Summary overlay (end-of-session) if (state.summary) { const summaryOverlay = buildSummaryOverlay(state.summary, cols, rows, theme, color); @@ -411,6 +444,12 @@ export function buildFrame( return compositeOverlay(baseFrame, editOverlay, cols); } + // Break-category picker overlay (during a live session) + if (state.breakpicker.open) { + const pickerOverlay = renderBreakPicker(state.breakpicker, cols, rows, theme, color); + return compositeOverlay(baseFrame, pickerOverlay, cols); + } + // Palette overlay if (state.palette.open) { const paletteOverlay = renderPalette(state.palette, cols, rows, theme, color); @@ -499,6 +538,8 @@ export async function runDashboardApp( form: emptySessionFormState(), edit: emptyEditFormState(), confirm: emptyConfirmState(), + breakpicker: emptyBreakPickerState(), + resume: emptyResumeState(), summary: null, theme: ctx.config.theme, displayStyle: ctx.config.displayStyle, @@ -506,6 +547,12 @@ export async function runDashboardApp( hideControls: false, }; + const journalFile = journalPathFor(ctx.config, ctx.paths); + /** The recovered journal record, held until the user resumes or discards. */ + let pendingResumeRec: ActiveSession | null = null; + /** Epoch ms of the last journal write (throttle the per-tick heartbeat). */ + let lastJournalMs = 0; + // If a pending session is provided, start it immediately if (opts.pendingSession) { const ps = opts.pendingSession; @@ -519,6 +566,19 @@ export async function runDashboardApp( }; state.view = "session"; state.theme = ps.theme ?? ctx.config.theme; + } else { + // No new session requested — offer to resume a prior one left by a crash. + const rec = readJournal(journalFile); + if (rec) { + pendingResumeRec = rec; + state.resume = openResumeState({ + goal: rec.session.goal, + label: rec.session.label, + focusS: rec.session.durationS, + breakS: rec.session.breakS, + heartbeatISO: new Date(rec.heartbeat).toISOString(), + }); + } } const screen = new Screen(process.stdout); @@ -540,7 +600,7 @@ export async function runDashboardApp( /** Start the 100ms tick interval. Guard against double-start. */ function startTick() { if (tickInterval !== null) return; - tickInterval = setInterval(render, 100); + tickInterval = setInterval(tick, 100); } /** Stop the tick interval. */ @@ -551,6 +611,75 @@ export async function runDashboardApp( } } + /** + * Snapshot the live session to the crash-recovery journal. The snapshot + * reuses Timer.toSession so a recovered session reconstructs through the + * exact same path a normal one would. Best-effort: a write failure must + * never disrupt the session. + */ + function writeLiveJournal(): void { + if (!state.live) return; + const t = state.live.timer; + const session = t.toSession({ + source: "hud", + goal: state.live.goal ?? undefined, + label: state.live.label ?? undefined, + focusTargetS: state.live.focusTargetS ?? undefined, + breakBudgetS: state.live.breakBudgetS ?? undefined, + }); + try { + writeJournal(journalFile, { + session, + onBreak: t.isOnBreak, + breakCategory: t.currentBreakCategory, + }); + } catch { + /* best-effort: a missed heartbeat just shortens recovery coverage */ + } + lastJournalMs = Date.now(); + } + + /** Remove the journal — the session ended cleanly (stop/cancel). */ + function clearLiveJournal(): void { + clearJournal(journalFile); + lastJournalMs = 0; + } + + /** Per-tick: redraw, and refresh the journal heartbeat at most every 5s. */ + function tick(): void { + render(); + if (state.live && Date.now() - lastJournalMs >= 5000) writeLiveJournal(); + } + + /** Reconstruct and start a live session from a recovered journal record. */ + function resumeFromJournal(rec: ActiveSession): void { + const s = rec.session; + state.live = { + timer: Timer.fromResume(s.durationS, s.breaks, s.start), + goal: s.goal, + label: s.label, + focusTargetS: s.focusTargetS, + breakBudgetS: s.breakBudgetS, + }; + state.resume = emptyResumeState(); + state.view = "session"; + startTick(); + writeLiveJournal(); // re-anchor the heartbeat to now + render(); + } + + /** Discard the live session WITHOUT saving (the [x] cancel control). */ + function cancelSession(): void { + if (!state.live) return; + stopTick(); + state.live = null; + state.zenLive = false; + state.hideControls = false; + state.breakpicker = emptyBreakPickerState(); + clearLiveJournal(); + render(); + } + function cleanup() { stopTick(); if (stopReader) { @@ -589,6 +718,9 @@ export async function runDashboardApp( const breakS = record.breakS; stopTick(); + // The session is over; from here persistence is handled by the summary + // modal (or handleSignal). Drop the journal so it is never re-offered. + clearLiveJournal(); state.live = null; // Reset live-only view toggles so the next session starts normally. state.zenLive = false; @@ -631,6 +763,8 @@ export async function runDashboardApp( if (state.summary) { appendSession(file, state.summary.record); } + // The session is now persisted; drop the journal so it is not re-offered. + clearLiveJournal(); cleanup(); } @@ -711,6 +845,7 @@ export async function runDashboardApp( state.form = emptySessionFormState(); state.view = "session"; startTick(); + writeLiveJournal(); // seed the crash-recovery journal immediately render(); } @@ -838,6 +973,30 @@ export async function runDashboardApp( } stopReader = startNavReader(process.stdin, (key) => { + // ── (0) Resume modal: r resume · d/Esc discard ──────────────────────── + if (state.resume.open) { + // Ctrl-C exits; the journal is left in place to re-offer next launch. + if (key.name === "char" && key.char === CTRL_C) { + cleanup(); + return; + } + const result = resumeApplyKey(state.resume, key); + state.resume = result.state; + if (result.action?.type === "resume" && pendingResumeRec) { + resumeFromJournal(pendingResumeRec); + pendingResumeRec = null; + return; + } + if (result.action?.type === "discard") { + clearLiveJournal(); + pendingResumeRec = null; + render(); + return; + } + render(); + return; + } + // ── (1) Summary modal: any key dismisses ────────────────────────────── if (state.summary !== null) { if (key.name === "char") { @@ -875,6 +1034,10 @@ export async function runDashboardApp( const result = confirmApplyKey(state.confirm, key); state.confirm = result.state; if (result.action?.type === "confirm" && result.action.payload) { + if (result.action.payload === CANCEL_LIVE) { + cancelSession(); // discard the live session without saving + return; + } state.sessions = deleteSession(file, result.action.payload); clampSelection(); render(); @@ -927,6 +1090,27 @@ export async function runDashboardApp( return; } + // ── (1d) Break-category picker ([m]): pick from ALL categories ───────── + if (state.breakpicker.open) { + // Ctrl-C closes the picker without exiting the app. + if (key.name === "char" && key.char === CTRL_C) { + state.breakpicker = emptyBreakPickerState(); + render(); + return; + } + const result = breakPickerApplyKey(state.breakpicker, key); + state.breakpicker = result.state; + if (result.action?.type === "pick" && state.live) { + const cat = result.action.category; + const t = state.live.timer; + if (t.isOnBreak) t.setBreakCategory(cat); + else t.startBreak(cat, null, suggestBreakS(t.elapsedS())); + writeLiveJournal(); + } + render(); + return; + } + // ── (2) Palette: feed keys to reducer ──────────────────────────────── if (state.palette.open) { const result = paletteApplyKey(state.palette, key); @@ -967,6 +1151,7 @@ export async function runDashboardApp( if (ch === kb.pause) { live.timer.togglePause(); + writeLiveJournal(); render(); return; } @@ -977,6 +1162,7 @@ export async function runDashboardApp( } else { live.timer.startBreak("rest", null, suggestBreakS(live.timer.elapsedS())); } + writeLiveJournal(); render(); return; } @@ -990,13 +1176,35 @@ export async function runDashboardApp( } else { live.timer.startBreak(cat, null, suggestBreakS(live.timer.elapsedS())); } + writeLiveJournal(); } render(); return; } + // [m] — open the picker for ALL categories (incl. coffee/sleep). + if (ch === "m") { + state.breakpicker = openBreakPickerState( + live.timer.isOnBreak ? live.timer.currentBreakCategory : undefined, + ); + render(); + return; + } + + // [x] — cancel (discard without saving), confirmed first. + if (ch === "x") { + state.confirm = openConfirmState({ + title: "Cancel session", + message: "Discard this session without saving?", + payload: CANCEL_LIVE, + }); + render(); + return; + } + if (ch === kb.reset) { live.timer.reset(); + writeLiveJournal(); render(); return; } @@ -1010,9 +1218,11 @@ export async function runDashboardApp( if (ch === (ctx.config.keybindings.category ?? "c")) { if (live.timer.isOnBreak) { const current = live.timer.currentBreakCategory; - const idx = BREAK_CATEGORIES.indexOf(current); - const next = BREAK_CATEGORIES[(idx + 1) % BREAK_CATEGORIES.length] ?? "rest"; + const idx = ALL_BREAK_CATEGORIES.indexOf(current); + const next = + ALL_BREAK_CATEGORIES[(idx + 1) % ALL_BREAK_CATEGORIES.length] ?? "rest"; live.timer.setBreakCategory(next); + writeLiveJournal(); } render(); return; @@ -1190,7 +1400,7 @@ export async function runDashboardApp( render(); return; } - }); + }, process.stdout); // 3rd arg enables bracketed-paste mode for the forms process.on("SIGINT", handleSignal); process.on("SIGTERM", handleSignal); @@ -1199,6 +1409,7 @@ export async function runDashboardApp( // Start tick interval if we launched with a pending session if (state.live) { startTick(); + writeLiveJournal(); // seed the crash-recovery journal immediately } // Initial render diff --git a/src/tui/breakpicker.ts b/src/tui/breakpicker.ts new file mode 100644 index 0000000..6dcd6b1 --- /dev/null +++ b/src/tui/breakpicker.ts @@ -0,0 +1,149 @@ +/** + * Break-category picker — a pure, self-contained transient overlay for the live + * session view. It lets the user pick any category from ALL_BREAK_CATEGORIES + * (the "more" picker that unlocks coffee/sleep beyond the 1–6 quick keys). + * + * Mirrors the confirm/palette design: types + pure functions only, no I/O. The + * caller (app.ts) wires the action that runs on pick/cancel. + */ + +import type { Key } from "../lib/tui/input.js"; +import type { ThemeName } from "../schemas/config.js"; +import { panel, padTo, truncate } from "../lib/tui/draw.js"; +import { paint, THEME_FG } from "../lib/theme.js"; +import { ALL_BREAK_CATEGORIES } from "../schemas/session.js"; +import type { BreakCategory } from "../schemas/session.js"; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +export interface BreakPickerState { + open: boolean; + index: number; +} + +/** Canonical empty / closed picker state. */ +export function emptyBreakPickerState(): BreakPickerState { + return { open: false, index: 0 }; +} + +/** + * Open the picker, seeding the selection at `current` if it is a known + * category. Falls back to index 0 when `current` is undefined or unknown. + */ +export function openBreakPickerState(current?: BreakCategory): BreakPickerState { + const found = current === undefined ? -1 : ALL_BREAK_CATEGORIES.indexOf(current); + return { open: true, index: Math.max(0, found) }; +} + +// --------------------------------------------------------------------------- +// Reducer +// --------------------------------------------------------------------------- + +export interface BreakPickerResult { + state: BreakPickerState; + action?: { type: "pick"; category: BreakCategory } | { type: "cancel" }; +} + +/** + * Pure reducer — never mutates `state`. + * + * up / k → move selection up (wraps) + * down / j → move selection down (wraps) + * enter → pick the indexed category, close + * escape → cancel, close + * 1..8 → directly pick that category (1-based) if in range + * anything else → no change + */ +export function breakPickerApplyKey(state: BreakPickerState, key: Key): BreakPickerResult { + const n = ALL_BREAK_CATEGORIES.length; + + if (key.name === "up" || (key.name === "char" && key.char === "k")) { + return { state: { ...state, index: (state.index - 1 + n) % n } }; + } + if (key.name === "down" || (key.name === "char" && key.char === "j")) { + return { state: { ...state, index: (state.index + 1) % n } }; + } + if (key.name === "enter") { + return { + state: emptyBreakPickerState(), + action: { type: "pick", category: ALL_BREAK_CATEGORIES[state.index]! }, + }; + } + if (key.name === "escape") { + return { state: emptyBreakPickerState(), action: { type: "cancel" } }; + } + if (key.name === "char" && key.char >= "1" && key.char <= "8") { + const idx = key.char.charCodeAt(0) - "1".charCodeAt(0); // 0-based + if (idx >= 0 && idx < n) { + return { + state: emptyBreakPickerState(), + action: { type: "pick", category: ALL_BREAK_CATEGORIES[idx]! }, + }; + } + return { state }; + } + + return { state }; +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +export interface BreakPickerOverlay { + rows: string[]; + top: number; + left: number; +} + +/** Render the break-category picker as a centered bordered panel. */ +export function renderBreakPicker( + state: BreakPickerState, + cols: number, + rows: number, + theme: ThemeName, + color: boolean, +): BreakPickerOverlay { + const boxWidth = Math.min(54, Math.max(30, cols - 4)); + const innerW = boxWidth - 2; + + const n = ALL_BREAK_CATEGORIES.length; + const clampedIndex = Math.max(0, Math.min(state.index, n - 1)); + + const body: string[] = []; + + for (let i = 0; i < n; i++) { + const category = ALL_BREAK_CATEGORIES[i]!; + const num = i + 1; // 1-based + const isActive = i === clampedIndex; + + let line: string; + if (isActive) { + const text = truncate(`› ${num} ${category}`, innerW); + line = color ? paint(text, theme, true) : text; + } else { + line = truncate(` ${num} ${category}`, innerW); + } + body.push(line); + } + + body.push(padTo("", innerW)); + body.push(truncate("[↑↓] select · [1-8] pick · [Enter] choose · [Esc] cancel", innerW)); + + const boxHeight = body.length + 2; // 2 borders + + const panelRows = panel({ + title: "Break category", + width: boxWidth, + height: boxHeight, + body, + color: color ? THEME_FG[theme] : undefined, + }); + + const left = Math.max(0, Math.floor((cols - boxWidth) / 2)); + const top = Math.max(0, Math.floor((rows - boxHeight) / 2)); + + return { rows: panelRows, top, left }; +} diff --git a/src/tui/editform.ts b/src/tui/editform.ts index 293bcbe..5ad1b4e 100644 --- a/src/tui/editform.ts +++ b/src/tui/editform.ts @@ -12,6 +12,7 @@ import type { ThemeName } from "../schemas/config.js"; import { panel, truncate, padTo } from "../lib/tui/draw.js"; import { paint, THEME_FG } from "../lib/theme.js"; import { parseDurationToS } from "../lib/format.js"; +import { lineApplyKey, withCursor } from "../lib/tui/lineedit.js"; // --------------------------------------------------------------------------- // Fields @@ -29,8 +30,8 @@ interface FieldSpec { /** Ordered fields, top to bottom. */ export const EDIT_FORM_FIELDS: FieldSpec[] = [ - { key: "goal", label: "Goal", hint: "goal/intention (optional, blank clears)" }, - { key: "label", label: "Name", hint: "session name (optional, blank clears)" }, + { key: "goal", label: "Goal", hint: "goal/intention (optional, blank clears)" }, + { key: "label", label: "Details", hint: "session details (optional, blank clears)" }, { key: "focus", label: "Focus", hint: "active focus time — e.g. 1h30m, 90m, 45s" }, { key: "break", label: "Break", hint: "total break time — e.g. 20m, 0 to clear breaks" }, ]; @@ -49,6 +50,8 @@ export interface EditFormState { startISO: string | null; values: EditFormValues; active: number; // index into EDIT_FORM_FIELDS + /** Cursor position (code units) within the active field's value. */ + cursor: number; error: string | null; } @@ -60,6 +63,7 @@ export function emptyEditFormState(): EditFormState { startISO: null, values: { goal: "", label: "", focus: "", break: "" }, active: 0, + cursor: 0, error: null, }; } @@ -70,7 +74,13 @@ export function openEditFormState(opts: { startISO: string; values: EditFormValues; }): EditFormState { - return { ...emptyEditFormState(), open: true, ...opts }; + // Park the cursor at the end of the first field's value. + return { + ...emptyEditFormState(), + open: true, + ...opts, + cursor: opts.values.goal.length, + }; } // --------------------------------------------------------------------------- @@ -84,15 +94,22 @@ export interface EditFormResult { | { type: "cancel" }; } +/** Move focus to another field and park the cursor at the end of its value. */ +function focusField(state: EditFormState, nextActive: number): EditFormState { + const key = EDIT_FORM_FIELDS[nextActive]?.key ?? "goal"; + return { ...state, active: nextActive, cursor: state.values[key].length, error: null }; +} + /** * Pure reducer — never mutates `state`. * - * escape → cancel (close, return emptyEditFormState) - * enter → submit the current values (with sessionId) - * tab / down → focus next field (wraps) - * up → focus previous field (wraps) - * backspace → delete last char of the active field - * printable char → append to the active field + * escape → cancel (close, return emptyEditFormState) + * enter → submit the current values (with sessionId) + * tab / down → focus next field (wraps) + * up → focus previous field (wraps) + * ← → Home End → move the cursor within the active field + * backspace / delete → delete around the cursor + * printable / paste → insert at the cursor (full paste supported) */ export function editFormApplyKey( state: EditFormState, @@ -117,41 +134,26 @@ export function editFormApplyKey( case "tab": case "down": - return { state: { ...state, active: (state.active + 1) % n, error: null } }; + return { state: focusField(state, (state.active + 1) % n) }; case "up": - return { state: { ...state, active: (state.active - 1 + n) % n, error: null } }; - - case "backspace": + return { state: focusField(state, (state.active - 1 + n) % n) }; + + default: { + const { state: line, handled } = lineApplyKey( + { text: state.values[activeKey], cursor: state.cursor }, + key, + ); + if (!handled) return { state }; return { state: { ...state, - values: { - ...state.values, - [activeKey]: state.values[activeKey].slice(0, -1), - }, + values: { ...state.values, [activeKey]: line.text }, + cursor: line.cursor, error: null, }, }; - - case "char": { - const ch = key.char; - const code = ch.codePointAt(0) ?? 0; - // Printable and not Ctrl-C - if (code >= 0x20 && ch !== "\x03") { - return { - state: { - ...state, - values: { ...state.values, [activeKey]: state.values[activeKey] + ch }, - error: null, - }, - }; - } - return { state }; } - - default: - return { state }; } } @@ -224,8 +226,8 @@ export function renderEditForm( EDIT_FORM_FIELDS.forEach((field, i) => { const isActive = i === clampedActive; const value = state.values[field.key]; - const cursor = isActive ? "▏" : ""; - const raw = `${field.label.padEnd(LABEL_WIDTH)} ${value}${cursor}`; + const shown = isActive ? withCursor(value, state.cursor) : value; + const raw = `${field.label.padEnd(LABEL_WIDTH)} ${shown}`; let line: string; if (isActive) { diff --git a/src/tui/resume.ts b/src/tui/resume.ts new file mode 100644 index 0000000..a6df4de --- /dev/null +++ b/src/tui/resume.ts @@ -0,0 +1,133 @@ +/** + * Resume-previous-session overlay — a pure, self-contained transient overlay + * shown at dashboard launch when a prior session was found unfinished (the + * process crashed or froze mid-session). It reports what would be restored and + * asks the user to resume or discard. Mirrors the confirm modal's design: + * types + pure functions only, no I/O. The caller (app.ts) does the actual + * restore; this module only reports the user's choice. + * + * Safety: Enter defaults to the safe, non-destructive choice (resume), so an + * accidental Enter never discards recovered work. `Esc` and `d` discard. + */ + +import type { Key } from "../lib/tui/input.js"; +import type { ThemeName } from "../schemas/config.js"; +import { panel, padTo, truncate } from "../lib/tui/draw.js"; +import { THEME_FG } from "../lib/theme.js"; +import { humanDuration } from "../lib/format.js"; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +/** Summary of the recovered session that would be restored. */ +export interface ResumeInfo { + goal: string | null; + label: string | null; + focusS: number; + breakS: number; + heartbeatISO: string; +} + +export interface ResumeState { + open: boolean; + info: ResumeInfo | null; +} + +/** Canonical empty / closed resume state. */ +export function emptyResumeState(): ResumeState { + return { open: false, info: null }; +} + +/** Open the resume overlay with the recovered session summary. */ +export function openResumeState(info: ResumeInfo): ResumeState { + return { open: true, info }; +} + +// --------------------------------------------------------------------------- +// Reducer +// --------------------------------------------------------------------------- + +export interface ResumeResult { + state: ResumeState; + action?: { type: "resume" } | { type: "discard" }; +} + +/** + * Pure reducer — never mutates `state`. + * + * r → resume (restore the recovered session) + * d / Esc → discard (drop the recovered session) + * Enter → resume (safe default — Enter never discards) + * anything else → no change (overlay stays open) + */ +export function resumeApplyKey(state: ResumeState, key: Key): ResumeResult { + if (key.name === "char" && key.char.toLowerCase() === "r") { + return { state: emptyResumeState(), action: { type: "resume" } }; + } + if (key.name === "enter") { + return { state: emptyResumeState(), action: { type: "resume" } }; + } + if ( + key.name === "escape" || + (key.name === "char" && key.char.toLowerCase() === "d") + ) { + return { state: emptyResumeState(), action: { type: "discard" } }; + } + return { state }; +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +export interface ResumeOverlay { + rows: string[]; + top: number; + left: number; +} + +/** Render the resume overlay as a centered bordered panel. */ +export function renderResume( + state: ResumeState, + cols: number, + rows: number, + theme: ThemeName, + color: boolean, +): ResumeOverlay { + const boxWidth = Math.min(54, Math.max(30, cols - 4)); + const innerW = boxWidth - 2; + + const body: string[] = []; + const info = state.info; + + if (info === null) { + body.push(padTo("", innerW)); + } else { + body.push(truncate("A session was interrupted:", innerW)); + body.push(truncate(" " + (info.goal ?? "(no goal)"), innerW)); + if (info.label !== null) { + body.push(truncate(" details: " + info.label, innerW)); + } + body.push(padTo("", innerW)); + body.push(truncate(" focus " + humanDuration(info.focusS), innerW)); + body.push(truncate(" break " + humanDuration(info.breakS), innerW)); + body.push(padTo("", innerW)); + body.push(truncate("[r] resume · [d] discard · [Esc] discard", innerW)); + } + + const boxHeight = body.length + 2; // 2 borders + + const panelRows = panel({ + title: "Resume previous session?", + width: boxWidth, + height: boxHeight, + body, + color: color ? THEME_FG[theme] : undefined, + }); + + const left = Math.max(0, Math.floor((cols - boxWidth) / 2)); + const top = Math.max(0, Math.floor((rows - boxHeight) / 2)); + + return { rows: panelRows, top, left }; +} diff --git a/src/tui/sessionform.ts b/src/tui/sessionform.ts index ecd735a..3712abc 100644 --- a/src/tui/sessionform.ts +++ b/src/tui/sessionform.ts @@ -11,6 +11,7 @@ import type { Key } from "../lib/tui/input.js"; import type { ThemeName } from "../schemas/config.js"; import { panel, truncate, padTo } from "../lib/tui/draw.js"; import { paint, THEME_FG } from "../lib/theme.js"; +import { lineApplyKey, withCursor } from "../lib/tui/lineedit.js"; // --------------------------------------------------------------------------- // Fields @@ -29,7 +30,7 @@ interface FieldSpec { /** Ordered fields, top to bottom. */ export const SESSION_FORM_FIELDS: FieldSpec[] = [ { key: "goal", label: "Goal", hint: "what you want to accomplish (optional)" }, - { key: "label", label: "Name", hint: "short session name (optional)" }, + { key: "label", label: "Details", hint: "extra details for this session (optional)" }, { key: "target", label: "Target", hint: "focus target — e.g. 1h, 90m, 25m (optional)" }, { key: "break", label: "Break budget", hint: "e.g. 20m, 5m (optional)" }, ]; @@ -44,6 +45,8 @@ export interface SessionFormState { open: boolean; values: SessionFormValues; active: number; // index into SESSION_FORM_FIELDS + /** Cursor position (code units) within the active field's value. */ + cursor: number; error: string | null; } @@ -53,6 +56,7 @@ export function emptySessionFormState(): SessionFormState { open: false, values: { goal: "", label: "", target: "", break: "" }, active: 0, + cursor: 0, error: null, }; } @@ -73,15 +77,22 @@ export interface SessionFormResult { | { type: "cancel" }; } +/** Move focus to another field and park the cursor at the end of its value. */ +function focusField(state: SessionFormState, nextActive: number): SessionFormState { + const key = SESSION_FORM_FIELDS[nextActive]?.key ?? "goal"; + return { ...state, active: nextActive, cursor: state.values[key].length, error: null }; +} + /** * Pure reducer — never mutates `state`. * - * escape → cancel (close) - * enter → submit the current values - * tab / down → focus next field (wraps) - * up → focus previous field (wraps) - * backspace → delete last char of the active field - * printable char → append to the active field + * escape → cancel (close) + * enter → submit the current values + * tab / down → focus next field (wraps) + * up → focus previous field (wraps) + * ← → Home End → move the cursor within the active field + * backspace / delete → delete around the cursor + * printable / paste → insert at the cursor (full paste supported) */ export function sessionFormApplyKey( state: SessionFormState, @@ -99,41 +110,27 @@ export function sessionFormApplyKey( case "tab": case "down": - return { state: { ...state, active: (state.active + 1) % n, error: null } }; + return { state: focusField(state, (state.active + 1) % n) }; case "up": - return { state: { ...state, active: (state.active - 1 + n) % n, error: null } }; - - case "backspace": + return { state: focusField(state, (state.active - 1 + n) % n) }; + + default: { + // Everything else is line editing on the active field. + const { state: line, handled } = lineApplyKey( + { text: state.values[activeKey], cursor: state.cursor }, + key, + ); + if (!handled) return { state }; return { state: { ...state, - values: { - ...state.values, - [activeKey]: state.values[activeKey].slice(0, -1), - }, + values: { ...state.values, [activeKey]: line.text }, + cursor: line.cursor, error: null, }, }; - - case "char": { - const ch = key.char; - const code = ch.codePointAt(0) ?? 0; - // Printable and not Ctrl-C - if (code >= 0x20 && ch !== "\x03") { - return { - state: { - ...state, - values: { ...state.values, [activeKey]: state.values[activeKey] + ch }, - error: null, - }, - }; - } - return { state }; } - - default: - return { state }; } } @@ -176,8 +173,8 @@ export function renderSessionForm( SESSION_FORM_FIELDS.forEach((field, i) => { const isActive = i === clampedActive; const value = state.values[field.key]; - const cursor = isActive ? "▏" : ""; - const raw = `${field.label.padEnd(LABEL_WIDTH)} ${value}${cursor}`; + const shown = isActive ? withCursor(value, state.cursor) : value; + const raw = `${field.label.padEnd(LABEL_WIDTH)} ${shown}`; let line: string; if (isActive) { diff --git a/src/tui/views/breaks.ts b/src/tui/views/breaks.ts index a0eab9a..795f625 100644 --- a/src/tui/views/breaks.ts +++ b/src/tui/views/breaks.ts @@ -10,16 +10,12 @@ import type { ThemeName } from "../../schemas/config.js"; import { panel, kv, barH, padTo } from "../../lib/tui/draw.js"; import { THEME_FG } from "../../lib/theme.js"; import { humanDuration } from "../../lib/format.js"; -import type { BreakCategory } from "../../schemas/session.js"; +import { + ALL_BREAK_CATEGORIES, + type BreakCategory, +} from "../../schemas/session.js"; -const ALL_CATEGORIES: BreakCategory[] = [ - "rest", - "meal", - "exercise", - "walk", - "distraction", - "other", -]; +const ALL_CATEGORIES: readonly BreakCategory[] = ALL_BREAK_CATEGORIES; export function renderBreaks( snap: DashboardSnapshot, diff --git a/src/tui/views/help.ts b/src/tui/views/help.ts index 0bd46ba..cdeb737 100644 --- a/src/tui/views/help.ts +++ b/src/tui/views/help.ts @@ -32,7 +32,8 @@ export function renderHelp(rect: Rect, theme: ThemeName, color: boolean): string // Starting a session body.push(padTo("Start a session (on the Session view, idle):", innerW)); - body.push(padTo("s / n / Enter open the new-session form (goal · name · target · break budget)", innerW)); + body.push(padTo("s / n / Enter open the new-session form (goal · details · target · break budget)", innerW)); + body.push(padTo("Text fields are full editors: ← → Home End move · paste works · Del/Backspace edit.", innerW)); body.push(padTo("…or from your shell: start --goal \"…\" --target 1h --break-budget 20m", innerW)); // Spacer @@ -40,8 +41,9 @@ export function renderHelp(rect: Rect, theme: ThemeName, color: boolean): string // Session controls body.push(padTo("Session controls (while a session runs):", innerW)); - body.push(padTo("p pause · b break · 1-6 category · z zen · Enter hide controls · r reset · q stop & save", innerW)); - body.push(padTo("categories: 1 rest · 2 meal · 3 exercise · 4 walk · 5 distraction · 6 other", innerW)); + body.push(padTo("p pause · b break · 1-6 quick category · m more categories · x cancel · z zen · Enter hide · r reset · q stop & save", innerW)); + body.push(padTo("quick: 1 rest · 2 meal · 3 exercise · 4 walk · 5 distraction · 6 other · m picker adds: coffee · sleep", innerW)); + body.push(padTo("x cancels without saving (test sessions); q stops & saves.", innerW)); // Spacer body.push(""); @@ -49,7 +51,7 @@ export function renderHelp(rect: Rect, theme: ThemeName, color: boolean): string // Global body.push(padTo("Global:", innerW)); body.push(padTo("/ command palette · d display style (block/simple/outline/minimal/classic/bold) · t theme · r refresh · q/Esc quit", innerW)); - body.push(padTo("Sessions list: Enter detail · e edit (focus/break/goal/name; end + timeline recompute) · Supr delete.", innerW)); + body.push(padTo("Sessions list: Enter detail · e edit (focus/break/goal/details; end + timeline recompute) · Supr delete.", innerW)); body.push(padTo("d/t saved as default; both also in the / palette.", innerW)); // Spacer diff --git a/src/tui/views/sessions.ts b/src/tui/views/sessions.ts index 7611048..d9235d8 100644 --- a/src/tui/views/sessions.ts +++ b/src/tui/views/sessions.ts @@ -119,7 +119,7 @@ export function sessionDetail( body.push(padTo(`Goal: ${session.goal}`, innerW)); } if (session.label) { - body.push(padTo(`Label: ${session.label}`, innerW)); + body.push(padTo(`Details: ${session.label}`, innerW)); } body.push(padTo("─".repeat(Math.min(innerW, 20)), innerW)); body.push(padTo("Timeline:", innerW)); diff --git a/src/version.ts b/src/version.ts index 27b43bb..805223e 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ /** Kept in sync with package.json "version" (asserted by a test). */ -export const VERSION = "3.7.0"; +export const VERSION = "3.8.0"; diff --git a/test/breakpicker.test.ts b/test/breakpicker.test.ts new file mode 100644 index 0000000..7de9520 --- /dev/null +++ b/test/breakpicker.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; +import { + emptyBreakPickerState, + openBreakPickerState, + breakPickerApplyKey, + renderBreakPicker, +} from "../src/tui/breakpicker.js"; +import { ALL_BREAK_CATEGORIES } from "../src/schemas/session.js"; +import type { Key } from "../src/lib/tui/input.js"; + +const N = ALL_BREAK_CATEGORIES.length; + +describe("breakpicker state", () => { + it("emptyBreakPickerState is closed at index 0", () => { + expect(emptyBreakPickerState()).toEqual({ open: false, index: 0 }); + }); + + it("openBreakPickerState seeds index at the current category", () => { + const s = openBreakPickerState("coffee"); + expect(s.open).toBe(true); + expect(s.index).toBe(ALL_BREAK_CATEGORIES.indexOf("coffee")); + expect(ALL_BREAK_CATEGORIES[s.index]).toBe("coffee"); + }); + + it("openBreakPickerState falls back to 0 for undefined/unknown", () => { + expect(openBreakPickerState().index).toBe(0); + expect(openBreakPickerState(undefined).index).toBe(0); + }); +}); + +describe("breakpicker reducer", () => { + const up: Key = { name: "up" }; + const down: Key = { name: "down" }; + const enter: Key = { name: "enter" }; + const escape: Key = { name: "escape" }; + + it("down wraps from last to first", () => { + const s = { open: true, index: N - 1 }; + const r = breakPickerApplyKey(s, down); + expect(r.state.index).toBe(0); + expect(r.action).toBeUndefined(); + }); + + it("up wraps from first to last", () => { + const s = { open: true, index: 0 }; + const r = breakPickerApplyKey(s, up); + expect(r.state.index).toBe(N - 1); + }); + + it("j mirrors down, k mirrors up", () => { + const s = { open: true, index: 2 }; + const jKey: Key = { name: "char", char: "j" }; + const kKey: Key = { name: "char", char: "k" }; + expect(breakPickerApplyKey(s, jKey).state.index).toBe(3); + expect(breakPickerApplyKey(s, kKey).state.index).toBe(1); + }); + + it("does not mutate the input state", () => { + const s = { open: true, index: 2 }; + breakPickerApplyKey(s, down); + expect(s).toEqual({ open: true, index: 2 }); + }); + + it("enter picks the indexed category and closes", () => { + const s = { open: true, index: 3 }; + const r = breakPickerApplyKey(s, enter); + expect(r.action).toEqual({ type: "pick", category: ALL_BREAK_CATEGORIES[3] }); + expect(r.state).toEqual(emptyBreakPickerState()); + }); + + it("escape cancels and closes", () => { + const s = { open: true, index: 3 }; + const r = breakPickerApplyKey(s, escape); + expect(r.action).toEqual({ type: "cancel" }); + expect(r.state).toEqual(emptyBreakPickerState()); + }); + + it("digit picks the right (1-based) category and closes", () => { + const s = { open: true, index: 0 }; + const r = breakPickerApplyKey(s, { name: "char", char: "7" }); + expect(r.action).toEqual({ type: "pick", category: ALL_BREAK_CATEGORIES[6] }); + expect(r.state).toEqual(emptyBreakPickerState()); + }); + + it("out-of-range digit is a no-op", () => { + const s = { open: true, index: 1 }; + const r = breakPickerApplyKey(s, { name: "char", char: "9" }); + expect(r.action).toBeUndefined(); + expect(r.state).toBe(s); + }); + + it("unrelated key leaves state unchanged with no action", () => { + const s = { open: true, index: 1 }; + const r = breakPickerApplyKey(s, { name: "char", char: "x" }); + expect(r.action).toBeUndefined(); + expect(r.state).toEqual(s); + }); +}); + +describe("breakpicker render", () => { + it("renders a panel containing the title and coffee/sleep", () => { + const o = renderBreakPicker(openBreakPickerState("rest"), 80, 24, "neon", false); + const joined = o.rows.join("\n"); + expect(joined).toContain("Break category"); + expect(joined).toContain("coffee"); + expect(joined).toContain("sleep"); + expect(typeof o.top).toBe("number"); + expect(typeof o.left).toBe("number"); + }); + + it("is pure — calling twice gives equal output", () => { + const s = openBreakPickerState("meal"); + const a = renderBreakPicker(s, 80, 24, "neon", true); + const b = renderBreakPicker(s, 80, 24, "neon", true); + expect(a).toEqual(b); + }); +}); diff --git a/test/dashboard.test.ts b/test/dashboard.test.ts index af6a772..b62c354 100644 --- a/test/dashboard.test.ts +++ b/test/dashboard.test.ts @@ -30,6 +30,8 @@ import { emptyPaletteState } from "../src/tui/palette.js"; import { emptySessionFormState } from "../src/tui/sessionform.js"; import { emptyEditFormState, openEditFormState } from "../src/tui/editform.js"; import { emptyConfirmState, openConfirmState } from "../src/tui/confirm.js"; +import { emptyBreakPickerState, openBreakPickerState } from "../src/tui/breakpicker.js"; +import { emptyResumeState, openResumeState } from "../src/tui/resume.js"; import type { ConfirmState } from "../src/tui/confirm.js"; // Command under test @@ -649,6 +651,8 @@ describe("buildFrame (WS5)", () => { edit: emptyEditFormState(), displayStyle: "block" as const, confirm: emptyConfirmState(), + breakpicker: emptyBreakPickerState(), + resume: emptyResumeState(), zenLive: false, hideControls: false, }; @@ -684,6 +688,8 @@ describe("buildFrame (WS5)", () => { edit: emptyEditFormState(), displayStyle: "block" as const, confirm: emptyConfirmState(), + breakpicker: emptyBreakPickerState(), + resume: emptyResumeState(), zenLive: false, hideControls: false, }; @@ -719,6 +725,8 @@ describe("buildFrame (WS5)", () => { edit: emptyEditFormState(), displayStyle: "block" as const, confirm: emptyConfirmState(), + breakpicker: emptyBreakPickerState(), + resume: emptyResumeState(), zenLive: false, hideControls: false, }; @@ -753,6 +761,8 @@ describe("buildFrame (WS5)", () => { edit: opts.edit ?? emptyEditFormState(), displayStyle: "block" as const, confirm: opts.confirm ?? emptyConfirmState(), + breakpicker: emptyBreakPickerState(), + resume: emptyResumeState(), zenLive: opts.zenLive ?? false, hideControls: opts.hideControls ?? false, }; @@ -769,6 +779,39 @@ describe("buildFrame (WS5)", () => { expect(out).not.toContain("pause"); }); + // ── v3.8.0: live footer advertises the picker + cancel controls ──────────── + it("live footer shows [m] (picker) and [x] cancel", () => { + const out = buildFrame(fcState(), 100, 24, makeFakeCtx()).map(stripAnsi).join("\n"); + expect(out).toContain("[m]"); + expect(out).toContain("cancel"); + }); + + // ── v3.8.0: break-category picker overlay composites over the frame ──────── + it("break-category picker overlay shows all categories incl. coffee + sleep", () => { + const state = { ...fcState(), breakpicker: openBreakPickerState() }; + const out = buildFrame(state, 100, 24, makeFakeCtx()).map(stripAnsi).join("\n"); + expect(out).toContain("Break category"); + expect(out).toContain("coffee"); + expect(out).toContain("sleep"); + }); + + // ── v3.8.0: resume overlay composites at launch (crash recovery) ────────── + it("resume overlay shows the recovered session summary", () => { + const state = { + ...fcState({ live: null }), + resume: openResumeState({ + goal: "Deep work", + label: "CKIS Backup", + focusS: 5400, + breakS: 900, + heartbeatISO: "2026-06-15T15:00:00.000Z", + }), + }; + const out = buildFrame(state, 100, 24, makeFakeCtx()).map(stripAnsi).join("\n"); + expect(out).toContain("Resume previous session?"); + expect(out).toContain("Deep work"); + }); + it("zen (zenLive) hides the goal from the body", () => { const out = buildFrame(fcState({ zenLive: true }), 100, 24, makeFakeCtx()).map(stripAnsi).join("\n"); expect(out).not.toContain("test-goal"); @@ -838,6 +881,8 @@ describe("buildFrame (WS5)", () => { edit: emptyEditFormState(), displayStyle: "block" as const, confirm: emptyConfirmState(), + breakpicker: emptyBreakPickerState(), + resume: emptyResumeState(), zenLive: false, hideControls: false, }; @@ -862,6 +907,8 @@ describe("buildFrame (WS5)", () => { edit: emptyEditFormState(), displayStyle: "block" as const, confirm: emptyConfirmState(), + breakpicker: emptyBreakPickerState(), + resume: emptyResumeState(), zenLive: false, hideControls: false, }; @@ -886,6 +933,8 @@ describe("buildFrame (WS5)", () => { edit: emptyEditFormState(), displayStyle: "block" as const, confirm: emptyConfirmState(), + breakpicker: emptyBreakPickerState(), + resume: emptyResumeState(), zenLive: false, hideControls: false, }; @@ -910,6 +959,8 @@ describe("buildFrame (WS5)", () => { edit: emptyEditFormState(), displayStyle: "block" as const, confirm: emptyConfirmState(), + breakpicker: emptyBreakPickerState(), + resume: emptyResumeState(), zenLive: false, hideControls: false, }; diff --git a/test/editform.test.ts b/test/editform.test.ts index 1b483e0..eaf1f0e 100644 --- a/test/editform.test.ts +++ b/test/editform.test.ts @@ -188,11 +188,18 @@ describe("editFormApplyKey", () => { } }); - it("unknown keys leave state unchanged", () => { + it("no-op keys (Ctrl-C) leave state unchanged", () => { + // home/left/right now move the cursor; a control char is a true no-op. const s = openSample(); - const next = editFormApplyKey(s, { name: "home" }).state; + const next = editFormApplyKey(s, { name: "char", char: "\x03" }).state; expect(next).toEqual(s); }); + + it("Home moves the cursor to the start of the active field", () => { + const s = openSample(); + const next = editFormApplyKey(s, { name: "home" }).state; + expect(next.cursor).toBe(0); + }); }); // --------------------------------------------------------------------------- @@ -255,10 +262,10 @@ describe("renderEditForm", () => { expect(startLine).toContain("—"); }); - it("shows field labels Goal, Name, Focus, Break", () => { + it("shows field labels Goal, Details, Focus, Break", () => { const joined = renderEditForm(openSample(), 80, 24, "neon", false).rows.join("\n"); expect(joined).toContain("Goal"); - expect(joined).toContain("Name"); + expect(joined).toContain("Details"); expect(joined).toContain("Focus"); expect(joined).toContain("Break"); }); diff --git a/test/input-paste.test.ts b/test/input-paste.test.ts new file mode 100644 index 0000000..221275e --- /dev/null +++ b/test/input-paste.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from "vitest"; +import { EventEmitter } from "node:events"; +import { + tokenize, + startNavReader, + PASTE_START, + PASTE_END, + type Key, +} from "../src/lib/tui/input.js"; + +describe("tokenize — multi-char chunks no longer drop characters", () => { + it("splits a batched plain-text chunk into one char Key each", () => { + expect(tokenize("abc")).toEqual([ + { name: "char", char: "a" }, + { name: "char", char: "b" }, + { name: "char", char: "c" }, + ]); + }); + + it("recognizes escape sequences embedded in a burst (arrow auto-repeat)", () => { + expect(tokenize("\x1b[A\x1b[A")).toEqual([{ name: "up" }, { name: "up" }]); + }); + + it("mixes text and control bytes correctly", () => { + expect(tokenize("a\x7fb")).toEqual([ + { name: "char", char: "a" }, + { name: "backspace" }, + { name: "char", char: "b" }, + ]); + }); + + it("keeps multibyte code points whole", () => { + expect(tokenize("a😀")).toEqual([ + { name: "char", char: "a" }, + { name: "char", char: "😀" }, + ]); + }); +}); + +/** Minimal fake of a raw-mode TTY ReadStream for the reader. */ +class FakeIn extends EventEmitter { + isTTY = true; + isRaw = false; + setRawMode(v: boolean) { + this.isRaw = v; + return this; + } + resume() { + return this; + } + pause() { + return this; + } + setEncoding() { + return this; + } + send(data: string) { + this.emit("data", data); + } +} + +class FakeOut { + isTTY = true; + written: string[] = []; + write(s: string) { + this.written.push(s); + return true; + } +} + +describe("startNavReader — bracketed paste assembly", () => { + it("emits a single paste Key for wrapped text, sanitized of newlines", () => { + const input = new FakeIn(); + const keys: Key[] = []; + const stop = startNavReader(input as never, (k) => keys.push(k)); + input.send(`${PASTE_START}line one\nline two${PASTE_END}`); + stop(); + expect(keys).toEqual([{ name: "paste", text: "line one\nline two" }]); + }); + + it("assembles a paste that spans multiple data events", () => { + const input = new FakeIn(); + const keys: Key[] = []; + startNavReader(input as never, (k) => keys.push(k)); + input.send(`${PASTE_START}hel`); + input.send("lo wor"); + input.send(`ld${PASTE_END}`); + expect(keys).toEqual([{ name: "paste", text: "hello world" }]); + }); + + it("handles text before and after the paste in the same chunk", () => { + const input = new FakeIn(); + const keys: Key[] = []; + startNavReader(input as never, (k) => keys.push(k)); + input.send(`x${PASTE_START}yo${PASTE_END}z`); + expect(keys).toEqual([ + { name: "char", char: "x" }, + { name: "paste", text: "yo" }, + { name: "char", char: "z" }, + ]); + }); + + it("toggles bracketed-paste mode on the output TTY and restores on stop", () => { + const input = new FakeIn(); + const out = new FakeOut(); + const stop = startNavReader(input as never, () => {}, out as never); + expect(out.written).toContain("\x1b[?2004h"); + stop(); + expect(out.written).toContain("\x1b[?2004l"); + }); +}); diff --git a/test/journal.test.ts b/test/journal.test.ts new file mode 100644 index 0000000..452f6a5 --- /dev/null +++ b/test/journal.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + writeJournal, + readJournal, + clearJournal, + journalPathFor, +} from "../src/lib/journal.js"; +import { Timer } from "../src/lib/timer.js"; +import { DEFAULT_CONFIG } from "../src/schemas/config.js"; + +let dir: string; +let file: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "fc-journal-")); + file = join(dir, "active-session.json"); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +/** A timer at a fixed clock for deterministic snapshots. */ +function timerAt(startMs: number, nowMs: number) { + let t = nowMs; + const clock = () => t; + const timer = new Timer(clock, startMs); + return { timer, clock, set: (ms: number) => (t = ms) }; +} + +describe("journal — path", () => { + it("sits next to sessions.json", () => { + const p = journalPathFor(DEFAULT_CONFIG); + expect(p.endsWith("active-session.json")).toBe(true); + }); +}); + +describe("journal — write/read/clear round-trip", () => { + it("persists a session snapshot and reads it back", () => { + const start = Date.parse("2026-06-15T09:00:00.000Z"); + const { timer, set } = timerAt(start, start); + set(start + 90 * 60 * 1000); // 90m focus + const session = timer.toSession({ source: "hud", goal: "Deep work", label: "Korvex" }); + writeJournal(file, { session, onBreak: false, breakCategory: "rest", heartbeat: start + 90 * 60 * 1000 }); + + const rec = readJournal(file); + expect(rec).not.toBeNull(); + expect(rec!.v).toBe(1); + expect(rec!.session.durationS).toBe(5400); + expect(rec!.session.goal).toBe("Deep work"); + expect(rec!.session.label).toBe("Korvex"); + }); + + it("clear removes the journal; read returns null after", () => { + writeJournal(file, { + session: new Timer(() => 1000, 0).toSession({ source: "hud" }), + onBreak: false, + breakCategory: "rest", + }); + expect(existsSync(file)).toBe(true); + clearJournal(file); + expect(existsSync(file)).toBe(false); + expect(readJournal(file)).toBeNull(); + }); + + it("returns null for a missing or corrupt journal (never throws)", () => { + expect(readJournal(join(dir, "nope.json"))).toBeNull(); + writeFileSync(file, "{ not json"); + expect(readJournal(file)).toBeNull(); + writeFileSync(file, JSON.stringify({ v: 99, garbage: true })); + expect(readJournal(file)).toBeNull(); + }); +}); + +describe("Timer.fromResume — conservative gap discard", () => { + it("rebuilds focus exactly and continues forward, ignoring the frozen gap", () => { + // Session ran 90m focus, then the machine froze for 2h before resume. + const breaks = new Timer(() => 0, 0).toSession({ source: "hud" }).breaks; + let now = Date.parse("2026-06-15T12:00:00.000Z"); + const clock = () => now; + const resumed = Timer.fromResume(5400, breaks, "2026-06-15T09:00:00.000Z", clock); + expect(resumed.elapsedS()).toBe(5400); // exactly the heartbeat focus, gap discarded + now += 60 * 1000; // 1 minute later + expect(resumed.elapsedS()).toBe(5460); // counts forward normally + }); + + it("restores accumulated break totals", () => { + const start = 0; + const { timer, set } = timerAt(start, start); + set(1000 * 1000); + timer.startBreak("meal"); + set(1000 * 1000 + 600 * 1000); // 10m meal break + timer.endBreak(); + set(1000 * 1000 + 600 * 1000 + 500 * 1000); + const snap = timer.toSession({ source: "hud" }); + expect(snap.breakS).toBe(600); + + let now = Date.parse("2026-06-15T15:00:00.000Z"); + const resumed = Timer.fromResume(snap.durationS, snap.breaks, snap.start, () => now); + expect(resumed.totalBreakS()).toBe(600); + expect(resumed.elapsedS()).toBe(snap.durationS); + // stopping later yields a consistent end - start = focus + break + now += 300 * 1000; + const out = resumed.toSession({ source: "hud" }); + const span = (Date.parse(out.end) - Date.parse(out.start)) / 1000; + expect(span).toBe(out.durationS + out.breakS); + // every recovered break falls within the reconstructed [start, end] window + for (const b of out.breaks) { + expect(Date.parse(b.start)).toBeGreaterThanOrEqual(Date.parse(out.start)); + expect(Date.parse(b.end)).toBeLessThanOrEqual(Date.parse(out.end)); + } + }); +}); diff --git a/test/lineedit.test.ts b/test/lineedit.test.ts new file mode 100644 index 0000000..c562beb --- /dev/null +++ b/test/lineedit.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import { + lineFrom, + insert, + backspace, + deleteForward, + left, + right, + home, + end, + sanitizeInline, + lineApplyKey, + type LineState, +} from "../src/lib/tui/lineedit.js"; +import type { Key } from "../src/lib/tui/input.js"; + +describe("lineedit — model", () => { + it("seeds cursor at end of text", () => { + expect(lineFrom("hello")).toEqual({ text: "hello", cursor: 5 }); + expect(lineFrom()).toEqual({ text: "", cursor: 0 }); + }); + + it("inserts at the cursor, not just the end", () => { + let s = lineFrom("ac"); + s = home(s); // cursor 0 + s = right(s); // cursor 1, between a and c + s = insert(s, "b"); + expect(s.text).toBe("abc"); + expect(s.cursor).toBe(2); + }); + + it("backspace deletes before cursor; no-op at start", () => { + let s: LineState = { text: "abc", cursor: 2 }; + s = backspace(s); + expect(s).toEqual({ text: "ac", cursor: 1 }); + s = home(s); + expect(backspace(s)).toEqual({ text: "ac", cursor: 0 }); + }); + + it("delete-forward removes at cursor; no-op at end", () => { + let s: LineState = { text: "abc", cursor: 1 }; + s = deleteForward(s); + expect(s).toEqual({ text: "ac", cursor: 1 }); + s = end(s); + expect(deleteForward(s)).toEqual({ text: "ac", cursor: 2 }); + }); + + it("cursor movement clamps to bounds", () => { + const s = lineFrom("hi"); + expect(left(left(left(s))).cursor).toBe(0); + expect(right(right(right(s))).cursor).toBe(2); + expect(home(s).cursor).toBe(0); + expect(end(home(s)).cursor).toBe(2); + }); + + it("preserves multibyte / emoji as single code points", () => { + let s = lineFrom("a😀b"); + s = home(s); + s = right(s); // after 'a' + s = deleteForward(s); // delete the emoji (2 code units, one code point) + expect(s.text).toBe("ab"); + }); +}); + +describe("lineedit — paste sanitization", () => { + it("collapses newlines/tabs to spaces and drops control chars", () => { + expect(sanitizeInline("a\nb\tc")).toBe("a b c"); + expect(sanitizeInline("x\x00\x07y")).toBe("xy"); + expect(sanitizeInline("clean text")).toBe("clean text"); + }); + + it("inserts a full pasted string at once (not just the first char)", () => { + const s = insert(lineFrom(""), "Deep work on StreamNet"); + expect(s.text).toBe("Deep work on StreamNet"); + expect(s.cursor).toBe(22); + }); + + it("paste in the middle splices correctly", () => { + let s = lineFrom("ad"); + s = home(s); + s = right(s); + s = insert(s, "bc"); + expect(s.text).toBe("abcd"); + expect(s.cursor).toBe(3); + }); +}); + +describe("lineedit — lineApplyKey routing", () => { + const apply = (s: LineState, k: Key) => lineApplyKey(s, k); + + it("handles editing keys and reports handled=true", () => { + expect(apply(lineFrom("a"), { name: "char", char: "b" })).toEqual({ + state: { text: "ab", cursor: 2 }, + handled: true, + }); + expect(apply({ text: "hi", cursor: 2 }, { name: "paste", text: "!!" }).state.text).toBe("hi!!"); + expect(apply({ text: "hi", cursor: 2 }, { name: "backspace" }).state.text).toBe("h"); + }); + + it("does NOT consume tab/enter/escape/up/down (handled=false)", () => { + for (const name of ["tab", "enter", "escape", "up", "down"] as const) { + const res = apply(lineFrom("x"), { name } as Key); + expect(res.handled).toBe(false); + expect(res.state).toEqual(lineFrom("x")); + } + }); + + it("never consumes Ctrl-C", () => { + const res = apply(lineFrom("x"), { name: "char", char: "\x03" }); + expect(res.handled).toBe(false); + }); +}); diff --git a/test/resume.test.ts b/test/resume.test.ts new file mode 100644 index 0000000..7a1e6dd --- /dev/null +++ b/test/resume.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest"; +import { + emptyResumeState, + openResumeState, + resumeApplyKey, + renderResume, + type ResumeInfo, +} from "../src/tui/resume.js"; +import { displayWidth } from "../src/lib/tui/draw.js"; +import { humanDuration } from "../src/lib/format.js"; + +const info: ResumeInfo = { + goal: "Ship the resume overlay", + label: "deep work", + focusS: 1500, + breakS: 300, + heartbeatISO: "2026-06-15T10:00:00.000Z", +}; + +const opened = openResumeState(info); + +describe("resume state", () => { + it("emptyResumeState is closed with null info", () => { + const s = emptyResumeState(); + expect(s.open).toBe(false); + expect(s.info).toBeNull(); + }); + + it("openResumeState carries info and is open", () => { + expect(opened.open).toBe(true); + expect(opened.info).toEqual(info); + }); +}); + +describe("resumeApplyKey", () => { + it("'r' resumes and closes", () => { + const r = resumeApplyKey(opened, { name: "char", char: "r" }); + expect(r.action).toEqual({ type: "resume" }); + expect(r.state.open).toBe(false); + expect(r.state.info).toBeNull(); + }); + + it("'R' (uppercase) also resumes", () => { + const r = resumeApplyKey(opened, { name: "char", char: "R" }); + expect(r.action?.type).toBe("resume"); + expect(r.state.open).toBe(false); + }); + + it("'d' discards and closes", () => { + const r = resumeApplyKey(opened, { name: "char", char: "d" }); + expect(r.action).toEqual({ type: "discard" }); + expect(r.state.open).toBe(false); + }); + + it("'D' (uppercase) also discards", () => { + const r = resumeApplyKey(opened, { name: "char", char: "D" }); + expect(r.action?.type).toBe("discard"); + }); + + it("Esc discards", () => { + expect(resumeApplyKey(opened, { name: "escape" }).action).toEqual({ type: "discard" }); + }); + + it("Enter resumes (safe default — never discards)", () => { + expect(resumeApplyKey(opened, { name: "enter" }).action).toEqual({ type: "resume" }); + }); + + it("an unrelated key leaves the overlay open with no action", () => { + const r = resumeApplyKey(opened, { name: "char", char: "x" }); + expect(r.action).toBeUndefined(); + expect(r.state.open).toBe(true); + expect(r.state.info).toEqual(info); + }); + + it("does not mutate the input state", () => { + const s = openResumeState(info); + resumeApplyKey(s, { name: "char", char: "r" }); + expect(s.open).toBe(true); + expect(s.info).toEqual(info); + }); +}); + +describe("renderResume", () => { + it("returns a centered panel containing the goal, title and focus duration", () => { + const o = renderResume(opened, 80, 24, "neon", false); + const joined = o.rows.join("\n"); + expect(joined).toContain("Ship the resume overlay"); + expect(joined).toContain("Resume previous session?"); + expect(joined).toContain(humanDuration(1500)); + expect(o.top).toBeGreaterThan(0); + expect(o.left).toBeGreaterThan(0); + }); + + it("includes the details line when label is set", () => { + const o = renderResume(opened, 80, 24, "neon", false); + expect(o.rows.join("\n")).toContain("details: deep work"); + }); + + it("omits the details line when label is null", () => { + const noLabel = openResumeState({ ...info, label: null }); + const o = renderResume(noLabel, 80, 24, "neon", false); + expect(o.rows.join("\n")).not.toContain("details:"); + }); + + it("shows a fallback when goal is null", () => { + const noGoal = openResumeState({ ...info, goal: null }); + const o = renderResume(noGoal, 80, 24, "neon", false); + const joined = o.rows.join("\n"); + expect(joined).not.toContain("Ship the resume overlay"); + expect(joined).toContain("(no goal)"); + expect(joined).toContain(humanDuration(1500)); + }); + + it("does not throw with null info and stays a valid panel", () => { + const o = renderResume(emptyResumeState(), 80, 24, "neon", false); + expect(o.rows.length).toBeGreaterThan(0); + const w = displayWidth(o.rows[0]!); + for (const row of o.rows) expect(displayWidth(row)).toBe(w); + }); + + it("every overlay row is within the box width", () => { + const o = renderResume(opened, 80, 24, "neon", true); + const w = displayWidth(o.rows[0]!); + for (const row of o.rows) expect(displayWidth(row)).toBe(w); + }); +});