Skip to content

feat(notifications): keep own sounds in the Enter cycle and save them with Ctrl+S - #1974

Open
Fivoryu wants to merge 9 commits into
Gentleman-Programming:mainfrom
Fivoryu:fix/notifications-enter-cycle-and-timing-rows
Open

Fivoryu wants to merge 9 commits into
Gentleman-Programming:mainfrom
Fivoryu:fix/notifications-enter-cycle-and-timing-rows

Conversation

@Fivoryu

@Fivoryu Fivoryu commented Oct 9, 2026 •

Copy link
Copy Markdown

Linked issue

Closes #1896

Triage note: #1896 is still untriaged (no labels), so it does not carry status:approved, and my account has READ on this repository, so I cannot label it. This PR is therefore opened for triage rather than after approval; the issue body already describes both parts.

PR type

  • New feature (type:feature)

The first commit is the bug fix the issue asks for in slice 1 and the rest is slice 2 without per-sound volume, so please apply whichever single type:* label fits your triage.

Summary

  • Make each sound row's Enter cycle per row: a row that owns a local file keeps it as the last step of its own cycle, so the first Enter no longer discards the assignment for good.
  • Expose the two existing global timing keys (minimumIntervalMs, coalesceWindowMs) as card rows: Enter opens an inline field and only a whole number inside the schema range is saved instead of being clamped.
  • Add a saved-sounds library written by Ctrl+S inside the path field and walked by every row's cycle: silence → included tones → saved sounds in saved order → the row's own unsaved file.

Changes

File Change
lib/notification-customize.ts Per-row cycle that includes the library, the two timing rows, and Ctrl+S saving through the already-validated path.
lib/visual-customize-view.ts CustomizeInline.input resolves { value, save }; the field intercepts ctrl+s and advertises Ctrl+S save only when the request asks for it.
lib/notification-sounds.ts New strict, bounded, atomic store for notifications-sounds.json.
lib/notification-policy.ts Export DEFAULT_IO and nativeFlavor so the new writer reuses one definition of the path policy and of the filesystem seam.
tests/notification-sounds.test.ts New suite: shape, canonical dedupe, bound, atomic write, and refusal to overwrite an unreadable library.
tests/notification-customize.test.ts Cycle tests for a saved and an unsaved file, the Ctrl+S save/duplicate/full/unreadable cases, and the eight-control card.
tests/visual-customize-view.test.ts The shortcut, the advertised hint, and the invisible-field guard.
docs/sound-notifications.md, README.md, docs/readme-reference.md New "Saved sounds" section, plus the controls and cycle description.
odd/tasks/notifications-saved-sounds.md Feature document: specs, tasks, log and evidence.

Design decision that needs your call

#1896 proposes the library inside the audio schema (audio.sounds) with a /v1 → /v2 bump. lib/notification-policy.ts validates the keys of audio exactly, so such a key makes any build that does not know it classify the configuration as malformed and offer "Replace invalid audio configuration?" — which for a shared or downgraded configuration can discard assignments. This PR keeps notifications.json at /v1 and writes the library to a second file under its own schema instead: older builds ignore it, and the assigned sound stays a file: reference, so audio keeps working even if that file is removed. If you prefer the schema route, the store is one module and migrating from the file is bounded.

Test plan

  • pnpm run typecheck: 186 recorded diagnostics, no regressions.
  • Notification suite on Linux with node 24 (notification-sounds, notification-customize, notification-audio, notification-policy, notification-scheduler, notification-service, gentle-notifications, notification-ui, visual-customize-view): 195 passed, 0 failed.
  • tests/gentle-shell.test.ts on Linux: 263 passed, 0 failed, including the customize overlay flow.
  • node scripts/verify-package-files.mjs: 196 files, 69 byte-pinned artifacts.
  • pnpm run check:runtime-modules: matches the TypeScript sources.
  • git diff --check: clean.
  • pnpm test in full and pnpm run test:packed-package locally: not run. The first needs a Linux toolchain this machine does not have and the second would shell out to a Windows npm under a Linux node, so both are left to CI.
  • Manual check in a real Pi TUI: the Enter cycle walks back to the assigned file (the reporter of the issue verified it before Ctrl+S existed). Physical listening stays unverified, as the current documentation already records.

Contributor checklist

Scope and review

