From f428a208dd913d94481b1853268d09e64a8e9e9a Mon Sep 17 00:00:00 2001 From: Eduardo Borjas Date: Mon, 15 Jun 2026 03:43:25 -0600 Subject: [PATCH 1/4] =?UTF-8?q?feat(v3.8.0):=20foundation=20=E2=80=94=20li?= =?UTF-8?q?ne-editor,=20crash-recovery=20journal,=20coffee/sleep=20breaks,?= =?UTF-8?q?=20picker+resume=20overlays?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure engines proven before UI wiring (SOP step 3): - lineedit.ts: code-point-aware single-line editor (insert-at-cursor, paste, ←/→/Home/End, delete) + bracketed-paste in input.ts (tokenize fixes the one-char-per-chunk truncation; DEC 2004 wrap so multi-line paste != Enter). - journal.ts + Timer.fromResume: live-session snapshot for crash recovery; conservative gap-discard reconstruction (never invents focus time). - coffee/sleep break categories (schema single source of truth: ALL_/QUICK_). - breakpicker.ts + resume.ts pure overlays (parallel Sonnet workers, in-scope). 711 tests green, typecheck clean. Co-Authored-By: Claude Opus 4.8 --- src/commands/start.ts | 16 ++--- src/lib/hud.ts | 2 + src/lib/journal.ts | 91 ++++++++++++++++++++++++ src/lib/timer.ts | 23 ++++++ src/lib/tui/input.ts | 105 ++++++++++++++++++++++++++- src/lib/tui/lineedit.ts | 129 +++++++++++++++++++++++++++++++++ src/schemas/session.ts | 14 ++++ src/tui/breakpicker.ts | 149 +++++++++++++++++++++++++++++++++++++++ src/tui/resume.ts | 133 ++++++++++++++++++++++++++++++++++ src/tui/views/breaks.ts | 14 ++-- test/breakpicker.test.ts | 117 ++++++++++++++++++++++++++++++ test/input-paste.test.ts | 111 +++++++++++++++++++++++++++++ test/journal.test.ts | 108 ++++++++++++++++++++++++++++ test/lineedit.test.ts | 112 +++++++++++++++++++++++++++++ test/resume.test.ts | 126 +++++++++++++++++++++++++++++++++ 15 files changed, 1230 insertions(+), 20 deletions(-) create mode 100644 src/lib/journal.ts create mode 100644 src/lib/tui/lineedit.ts create mode 100644 src/tui/breakpicker.ts create mode 100644 src/tui/resume.ts create mode 100644 test/breakpicker.test.ts create mode 100644 test/input-paste.test.ts create mode 100644 test/journal.test.ts create mode 100644 test/lineedit.test.ts create mode 100644 test/resume.test.ts 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/timer.ts b/src/lib/timer.ts index fd1854e..c6d9e78 100644 --- a/src/lib/timer.ts +++ b/src/lib/timer.ts @@ -42,6 +42,29 @@ 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). + */ + static fromResume( + focusS: number, + breaks: Break[], + clock: Clock = Date.now, + ): Timer { + const now = clock(); + const breakMs = breaks.reduce((sum, b) => sum + b.durationS, 0) * 1000; + const t = new Timer(clock, now - Math.max(0, focusS) * 1000 - breakMs); + t.totalBreakMs = breakMs; + t.breaks.push(...breaks); + 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..730e058 --- /dev/null +++ b/src/lib/tui/lineedit.ts @@ -0,0 +1,129 @@ +/** + * 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 }; +} + +/** + * 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/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/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/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/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/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..a062707 --- /dev/null +++ b/test/journal.test.ts @@ -0,0 +1,108 @@ +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, 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, () => 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); + }); +}); 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); + }); +}); From a0ed0a1cb38e5b5705230ed222b57c5a13529369 Mon Sep 17 00:00:00 2001 From: Eduardo Borjas Date: Mon, 15 Jun 2026 04:01:10 -0600 Subject: [PATCH 2/4] feat(v3.8.0): integrate details rename, line-editor, cancel, break picker, crash recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Name → Details everywhere: forms, detail view, help, and CLI (start/log/edit now take --details; --name/--label kept as hidden deprecated aliases — no workflow break). Manifest updated. - All text fields are real line editors (cursor + paste) via the lineedit model. - Live session: [x] cancel (confirm → discard, no save), [m] break-category picker (unlocks coffee/sleep without cluttering the 1-6 row), [c] cycle now spans all categories. - Crash recovery: journal written on start + each transition + 5s heartbeat, cleared on stop/cancel; on launch an orphaned journal opens the Resume overlay → Timer.fromResume rebuilds the session (gap discarded, never invents focus). 712 tests + 3 new buildFrame overlay tests green; lint + typecheck clean. Smoke-verified --details/--name alias end-to-end. Co-Authored-By: Claude Opus 4.8 --- src/cli.ts | 24 ++-- src/lib/manifest.ts | 12 +- src/lib/tui/lineedit.ts | 10 ++ src/tui/app.ts | 241 +++++++++++++++++++++++++++++++++++--- src/tui/editform.ts | 76 ++++++------ src/tui/sessionform.ts | 67 +++++------ src/tui/views/help.ts | 10 +- src/tui/views/sessions.ts | 2 +- test/dashboard.test.ts | 51 ++++++++ test/editform.test.ts | 15 ++- 10 files changed, 398 insertions(+), 110 deletions(-) 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/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/tui/lineedit.ts b/src/lib/tui/lineedit.ts index 730e058..4a4b0b3 100644 --- a/src/lib/tui/lineedit.ts +++ b/src/lib/tui/lineedit.ts @@ -93,6 +93,16 @@ 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 diff --git a/src/tui/app.ts b/src/tui/app.ts index 337c698..d141b69 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), + 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; @@ -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/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/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/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/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"); }); From bdd8bb218d837c9af2ba9af6b05e80aea0af076c Mon Sep 17 00:00:00 2001 From: Eduardo Borjas Date: Mon, 15 Jun 2026 04:05:40 -0600 Subject: [PATCH 3/4] =?UTF-8?q?fix(v3.8.0):=20audit=20=E2=80=94=20enable?= =?UTF-8?q?=20bracketed=20paste=20in=20dashboard;=20keep=20recovered=20bre?= =?UTF-8?q?aks=20in-window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs found auditing the full diff: 1. startNavReader was called without the output stream, so DEC 2004 bracketed paste was never enabled in the dashboard — multi-line pastes could submit mid-text. Pass process.stdout. 2. Timer.fromResume kept breaks' original timestamps while shifting the start, producing recovered records with breaks before their own start. Shift breaks by the same delta so they keep spacing and stay within [start, now]. 715 tests + lint + typecheck + build green. Co-Authored-By: Claude Opus 4.8 --- src/lib/timer.ts | 18 ++++++++++++++++-- src/tui/app.ts | 4 ++-- test/journal.test.ts | 9 +++++++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/lib/timer.ts b/src/lib/timer.ts index c6d9e78..d73730f 100644 --- a/src/lib/timer.ts +++ b/src/lib/timer.ts @@ -51,17 +51,31 @@ export class Timer { * 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 t = new Timer(clock, now - Math.max(0, focusS) * 1000 - breakMs); + const startMs = now - Math.max(0, focusS) * 1000 - breakMs; + const delta = startMs - Date.parse(originalStartISO); + const t = new Timer(clock, startMs); t.totalBreakMs = breakMs; - t.breaks.push(...breaks); + 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; } diff --git a/src/tui/app.ts b/src/tui/app.ts index d141b69..800ece5 100644 --- a/src/tui/app.ts +++ b/src/tui/app.ts @@ -655,7 +655,7 @@ export async function runDashboardApp( function resumeFromJournal(rec: ActiveSession): void { const s = rec.session; state.live = { - timer: Timer.fromResume(s.durationS, s.breaks), + timer: Timer.fromResume(s.durationS, s.breaks, s.start), goal: s.goal, label: s.label, focusTargetS: s.focusTargetS, @@ -1400,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); diff --git a/test/journal.test.ts b/test/journal.test.ts index a062707..452f6a5 100644 --- a/test/journal.test.ts +++ b/test/journal.test.ts @@ -78,7 +78,7 @@ describe("Timer.fromResume — conservative gap discard", () => { 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, clock); + 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 @@ -96,7 +96,7 @@ describe("Timer.fromResume — conservative gap discard", () => { expect(snap.breakS).toBe(600); let now = Date.parse("2026-06-15T15:00:00.000Z"); - const resumed = Timer.fromResume(snap.durationS, snap.breaks, () => now); + 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 @@ -104,5 +104,10 @@ describe("Timer.fromResume — conservative gap discard", () => { 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)); + } }); }); From bbf6f90d54a33fc71d904013c6ca00243e6a5f49 Mon Sep 17 00:00:00 2001 From: Eduardo Borjas Date: Mon, 15 Jun 2026 04:09:50 -0600 Subject: [PATCH 4/4] docs(v3.8.0): version bump, CHANGELOG, README (recovery, cancel, picker, details, editors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - version.ts + package.json → 3.8.0 - CHANGELOG: Added (crash recovery, [x] cancel, coffee/sleep, [m] picker) + Changed (Name→Details with hidden aliases, line editors + paste) - README: live-controls table, new-session form (Details), Cancel + Crash recovery sections, line-editor note, schema category set, agent examples, roadmap v3.8.0 row. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++ README.md | 59 ++++++++++++++++++++++++++++++++++++++++++++------ package.json | 2 +- src/version.ts | 2 +- 4 files changed, 94 insertions(+), 9 deletions(-) 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/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";