The branch touches eleven files, all inside the notification surface. Nothing else moves: no change to the notifications.json schema, no new dependency, no player, backend or scheduler change, and no shell script (shellcheck is not applicable). Out of scope on purpose: per-sound volume, the /v2 schema move, and the mixed-group question the issue records — Enter on a mixed type still applies that type's recommended tone.

Summary by CodeRabbit

  • New Features
    • Added editable minimum-interval and coalescing-window settings for notifications, with validation for whole-number values from 0 to 60,000 ms.
    • Notification sounds can now cycle through built-in tones, saved sounds, and a row’s assigned local sound. Supported local files include WAV, OGG, and FLAC.
    • Press Ctrl+S to save and assign a validated sound; Enter assigns it without saving. The saved sound library supports up to eight sounds.
  • Documentation
    • Updated notification guides with the new controls, sound options, and keyboard shortcuts.

Gentleman-Programming#1896 slice 1. No schema change: both timing keys already exist in
`gentle-shell.notifications/v1`.

The card's Enter cycle was the fixed constant `[null, ...builtins]`. A `file:`
reference is never a member, so `indexOf`/`findIndex` returned -1, `(-1 + 1) % 4`
selected silence, and the user's own sound was unreachable afterwards. The toast
still said "Audio notification preferences saved."

- Make the cycle per row: `cycleFor()` appends the local reference the row owns
  and `ownedFile()` keeps it remembered while the row is silenced, so Enter reads
  `file -> silence -> success tone -> error tone -> attention tone -> file` and
  returns to the assignment. The row also re-reads its own file from the current
  value, so a reference replaced with `f` cannot come back from the memory.
- Expose the two global timing keys (`minimumIntervalMs`, `coalesceWindowMs`) as
  card rows. Enter opens an inline field prefilled with the current value; only a
  whole number inside the schema range is saved, anything else is refused with a
  notice and writes nothing. `0` still disables that timing window.

The issue sketched the cycle as
`[null, ...BUILTIN_NOTIFICATION_IDS, ...(current?.startsWith("file:") ? [current] : [])]`,
which recomputes the cycle from the current value alone: entering from a file it
still selects silence, and the file is unreachable on every later press. That is
the same outcome as the bug. The remembered per-row reference is what makes the
stated goal ("an assignment is never discarded") hold, so the cycle is per row as
the requirement says rather than as the sketch spells it.

The remembered reference lives in the open card; reopening `/gentle:customize`
rebuilds the rows, so a row silenced before closing reads as silence again.
Persisting a set of own sounds is the library that stays in slice 2, together
with the `/v2` decision and the mixed-group question.

Verified on Windows, node 22.23.0:
- tests/notification-customize.test.ts: 41/42; the only failure is the
  pre-existing `f opens an inline field ...` case, which writes a POSIX-style
  path the native win32 path flavor refuses and fails identically without this
  change.
- notification + customize-view group: 24 pre-existing failures / 150 pass /
  174 tests, against 24 / 146 / 170 for the same command without this change.
  Same failure set, 4 new tests all green, no new failure.
- `pnpm run typecheck`: 186 recorded diagnostics, no regressions.

Refs Gentleman-Programming#1896
Gentleman-Programming#1896 slice 2 reduced to what the user asked for: a persisted library of own
sounds, saved with `Ctrl+S` from the path field, walked by each row's `Enter`
cycle. Specs S1-S8 quote the request verbatim; the persistence decision (a
separate file, so `notifications.json` stays `/v1`) is recorded in the log with
its evidence.

Refs Gentleman-Programming#1896
Saved own sounds need somewhere to live before `Enter` can rotate among them.
`notifications.json` validates its keys exactly, so a library key inside
`audio` would make every build that does not know it classify the audio
configuration as malformed and offer "Replace invalid audio configuration?".

`lib/notification-sounds.ts` stores the library beside the audio configuration
under its own schema (`gentle-shell.notification-sounds/v1`), so the existing
file and its `/v1` contract do not change and older builds simply ignore it.

- Strict shape: `{ schema, sounds: [{ path }] }`, one key per entry, and every
  path re-validated with the same absolute-path policy the sound assignments
  use. An unknown schema, an extra key or an invalid path invalidates the whole
  file instead of being partially accepted.
- One entry per file: the comparison key folds Windows case and separators and
  stays case-sensitive on POSIX, while the stored entry keeps the user's own
  spelling.
- Bounded at eight entries: a full library refuses a new sound and reports it
  rather than dropping one the user saved. A missing file is an empty library
  that is never created by a read.
- The write is atomic (same-directory exclusive `0600` temporary plus rename)
  and validates the whole list before any filesystem call.

`notification-policy.ts` now exports its `DEFAULT_IO` and `nativeFlavor`: the
writer and the path policy are security-relevant seams that must have exactly
one definition rather than a copy.

Verified on Windows, node 22.23.0:
- tests/notification-sounds.test.ts 9/9.
- `pnpm run typecheck`: 186 recorded diagnostics, no regressions.

Refs Gentleman-Programming#1896
The inline bridge resolved a field to its text or to cancellation, so a row
could not tell "the user pressed Enter" from "the user asked to save this
value". `CustomizeInline.input` now resolves `{ value, save }`, the view
intercepts `ctrl+s` (`\x13` and the terminal's CSI-u sequence, and the shortcut
never becomes text), and the field footer advertises `Ctrl+S save` only when
the request asks for it with `allowSave`. A field that is not fully visible
cannot submit through the shortcut, exactly like Enter.

No row uses the flag yet: the notifications card only adapts to the new result
shape, so its behavior is unchanged in this commit.

Verified on Windows, node 22.23.0:
- tests/visual-customize-view.test.ts 42/42, including the new shortcut, hint
  and invisible-field cases.
- tests/notification-customize.test.ts 41/42: the only failure is the
  pre-existing `f opens an inline field ...` case, which writes a POSIX-style
  path the native win32 path flavor refuses.
- `pnpm run typecheck`: 186 recorded diagnostics, no regressions.

Refs Gentleman-Programming#1896
The saved-sounds file had no writer and no reader. `Ctrl+S` in the path field
now adds the already-validated sound to it and assigns it, `Enter` keeps
assigning without saving, and every row's `Enter` cycle walks
`silence -> included tones -> saved sounds in saved order -> the row's own
unsaved file`.

- The cycle takes the saved list first and appends the row's own reference only
  when the library does not already carry it. Ordering it the other way let a
  saved sound jump the queue as soon as a row walked over it; the new cycle
  test caught exactly that.
- A save reports what happened: saved, already saved, refused because the list
  is unreadable (the card names the file and never overwrites it), or refused
  because the list is full. A failed save still assigns the sound in the same
  action, so the user is never left without the sound they picked.
- The library is read once per card and refreshed in memory after a successful
  save, so rendering a row never touches the filesystem.

Docs: `docs/sound-notifications.md` gains a "Saved sounds" section (path,
schema, strict shape, one entry per file, the eight-sound bound, the atomic
write, and why `notifications.json` stays `/v1`); the per-row memory note now
says what survives closing the card. README and the technical reference carry
one clause each.

Verified on Windows, node 22.23.0:
- notification group: 24 pre-existing failures / 170 pass / 194 tests, against
  24 / 146 / 170 before this feature. The 24 are the 23 failures in files this
  change does not touch plus the known `f opens an inline field ...` case, and
  all 20 new tests pass.
- `pnpm run typecheck`: 186 recorded diagnostics, no regressions.

Refs Gentleman-Programming#1896
T1-T3 landed as `ebb9faea0`, `c1e74ec6a` and `7fe52d6d7`; the log records the RED
observed per unit, the cycle-order bug the new test caught, the platform
baseline this change does not move, and what stays out of scope (per-sound
volume, the `/v2` move and the mixed-group question).

Refs Gentleman-Programming#1896
The library tests used Windows paths against the default path flavor, which is
`posix` off Windows, so `writeSavedSounds` and `resolveSavedSounds` rejected
them and four tests failed on Linux. The flavor is now an explicit input:
`win32` for the documents that carry Windows paths and a new `posix`
round-trip case for the other branch. The full-library card test seeds
platform-native paths for the same reason, and the card keeps using the native
flavor, which is what production does.

Verified with node 24 on both platforms:
- Linux (the platform CI runs the full suite on): notification group 195/195
  and tests/gentle-shell.test.ts 263/263.
- Windows: 102 tests with the single pre-existing `f opens an inline field ...`
  failure, an environment artifact of this machine (the case writes a POSIX
  path the native win32 flavor refuses) that CI's Windows job does not run.

Refs Gentleman-Programming#1896
@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 088850f2-fd3d-48de-a9c9-fca1797fea3c

📥 Commits

Reviewing files that changed from the base of the PR and between be2869b and ab25990.


📒 Files selected for processing (11)
  • README.md
  • docs/readme-reference.md
  • docs/sound-notifications.md
  • lib/notification-customize.ts
  • lib/notification-policy.ts
  • lib/notification-sounds.ts
  • lib/visual-customize-view.ts
  • odd/tasks/notifications-saved-sounds.md
  • tests/notification-customize.test.ts
  • tests/notification-sounds.test.ts
  • tests/visual-customize-view.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.



📝 Walkthrough

Priority: ➖ Normal

Change: Feature

Merge Risk: ⚪ Minimal · up to ab259

No actionable issue identified here prevents merging after normal checks.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check Warning Direct issue #1896 remains only partly implemented. The diff adds the slice 1 timing rows, per-row file retention, saved-sound storage, canonical deduplication, bounded capacity, Ctrl+S saving, and li… Implement per-sound volume as an integer percentage with validation and persistence. Apply attenuation through the specified player routes, including PCM gain where the route has no volume parameter, and add automated coverage. Resolve and …
Docstring Coverage Warning Docstring coverage is 63.64% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 22 functions across 7 files. (4 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Out of Scope Changes check Passed The changed source, tests, and documentation stay within issue #1896's notification customization scope. The timing rows, inline save shortcut, saved-sounds store, path-policy exports, cycle behavior,…
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main notification changes: retaining each row’s own sound in the Enter cycle and saving sounds with Ctrl+S.

Full details: Linked Issues check

Explanation

Direct issue #1896 remains only partly implemented. The diff adds the slice 1 timing rows, per-row file retention, saved-sound storage, canonical deduplication, bounded capacity, Ctrl+S saving, and library cycling. The diff does not add per-sound volume data or playback attenuation for the required routes. It also keeps mixed-group Enter behavior as group.recommended; the issue requires a decision before the library ships, and the PR explicitly leaves this behavior unresolved. The separate library schema is a documented implementation of the issue's schema decision and is not itself a gap.

Resolution

Implement per-sound volume as an integer percentage with validation and persistence. Apply attenuation through the specified player routes, including PCM gain where the route has no volume parameter, and add automated coverage. Resolve and document mixed-group Enter behavior, with automated coverage, before treating the saved-sounds library as complete.


Full details: Docstring Coverage

Explanation

Docstring coverage is 63.64% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 22 functions across 7 files. (4 skipped: 4 unsupported.)



  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR


  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ation

The branch is pushed and the PR is open against `main` with the design question
about the library file called out. The log also records what running the tests on
Linux changed: the saved-sounds tests were pinning the wrong path flavor, which
would have failed CI.

Refs Gentleman-Programming#1896
The log recorded a commit count that the very commit recording it changes, so it
now names the surface instead.

Refs Gentleman-Programming#1896
@carlosmoradev

Copy link
Copy Markdown
Contributor

Thorough work on this slice @Fivoryu.

Regarding your design question on schema bump vs separate file:

Keeping notifications.json on v1 and isolating the palette in notifications-sounds.json is definitely the right architectural decision.

lib/notification-policy.ts performs strict key validation (keysMatch). If you introduce an unexpected key into audio, any older build or concurrent session running on the machine will immediately mark notifications.json as malformed and prompt the user to replace or wipe their settings. By keeping the palette in a separate file, older versions remain completely unaffected, and individual sound assignments stay standard file: URIs.

A few quick observations on the implementation:

  1. Atomic persistence: Using an exclusive 0600 temporary file with randomUUID() and renaming ensures crash safety without partial writes.
  2. Path normalization: Canonicalizing Windows paths via win32.normalize and case-folding while preserving the user's original casing in the payload avoids duplicate entries on case-insensitive filesystems.
  3. Cycle stability: Using new Set in cycleFor cleanly preserves insertion order and prevents duplicate entries during cycling.

Code looks clean, robust, and well-covered by tests.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(notifications): per-sound volume, saved-sounds library, and a cycle that keeps an assigned file

2 participants